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

fix(create-objectstack): a blank scaffold installs on npm 10 again — its `package.json` overrides `vitest` to the range it already declares

Clause-②: no

A project from `npm create objectstack` (or `npx create-objectstack`) could not be installed with npm 10, the npm that Node 22 bundles and the one the scaffolder falls back to when pnpm is absent. `npm install` exited 1 with `Cannot read properties of null (reading 'edgesOut')`. The template's own dependencies were not the cause. Optional `vitest` peers deeper in the dependency tree led npm 10 to vitest 4.1.12, whose peer declarations contradict each other, and npm 10's resolver crashes on that. npm 11 installs the same project.

The blank template now ships:

```json
"overrides": {
"vitest": "~4.0.0"
}
```

This routes every `vitest` in the npm dependency graph to the range the project declares in `devDependencies`. npm 11 already resolves the whole tree to that version, and with the override npm 10.9.x installs the identical tree. pnpm does not read npm's `overrides` field, so pnpm installs are unchanged.

If you change `devDependencies.vitest` in a scaffolded project, change `overrides.vitest` to the same value. npm refuses an override that differs from the direct dependency (`EOVERRIDE: Override for vitest@… conflicts with direct dependency`). An existing project that hits the `edgesOut` crash on npm 10 can install with npm 11 or later.
27 changes: 25 additions & 2 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,34 @@ ARG OS_CLI_VERSION=latest
# deployment can reach, so the two files must move in the same commit:
# `pnpm check:docs-image-tag` fails when the install line and that table
# disagree on the package set or on a version range.
RUN npm install -g \
#
# The install runs under a PINNED npm 11, not the image's own npm (#23788).
# node:22-slim bundles npm 10 (10.9.9, measured 2026-10-16), whose arborist
# crashes on this install with `Cannot read properties of null (reading
# 'edgesOut')` since vitest@4.1.12 was published: optional `vitest` peers in
# the CLI's tree reach its self-contradicting peer set. npm 11 installs it,
# and a global install reads no `overrides`, so the fix has to be the installer.
# The version is the one `PROBE_INSTALLER_NPM` in
# scripts/check-vendor-export-contract.mjs holds for the same crash.
#
# The installer is fetched into /tmp with the image's npm (npm bundles its
# dependencies, so that fetch walks no peer graph), put first on PATH for this
# RUN only, and deleted in the same layer: the image still ships node:22-slim's
# npm, and does not grow by the installer's 20 MB. The `test` refuses to
# continue if PATH did not hand the install to that exact npm. Not taken:
# `npm install -g npm@…`, which replaces the image's npm for every user of the
# image; `npx npm@…`, which leaves 20 MB in ~/.npm/_npx after `npm cache clean`
# (measured); `--legacy-peer-deps`, which also skips REQUIRED peers (measured on
# the vendor-export probe).
RUN npm install --prefix /tmp/os-installer-npm --no-save --no-audit --no-fund "npm@11.21.0" \
&& export PATH="/tmp/os-installer-npm/node_modules/.bin:$PATH" \
&& test "$(npm --version)" = "11.21.0" \
&& npm install -g \
@objectstack/cli@${OS_CLI_VERSION} \
"pg@^8.0.0" \
"mysql2@^3.0.0" \
&& npm cache clean --force
&& npm cache clean --force \
&& rm -rf /tmp/os-installer-npm

LABEL org.opencontainers.image.source="https://github.com/objectstack-ai/objectstack" \
org.opencontainers.image.title="ObjectStack" \
Expand Down
47 changes: 47 additions & 0 deletions packages/create-objectstack/src/template-consistency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,53 @@ describe('bundled template declared version surfaces', () => {
expect(templatePkg.packageManager).toBeUndefined();
});

// npm 10, the npm Node 22 bundles and the one `npx create-objectstack` falls
// back to without pnpm, could not install this scaffold at all. Its arborist
// crashes in `#loadPeerSet` with `Cannot read properties of null (reading
// 'edgesOut')`. The template's own `vitest` range is not the path in: the
// crash enters through OPTIONAL `vitest` peers deeper in the closure
// (`@objectstack/metadata-core` peers `^4.0.0`, better-auth peers
// `^2 || ^3 || ^4`). npm 10 resolves those to vitest 4.1.12 (published
// 2026-10-15), which peers `@vitest/browser-webdriverio: ^4.1.11`; that
// package's only match, 4.1.11, peers `vitest: 4.1.11` exactly. Measured
// 2026-10-16 on a fresh scaffold, each leg with a fresh `--cache`:
//
// no override npm 10.9.4 / 10.9.9 exit 1, the crash (10.9.9 also
// with `vitest` dropped from devDependencies)
// npm 11.21.x exit 0, one vitest@4.0.18 in the tree
// "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 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.
//
// ⛔ Not `"$vitest"`. It installs too, but npm then stops hoisting
// `@objectstack/metadata-core` and nests 17 copies of it (measured, on 10.9.9
// and 11.21.0), and a duplicated module is a duplicated `instanceof` identity.
// 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.
// ⛔ 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', () => {
const templatePkg = JSON.parse(readTemplateFile('package.json'));
const declared: unknown = templatePkg.devDependencies?.vitest;
expect(
typeof declared,
`${template}/package.json must declare vitest directly — the override below is ` +
'only coherent against a direct dependency',
).toBe('string');
expect(
templatePkg.overrides?.vitest,
`${template}/package.json must declare "overrides": { "vitest": "${String(declared)}" } — ` +
'without it npm 10 crashes (`edgesOut` of null) installing the scaffold, and with any ' +
'other value npm refuses it as conflicting with the direct dependency (EOVERRIDE)',
).toBe(declared);
});

// NOTE the file: this stamp lives in `objectstack.config.ts`, inside the
// `defineStack({ manifest: … })` literal. It is NOT in
// `objectstack.manifest.json` — the two were conflated in this suite's own
Expand Down
3 changes: 3 additions & 0 deletions packages/create-objectstack/src/templates/blank/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,8 @@
"@objectstack/verify": "^18.0.0",
"typescript": "^5.3.0",
"vitest": "~4.0.0"
},
"overrides": {
"vitest": "~4.0.0"
}
}
102 changes: 95 additions & 7 deletions scripts/check-docs-image-tag.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -414,8 +414,19 @@ export const DRIVER_PROMISE = {
readme: 'docker/README.md',
/** The README section whose first table lists what the image installs. */
heading: '## Database drivers in the image',
/** The Dockerfile command that installs them. Matched on a trimmed line. */
installPrefix: 'RUN npm install -g',
/**
* The Dockerfile command that installs them. Matched at the start of a trimmed
* line, after one of `installLeaders` and a space.
*/
installPrefix: 'npm install -g',
/**
* What may open the install line: `RUN` when the install starts its instruction,
* `&&` when an earlier step of the SAME instruction runs first. The image fetches
* a pinned npm in that earlier step (#23788). That fetch is an `npm install`
* without `-g`, so it is not an anchor, and the gate reads the driver line rather
* than the installer line.
*/
installLeaders: ['RUN', '&&'],
/**
* Installed packages that are NOT drivers.
*
Expand Down Expand Up @@ -691,22 +702,38 @@ export function splitPackageSpec(spec) {
return { name: spec.slice(0, at), range: spec.slice(at + 1) };
}

/**
* The leader that opens `line` as the driver install, or `null` when it is not
* one: a trimmed line spelled `<leader> npm install -g` for a leader in
* `DRIVER_PROMISE.installLeaders`.
*
* @param {string} line
* @returns {string | null}
*/
export function installLeader(line) {
const trimmed = line.trim();
return DRIVER_PROMISE.installLeaders.find(
(leader) => trimmed.startsWith(`${leader} ${DRIVER_PROMISE.installPrefix}`),
) ?? null;
}

/**
* The drivers `docker/Dockerfile`'s global install puts into the image.
*
* Line continuations are joined before tokenising, because the install list is
* one package per line -- reading only the anchor line would see the CLI and
* none of the drivers, i.e. report an empty list over a populated one.
*
* The command is cut at the first `&&`: `npm cache clean --force` follows on the
* same logical line and its tokens are not packages.
* The command runs from its leader to the next `&&`: `npm cache clean --force`
* follows on the same logical line and its tokens are not packages. A `&&` leader
* is the separator BEFORE the install, so it is dropped before that cut.
*
* @param {string} text
* @returns {{ found: boolean, line: number, drivers: { name: string, range: string, raw: string }[] }}
*/
export function extractInstalledDrivers(text) {
const lines = text.split('\n');
const start = lines.findIndex((line) => line.trim().startsWith(DRIVER_PROMISE.installPrefix));
const start = lines.findIndex((line) => installLeader(line) !== null);
if (start === -1) return { found: false, line: 0, drivers: [] };

let joined = '';
Expand All @@ -717,7 +744,7 @@ export function extractInstalledDrivers(text) {
if (!continues) break;
}

const command = joined.split('&&')[0];
const command = joined.trim().slice(installLeader(lines[start]).length).split('&&')[0];
const drivers = [];
for (const token of command.trim().split(/\s+/)) {
// Quotes are how a `^` range survives some shells; they are not part of the
Expand Down Expand Up @@ -813,7 +840,7 @@ export function checkDriverPromise({ root }) {
file: DRIVER_PROMISE.dockerfile,
line: 0,
detail:
`no line starts with '${DRIVER_PROMISE.installPrefix}', so this gate cannot see what the image `
`no line starts with ${DRIVER_PROMISE.installLeaders.map((leader) => `'${leader} ${DRIVER_PROMISE.installPrefix}'`).join(' or ')}, so this gate cannot see what the image `
+ 'installs and would report OK over a comparison it never made. Either the install was re-spelled '
+ '-- re-anchor DRIVER_PROMISE.installPrefix -- or the image stopped installing drivers, in which '
+ `case delete the '${DRIVER_PROMISE.heading}' section of ${DRIVER_PROMISE.readme} and this limb in `
Expand Down Expand Up @@ -2064,6 +2091,67 @@ async function selfTest() {
+ `this limb reports OK over a comparison it never made. Got ${JSON.stringify(driverNoInstall.findings.map((f) => f.kind))}`,
);

// #23788: the image fetches a pinned npm earlier in the SAME RUN, so the driver
// install opens with `&&`, and the line above it is an `npm install` too. The
// anchor must be the driver line, and the installer's spec is not a driver.
const PINNED_INSTALLER_INSTALL = [
'RUN npm install --prefix /tmp/os-installer-npm --no-save --no-audit --no-fund "npm@11.21.0" \\',
' && export PATH="/tmp/os-installer-npm/node_modules/.bin:$PATH" \\',
' && test "$(npm --version)" = "11.21.0" \\',
' && npm install -g \\',
' @objectstack/cli@${OS_CLI_VERSION} \\',
' "pg@^8.0.0" \\',
' "mysql2@^3.0.0" \\',
' && npm cache clean --force \\',
' && rm -rf /tmp/os-installer-npm',
];
const pinnedText = driverDockerfile(PINNED_INSTALLER_INSTALL);
const pinnedInstalled = extractInstalledDrivers(pinnedText);
const pinnedDriverLine = pinnedText.split('\n').findIndex((l) => l.trim() === '&& npm install -g \\') + 1;
const pinnedFetchLine = pinnedText.split('\n').findIndex((l) => l.includes('"npm@11.21.0"')) + 1;
assert(
pinnedInstalled.found && pinnedDriverLine > 0 && pinnedInstalled.line === pinnedDriverLine
&& pinnedInstalled.line !== pinnedFetchLine,
`with a pinned-npm fetch ahead of it, the anchor is the \`&& npm install -g\` driver line (${pinnedDriverLine}), `
+ `not the installer line (${pinnedFetchLine}) -- got line ${pinnedInstalled.line}`,
);
assert(
pinnedInstalled.drivers.map((d) => d.raw).join(',') === 'pg@^8.0.0,mysql2@^3.0.0',
'the driver line under a `&&` leader yields exactly the two driver specs: the leader is dropped before '
+ `the cut at the next \`&&\` -- got ${JSON.stringify(pinnedInstalled.drivers.map((d) => d.raw))}`,
);
assert(
!pinnedInstalled.drivers.some((d) => d.name === 'npm' || d.raw.includes('11.21.0')),
`the installer's \`npm@11.21.0\` is not a driver -- got ${JSON.stringify(pinnedInstalled.drivers.map((d) => d.raw))}`,
);
const driverPinned = driverFixture('drv-pinned-installer', PINNED_INSTALLER_INSTALL, CLEAN_ROWS);
assert(
driverPinned.findings.length === 0
&& driverPinned.stats.installed === 2 && driverPinned.stats.promised === 2 && driverPinned.stats.matched === 2,
'the pinned-installer Dockerfile agreeing with the table is green AND compared 2 against 2 -- '
+ `got ${JSON.stringify({ findings: driverPinned.findings.map((f) => f.kind), stats: driverPinned.stats })}`,
);
const driverPinnedDrift = driverFixture('drv-pinned-drift', PINNED_INSTALLER_INSTALL, [
'| `postgres://` | `pg@^7.0.0` |',
CLEAN_ROWS[1],
]);
assert(
driverPinnedDrift.findings.length === 1 && driverPinnedDrift.findings[0].kind === 'DRIVER-RANGE-MISMATCH',
'a drifted range is still refused when the driver line opens with `&&` -- '
+ `got ${JSON.stringify(driverPinnedDrift.findings.map((f) => f.kind))}`,
);
// An npm UPGRADE spelled as a global install ahead of the drivers is an anchor
// itself. It is read as the install line and refused, never skipped into a green.
const driverGlobalUpgrade = driverFixture('drv-global-npm-upgrade', [
'RUN npm install -g npm@11.21.0 \\',
...CLEAN_INSTALL.map((line) => line.replace(/^RUN /, ' && ')),
], CLEAN_ROWS);
assert(
driverGlobalUpgrade.findings.some((f) => f.kind === 'DRIVER-UNDOCUMENTED' && f.detail.includes('npm@11.21.0')),
'a global `npm install -g npm@…` ahead of the driver line is read as the install line and refused '
+ `(DRIVER-UNDOCUMENTED naming npm@11.21.0) -- got ${JSON.stringify(driverGlobalUpgrade.findings.map((f) => f.kind))}`,
);

const driverEmptyInstall = driverFixture('drv-emptyinstall', [
'RUN npm install -g @objectstack/cli@${OS_CLI_VERSION} \\',
' && npm cache clean --force',
Expand Down
Loading
Loading