diff --git a/.changeset/init-scaffold-pnpm11-allow-builds.md b/.changeset/init-scaffold-pnpm11-allow-builds.md new file mode 100644 index 0000000000..0241e5ea2a --- /dev/null +++ b/.changeset/init-scaffold-pnpm11-allow-builds.md @@ -0,0 +1,33 @@ +--- +"@objectstack/cli": patch +--- + +`objectstack init` now writes both build-approval keys into the scaffolded +`pnpm-workspace.yaml`, so a brand-new project's first `pnpm install` succeeds +on pnpm 11 (#10405). + +The renderer emitted only `onlyBuiltDependencies`. pnpm 11 does not read that +key at all, and it turned an unapproved dependency build script from a warning +into a hard error — so `objectstack init my-app && cd my-app && pnpm install` +exited 1 with `ERR_PNPM_IGNORED_BUILDS`, on the very first command after +scaffolding. The rendered file now also carries `allowBuilds`, built from the +same source list, which is the only key pnpm 11 reads. Measured one clean +install per pnpm version, each with its own store: pnpm 10.0.0-10.25.0 read +`onlyBuiltDependencies`, 10.26.0-10.34.x read either key, and 11.x reads +`allowBuilds` only — so both keys are load-bearing and neither is redundant. + +Build permission is still granted to exactly the two packages that need it and +nothing else: `esbuild` (a `postinstall` that installs its platform binary, +used to compile `objectstack.config.ts`) and `better-sqlite3` (ships a +`binding.gyp`, which pnpm treats as a native build; without it `objectstack +serve` can fail with "Could not locate the bindings file"). No wildcard. + +Existing scaffolds are unaffected — `init` never overwrites a +`pnpm-workspace.yaml` that is already there. To fix a project scaffolded by an +earlier CLI, add to its `pnpm-workspace.yaml`: + +```yaml +allowBuilds: + better-sqlite3: true + esbuild: true +``` diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 72ebf9783c..50abfc0873 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -117,6 +117,27 @@ export const SCAFFOLD_ALLOWED_PEER_VERSIONS: Record = { * 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. + * + * 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 + * clean install per pnpm version, each with its own store: + * + * pnpm 10.0.0 – 10.25.0 honour `onlyBuiltDependencies`; `allowBuilds` + * alone leaves the builds unrun (a warning, exit 0). + * pnpm 10.26.0 – 10.34.x honour either key. + * pnpm 11.x honour `allowBuilds` ONLY. With + * `onlyBuiltDependencies` alone, `pnpm install` + * exits 1 with ERR_PNPM_IGNORED_BUILDS — byte for + * byte the same failure as approving nothing at + * all, because pnpm 11 turned an unapproved build + * script from a warning into a hard error. + * + * Emitting only the older key is what made a freshly scaffolded project fail + * its very first `pnpm install` for every user on pnpm 11. Both lists come + * from the same `builtDeps` argument, so the two populations can never be + * granted different build permission. This is the shape + * `packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml` + * already ships for the other scaffold path. */ export function renderPnpmWorkspaceYaml( builtDeps: string[] = SCAFFOLD_BUILT_DEPENDENCIES, @@ -125,12 +146,26 @@ export function renderPnpmWorkspaceYaml( const peerEntries = Object.entries(allowedPeerVersions); return [ - '# Allowlist native dependency build scripts so `pnpm install` compiles', - '# them (pnpm 10+ blocks build scripts by default). Without this,', - '# better-sqlite3 ships uncompiled and `objectstack serve` fails with', - '# "Could not locate the bindings file".', + '# 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', + '# error. Without the build, better-sqlite3 can ship without a usable', + '# binding and `objectstack serve` fails with "Could not locate the', + '# bindings file".', + '#', + '# Both keys are needed; no single pnpm version range reads both:', + '# allowBuilds pnpm >= 10.26, and the ONLY key pnpm 11 reads', + '# — with onlyBuiltDependencies alone pnpm 11', + '# exits 1 exactly as if nothing were approved.', + '# onlyBuiltDependencies pnpm 10.0–10.25, which ignore allowBuilds.', + '# pnpm 11 ignores this key.', + '#', + '# npm, yarn and bun ignore this file and build native modules anyway.', 'onlyBuiltDependencies:', ...builtDeps.map((d) => ` - ${d}`), + '', + 'allowBuilds:', + ...builtDeps.map((d) => ` ${d}: true`), // No rules, no header: a bare `peerDependencyRules:` would advertise a // declaration that is not there. ...(peerEntries.length === 0 ? [] : [ diff --git a/packages/cli/test/init.test.ts b/packages/cli/test/init.test.ts index 535732fe5b..bb6a1a345f 100644 --- a/packages/cli/test/init.test.ts +++ b/packages/cli/test/init.test.ts @@ -118,6 +118,75 @@ describe('native build allowlist (pnpm-workspace.yaml)', () => { }); }); +// pnpm 11 honours ONLY `allowBuilds`. Rendering `onlyBuiltDependencies` alone +// — which is what this scaffolder shipped — makes a brand-new project's very +// first `pnpm install` exit 1 with ERR_PNPM_IGNORED_BUILDS, byte for byte the +// same failure as approving nothing at all. Measured one clean install per +// pnpm version, each with its own store, on a project scaffolded by +// `objectstack init -t app`: +// +// 10.0.0–10.25.0 read onlyBuiltDependencies; allowBuilds alone leaves the +// builds unrun (warning, exit 0) +// 10.26.0–10.34.x read either key +// 11.x read allowBuilds only; onlyBuiltDependencies alone exits 1 +// +// So both keys are load-bearing and neither is redundant. The blank template +// (`packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml`) +// already ships this shape and ratchets it in `template-consistency.test.ts`; +// these are the mirrored ratchets for the second scaffold path. +describe('pnpm 11 build approvals in the rendered workspace file', () => { + // Strip comments first: the prose above these keys names them, and must not + // be what satisfies an assertion about the settings themselves. + const settings = renderPnpmWorkspaceYaml().replace(/^\s*#.*$/gm, ''); + + const blockOf = (key: string, re: RegExp) => + re.exec(settings)?.[1] ?? `__no ${key} block__`; + + it('sets allowBuilds. = true, the only key pnpm 11 reads', () => { + const block = blockOf('allowBuilds', /^allowBuilds:\n((?:[ \t]+.*\n?)*)/m); + for (const pkg of SCAFFOLD_BUILT_DEPENDENCIES) { + expect( + new RegExp(`^\\s+${pkg}:\\s*true\\s*$`, 'm').test(block), + `allowBuilds must set "${pkg}: true" — pnpm 11 ignores onlyBuiltDependencies ` + + 'and exits 1 on an unapproved build script', + ).toBe(true); + } + }); + + it('keeps listing the same packages under onlyBuiltDependencies for pnpm < 10.26', () => { + const block = blockOf('onlyBuiltDependencies', /^onlyBuiltDependencies:\n((?:[ \t]*-.*\n?)*)/m); + for (const pkg of SCAFFOLD_BUILT_DEPENDENCIES) { + expect( + new RegExp(`^\\s*-\\s*${pkg}\\s*$`, 'm').test(block), + `onlyBuiltDependencies must list "${pkg}" — pnpm 10.0–10.25 ignore allowBuilds`, + ).toBe(true); + } + }); + + it('grants exactly the same set under both keys', () => { + // Two hand-maintained lists would drift, and the drift is invisible: each + // key is read by a different pnpm population, so a package dropped from one + // of them still builds fine on whichever pnpm the author happened to run. + const only = [...blockOf('onlyBuiltDependencies', /^onlyBuiltDependencies:\n((?:[ \t]*-.*\n?)*)/m) + .matchAll(/^\s*-\s*(\S+)\s*$/gm)].map((m) => m[1]); + const allow = [...blockOf('allowBuilds', /^allowBuilds:\n((?:[ \t]+.*\n?)*)/m) + .matchAll(/^\s+(\S+):\s*true\s*$/gm)].map((m) => m[1]); + expect(allow.sort()).toEqual([...SCAFFOLD_BUILT_DEPENDENCIES].sort()); + expect(only.sort()).toEqual(allow.sort()); + }); + + it('approves named packages only — never a wildcard', () => { + // `allowBuilds` is not a blank cheque: whatever this scaffolder writes + // becomes the standing build permission of every project created from it. + // A glob would hand arbitrary install-time code execution to any future + // transitive dependency, in every new project, silently. + for (const pkg of SCAFFOLD_BUILT_DEPENDENCIES) { + expect(pkg, 'build approvals must name one package each').not.toMatch(/[*?]/); + } + expect(settings).not.toMatch(/^\s*['"]?\*/m); + }); +}); + // A brand-new scaffold's first `pnpm install` reported two unmet peers, on the // one screen where a newcomer decides whether this project is solid, with // nothing they did to cause it (#10326). Both ranges belong to third-party