From 2ee9cfb6f2d3c7f467fdc2ab3dac8245ffcef2b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 01:19:43 +0000 Subject: [PATCH] fix(cli): scaffold both pnpm build-approval keys so a new project installs on pnpm 11 (#10405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `renderPnpmWorkspaceYaml()` wrote only `onlyBuiltDependencies`. pnpm 11 does not read that key, 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 first command a newcomer runs after scaffolding. The rendered file now also carries `allowBuilds`, built from the same `SCAFFOLD_BUILT_DEPENDENCIES` list, which is the only key pnpm 11 reads. This is the shape the blank template already ships for the other scaffold path. 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 only 10.26.0 – 10.34.x read either key 11.x read allowBuilds only so both keys are load-bearing and neither is redundant. Build permission is still granted to exactly two named packages, no wildcard: esbuild (postinstall installs its platform binary, used to compile objectstack.config.ts) and better-sqlite3 (ships a binding.gyp, which pnpm treats as a native build). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r --- .../init-scaffold-pnpm11-allow-builds.md | 33 +++++++++ packages/cli/src/commands/init.ts | 43 ++++++++++-- packages/cli/test/init.test.ts | 69 +++++++++++++++++++ 3 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 .changeset/init-scaffold-pnpm11-allow-builds.md 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