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
19 changes: 19 additions & 0 deletions .changeset/23805-scaffold-vitest-override.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/cli': patch
---

Clause-②: no

`os init` (every template: `app`, `plugin`, `empty`) and `os create plugin` now emit
`"overrides": { "vitest": "~4.0.0" }` in the scaffolded `package.json`, so the project installs on
npm 10, the npm Node 22 bundles. Since vitest 4.1.12 shipped a peer set that contradicts itself,
`npm install` of a fresh `os init` project crashed on npm 10.9.x with
`Cannot read properties of null (reading 'edgesOut')`. The override routes every `vitest` edge in
the npm graph to the range the scaffold declares; npm 11 installs the same tree, and pnpm ignores
the field.

The override equals `devDependencies.vitest` wherever the manifest declares vitest directly,
because npm refuses an override that differs from the direct dependency (`EOVERRIDE`). In an `app`
or `empty` project, add vitest at the same range (`npm install -D vitest@~4.0.0`); a bare
`npm install -D vitest` is refused with `EOVERRIDE` while the override is in place. Existing
projects can add the same `overrides` block by hand. `os create plugin --in-repo` is unchanged.
8 changes: 8 additions & 0 deletions packages/cli/src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ import {
NPM_PACKAGE_NAME_MAX_LENGTH,
renderPnpmWorkspaceYaml,
renderScaffoldTsconfig,
SCAFFOLD_NPM_OVERRIDES,
SCAFFOLD_PNPM_RANGE,
SCAFFOLD_TSCONFIG_INCLUDE_SRC_ONLY,
SCAFFOLD_TYPES_NODE_RANGE,
Expand Down Expand Up @@ -408,6 +409,13 @@ export const templates: Record<string, CreateTemplate> = {
typescript: SCAFFOLD_TYPESCRIPT_RANGE,
vitest: SCAFFOLD_VITEST_RANGE,
},
// The same npm override `os init` emits, from the same constant, so
// it always equals `devDependencies.vitest` above (npm refuses one
// that differs: `EOVERRIDE`) — see SCAFFOLD_NPM_OVERRIDES for the
// npm 10 crash it prevents. Standalone only: npm reads `overrides`
// from the ROOT manifest alone and pnpm never reads it, so inside
// this monorepo's workspace the block would be inert.
...(standalone ? { overrides: { ...SCAFFOLD_NPM_OVERRIDES } } : {}),
}),
'tsconfig.json': (name: string) =>
standalone
Expand Down
63 changes: 60 additions & 3 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,9 +390,13 @@ export const SCAFFOLD_TYPESCRIPT_RANGE = '^5.3.0';
* `--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` installs on
* all three (vitest 4.0.18, vite 7.3.7), and so do both CLI-emitted plugin
* manifests. The cost: a scaffold gets vitest 4.0.18 rather than 4.1.11.
* 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.
*
* ⚠️ 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 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
Expand All @@ -415,6 +419,56 @@ export const SCAFFOLD_TYPESCRIPT_RANGE = '^5.3.0';
*/
export const SCAFFOLD_VITEST_RANGE = '~4.0.0';

/**
* The npm `overrides` block every STANDALONE scaffold emits: `os init`'s three
* templates, `os create plugin` outside the monorepo, and — by generation, not
* import — `create-objectstack`'s bundled template (the `overrides.vitest` row
* in `scripts/sync-scaffold-emission-policy.mjs`'s `POLICY_STAMPS`).
*
* 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`:
*
* without the override npm 10.9.9 `os init -t app`, `-t plugin` and
* `-t empty` all exit 1 with `Cannot
* read properties of null (reading
* 'edgesOut')`; `os create plugin` (no
* `@objectstack/cli` in its closure)
* exits 0
* with the override npm 10.9.9 all four exit 0
* npm 11.21.1 all four exit 0
*
* So the override goes on EVERY template, not only the ones that declare
* vitest: `-t app` and `-t empty` declare none and crashed all the same,
* because what they share is `@objectstack/cli`. Where a manifest does declare
* 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
* project that could not install at all. pnpm ignores npm's `overrides` field,
* so pnpm users see no change.
*
* ⛔ Not `"$vitest"`: it needs a direct dependency to point at (two of the
* three `os init` templates have none), and where it does apply, npm stops
* hoisting `@objectstack/metadata-core` and nests copies of it (measured on the
* bundled template) — a duplicated module is a duplicated `instanceof` identity.
*
* The re-check that lets this go: the same empty-directory install of an
* emitted `-t app` manifest WITHOUT this block, on the newest npm 10 and on npm
* 11. When both exit 0, delete it here and its `POLICY_STAMPS` row together.
*/
export const SCAFFOLD_NPM_OVERRIDES: Readonly<Record<string, string>> = Object.freeze({
vitest: SCAFFOLD_VITEST_RANGE,
});

/** The `@types/node` range a scaffolded project declares. */
export const SCAFFOLD_TYPES_NODE_RANGE = '^22.0.0';

Expand Down Expand Up @@ -495,6 +549,9 @@ export function renderScaffoldPackageJson(
scripts: template.scripts,
dependencies: template.dependencies,
devDependencies: template.devDependencies,
// Every template, whether or not it declares vitest itself — without it
// npm 10 cannot install any of them (see SCAFFOLD_NPM_OVERRIDES).
overrides: { ...SCAFFOLD_NPM_OVERRIDES },
};
}

Expand Down
110 changes: 110 additions & 0 deletions packages/cli/test/scaffold-npm-overrides.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* PIN — every standalone manifest `os init` and `os create` emit carries the
* npm `overrides` block, built from the one vitest constant.
*
* ## The defect
*
* vitest 4.1.12 shipped a self-contradicting peer set (it peers
* `@vitest/browser-webdriverio ^4.1.11`, whose only release peers `vitest:
* 4.1.11` exactly). npm 10 — the npm Node 22 bundles — reaches it through
* OPTIONAL `vitest` peers in the `@objectstack/cli` closure, even with vitest
* pinned to `~4.0.0` at the project root, and its arborist crashes with
* `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`.
*
* ## 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.
*
* 1. Each emission's `overrides.vitest` IS `SCAFFOLD_VITEST_RANGE`.
* 2. Where an emission also declares `vitest` directly, the two are equal —
* npm refuses an override that differs from the direct dependency's own
* spec (`EOVERRIDE`), so a mismatch is an install failure of its own.
* 3. `os create plugin --in-repo` emits NO `overrides`: npm reads the field
* only from the root manifest and pnpm never reads it, so inside the
* workspace it would be a declaration nothing honours.
*
* That the emitted manifests really install is not decidable here; the npm
* matrix above is the measurement, and `create-objectstack`'s bundled template
* reaches the same constant through `scripts/sync-scaffold-emission-policy.mjs`
* (`pnpm check:scaffold-emission-policy`).
*/

import { describe, it, expect } from 'vitest';
import {
renderScaffoldPackageJson,
SCAFFOLD_NPM_OVERRIDES,
SCAFFOLD_VITEST_RANGE,
TEMPLATES,
} from '../src/commands/init.js';
import { DEFAULT_PLACEMENT, templates } from '../src/commands/create.js';

const PROBE_NAME = 'overrides-probe';

type Manifest = {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
overrides?: Record<string, string>;
};

/** Every standalone manifest the two commands emit, derived from the live maps. */
function standaloneManifests(): Array<{ id: string; manifest: Manifest }> {
const out: Array<{ id: string; manifest: Manifest }> = [];
for (const [key, template] of Object.entries(TEMPLATES)) {
out.push({ id: `os init -t ${key}`, manifest: renderScaffoldPackageJson(PROBE_NAME, template) as Manifest });
}
for (const [key, template] of Object.entries(templates)) {
const render = template.filesFor(DEFAULT_PLACEMENT)['package.json'];
if (!render) continue;
out.push({ id: `os create ${key}`, manifest: render(PROBE_NAME) as Manifest });
}
return out;
}

describe('npm overrides in every standalone scaffold manifest', () => {
it('harvests every emission, and the default placement IS standalone (control)', () => {
expect(DEFAULT_PLACEMENT).toBe('standalone');
const ids = standaloneManifests().map((m) => m.id);
expect(ids).toEqual(expect.arrayContaining(['os init -t app', 'os init -t plugin', 'os create plugin']));
// Assertion 2 below must range over something, or it certifies nothing.
const direct = standaloneManifests().filter((m) => m.manifest.devDependencies?.vitest !== undefined);
expect(direct.map((m) => m.id)).toEqual(expect.arrayContaining(['os init -t plugin', 'os create plugin']));
});

it('the override map is built from the vitest constant', () => {
expect(SCAFFOLD_NPM_OVERRIDES.vitest).toBe(SCAFFOLD_VITEST_RANGE);
expect(Object.isFrozen(SCAFFOLD_NPM_OVERRIDES)).toBe(true);
});

it.each(standaloneManifests())('$id emits overrides.vitest = SCAFFOLD_VITEST_RANGE', ({ manifest }) => {
expect(manifest.overrides).toEqual({ ...SCAFFOLD_NPM_OVERRIDES });
expect(manifest.overrides?.vitest).toBe(SCAFFOLD_VITEST_RANGE);
});

it.each(standaloneManifests())(
'$id: every override naming a direct dependency equals that dependency (npm EOVERRIDE)',
({ manifest }) => {
const direct = { ...(manifest.dependencies ?? {}), ...(manifest.devDependencies ?? {}) };
for (const [name, range] of Object.entries(manifest.overrides ?? {})) {
if (direct[name] === undefined) continue;
expect(range, `overrides.${name} vs the direct ${name} dependency`).toBe(direct[name]);
}
},
);

it('os create --in-repo emits no overrides block — npm and pnpm both ignore it in a workspace member', () => {
const render = templates.plugin.filesFor('in-repo')['package.json'];
const manifest = render(PROBE_NAME) as Manifest;
// Control: the in-repo manifest is a real one, with vitest declared.
expect(manifest.devDependencies?.vitest).toBe(SCAFFOLD_VITEST_RANGE);
expect(manifest).not.toHaveProperty('overrides');
});
});
7 changes: 5 additions & 2 deletions packages/create-objectstack/src/template-consistency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,11 @@ describe('bundled template declared version surfaces', () => {
// The literal costs one thing instead, and loudly: npm refuses an override
// that differs from the direct dependency's own spec (`EOVERRIDE: Override
// for vitest@^4.1.0 conflicts with direct dependency`, measured), so the two
// move together. That is the second assertion. `devDependencies.vitest` is
// stamped from `SCAFFOLD_VITEST_RANGE` at build; this override is not.
// move together. That is the second assertion. Both are stamped from
// `SCAFFOLD_VITEST_RANGE` at build (the `devDependencies.vitest` and
// `overrides.vitest` rows of the emission-policy stamp script's
// `POLICY_STAMPS`), the constant `os init` / `os create` emit their own
// override from.
// ⛔ Not a shipped `.npmrc` with `legacy-peer-deps`, which changes peer
// resolution for every npm user of the scaffold.
it('package.json overrides vitest to its own declared range, so npm 10 installs it', () => {
Expand Down
Loading
Loading