Skip to content

Commit 568de19

Browse files
Elon Muskclaude
andauthored
fix(cli): scaffolded pnpm-workspace.yaml declares an explicit empty packages list (#10933) (#11057)
Both scaffold paths now render `packages: []` — `renderPnpmWorkspaceYaml` in `objectstack init`, and the bundled `blank` template that `npx create-objectstack` copies. The file was deliberately keyless so it would act purely as a settings file for a single-package project; that intent is now written down instead of inferred from a missing key. 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, with no hint that the cause is their pnpm version — and no `engines.pnpm` floor could ever reach them, because they never got as far as the engines check. Measured, one clean install per pnpm version, each with its own store: 9.15.9, 10.0.0, 10.4.0 raw workspace error BEFORE; the floor's own ERR_PNPM_UNSUPPORTED_ENGINE naming ">=10.15" AFTER 10.5.0–10.14.0 ERR_PNPM_UNSUPPORTED_ENGINE, unchanged 10.15.0, 10.34.5, 11.22.0 installs, byte-identical pnpm-lock.yaml The empty key was measured EQUIVALENT to omission on every supported pnpm (10.15.0, 10.34.5, 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`, identical second-install "Already up to date". The triage fallback (drop to option C if it is not equivalent) is therefore not taken. It 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 as licence to add member packages to an app. `engines.pnpm` is unchanged at `>=10.15`. It is, however, now what refuses 10.0–10.4 rather than the workspace file: with the floor lowered those versions install (exit 0, measured), but 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 admitting them is a support decision and not a side effect of this change. Both docblocks and both test suites record that measurement, and the decision is filed unassigned as #11048. Fixes #10933 Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3e26359 commit 568de19

5 files changed

Lines changed: 264 additions & 58 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/cli": patch
3+
"create-objectstack": patch
4+
---
5+
6+
Scaffolded projects declare an explicit empty `packages: []` in their
7+
`pnpm-workspace.yaml` (#10933). Both scaffold paths render it —
8+
`renderPnpmWorkspaceYaml` in `objectstack init`, and the bundled `blank`
9+
template `npx create-objectstack` copies.
10+
11+
The file was deliberately keyless so it would act purely as a settings file.
12+
That intent is now written down rather than inferred from a missing key, and
13+
writing it down is what fixes a first-command failure: pnpm 9.x and 10.0–10.4
14+
parse `pnpm-workspace.yaml` **before** they read `engines`, so they refused a
15+
brand-new project outright with
16+
17+
```
18+
ERROR packages field missing or empty
19+
```
20+
21+
naming a file the user never wrote and giving no hint that the cause is their
22+
pnpm version — and no `engines.pnpm` floor could reach them, because they never
23+
got as far as the engines check. Measured, one clean install per pnpm version,
24+
each with its own store:
25+
26+
| pnpm | before | after |
27+
|---|---|---|
28+
| 9.15.9, 10.0.0, 10.4.0 | `ERROR packages field missing or empty` | `ERR_PNPM_UNSUPPORTED_ENGINE`, naming `>=10.15` |
29+
| 10.5.0–10.14.0 | `ERR_PNPM_UNSUPPORTED_ENGINE` | unchanged |
30+
| 10.15.0, 10.34.5, 11.22.0 | installs | installs, byte-identical `pnpm-lock.yaml` |
31+
32+
So every unsupported pnpm now reports the same actionable cause, and supported
33+
pnpm is unaffected: the empty key was measured equivalent to omission on
34+
10.15.0, 10.34.5 and 11.22.0 — identical lockfile bytes, identical
35+
`node_modules/.modules.yaml` once the run-local `prunedAt`/`storeDir` fields are
36+
dropped, identical `pnpm ls -r --depth -1`, and an identical second-install
37+
"Already up to date".
38+
39+
The declaration is an **empty** list on purpose. `packages: ['.']` satisfies the
40+
same parsers but declares the project root a workspace *member* — a monorepo
41+
root — which a single-package scaffold is not, and which reads to the next
42+
author (human or AI) as an invitation to add member packages to an app.
43+
44+
`engines.pnpm` is unchanged at `>=10.15`.

packages/cli/src/commands/init.ts

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -117,36 +117,44 @@ export const SCAFFOLD_ALLOWED_PEER_VERSIONS: Record<string, string> = {
117117
* Lowest pnpm that can actually install this scaffold, declared as
118118
* `engines.pnpm` in the generated `package.json`.
119119
*
120-
* The rendered `pnpm-workspace.yaml` is a settings-only file with no
121-
* `packages:` key (see `renderPnpmWorkspaceYaml` below). Early pnpm 10 refuses
122-
* that file outright: `pnpm install` exits 1 with "ERROR packages field
123-
* missing or empty" before it resolves a single dependency, so a brand-new
124-
* project cannot be installed at all. pnpm 10.15.0 and everything above accept
125-
* the keyless file.
120+
* The rendered `pnpm-workspace.yaml` declares an explicit empty `packages: []`
121+
* (see `renderPnpmWorkspaceYaml` below). It did not always, and that history is
122+
* why this floor is reachable at all: while the key was omitted, pnpm 10.0–10.4
123+
* refused the file outright — `pnpm install` exited 1 with "ERROR packages
124+
* field missing or empty" before resolving a single dependency — and those
125+
* versions parse `pnpm-workspace.yaml` BEFORE they read `engines`, so no floor
126+
* value could ever be consulted on that band.
126127
*
127-
* Declaring the floor does not repair those pnpm versions — it makes them
128+
* Declaring the floor does not repair the versions below it — it makes them
128129
* report a cause the user can act on instead of a workspace error about a file
129130
* they did not write. Measured on the rendered shape, one clean install per
130131
* pnpm version, each with its own store:
131132
*
132-
* pnpm 10.0.0 – 10.4.0 pnpm parses `pnpm-workspace.yaml` BEFORE it reads
133-
* `engines`, so these still print the raw "packages
134-
* field missing or empty". The floor cannot reach
135-
* this sliver; only a decision about the `packages:`
136-
* key itself closes it.
137-
* pnpm 10.5.0 – 10.14.0 refused as ERR_PNPM_UNSUPPORTED_ENGINE — "Your
138-
* pnpm version is incompatible with <project>.
139-
* Expected version: >=10.15".
133+
* pnpm 9.15.9, 10.0.0, refused as ERR_PNPM_UNSUPPORTED_ENGINE — "Your
134+
* 10.4.0, 10.5.0–10.14.0 pnpm version is incompatible with PROJECT.
135+
* Expected version: >=10.15". With the key omitted,
136+
* 9.x and 10.0–10.4 printed the raw workspace error
137+
* here instead, naming a file the user never wrote.
140138
* pnpm >= 10.15.0 installs; unchanged by this declaration.
141139
*
140+
* ⚠️ So this floor, not the workspace file, is now what stops pnpm 10.0–10.4:
141+
* with the floor lowered they install (measured, exit 0). Admitting them is a
142+
* support decision rather than an edit — measured on 10.0.0 and 10.4.0, they
143+
* read neither the build allowlist nor the peer rules out of
144+
* `pnpm-workspace.yaml` ("The following dependencies have build scripts that
145+
* were ignored: better-sqlite3, esbuild"), so a scaffold installed there is
146+
* quietly missing its native builds. Do not move this floor as a side effect;
147+
* whether to admit that band at all is #11048.
148+
*
142149
* `engines.pnpm` rather than a `packageManager` stamp, on purpose. npm, yarn
143150
* and bun ignore `engines.pnpm` entirely, so the scaffold keeps working for all
144151
* four package managers `objectstack init` can hand off to (see
145152
* `detectPackageManager`). `packageManager: "pnpm@x.y.z"` would instead declare
146153
* the project pnpm-only — corepack-driven yarn refuses to run in such a project
147-
* — and pin one exact version that goes stale on every pnpm release. It also
148-
* buys nothing on 10.0–10.4, which reach the workspace error before they read
149-
* that field either.
154+
* — and pin one exact version that goes stale on every pnpm release. Those two
155+
* reasons carry the choice on their own: the third one recorded when the stamp
156+
* was rejected ("it buys nothing on 10.0–10.4") was measured against the
157+
* keyless file, and the explicit `packages:` key retires it.
150158
*/
151159
export const SCAFFOLD_PNPM_RANGE = '>=10.15';
152160

@@ -179,8 +187,14 @@ export function renderScaffoldPackageJson(
179187
/**
180188
* Render the `pnpm-workspace.yaml` that allowlists native build scripts and
181189
* declares the two known-benign peer skews.
182-
* Kept minimal (no `packages:` key) so it acts purely as a settings file for
183-
* the single-package scaffold rather than declaring a workspace.
190+
* Declares an explicit empty `packages: []`: a workspace root with no member
191+
* packages, which is what a single-package scaffold is — the file stays purely
192+
* a settings file. Spelling the key out is what lets pnpm 10.0–10.4 (and 9.x)
193+
* parse the file at all; they read it before `engines` and refuse a file
194+
* without the key outright. ⛔ Never `packages: ['.']`: that declares the
195+
* project root a workspace MEMBER, i.e. a monorepo root, which this is not —
196+
* and it is the shape an AI reader would take as licence to add member packages
197+
* to a scaffolded app.
184198
*
185199
* The allowlist is emitted TWICE, under two keys that no single pnpm version
186200
* range reads both of. Measured against a scaffold of this exact shape, one
@@ -210,6 +224,15 @@ export function renderPnpmWorkspaceYaml(
210224
const peerEntries = Object.entries(allowedPeerVersions);
211225

212226
return [
227+
'# An explicit EMPTY workspace: this project has no member packages, so',
228+
'# this file is settings-only. The key is not decoration — pnpm 9.x and',
229+
'# 10.0–10.4 parse this file BEFORE they read `engines`, and refuse a file',
230+
'# without a `packages:` key outright ("ERROR packages field missing or',
231+
'# empty") before resolving a single dependency.',
232+
'# Not `packages: [\'.\']`: that would declare this project a workspace',
233+
'# MEMBER — a monorepo root, which it is not.',
234+
'packages: []',
235+
'',
213236
'# pnpm does not run dependency build scripts unless they are approved',
214237
'# here. Without this file a fresh `pnpm install` exits 1 on pnpm 11 with',
215238
'# ERR_PNPM_IGNORED_BUILDS — pnpm 10 only warned, pnpm 11 made it a hard',

packages/cli/test/init.test.ts

Lines changed: 90 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -108,29 +108,93 @@ describe('native build allowlist (pnpm-workspace.yaml)', () => {
108108
const yaml = renderPnpmWorkspaceYaml();
109109
expect(yaml).toMatch(/^onlyBuiltDependencies:/m);
110110
expect(yaml).toMatch(/^ {2}- better-sqlite3$/m);
111-
// No `packages:` key — this is a settings file, not a workspace declaration.
112-
expect(yaml).not.toMatch(/^packages:/m);
113111
});
114112
});
115113

116-
// That missing `packages:` key is exactly what early pnpm 10 refuses: it exits
117-
// 1 with "ERROR packages field missing or empty" before resolving a single
118-
// dependency, so a freshly scaffolded project cannot be installed at all.
119-
// Measured on the rendered shape, one clean install per pnpm version, each with
120-
// its own store:
114+
// A scaffolded project is a workspace root with NO member packages, and the
115+
// rendered file says so in one line rather than leaving it to be inferred from
116+
// the absence of a key. That is not cosmetic: pnpm 9.x and 10.0–10.4 parse
117+
// `pnpm-workspace.yaml` BEFORE they read `engines`, and a file without the key
118+
// is refused outright — `pnpm install` exits 1 with "ERROR packages field
119+
// missing or empty" before it resolves a single dependency, naming a file the
120+
// user never wrote. Measured on the rendered shape, one clean install per pnpm
121+
// version, each with its own store:
121122
//
122-
// 10.0.0–10.4.0 parse pnpm-workspace.yaml BEFORE reading `engines`, so a
123-
// floor cannot reach them — still the raw workspace error.
124-
// Closing that sliver needs a decision about the `packages:`
125-
// key itself, which is deliberately not made here.
126-
// 10.5.0–10.14.0 refused as ERR_PNPM_UNSUPPORTED_ENGINE, naming the range.
127-
// >=10.15.0 install succeeds.
123+
// 9.15.9 / 10.0.0 / 10.4.0 raw workspace error BEFORE → the floor's own
124+
// ERR_PNPM_UNSUPPORTED_ENGINE AFTER.
125+
// 10.15.0 / 10.34.5 / 11.22.0 install succeeds either way, and the two
126+
// renders are equivalent: byte-identical
127+
// `pnpm-lock.yaml`, `.modules.yaml` identical once
128+
// run-local `prunedAt`/`storeDir` are dropped, and
129+
// `pnpm ls -r --depth -1` reporting one project.
128130
//
129-
// The floor is what turns the reachable part of that band from an error about a
130-
// file the user never wrote into "your pnpm is too old". These assertions pin
131+
// ⛔ NOT `packages: ['.']`, which satisfies the same parsers but declares the
132+
// project root a workspace MEMBER — a monorepo root. The scaffold's output is
133+
// the start of every AI-written app on this platform, so a line that reads as
134+
// "add member packages here" is the expensive half of that choice.
135+
describe('explicit empty workspace declaration in the rendered file', () => {
136+
// Comments are stripped first: the prose above the key names it, and must
137+
// not be what satisfies an assertion about the declaration itself.
138+
const settings = renderPnpmWorkspaceYaml().replace(/^\s*#.*$/gm, '');
139+
140+
it('declares `packages:` — the key early pnpm refuses the file without', () => {
141+
expect(
142+
/^packages:/m.test(settings),
143+
'the rendered pnpm-workspace.yaml must declare `packages:` — without it pnpm 9.x ' +
144+
'and 10.0–10.4 exit 1 with "packages field missing or empty" before reading engines',
145+
).toBe(true);
146+
});
147+
148+
it('declares it EMPTY — a workspace root with no member packages', () => {
149+
const inline = /^packages:[ \t]*(.*)$/m.exec(settings);
150+
expect(inline, '`packages:` must be declared inline').not.toBeNull();
151+
expect(
152+
inline![1].trim(),
153+
"`packages:` must be an empty list; `['.']` would declare the project root a " +
154+
'workspace MEMBER (a monorepo root), which a single-package scaffold is not',
155+
).toBe('[]');
156+
});
157+
158+
it('declares no member in any spelling', () => {
159+
// Covers the inline form (`['.']`, `["packages/*"]`) and the block form
160+
// (`packages:` followed by ` - …`), so neither can arrive unnoticed.
161+
expect(settings).not.toMatch(/^packages:[ \t]*\[[ \t]*[^\]\s]/m);
162+
expect(settings).not.toMatch(/^packages:[ \t]*\n[ \t]*-/m);
163+
});
164+
165+
it('adds no other top-level setting to the rendered file', () => {
166+
// The rest of the file is what it was: the same four keys, same order. A
167+
// "restore the packages key" edit that also drags a setting in fails here.
168+
const keys = [...settings.matchAll(/^([A-Za-z][\w-]*):/gm)].map((m) => m[1]);
169+
expect(keys).toEqual(['packages', 'onlyBuiltDependencies', 'allowBuilds', 'peerDependencyRules']);
170+
});
171+
});
172+
173+
// The explicit `packages:` key above is what makes this floor reachable at all.
174+
// While the key was omitted, pnpm 9.x and 10.0–10.4 never got as far as
175+
// `engines` — they parse the workspace file first and refused it outright — so
176+
// no floor value could reach them. With the key present the entire band below
177+
// the floor reports the same actionable cause instead. Measured on the rendered
178+
// shape, one clean install per pnpm version, each with its own store:
179+
//
180+
// 9.15.9, 10.0.0, 10.4.0, ERR_PNPM_UNSUPPORTED_ENGINE naming ">=10.15".
181+
// 10.5.0–10.14.0 (9.x and 10.0–10.4 printed the raw workspace
182+
// error here before the key existed.)
183+
// >=10.15.0 install succeeds, byte-identical lockfile.
184+
//
185+
// ⚠️ So the floor, not the workspace file, is now what stops 10.0–10.4: with the
186+
// floor lowered they install (exit 0, measured) — but they read neither the
187+
// build allowlist nor the peer rules out of `pnpm-workspace.yaml`, so a scaffold
188+
// there is quietly missing its native builds. Admitting that band is a support
189+
// decision (#11048), not a value this suite should drift. These assertions pin
131190
// the declared range, not pnpm's wording.
132191
describe('pnpm floor in the rendered package.json', () => {
133-
/** First pnpm measured to accept the keyless workspace file. */
192+
/**
193+
* Lowest pnpm measured to install the rendered shape AND honour the workspace
194+
* file's settings — its build allowlist actually runs there (`node-gyp
195+
* rebuild` for better-sqlite3). 10.0.0 and 10.4.0 install too, now that
196+
* `packages:` is explicit, but skip those builds with only a warning.
197+
*/
134198
const FIRST_GOOD: [number, number, number] = [10, 15, 0];
135199

136200
function parseFloor(range: string): [number, number, number] {
@@ -149,14 +213,20 @@ describe('pnpm floor in the rendered package.json', () => {
149213
}
150214
});
151215

152-
it('sets the floor at or above the first pnpm that accepts the keyless workspace file', () => {
216+
it('sets the floor at or above the first pnpm measured to honour the rendered workspace file', () => {
153217
expect(rank(parseFloor(SCAFFOLD_PNPM_RANGE))).toBeGreaterThanOrEqual(rank(FIRST_GOOD));
154218
});
155219

156-
it('excludes every pnpm version measured to refuse the rendered workspace file', () => {
220+
it('excludes every pnpm version this scaffold is not supported on', () => {
157221
const declared = rank(parseFloor(SCAFFOLD_PNPM_RANGE));
158-
const refused: [number, number, number][] = [[10, 0, 0], [10, 5, 0], [10, 14, 0]];
159-
for (const v of refused) {
222+
// 10.0.0 and 10.4.0 were measured to install and then IGNORE the workspace
223+
// file's build allowlist ("The following dependencies have build scripts
224+
// that were ignored: better-sqlite3, esbuild"). 10.5.0 and 10.14.0 are
225+
// refused by the floor itself and were never measured past it — they stay
226+
// listed because nothing has shown them to honour the file, and dropping
227+
// them would silently widen what the scaffold claims to support.
228+
const unsupported: [number, number, number][] = [[10, 0, 0], [10, 4, 0], [10, 5, 0], [10, 14, 0]];
229+
for (const v of unsupported) {
160230
expect(rank(v), `pnpm ${v.join('.')} must fall below the declared floor`).toBeLessThan(declared);
161231
}
162232
});

0 commit comments

Comments
 (0)