diff --git a/.changeset/scaffold-explicit-empty-workspace.md b/.changeset/scaffold-explicit-empty-workspace.md new file mode 100644 index 0000000000..a4a83c34b4 --- /dev/null +++ b/.changeset/scaffold-explicit-empty-workspace.md @@ -0,0 +1,44 @@ +--- +"@objectstack/cli": patch +"create-objectstack": patch +--- + +Scaffolded projects declare an explicit empty `packages: []` in their +`pnpm-workspace.yaml` (#10933). Both scaffold paths render it — +`renderPnpmWorkspaceYaml` in `objectstack init`, and the bundled `blank` +template `npx create-objectstack` copies. + +The file was deliberately keyless so it would act purely as a settings file. +That intent is now written down rather than inferred from a missing key, and +writing it down is what fixes a first-command failure: pnpm 9.x and 10.0–10.4 +parse `pnpm-workspace.yaml` **before** they read `engines`, so they refused a +brand-new project outright with + +``` + ERROR packages field missing or empty +``` + +naming a file the user never wrote and giving no hint that the cause is their +pnpm version — and no `engines.pnpm` floor could reach them, because they never +got as far as the engines check. Measured, one clean install per pnpm version, +each with its own store: + +| pnpm | before | after | +|---|---|---| +| 9.15.9, 10.0.0, 10.4.0 | `ERROR packages field missing or empty` | `ERR_PNPM_UNSUPPORTED_ENGINE`, naming `>=10.15` | +| 10.5.0–10.14.0 | `ERR_PNPM_UNSUPPORTED_ENGINE` | unchanged | +| 10.15.0, 10.34.5, 11.22.0 | installs | installs, byte-identical `pnpm-lock.yaml` | + +So every unsupported pnpm now reports the same actionable cause, and supported +pnpm is unaffected: the empty key was measured equivalent to omission on +10.15.0, 10.34.5 and 11.22.0 — identical lockfile bytes, identical +`node_modules/.modules.yaml` once the run-local `prunedAt`/`storeDir` fields are +dropped, identical `pnpm ls -r --depth -1`, and an identical second-install +"Already up to date". + +The declaration is an **empty** list on purpose. `packages: ['.']` satisfies the +same parsers but declares the project root a workspace *member* — a monorepo +root — which a single-package scaffold is not, and which reads to the next +author (human or AI) as an invitation to add member packages to an app. + +`engines.pnpm` is unchanged at `>=10.15`. diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 8ff3da7219..3efabd08e4 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -117,36 +117,44 @@ export const SCAFFOLD_ALLOWED_PEER_VERSIONS: Record = { * Lowest pnpm that can actually install this scaffold, declared as * `engines.pnpm` in the generated `package.json`. * - * The rendered `pnpm-workspace.yaml` is a settings-only file with no - * `packages:` key (see `renderPnpmWorkspaceYaml` below). Early pnpm 10 refuses - * that file outright: `pnpm install` exits 1 with "ERROR packages field - * missing or empty" before it resolves a single dependency, so a brand-new - * project cannot be installed at all. pnpm 10.15.0 and everything above accept - * the keyless file. + * The rendered `pnpm-workspace.yaml` declares an explicit empty `packages: []` + * (see `renderPnpmWorkspaceYaml` below). It did not always, and that history is + * why this floor is reachable at all: while the key was omitted, pnpm 10.0–10.4 + * refused the file outright — `pnpm install` exited 1 with "ERROR packages + * field missing or empty" before resolving a single dependency — and those + * versions parse `pnpm-workspace.yaml` BEFORE they read `engines`, so no floor + * value could ever be consulted on that band. * - * Declaring the floor does not repair those pnpm versions — it makes them + * Declaring the floor does not repair the versions below it — it makes them * report a cause the user can act on instead of a workspace error about a file * they did not write. Measured on the rendered shape, one clean install per * pnpm version, each with its own store: * - * pnpm 10.0.0 – 10.4.0 pnpm parses `pnpm-workspace.yaml` BEFORE it reads - * `engines`, so these still print the raw "packages - * field missing or empty". The floor cannot reach - * this sliver; only a decision about the `packages:` - * key itself closes it. - * pnpm 10.5.0 – 10.14.0 refused as ERR_PNPM_UNSUPPORTED_ENGINE — "Your - * pnpm version is incompatible with . - * Expected version: >=10.15". + * pnpm 9.15.9, 10.0.0, refused as ERR_PNPM_UNSUPPORTED_ENGINE — "Your + * 10.4.0, 10.5.0–10.14.0 pnpm version is incompatible with PROJECT. + * Expected version: >=10.15". With the key omitted, + * 9.x and 10.0–10.4 printed the raw workspace error + * here instead, naming a file the user never wrote. * pnpm >= 10.15.0 installs; unchanged by this declaration. * + * ⚠️ So this floor, not the workspace file, is now what stops pnpm 10.0–10.4: + * with the floor lowered they install (measured, exit 0). Admitting them is a + * support decision rather than an edit — measured on 10.0.0 and 10.4.0, they + * read neither the build allowlist nor the peer rules out of + * `pnpm-workspace.yaml` ("The following dependencies have build scripts that + * were ignored: better-sqlite3, esbuild"), so a scaffold installed there is + * quietly missing its native builds. Do not move this floor as a side effect; + * whether to admit that band at all is #11048. + * * `engines.pnpm` rather than a `packageManager` stamp, on purpose. npm, yarn * and bun ignore `engines.pnpm` entirely, so the scaffold keeps working for all * four package managers `objectstack init` can hand off to (see * `detectPackageManager`). `packageManager: "pnpm@x.y.z"` would instead declare * the project pnpm-only — corepack-driven yarn refuses to run in such a project - * — and pin one exact version that goes stale on every pnpm release. It also - * buys nothing on 10.0–10.4, which reach the workspace error before they read - * that field either. + * — and pin one exact version that goes stale on every pnpm release. Those two + * reasons carry the choice on their own: the third one recorded when the stamp + * was rejected ("it buys nothing on 10.0–10.4") was measured against the + * keyless file, and the explicit `packages:` key retires it. */ export const SCAFFOLD_PNPM_RANGE = '>=10.15'; @@ -179,8 +187,14 @@ export function renderScaffoldPackageJson( /** * Render the `pnpm-workspace.yaml` that allowlists native build scripts and * declares the two known-benign peer skews. - * Kept minimal (no `packages:` key) so it acts purely as a settings file for - * the single-package scaffold rather than declaring a workspace. + * Declares an explicit empty `packages: []`: a workspace root with no member + * packages, which is what a single-package scaffold is — the file stays purely + * a settings file. Spelling the key out is what lets pnpm 10.0–10.4 (and 9.x) + * parse the file at all; they read it before `engines` and refuse a file + * without the key outright. ⛔ Never `packages: ['.']`: that declares the + * project root a workspace MEMBER, i.e. a monorepo root, which this is not — + * and it is the shape an AI reader would take as licence to add member packages + * to a scaffolded app. * * The allowlist is emitted TWICE, under two keys that no single pnpm version * range reads both of. Measured against a scaffold of this exact shape, one @@ -210,6 +224,15 @@ export function renderPnpmWorkspaceYaml( const peerEntries = Object.entries(allowedPeerVersions); return [ + '# An explicit EMPTY workspace: this project has no member packages, so', + '# this file is settings-only. The key is not decoration — pnpm 9.x and', + '# 10.0–10.4 parse this file BEFORE they read `engines`, and refuse a file', + '# without a `packages:` key outright ("ERROR packages field missing or', + '# empty") before resolving a single dependency.', + '# Not `packages: [\'.\']`: that would declare this project a workspace', + '# MEMBER — a monorepo root, which it is not.', + 'packages: []', + '', '# pnpm does not run dependency build scripts unless they are approved', '# here. Without this file a fresh `pnpm install` exits 1 on pnpm 11 with', '# ERR_PNPM_IGNORED_BUILDS — pnpm 10 only warned, pnpm 11 made it a hard', diff --git a/packages/cli/test/init.test.ts b/packages/cli/test/init.test.ts index d97a1efcf0..f71d86fdf3 100644 --- a/packages/cli/test/init.test.ts +++ b/packages/cli/test/init.test.ts @@ -108,29 +108,93 @@ describe('native build allowlist (pnpm-workspace.yaml)', () => { const yaml = renderPnpmWorkspaceYaml(); expect(yaml).toMatch(/^onlyBuiltDependencies:/m); expect(yaml).toMatch(/^ {2}- better-sqlite3$/m); - // No `packages:` key — this is a settings file, not a workspace declaration. - expect(yaml).not.toMatch(/^packages:/m); }); }); -// That missing `packages:` key is exactly what early pnpm 10 refuses: it exits -// 1 with "ERROR packages field missing or empty" before resolving a single -// dependency, so a freshly scaffolded project cannot be installed at all. -// Measured on the rendered shape, one clean install per pnpm version, each with -// its own store: +// A scaffolded project is a workspace root with NO member packages, and the +// rendered file says so in one line rather than leaving it to be inferred from +// the absence of a key. That is not cosmetic: pnpm 9.x and 10.0–10.4 parse +// `pnpm-workspace.yaml` BEFORE they read `engines`, and a file without the key +// is refused outright — `pnpm install` exits 1 with "ERROR packages field +// missing or empty" before it resolves a single dependency, naming a file the +// user never wrote. Measured on the rendered shape, one clean install per pnpm +// version, each with its own store: // -// 10.0.0–10.4.0 parse pnpm-workspace.yaml BEFORE reading `engines`, so a -// floor cannot reach them — still the raw workspace error. -// Closing that sliver needs a decision about the `packages:` -// key itself, which is deliberately not made here. -// 10.5.0–10.14.0 refused as ERR_PNPM_UNSUPPORTED_ENGINE, naming the range. -// >=10.15.0 install succeeds. +// 9.15.9 / 10.0.0 / 10.4.0 raw workspace error BEFORE → the floor's own +// ERR_PNPM_UNSUPPORTED_ENGINE AFTER. +// 10.15.0 / 10.34.5 / 11.22.0 install succeeds either way, and the two +// renders are equivalent: byte-identical +// `pnpm-lock.yaml`, `.modules.yaml` identical once +// run-local `prunedAt`/`storeDir` are dropped, and +// `pnpm ls -r --depth -1` reporting one project. // -// The floor is what turns the reachable part of that band from an error about a -// file the user never wrote into "your pnpm is too old". These assertions pin +// ⛔ NOT `packages: ['.']`, which satisfies the same parsers but declares the +// project root a workspace MEMBER — a monorepo root. The scaffold's output is +// the start of every AI-written app on this platform, so a line that reads as +// "add member packages here" is the expensive half of that choice. +describe('explicit empty workspace declaration in the rendered file', () => { + // Comments are stripped first: the prose above the key names it, and must + // not be what satisfies an assertion about the declaration itself. + const settings = renderPnpmWorkspaceYaml().replace(/^\s*#.*$/gm, ''); + + it('declares `packages:` — the key early pnpm refuses the file without', () => { + expect( + /^packages:/m.test(settings), + 'the rendered pnpm-workspace.yaml must declare `packages:` — without it pnpm 9.x ' + + 'and 10.0–10.4 exit 1 with "packages field missing or empty" before reading engines', + ).toBe(true); + }); + + it('declares it EMPTY — a workspace root with no member packages', () => { + const inline = /^packages:[ \t]*(.*)$/m.exec(settings); + expect(inline, '`packages:` must be declared inline').not.toBeNull(); + expect( + inline![1].trim(), + "`packages:` must be an empty list; `['.']` would declare the project root a " + + 'workspace MEMBER (a monorepo root), which a single-package scaffold is not', + ).toBe('[]'); + }); + + it('declares no member in any spelling', () => { + // Covers the inline form (`['.']`, `["packages/*"]`) and the block form + // (`packages:` followed by ` - …`), so neither can arrive unnoticed. + expect(settings).not.toMatch(/^packages:[ \t]*\[[ \t]*[^\]\s]/m); + expect(settings).not.toMatch(/^packages:[ \t]*\n[ \t]*-/m); + }); + + it('adds no other top-level setting to the rendered file', () => { + // The rest of the file is what it was: the same four keys, same order. A + // "restore the packages key" edit that also drags a setting in fails here. + const keys = [...settings.matchAll(/^([A-Za-z][\w-]*):/gm)].map((m) => m[1]); + expect(keys).toEqual(['packages', 'onlyBuiltDependencies', 'allowBuilds', 'peerDependencyRules']); + }); +}); + +// The explicit `packages:` key above is what makes this floor reachable at all. +// While the key was omitted, pnpm 9.x and 10.0–10.4 never got as far as +// `engines` — they parse the workspace file first and refused it outright — so +// no floor value could reach them. With the key present the entire band below +// the floor reports the same actionable cause instead. Measured on the rendered +// shape, one clean install per pnpm version, each with its own store: +// +// 9.15.9, 10.0.0, 10.4.0, ERR_PNPM_UNSUPPORTED_ENGINE naming ">=10.15". +// 10.5.0–10.14.0 (9.x and 10.0–10.4 printed the raw workspace +// error here before the key existed.) +// >=10.15.0 install succeeds, byte-identical lockfile. +// +// ⚠️ So the floor, not the workspace file, is now what stops 10.0–10.4: with the +// floor lowered they install (exit 0, measured) — but they read neither the +// build allowlist nor the peer rules out of `pnpm-workspace.yaml`, so a scaffold +// there is quietly missing its native builds. Admitting that band is a support +// decision (#11048), not a value this suite should drift. These assertions pin // the declared range, not pnpm's wording. describe('pnpm floor in the rendered package.json', () => { - /** First pnpm measured to accept the keyless workspace file. */ + /** + * Lowest pnpm measured to install the rendered shape AND honour the workspace + * file's settings — its build allowlist actually runs there (`node-gyp + * rebuild` for better-sqlite3). 10.0.0 and 10.4.0 install too, now that + * `packages:` is explicit, but skip those builds with only a warning. + */ const FIRST_GOOD: [number, number, number] = [10, 15, 0]; function parseFloor(range: string): [number, number, number] { @@ -149,14 +213,20 @@ describe('pnpm floor in the rendered package.json', () => { } }); - it('sets the floor at or above the first pnpm that accepts the keyless workspace file', () => { + it('sets the floor at or above the first pnpm measured to honour the rendered workspace file', () => { expect(rank(parseFloor(SCAFFOLD_PNPM_RANGE))).toBeGreaterThanOrEqual(rank(FIRST_GOOD)); }); - it('excludes every pnpm version measured to refuse the rendered workspace file', () => { + it('excludes every pnpm version this scaffold is not supported on', () => { const declared = rank(parseFloor(SCAFFOLD_PNPM_RANGE)); - const refused: [number, number, number][] = [[10, 0, 0], [10, 5, 0], [10, 14, 0]]; - for (const v of refused) { + // 10.0.0 and 10.4.0 were measured to install and then IGNORE the workspace + // file's build allowlist ("The following dependencies have build scripts + // that were ignored: better-sqlite3, esbuild"). 10.5.0 and 10.14.0 are + // refused by the floor itself and were never measured past it — they stay + // listed because nothing has shown them to honour the file, and dropping + // them would silently widen what the scaffold claims to support. + const unsupported: [number, number, number][] = [[10, 0, 0], [10, 4, 0], [10, 5, 0], [10, 14, 0]]; + for (const v of unsupported) { expect(rank(v), `pnpm ${v.join('.')} must fall below the declared floor`).toBeLessThan(declared); } }); diff --git a/packages/create-objectstack/src/template-consistency.test.ts b/packages/create-objectstack/src/template-consistency.test.ts index 4f37b9d9f6..2ae47f3014 100644 --- a/packages/create-objectstack/src/template-consistency.test.ts +++ b/packages/create-objectstack/src/template-consistency.test.ts @@ -127,30 +127,34 @@ describe('bundled template declared version surfaces', () => { } }); - // The bundled `pnpm-workspace.yaml` is a settings-only file with no - // `packages:` key, and early pnpm 10 refuses such a file outright: `pnpm - // install` exits 1 with "ERROR packages field missing or empty" before it - // resolves a single dependency, so a scaffolded project cannot be installed - // at all. Measured on the bundled shape, one clean install per pnpm - // version, each with its own store: + // The bundled `pnpm-workspace.yaml` declares an explicit empty `packages: + // []` — see the block at the end of this file. That key is what makes this + // floor reachable: while it was omitted, pnpm 9.x and 10.0–10.4 parsed the + // workspace file before reading `engines` and refused it outright ("ERROR + // packages field missing or empty"), so no floor value could reach them. + // Measured on the bundled shape, one clean install per pnpm version, each + // with its own store: // - // 10.0.0–10.4.0 parse pnpm-workspace.yaml BEFORE reading `engines`, so - // the floor cannot reach them — still the raw workspace - // error. Only a decision about the `packages:` key - // itself closes that sliver, and it is not made here. - // 10.5.0–10.14.0 refused as ERR_PNPM_UNSUPPORTED_ENGINE. - // >=10.15.0 install succeeds. + // 9.15.9, 10.0.0, 10.4.0, ERR_PNPM_UNSUPPORTED_ENGINE naming the range + // 10.5.0–10.14.0 (the first three printed the raw workspace + // error before the key existed). + // >=10.15.0 install succeeds. + // + // ⚠️ With the floor lowered, 10.0.0 and 10.4.0 install (exit 0, measured) — + // but they read neither the build allowlist nor the peer rules out of the + // workspace file, so admitting them is a support decision (#11048), not a + // value to drift here. // // `objectstack init` (packages/cli/src/commands/init.ts) is the other // scaffold path and declares the same floor from `SCAFFOLD_PNPM_RANGE`. - it('package.json declares a pnpm floor at or above the version that accepts the keyless workspace file', () => { + it('package.json declares a pnpm floor at or above the version measured to honour the workspace file', () => { const templatePkg = JSON.parse(readTemplateFile('package.json')); const range: unknown = templatePkg.engines?.pnpm; expect( typeof range, - `${template}/package.json must declare engines.pnpm — without it, pnpm 10.5–10.14 ` + - 'hit "packages field missing or empty" on a brand-new project instead of being told ' + - 'to upgrade', + `${template}/package.json must declare engines.pnpm — without it, every pnpm below ` + + 'the supported floor installs a scaffold whose native build approvals it never read, ' + + 'instead of being told to upgrade', ).toBe('string'); const match = /^>=\s*(\d+)\.(\d+)(?:\.(\d+))?$/.exec(String(range).trim()); @@ -160,8 +164,8 @@ describe('bundled template declared version surfaces', () => { const declared = rank(Number(match![1]), Number(match![2]), Number(match![3] ?? '0')); expect( declared, - `engines.pnpm "${range}" admits pnpm versions measured to refuse this template's ` + - 'pnpm-workspace.yaml — the first accepting version is 10.15.0', + `engines.pnpm "${range}" admits pnpm versions measured NOT to honour this template's ` + + 'pnpm-workspace.yaml — the first version measured to run its build allowlist is 10.15.0', ).toBeGreaterThanOrEqual(rank(10, 15, 0)); }); @@ -371,6 +375,62 @@ describe('templates survive npm packing', () => { }); }); +// A scaffolded project is a workspace root with NO member packages, and the +// bundled file says so in one line instead of leaving it to be inferred from a +// missing key. Not cosmetic: pnpm 9.x and 10.0–10.4 parse `pnpm-workspace.yaml` +// BEFORE they read `engines`, and refuse a file without the key outright — +// `pnpm install` exits 1 with "ERROR packages field missing or empty" before +// resolving a single dependency, naming a file the user never wrote. Measured +// on the bundled shape, one clean install per pnpm version, each with its own +// store: 9.15.9 / 10.0.0 / 10.4.0 went from that raw workspace error to the +// floor's own ERR_PNPM_UNSUPPORTED_ENGINE, and 10.15.0 / 10.34.5 / 11.22.0 +// install exactly as before — byte-identical `pnpm-lock.yaml`. +// +// ⛔ NOT `packages: ['.']`: it satisfies the same parsers but declares the +// project root a workspace MEMBER — a monorepo root. This template is the start +// of every AI-written app on the platform, so a line that reads as "add member +// packages here" is the expensive half of that choice. +// +// `objectstack init` renders the second copy of this rule +// (`renderPnpmWorkspaceYaml` in packages/cli/src/commands/init.ts) and is +// ratcheted there; both paths carry it until the two renderers are unified. +describe('blank template explicit empty workspace', () => { + const wsPath = path.join(pkgRoot, 'src', 'templates', 'blank', 'pnpm-workspace.yaml'); + // Same comment-stripping as the blocks below: the prose above the key names + // it, and must not be what satisfies an assertion about the declaration. + const settings = fs.existsSync(wsPath) + ? fs.readFileSync(wsPath, 'utf8').replace(/^\s*#.*$/gm, '') + : ''; + + it('declares `packages:` — the key early pnpm refuses the file without', () => { + expect( + /^packages:/m.test(settings), + 'the bundled pnpm-workspace.yaml must declare `packages:` — without it pnpm 9.x and ' + + '10.0–10.4 exit 1 with "packages field missing or empty" before reading engines', + ).toBe(true); + }); + + it('declares it EMPTY — a workspace root with no member packages', () => { + const inline = /^packages:[ \t]*(.*)$/m.exec(settings); + expect(inline, '`packages:` must be declared inline').not.toBeNull(); + expect( + inline![1].trim(), + "`packages:` must be an empty list; `['.']` would declare the project root a " + + 'workspace MEMBER (a monorepo root), which a single-package scaffold is not', + ).toBe('[]'); + }); + + it('declares no member in any spelling', () => { + expect(settings).not.toMatch(/^packages:[ \t]*\[[ \t]*[^\]\s]/m); + expect(settings).not.toMatch(/^packages:[ \t]*\n[ \t]*-/m); + }); + + it('adds no other top-level setting to the bundled file', () => { + const keys = [...settings.matchAll(/^([A-Za-z][\w-]*):/gm)].map((m) => m[1]); + expect(keys).toEqual(['packages', 'onlyBuiltDependencies', 'allowBuilds', 'peerDependencyRules']); + }); +}); + // pnpm 11 turned an unapproved dependency build script from a warning into a // hard error, so the template declaring nothing meant `npx create-objectstack` // + `pnpm install` exited 1 for every user on a current pnpm (#3119). Both keys diff --git a/packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml b/packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml index 9657121786..1cc1696274 100644 --- a/packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml +++ b/packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml @@ -1,3 +1,12 @@ +# An explicit EMPTY workspace: this project has no member packages, so +# this file is settings-only. The key is not decoration — pnpm 9.x and +# 10.0–10.4 parse this file BEFORE they read `engines`, and refuse a file +# without a `packages:` key outright ("ERROR packages field missing or +# empty") before resolving a single dependency. +# Not `packages: ['.']`: that would declare this project a workspace +# MEMBER — a monorepo root, which it is not. +packages: [] + # pnpm does not run dependency install scripts unless they are approved here. # Without this file a fresh `pnpm install` on pnpm 11 exits 1 with # ERR_PNPM_IGNORED_BUILDS — pnpm 10 only warned, pnpm 11 made it a hard error.