diff --git a/.changeset/23788-blank-scaffold-npm10-vitest-override.md b/.changeset/23788-blank-scaffold-npm10-vitest-override.md new file mode 100644 index 00000000000..e4299427b3a --- /dev/null +++ b/.changeset/23788-blank-scaffold-npm10-vitest-override.md @@ -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. diff --git a/docker/Dockerfile b/docker/Dockerfile index 8baed87cb93..26ff3696bfe 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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" \ diff --git a/packages/create-objectstack/src/template-consistency.test.ts b/packages/create-objectstack/src/template-consistency.test.ts index 8204eebf835..9a02a6fb7d7 100644 --- a/packages/create-objectstack/src/template-consistency.test.ts +++ b/packages/create-objectstack/src/template-consistency.test.ts @@ -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 diff --git a/packages/create-objectstack/src/templates/blank/package.json b/packages/create-objectstack/src/templates/blank/package.json index c1862cd7199..1bbbff43ce0 100644 --- a/packages/create-objectstack/src/templates/blank/package.json +++ b/packages/create-objectstack/src/templates/blank/package.json @@ -29,5 +29,8 @@ "@objectstack/verify": "^18.0.0", "typescript": "^5.3.0", "vitest": "~4.0.0" + }, + "overrides": { + "vitest": "~4.0.0" } } diff --git a/scripts/check-docs-image-tag.mjs b/scripts/check-docs-image-tag.mjs index e27045098c8..8dc33074efe 100644 --- a/scripts/check-docs-image-tag.mjs +++ b/scripts/check-docs-image-tag.mjs @@ -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. * @@ -691,6 +702,21 @@ 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 ` 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. * @@ -698,15 +724,16 @@ export function splitPackageSpec(spec) { * 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 = ''; @@ -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 @@ -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 ` @@ -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', diff --git a/scripts/check-vendor-export-contract.mjs b/scripts/check-vendor-export-contract.mjs index febf7b19611..c55cf5fc8b6 100644 --- a/scripts/check-vendor-export-contract.mjs +++ b/scripts/check-vendor-export-contract.mjs @@ -74,6 +74,11 @@ * ⛔ It never "passes" when it cannot measure — an unreachable registry is * EXIT_PREREQUISITE_NOT_MET, never a green. * + * The scratch installs run under a PINNED npm (`PROBE_INSTALLER_NPM`), not + * whichever npm the runner bundles: this leg measures export maps, and npm + * 10's arborist crashes on upstream peer graphs that have nothing to do with + * them. The constant's own comment holds the reading. + * * ## Scope: why a ledger of vendor NAMES and not every dependency * * Requiring an exact pin on every third-party range would be a different @@ -377,6 +382,101 @@ async function runOffline(root = repoRoot) { // ── the network leg ──────────────────────────────────────────────────────── +/** + * The npm that `--resolve` installs every scratch project WITH. It is part of + * the instrument, so it is pinned. + * + * This leg measures a vendor's EXPORT SURFACE. It does not measure how an + * installer resolves peers. npm 10 (the npm Node 22 bundles, and the CI + * runner's) crashes in arborist's `#loadPeerSet` with `Cannot read properties + * of null (reading 'edgesOut')` on upstream peer graphs that npm 11 installs. + * A crash there says nothing about the surface, but it read as PREREQUISITE + * NOT MET on every run. Measured 2026-10-16, each leg in an empty directory + * with a fresh `--cache`, on `npm install better-auth@1.7.3 --no-package-lock`: + * + * npm 10.9.4, 10.9.9 exit 1, the `edgesOut` crash + * npm 11.21.0, 11.21.1 exit 0; both governed specifiers import + * npm 10.9.9 --before=2026-10-15T14:50:00Z exit 0 + * npm 10.9.9 --before=2026-10-15T14:52:00Z exit 1, the crash + * + * The trigger in that window is vitest@4.1.12 (published 14:51Z), reached + * through better-auth's OPTIONAL `vitest` peer. It peers + * `@vitest/browser-webdriverio: ^4.1.11`, whose only matching release, 4.1.11, + * peers `vitest: 4.1.11` exactly, so the peer set contradicts itself. The next + * upstream graph of that shape would break this leg the same way, so the fix + * is the installer rather than a pin on vitest. + * + * ⛔ Not `--legacy-peer-deps`. It skips REQUIRED peers too, and + * `@better-auth/core/context` then fails to import (`Cannot find package + * 'better-call'`, measured on 10.9.9). That would be a "does not load" finding + * about a package that loads for every consumer, a false red. ⛔ Not + * `--omit=peer`: the crash happens while the ideal tree is built, before any + * omit applies (measured: still exit 1). + * + * The installer is fetched once per run into the scratch directory, using the + * runner's own npm. npm bundles its dependencies, so that install walks no + * peer graph. Failing to fetch it is PREREQUISITE NOT MET, as for any + * unreachable registry. A vendor that this npm cannot install is still + * PREREQUISITE NOT MET, so only the npm-10 crash class stops masking the + * measurement. Exact, never a range: two runs of one commit must use the same + * instrument. + * + * Re-check before moving it: run the install above in an empty directory with + * a fresh `--cache` on the candidate npm, and import both governed specifiers + * from it. + */ +export const PROBE_INSTALLER_NPM = '11.21.0'; + +/** + * The argv (after `process.execPath`) of one scratch install under the pinned + * installer. Exported so the self-test pins the flags: a probe install never + * carries a flag that changes WHICH packages land (see `PROBE_INSTALLER_NPM`). + */ +export function probeInstallArgv(installerCli, spec) { + return [installerCli, 'install', spec, '--no-package-lock', '--no-audit', '--no-fund']; +} + +/** + * Install `npm@PROBE_INSTALLER_NPM` under `scratch` with the runner's npm, and + * return the path of its CLI entry. Exits PREREQUISITE NOT MET when it cannot. + */ +function fetchProbeInstaller(scratch) { + const dir = join(scratch, '.probe-installer'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'package.json'), + `${JSON.stringify({ name: 'vendor-export-probe-installer', private: true, version: '0.0.0' }, null, 2)}\n`, + ); + try { + execFileSync('npm', ['install', `npm@${PROBE_INSTALLER_NPM}`, '--no-package-lock', '--no-audit', '--no-fund'], { + cwd: dir, + stdio: ['ignore', 'ignore', 'pipe'], + }); + } catch (e) { + console.error( + `PREREQUISITE NOT MET: could not fetch the probe's installer npm@${PROBE_INSTALLER_NPM} (${e?.message ?? e}).\n` + + ` --resolve installs every scratch project with it; without it nothing is measured, which is NOT a pass.`, + ); + process.exit(EXIT_PREREQUISITE_NOT_MET); + } + const root = join(dir, 'node_modules', 'npm'); + let version = null; + try { + version = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version ?? null; + } catch { + /* reported below */ + } + const cli = join(root, 'bin', 'npm-cli.js'); + if (version !== PROBE_INSTALLER_NPM || !existsSync(cli)) { + console.error( + `PREREQUISITE NOT MET: the probe's installer did not land as npm@${PROBE_INSTALLER_NPM} ` + + `(found ${version ?? 'nothing'} at ${root}).`, + ); + process.exit(EXIT_PREREQUISITE_NOT_MET); + } + return cli; +} + function registryVersions(name) { const raw = execFileSync('npm', ['view', name, 'versions', '--json'], { encoding: 'utf8', @@ -392,6 +492,7 @@ async function runResolve(root = repoRoot) { const { edges, declarations } = collectVendorEdges(root); const scratch = mkdtempSync(join(tmpdir(), 'os-vendor-export-')); try { + const installerCli = fetchProbeInstaller(scratch); for (const [vendor, bySpecifier] of [...edges].sort()) { const declared = declarations.get(vendor) ?? []; if (declared.length === 0) continue; @@ -419,13 +520,14 @@ async function runResolve(root = repoRoot) { `${JSON.stringify({ name: 'vendor-export-probe', private: true, version: '0.0.0', type: 'module' }, null, 2)}\n`, ); try { - execFileSync('npm', ['install', `${vendor}@${version}`, '--no-package-lock', '--no-audit', '--no-fund'], { + execFileSync(process.execPath, probeInstallArgv(installerCli, `${vendor}@${version}`), { cwd: dir, stdio: ['ignore', 'ignore', 'pipe'], }); } catch (e) { console.error( - `PREREQUISITE NOT MET: could not install ${vendor}@${version} (${e?.message ?? e}).`, + `PREREQUISITE NOT MET: could not install ${vendor}@${version} with npm@${PROBE_INSTALLER_NPM} ` + + `(${e?.message ?? e}).`, ); process.exit(EXIT_PREREQUISITE_NOT_MET); } @@ -518,6 +620,31 @@ function selfTest() { eq('^1.7.2 admits 1.7.3', semver.satisfies('1.7.3', '^1.7.2'), true); eq('1.7.2 admits only itself', semver.satisfies('1.7.3', '1.7.2'), false); + // ── the --resolve installer ─────────────────────────────────────────────── + // Exact, so two runs of one commit use one instrument; npm 11 or later, + // because npm 10's arborist crashes on upstream peer graphs unrelated to the + // export surface this leg measures (`PROBE_INSTALLER_NPM` holds the reading). + if (semver.valid(PROBE_INSTALLER_NPM) === null) { + failures.push(`PROBE_INSTALLER_NPM "${PROBE_INSTALLER_NPM}" is not an exact version — the instrument would move between runs.`); + } else if (semver.major(PROBE_INSTALLER_NPM) < 11) { + failures.push( + `PROBE_INSTALLER_NPM "${PROBE_INSTALLER_NPM}" is npm ${semver.major(PROBE_INSTALLER_NPM)} — npm 10's arborist ` + + 'crashes (`edgesOut` of null) on upstream peer graphs, which reads as PREREQUISITE NOT MET on every run.', + ); + } + // The flags are pinned whole. `--legacy-peer-deps` was measured to drop + // @better-auth/core's REQUIRED peers, so its specifier stops importing: a + // false "does not load" finding. Any flag that changes which packages land is + // a change to the instrument and belongs in that constant's reading first. + eq('probe install argv', probeInstallArgv('/x/npm-cli.js', 'better-auth@1.7.3'), [ + '/x/npm-cli.js', + 'install', + 'better-auth@1.7.3', + '--no-package-lock', + '--no-audit', + '--no-fund', + ]); + // ── the repo's own state, read through the same collector ───────────────── // // [#17440] This case used to be anchored on `@better-auth/core/db` naming @@ -593,7 +720,9 @@ if (!isMain) { } else { const wantResolve = args.includes('--resolve'); const { findings, checked } = wantResolve ? await runResolve() : await runOffline(); - const leg = wantResolve ? 'registry resolution' : 'installed workspace'; + const leg = wantResolve + ? `registry resolution, installed with npm@${PROBE_INSTALLER_NPM}` + : 'installed workspace'; if (findings.length > 0) { console.error(`VERDICT: FAIL — vendor export contract (${leg})`); for (const f of findings) console.error(` - ${f}`);