Skip to content

Commit 9d101d2

Browse files
Elon Muskclaude
andauthored
fix(cli): declare a pnpm floor in the scaffolded package.json (#10932)
Both scaffold paths write a settings-only pnpm-workspace.yaml with no `packages:` key, which early pnpm 10 refuses outright — `pnpm install` exits 1 with "ERROR packages field missing or empty" before resolving a single dependency, so a brand-new project could not be installed at all. Declaring `engines.pnpm: ">=10.15"` makes pnpm report its own version instead. Measured, one clean install per pnpm version, each with its own store: 10.5.0-10.14.0 now refuse with ERR_PNPM_UNSUPPORTED_ENGINE naming the range; 10.0.0-10.4.0 parse the workspace file before reading engines so they are unchanged; >=10.15.0 installs as before. engines.pnpm rather than a packageManager stamp: npm, yarn and bun ignore it, so the scaffold keeps working for all four package managers the CLI hands off to. Part of #10497 Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent eee2b65 commit 9d101d2

5 files changed

Lines changed: 217 additions & 21 deletions

File tree

.changeset/scaffold-pnpm-floor.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/cli": patch
3+
"create-objectstack": patch
4+
---
5+
6+
Declare a pnpm floor (`engines.pnpm: ">=10.15"`) in the `package.json` both
7+
scaffolders write, so an unsupported pnpm reports its own version instead of an
8+
error about a file the user never wrote.
9+
10+
Both scaffold paths emit a settings-only `pnpm-workspace.yaml` with no
11+
`packages:` key. Early pnpm 10 refuses that file outright — `pnpm install` exits
12+
1 with `ERROR packages field missing or empty` before resolving a single
13+
dependency, so a brand-new project could not be installed at all. Measured on
14+
the rendered shape, one clean install per pnpm version, each with its own store:
15+
16+
| pnpm | before | after |
17+
| --- | --- | --- |
18+
| 10.0.0 – 10.4.0 | `packages field missing or empty` | unchanged — see below |
19+
| 10.5.0 – 10.14.0 | `packages field missing or empty` | `ERR_PNPM_UNSUPPORTED_ENGINE`, naming the expected range |
20+
| >= 10.15.0 | installs | installs |
21+
22+
The floor is a diagnosis, not a repair: pnpm 10.0.0–10.4.0 parse
23+
`pnpm-workspace.yaml` *before* they read `engines`, so they still print the raw
24+
workspace error. Closing that remaining sliver requires deciding what a
25+
single-package scaffold should declare under `packages:`, which is tracked
26+
separately and deliberately not decided here.
27+
28+
`engines.pnpm` rather than a `packageManager` stamp: npm, yarn and bun ignore
29+
`engines.pnpm` entirely, so the scaffold keeps working for all four package
30+
managers `objectstack init` hands off to. A `packageManager: "pnpm@x.y.z"` stamp
31+
would declare the project pnpm-only (corepack-driven yarn refuses to run in such
32+
a project) and pin one exact version that goes stale on every pnpm release — and
33+
it buys nothing on 10.0–10.4, which reach the workspace error before reading
34+
that field either.
35+
36+
No existing project is affected; this only changes what a newly scaffolded
37+
`package.json` contains.

packages/cli/src/commands/init.ts

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,69 @@ export const SCAFFOLD_ALLOWED_PEER_VERSIONS: Record<string, string> = {
112112
'@better-auth/scim>better-call': '1.4.0',
113113
};
114114

115+
/**
116+
* Lowest pnpm that can actually install this scaffold, declared as
117+
* `engines.pnpm` in the generated `package.json`.
118+
*
119+
* The rendered `pnpm-workspace.yaml` is a settings-only file with no
120+
* `packages:` key (see `renderPnpmWorkspaceYaml` below). Early pnpm 10 refuses
121+
* that file outright: `pnpm install` exits 1 with "ERROR packages field
122+
* missing or empty" before it resolves a single dependency, so a brand-new
123+
* project cannot be installed at all. pnpm 10.15.0 and everything above accept
124+
* the keyless file.
125+
*
126+
* Declaring the floor does not repair those pnpm versions — it makes them
127+
* report a cause the user can act on instead of a workspace error about a file
128+
* they did not write. Measured on the rendered shape, one clean install per
129+
* pnpm version, each with its own store:
130+
*
131+
* pnpm 10.0.0 – 10.4.0 pnpm parses `pnpm-workspace.yaml` BEFORE it reads
132+
* `engines`, so these still print the raw "packages
133+
* field missing or empty". The floor cannot reach
134+
* this sliver; only a decision about the `packages:`
135+
* key itself closes it.
136+
* pnpm 10.5.0 – 10.14.0 refused as ERR_PNPM_UNSUPPORTED_ENGINE — "Your
137+
* pnpm version is incompatible with <project>.
138+
* Expected version: >=10.15".
139+
* pnpm >= 10.15.0 installs; unchanged by this declaration.
140+
*
141+
* `engines.pnpm` rather than a `packageManager` stamp, on purpose. npm, yarn
142+
* and bun ignore `engines.pnpm` entirely, so the scaffold keeps working for all
143+
* four package managers `objectstack init` can hand off to (see
144+
* `detectPackageManager`). `packageManager: "pnpm@x.y.z"` would instead declare
145+
* the project pnpm-only — corepack-driven yarn refuses to run in such a project
146+
* — and pin one exact version that goes stale on every pnpm release. It also
147+
* buys nothing on 10.0–10.4, which reach the workspace error before they read
148+
* that field either.
149+
*/
150+
export const SCAFFOLD_PNPM_RANGE = '>=10.15';
151+
152+
/**
153+
* Render the `package.json` written into a freshly scaffolded project.
154+
*
155+
* Exported so the shape is asserted directly rather than re-declared by a test
156+
* that only claims to mirror it — a hand-copied mirror silently stops tracking
157+
* this function the moment a field is added here.
158+
*/
159+
export function renderScaffoldPackageJson(
160+
projectName: string,
161+
template: { scripts: Record<string, string>; dependencies: Record<string, string>; devDependencies: Record<string, string> },
162+
): Record<string, unknown> {
163+
return {
164+
name: projectName,
165+
version: '0.1.0',
166+
private: true,
167+
type: 'module',
168+
// Not a build-script allowlist (that lives in pnpm-workspace.yaml, which
169+
// current pnpm reads instead of the package.json `pnpm` field) — this is
170+
// the minimum pnpm that accepts that file at all.
171+
engines: { pnpm: SCAFFOLD_PNPM_RANGE },
172+
scripts: template.scripts,
173+
dependencies: template.dependencies,
174+
devDependencies: template.devDependencies,
175+
};
176+
}
177+
115178
/**
116179
* Render the `pnpm-workspace.yaml` that allowlists native build scripts and
117180
* declares the two known-benign peer skews.
@@ -582,15 +645,7 @@ export default class Init extends Command {
582645
// 1. Create package.json if missing
583646
const pkgPath = path.join(targetDir, 'package.json');
584647
if (!fs.existsSync(pkgPath)) {
585-
const pkg = {
586-
name: projectName,
587-
version: '0.1.0',
588-
private: true,
589-
type: 'module',
590-
scripts: template.scripts,
591-
dependencies: template.dependencies,
592-
devDependencies: template.devDependencies,
593-
};
648+
const pkg = renderScaffoldPackageJson(projectName, template);
594649
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
595650
createdFiles.push('package.json');
596651
} else {

packages/cli/test/init.test.ts

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import fs from 'fs';
66
import os from 'os';
77
import path from 'path';
88
import { fileURLToPath } from 'url';
9-
import { TEMPLATES, getCliVersion, detectPackageManager, sanitizeNamespace, SCAFFOLD_BUILT_DEPENDENCIES, SCAFFOLD_ALLOWED_PEER_VERSIONS, renderPnpmWorkspaceYaml } from '../src/commands/init';
9+
import { TEMPLATES, getCliVersion, detectPackageManager, sanitizeNamespace, SCAFFOLD_BUILT_DEPENDENCIES, SCAFFOLD_ALLOWED_PEER_VERSIONS, renderPnpmWorkspaceYaml, renderScaffoldPackageJson, SCAFFOLD_PNPM_RANGE } from '../src/commands/init';
1010

1111
const __dirname = path.dirname(fileURLToPath(import.meta.url));
1212
const pkg = JSON.parse(
@@ -95,18 +95,13 @@ describe('native build allowlist (pnpm-workspace.yaml)', () => {
9595
});
9696

9797
it('does NOT put the allowlist in package.json (current pnpm ignores it)', () => {
98-
const t = TEMPLATES.app;
99-
// Mirror init.ts's package.json construction.
100-
const pkgJson: Record<string, unknown> = {
101-
name: 'my-app',
102-
version: '0.1.0',
103-
private: true,
104-
type: 'module',
105-
scripts: t.scripts,
106-
dependencies: t.dependencies,
107-
devDependencies: t.devDependencies,
108-
};
98+
// Read the real renderer rather than a hand-copied mirror of it: the
99+
// previous version of this test re-declared the object literal inline, so
100+
// it asserted against its own copy and would have kept passing however far
101+
// init.ts drifted from it.
102+
const pkgJson = renderScaffoldPackageJson('my-app', TEMPLATES.app);
109103
expect(pkgJson.pnpm).toBeUndefined();
104+
expect((pkgJson.engines as Record<string, string>).pnpm).toBeDefined();
110105
});
111106

112107
it('renders a pnpm-workspace.yaml that allowlists better-sqlite3', () => {
@@ -118,6 +113,65 @@ describe('native build allowlist (pnpm-workspace.yaml)', () => {
118113
});
119114
});
120115

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:
121+
//
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.
128+
//
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+
// the declared range, not pnpm's wording.
132+
describe('pnpm floor in the rendered package.json', () => {
133+
/** First pnpm measured to accept the keyless workspace file. */
134+
const FIRST_GOOD: [number, number, number] = [10, 15, 0];
135+
136+
function parseFloor(range: string): [number, number, number] {
137+
const m = /^>=\s*(\d+)\.(\d+)(?:\.(\d+))?$/.exec(range.trim());
138+
if (!m) throw new Error(`expected a plain ">=" floor, got "${range}"`);
139+
return [Number(m[1]), Number(m[2]), Number(m[3] ?? '0')];
140+
}
141+
142+
const rank = ([maj, min, pat]: [number, number, number]) => maj * 1e6 + min * 1e3 + pat;
143+
144+
it('declares engines.pnpm in every template', () => {
145+
for (const key of Object.keys(TEMPLATES)) {
146+
const pkgJson = renderScaffoldPackageJson('my-app', TEMPLATES[key]);
147+
const engines = pkgJson.engines as Record<string, string> | undefined;
148+
expect(engines?.pnpm, `template "${key}"`).toBe(SCAFFOLD_PNPM_RANGE);
149+
}
150+
});
151+
152+
it('sets the floor at or above the first pnpm that accepts the keyless workspace file', () => {
153+
expect(rank(parseFloor(SCAFFOLD_PNPM_RANGE))).toBeGreaterThanOrEqual(rank(FIRST_GOOD));
154+
});
155+
156+
it('excludes every pnpm version measured to refuse the rendered workspace file', () => {
157+
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) {
160+
expect(rank(v), `pnpm ${v.join('.')} must fall below the declared floor`).toBeLessThan(declared);
161+
}
162+
});
163+
164+
it('does NOT pin a packageManager — the scaffold also supports npm, yarn and bun', () => {
165+
// `packageManager: "pnpm@x.y.z"` would declare the project pnpm-only
166+
// (corepack-driven yarn refuses to run in such a project) and pin one exact
167+
// version that goes stale on every pnpm release. `detectPackageManager`
168+
// hands off to whichever of the four invoked the CLI, and all three others
169+
// ignore `engines.pnpm` — so the floor costs them nothing.
170+
const pkgJson = renderScaffoldPackageJson('my-app', TEMPLATES.app);
171+
expect(pkgJson.packageManager).toBeUndefined();
172+
});
173+
});
174+
121175
// pnpm 11 honours ONLY `allowBuilds`. Rendering `onlyBuiltDependencies` alone
122176
// — which is what this scaffolder shipped — makes a brand-new project's very
123177
// first `pnpm install` exit 1 with ERR_PNPM_IGNORED_BUILDS, byte for byte the

packages/create-objectstack/src/template-consistency.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,53 @@ describe('bundled template declared version surfaces', () => {
127127
}
128128
});
129129

130+
// The bundled `pnpm-workspace.yaml` is a settings-only file with no
131+
// `packages:` key, and early pnpm 10 refuses such a file outright: `pnpm
132+
// install` exits 1 with "ERROR packages field missing or empty" before it
133+
// resolves a single dependency, so a scaffolded project cannot be installed
134+
// at all. Measured on the bundled shape, one clean install per pnpm
135+
// version, each with its own store:
136+
//
137+
// 10.0.0–10.4.0 parse pnpm-workspace.yaml BEFORE reading `engines`, so
138+
// the floor cannot reach them — still the raw workspace
139+
// error. Only a decision about the `packages:` key
140+
// itself closes that sliver, and it is not made here.
141+
// 10.5.0–10.14.0 refused as ERR_PNPM_UNSUPPORTED_ENGINE.
142+
// >=10.15.0 install succeeds.
143+
//
144+
// `objectstack init` (packages/cli/src/commands/init.ts) is the other
145+
// scaffold path and declares the same floor from `SCAFFOLD_PNPM_RANGE`.
146+
it('package.json declares a pnpm floor at or above the version that accepts the keyless workspace file', () => {
147+
const templatePkg = JSON.parse(readTemplateFile('package.json'));
148+
const range: unknown = templatePkg.engines?.pnpm;
149+
expect(
150+
typeof range,
151+
`${template}/package.json must declare engines.pnpm — without it, pnpm 10.5–10.14 ` +
152+
'hit "packages field missing or empty" on a brand-new project instead of being told ' +
153+
'to upgrade',
154+
).toBe('string');
155+
156+
const match = /^>=\s*(\d+)\.(\d+)(?:\.(\d+))?$/.exec(String(range).trim());
157+
expect(match, `engines.pnpm "${range}" must be a plain ">=" floor`).not.toBeNull();
158+
159+
const rank = (maj: number, min: number, pat: number) => maj * 1e6 + min * 1e3 + pat;
160+
const declared = rank(Number(match![1]), Number(match![2]), Number(match![3] ?? '0'));
161+
expect(
162+
declared,
163+
`engines.pnpm "${range}" admits pnpm versions measured to refuse this template's ` +
164+
'pnpm-workspace.yaml — the first accepting version is 10.15.0',
165+
).toBeGreaterThanOrEqual(rank(10, 15, 0));
166+
});
167+
168+
// `packageManager: "pnpm@x.y.z"` would declare the scaffolded project
169+
// pnpm-only — corepack-driven yarn refuses to run in such a project — and
170+
// pin one exact version that goes stale on every pnpm release. npm, yarn
171+
// and bun all ignore `engines.pnpm`, so the floor above costs them nothing.
172+
it('package.json does NOT pin a packageManager', () => {
173+
const templatePkg = JSON.parse(readTemplateFile('package.json'));
174+
expect(templatePkg.packageManager).toBeUndefined();
175+
});
176+
130177
// NOTE the file: this stamp lives in `objectstack.config.ts`, inside the
131178
// `defineStack({ manifest: … })` literal. It is NOT in
132179
// `objectstack.manifest.json` — the two were conflated in this suite's own

packages/create-objectstack/src/templates/blank/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
"version": "0.1.0",
44
"private": true,
55
"type": "module",
6+
"engines": {
7+
"pnpm": ">=10.15"
8+
},
69
"scripts": {
710
"dev": "objectstack dev",
811
"start": "objectstack start",

0 commit comments

Comments
 (0)