Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/25165-scaffold-vitest-advisory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/cli': patch
'create-objectstack': patch
---

fix(cli,create-objectstack): new projects declare vitest `~4.1.12`, outside GHSA-qxg6-89mg-rqv8, and still install on npm 10

Clause-②: no
Metadata-protocol: none

The vitest range every scaffold declares moves from `~4.0.0` to `~4.1.12`: the `create-objectstack` blank template, `os init -t plugin` and `os create plugin` (in `devDependencies`), and the npm `overrides` block that every standalone scaffold emits, `os init -t app` and `-t empty` included. `~4.0.0` resolved to vitest and `@vitest/mocker` 4.0.18, inside GHSA-qxg6-89mg-rqv8 (`@vitest/mocker` from 2.1.0-beta.7 up to the fix in 4.1.12), which has no 4.0.x fix. A new project now resolves vitest and `@vitest/mocker` 4.1.12 or a later 4.1.x patch, and `npm audit` of a fresh scaffold lists no vitest or `@vitest/*` advisory.

npm 10, the npm Node 22 bundles, still installs every scaffold. The `overrides.vitest` entry, which equals `devDependencies.vitest`, is what makes that hold: it settles vitest 4.1.12's contradictory optional peer (`@vitest/browser-webdriverio`), which npm 10 otherwise crashes on with `Cannot read properties of null (reading 'edgesOut')`. Measured on npm 10.9.4, 10.9.9 and 11.21.1. pnpm ignores npm's `overrides` field.

**An existing project made by an earlier `create-objectstack`, `os init` or `os create plugin`** keeps its own `~4.0.0`. To move it, set `overrides.vitest` to `~4.1.12` in `package.json`, and `devDependencies.vitest` too where the project declares it, then run `npm install`. npm refuses an override that differs from the direct dependency (`EOVERRIDE`), so the two move together.
10 changes: 6 additions & 4 deletions content/docs/deployment/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,21 @@ Solutions to the most common issues encountered when working with ObjectStack.

### "Cannot read properties of null (reading 'edgesOut')" from npm install

**Symptom:** npm exits 1 with `npm error Cannot read properties of null (reading 'edgesOut')` while installing `@objectstack/cli` or `@objectstack/plugin-auth`. This includes the install step of `npm create objectstack` and `npm install -g @objectstack/cli`.
**Symptom:** npm exits 1 with `npm error Cannot read properties of null (reading 'edgesOut')` while installing `@objectstack/cli` or `@objectstack/plugin-auth`. This includes `npm install -g @objectstack/cli`, and `npm install` in a project whose `package.json` has no `vitest` override (fix 2).

**Cause:** npm 10, the npm that Node 22 ships, crashes on an optional `vitest` peer in the auth library's dependency tree: the newest vitest 4 has no matching `@vitest/browser-webdriverio` release. npm 11 and pnpm install the same packages, and `npm ci` from a committed lockfile is not affected.
**Cause:** npm 10, the npm that Node 22 ships, crashes on the newest vitest 4, 4.1.12. Its optional peer `@vitest/browser-webdriverio ^4.1.11` has one matching release, 4.1.11, and that release requires exactly vitest 4.1.11. npm 10 reaches vitest through an optional `vitest` peer in the auth library's dependency tree, or through your project's own `vitest`. npm 11 and pnpm install the same packages, and `npm ci` from a committed lockfile is not affected.

**Fix** (either one):

1. **Use npm 11** (it needs Node 22.9+): run `npm install -g npm@11`, then repeat the install. This is the only fix for `npm install -g`, because a global install reads no `overrides`. In a Dockerfile, install under a pinned npm 11, as the [self-built runtime recipe](/docs/deployment/self-hosting#docker-official-image--the-standard-path) does.
2. **Stay on npm 10 and pin vitest** in your project's root `package.json`. Then run `npm install` again and commit the lockfile:
2. **Stay on npm 10 and pin vitest** in your project's root `package.json`. The override puts every `vitest` in the tree, optional peers included, on one range, and npm 10 installs it. Then run `npm install` again and commit the lockfile:

```json title="package.json"
"overrides": { "vitest": "~4.0.0" }
"overrides": { "vitest": "~4.1.12" }
```

If `devDependencies` lists `vitest`, set it to the same `~4.1.12`: npm refuses an override that differs from a direct dependency (`EOVERRIDE`). A project created by `npm create objectstack`, `os init` or `os create plugin` already has this block. If yours reads `~4.0.0`, an earlier scaffold's value, change both to `~4.1.12`: `~4.0.0` installs vitest 4.0.18, which is affected by security advisory GHSA-qxg6-89mg-rqv8.

This entry, the npm 11 steps in [Self-hosting](/docs/deployment/self-hosting), and the npm 10 notes in [Getting Started](/docs/getting-started#prerequisites) and [Your First Project](/docs/getting-started/your-first-project#prerequisites) can be removed once `@vitest/browser-webdriverio` publishes the version that `npm view vitest dist-tags` lists as `V4`, and a plain npm 10 install of `@objectstack/plugin-auth` passes.

---
Expand Down
99 changes: 59 additions & 40 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,9 +344,10 @@ export const SCAFFOLD_PNPM_RANGE = '>=10.15';
// `^4.0.0` over `^4.0.18`: no recorded decision exists for either, both were
// written in the same 2026-02-07 commit, and `^4.0.18` claims a PATCH-level
// floor nothing justifies while being strictly the narrower of the two. (The
// vitest range has since moved to `~4.0.0` because `^4.0.0` stopped
// installing on npm 10. That was an install failure, not a support decision;
// the comment on `SCAFFOLD_VITEST_RANGE` holds the reading.)
// vitest range has since moved twice: to `~4.0.0` when `^4.0.0` stopped
// installing on npm 10, then to `~4.1.12` for a security advisory on
// `@vitest/mocker`. Neither was a support decision; the comment on
// `SCAFFOLD_VITEST_RANGE` holds the reading.)
//
// ⚠️ Neither choice changes what a scaffolded project INSTALLS. Measured
// against the registry: `^5.3.0` and `^5.8.0` both resolve to typescript
Expand Down Expand Up @@ -386,39 +387,52 @@ export const SCAFFOLD_TYPESCRIPT_RANGE = '^5.3.0';
/**
* The vitest range a scaffolded project declares when its template tests.
*
* `~4.0.0`, not `^4.0.0`, because `^4.0.0` does not INSTALL on npm 10, the npm
* that Node 22 bundles. Measured 2026-10-12, in an empty directory with a fresh
* `--cache` and a manifest holding only this one devDependency (the full matrix
* is in the PR that set this value): on npm 10.9.4 and 10.9.9, `npm install`
* exits 1 with `Cannot read properties of null (reading 'edgesOut')` from
* arborist's `#loadPeerSet`, and npm 11.21.0 installs it. `~4.0.0` installed on
* all three (vitest 4.0.18, vite 7.3.7), and so did both CLI-emitted plugin
* manifests. The cost: a scaffold gets vitest 4.0.18 rather than 4.1.x.
* `~4.1.12`: the floor is the first 4.x release outside GHSA-qxg6-89mg-rqv8,
* which affects `@vitest/mocker` from 2.1.0-beta.7 up to the fix in 4.1.12.
* vitest depends on its own `@vitest/mocker` at the exact same version, so the
* vitest floor IS the mocker floor. The tilde admits patch releases only, as
* the previous `~4.0.0` did, because both npm 10 failures recorded below
* arrived with a new minor's peer graph. ⛔ Never move the floor back below
* 4.1.12 to get around a future npm 10 failure: no 4.0.x fix exists, and
* `test/scaffold-npm-overrides.test.ts` pins the floor.
*
* ⚠️ The range alone stopped being enough, so it is also emitted as an npm
* OVERRIDE — see {@link SCAFFOLD_NPM_OVERRIDES}, which is built from this
* constant, so the declared range and the override cannot disagree.
* The range installs on npm 10, the npm Node 22 bundles, only TOGETHER with
* the npm override — see {@link SCAFFOLD_NPM_OVERRIDES}, built from this
* constant, so the declared range and the override cannot disagree. vitest
* 4.1.12 itself peers `@vitest/browser-webdriverio ^4.1.11`, whose only match,
* 4.1.11, peers `vitest: 4.1.11` exactly, and npm 10's arborist crashes on
* that set. Measured 2026-10-22, each manifest in an empty directory with a
* fresh `--cache`, the `@objectstack/*` ranges resolving to the published
* 18.0.0 (blank template, `os init -t plugin`, `os create plugin`):
*
* The cause is upstream, in the peer graph. Every vitest 4.1.x admits vite 8.
* vite 8 declares an optional `@vitejs/devtools` peer, which reaches
* `@vitejs/devtools-vitest`, which peers `vitest@*`. npm 10 resolves that peer
* to vitest's `latest` release, whose exact-version peers then contradict the
* vitest at the root, and npm 10's arborist crashes on it. vitest 4.0.x admits
* only vite `^6 || ^7`, and no vite 6 or 7 release declares the devtools peer,
* so the `vitest@*` peer never enters the graph.
* `~4.1.12` + override npm 10.9.4, 10.9.9, exit 0; one vitest and one
* 11.21.1 `@vitest/mocker`, both 4.1.12;
* `npm audit` lists no vitest or
* `@vitest/*` entry
* `~4.1.12`, no override npm 10.9.9 exit 1, `Cannot read properties
* of null (reading 'edgesOut')`
* `~4.0.0` + override npm 10.9.9, 11.21.1 exit 0 on vitest and
* `@vitest/mocker` 4.0.18, which
* `npm audit` reports
*
* ⛔ Not `^5.0.0`, although it installs today. It installs only because it
* resolves to exactly the version `latest` names: pinned to 5.0.0 or 5.0.2 it
* crashes the same way. So it would break again, with no change here, the day
* `latest` moves past 5.x. ⛔ Not a shipped `.npmrc` with `legacy-peer-deps`
* either, which changes peer resolution for every npm user of the scaffold.
* The install does not depend on where vitest's `latest` tag points: `latest`
* is 5.0.5, outside this range, and the range installs. The override routes
* every `vitest` edge in the npm graph to this range, the optional peers deep
* in the closure included.
*
* The re-check that lets this move back: run `npm install` of
* `{"devDependencies":{"vitest":"^4.0.0"}}` (or the range you want) in an empty
* directory with a fresh `--cache`, on the newest npm 10 and on npm 11. When
* both exit 0, widen this.
* ⛔ Not the 5.x line, although `^5.0.5` + override installed on the same
* matrix and cleared the advisory too. vitest 5 declares `engines.node`
* `^22.12.0 || ^24.0.0 || >=26.0.0`, narrower than the `>=22.0.0` the CLI and
* `create-objectstack` declare, and this repo's own suites run vitest 4.
* Moving a scaffold to a new major is its own change.
* ⛔ Not a shipped `.npmrc` with `legacy-peer-deps` either, which changes peer
* resolution for every npm user of the scaffold.
*
* The re-check before moving this: `npm install` of each emitted manifest in
* an empty directory with a fresh `--cache`, on the newest npm 10 and on npm
* 11, then `npm audit` of the installed tree.
*/
export const SCAFFOLD_VITEST_RANGE = '~4.0.0';
export const SCAFFOLD_VITEST_RANGE = '~4.1.12';

/**
* The npm `overrides` block every STANDALONE scaffold emits: `os init`'s three
Expand All @@ -429,11 +443,16 @@ export const SCAFFOLD_VITEST_RANGE = '~4.0.0';
* Why it exists. vitest 4.1.12 (published 2026-10-15) peers
* `@vitest/browser-webdriverio ^4.1.11`, and that package's only match, 4.1.11,
* peers `vitest: 4.1.11` exactly — a peer set that contradicts itself. A
* scaffold reaches it even with vitest pinned to `~4.0.0` at its root, through
* OPTIONAL `vitest` peers deeper in the closure (`@objectstack/metadata-core`
* peers `^4.0.0`, better-auth peers `^2 || ^3 || ^4`), which npm 10 resolves to
* vitest's newest 4.x. Measured 2026-10-16, each manifest in an empty
* directory with a fresh `--cache`, against the published `18.0.0-next.2`:
* scaffold reaches it through its own root vitest, now that the range is
* `~4.1.12`, and, in a template that declares no vitest, through OPTIONAL
* `vitest` peers deeper in the closure (better-auth peers vitest optionally),
* which npm 10 resolves to vitest's newest 4.x. Re-measured 2026-10-22 against
* the published 18.0.0 with the range at `~4.1.12`: WITHOUT this block, `os
* init -t app`, `-t plugin` and `os create plugin` each exit 1 on npm 10.9.9
* with the crash below; WITH it, those three and `-t empty` exit 0 on npm
* 10.9.9 and 11.21.1 (the full matrix is on `SCAFFOLD_VITEST_RANGE`). The
* first reading, 2026-10-16, each manifest in an empty directory with a fresh
* `--cache`, against the published `18.0.0-next.2`, with the range at `~4.0.0`:
*
* without the override npm 10.9.9 `os init -t app`, `-t plugin` and
* `-t empty` all exit 1 with `Cannot
Expand All @@ -450,10 +469,10 @@ export const SCAFFOLD_VITEST_RANGE = '~4.0.0';
* vitest, the override equals that declaration — npm refuses an override that
* differs from the direct dependency's own spec (`EOVERRIDE`).
*
* The cost, measured on an emitted `-t app` (npm 10.9.9): a later bare `npm
* install -D vitest` is refused with `EOVERRIDE: Override for vitest@*
* conflicts with direct dependency`; `npm install -D vitest@~4.0.0` (this
* range) installs. Loud and self-describing, where the alternative was a
* The cost, measured on an emitted `-t app` (npm 10.9.9, re-measured with the
* range at `~4.1.12`): a later bare `npm install -D vitest` is refused with
* `EOVERRIDE: Override for vitest@* conflicts with direct dependency`; `npm
* install -D vitest@~4.1.12` (this range) installs. Loud and self-describing, where the alternative was a
* project that could not install at all. pnpm ignores npm's `overrides` field,
* so pnpm users see no change.
*
Expand Down
47 changes: 41 additions & 6 deletions packages/cli/test/scaffold-npm-overrides.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,20 @@
* `Cannot read properties of null (reading 'edgesOut')`. Measured 2026-10-16:
* `os init -t app`, `-t plugin` and `-t empty` all failed `npm install` on npm
* 10.9.9; with `"overrides": { "vitest": "~4.0.0" }` all of them, and `os
* create plugin`, installed on npm 10.9.9 and npm 11.21.1. The full reading is
* on `SCAFFOLD_NPM_OVERRIDES` in `src/commands/init.ts`.
* create plugin`, installed on npm 10.9.9 and npm 11.21.1. The range has since
* moved to `~4.1.12` (GHSA-qxg6-89mg-rqv8 has no 4.0.x fix), and the override
* still decides it: re-measured 2026-10-22, without it `os init -t app`, `-t
* plugin` and `os create plugin` each crash npm 10.9.9, and with it the
* emissions install on npm 10.9.4, 10.9.9 and 11.21.1. The full readings are on `SCAFFOLD_VITEST_RANGE` and
* `SCAFFOLD_NPM_OVERRIDES` in `src/commands/init.ts`.
*
* ## What is asserted, and why no range is written down here
*
* Every expectation is DERIVED from the exported constants, never a literal:
* a pin comparing against `'~4.0.0'` would stay green on a tree where the
* constant moved and one emission was left behind — the drift it exists to
* catch.
* Every equality is DERIVED from the exported constants, never a literal: a
* pin comparing against a spelled-out range would stay green on a tree where
* the constant moved and one emission was left behind — the drift it exists
* to catch. The one literal is a LOWER bound, the advisory's fixed release, in
* the floor pin at the end of this file.
*
* 1. Each emission's `overrides.vitest` IS `SCAFFOLD_VITEST_RANGE`.
* 2. Where an emission also declares `vitest` directly, the two are equal —
Expand Down Expand Up @@ -108,3 +113,33 @@ describe('npm overrides in every standalone scaffold manifest', () => {
expect(manifest).not.toHaveProperty('overrides');
});
});

/**
* The floor every scaffold's vitest range must keep: the first release of each
* major outside GHSA-qxg6-89mg-rqv8 (`@vitest/mocker` from 2.1.0-beta.7 up to
* the fix in 4.1.12, and from 5.0.0-beta.1 up to the fix in 5.0.3; read from
* the npm registry's advisory endpoint, 2026-10-22; the same floors clear
* GHSA-xp26-3454-mjgp, GHSA-82fw-gwwq-j7x9 and vitest's own
* GHSA-5xrq-8626-4rwp by that reading). vitest depends on its own
* `@vitest/mocker` at the exact same version, so a vitest floor is a mocker
* floor. A major with no row here is refused rather than passed: moving the
* scaffold to it is the moment to read the advisory for that line.
*/
const FIXED_FLOOR_BY_MAJOR: Readonly<Record<number, readonly [number, number, number]>> = {
4: [4, 1, 12],
5: [5, 0, 3],
};

describe('the scaffold vitest range stays outside GHSA-qxg6-89mg-rqv8', () => {
it('its lowest admitted version is at or above the advisory fix for its major', () => {
// Only the two spellings the scaffold uses: an unreadable range is a
// failure here, never a pass.
const match = /^[~^](\d+)\.(\d+)\.(\d+)$/.exec(SCAFFOLD_VITEST_RANGE);
expect(match, `SCAFFOLD_VITEST_RANGE "${SCAFFOLD_VITEST_RANGE}" is not a ~X.Y.Z or ^X.Y.Z range`).not.toBeNull();
const floor = match!.slice(1).map(Number) as [number, number, number];
const fixed = FIXED_FLOOR_BY_MAJOR[floor[0]];
expect(fixed, `no advisory floor is recorded for vitest ${floor[0]}.x — read the advisory, then add the row`).toBeDefined();
const atOrAbove = floor[1] !== fixed![1] ? floor[1] > fixed![1] : floor[2] >= fixed![2];
expect(atOrAbove, `SCAFFOLD_VITEST_RANGE "${SCAFFOLD_VITEST_RANGE}" admits ${floor.join('.')}, below the fix ${fixed!.join('.')}`).toBe(true);
});
});
11 changes: 11 additions & 0 deletions packages/create-objectstack/src/template-consistency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,17 @@ describe('bundled template declared version surfaces', () => {
// "vitest": "~4.0.0" npm 10.9.x / 11.21.x exit 0, every one the same tree
// as npm 11 installs without it
//
// The range has since moved to `~4.1.12`: 4.0.18's `@vitest/mocker` is inside
// GHSA-qxg6-89mg-rqv8, which has no 4.0.x fix. Re-measured 2026-10-22 on this
// template against the published 18.0.0, each leg with a fresh `--cache`:
//
// "~4.1.12", no override npm 10.9.9 exit 1, the crash (vitest 4.1.12
// itself peers the contradiction now)
// "~4.1.12" + override npm 10.9.4 / 10.9.9 exit 0, one vitest and one
// / 11.21.1 @vitest/mocker, both 4.1.12; `npm
// audit` lists no vitest entry, and
// `npm test` passes on npm 10.9.9
//
// The override routes every `vitest` edge in the npm graph to the range this
// project declares, the vitest npm 11 already dedupes the whole tree to. pnpm
// ignores npm's `overrides` field (measured on 10.28.0): pnpm users see no change.
Expand Down
4 changes: 2 additions & 2 deletions packages/create-objectstack/src/templates/blank/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@
"@objectstack/skills": "^18.0.0",
"@objectstack/verify": "^18.0.0",
"typescript": "^5.3.0",
"vitest": "~4.0.0"
"vitest": "~4.1.12"
},
"overrides": {
"vitest": "~4.0.0"
"vitest": "~4.1.12"
}
}
Loading