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
33 changes: 33 additions & 0 deletions .changeset/init-scaffold-pnpm11-allow-builds.md
Original file line numberDiff line numberDiff line change
@@ -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
```
43 changes: 39 additions & 4 deletions packages/cli/src/commands/init.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -117,6 +117,27 @@ export const SCAFFOLD_ALLOWED_PEER_VERSIONS: Record<string, string> = {
* 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,
Expand All@@ -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 ? [] : [
Expand Down
69 changes: 69 additions & 0 deletions packages/cli/test/init.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.<pkg> = 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
Expand Down
Loading