diff --git a/.changeset/scaffold-pnpm-floor.md b/.changeset/scaffold-pnpm-floor.md new file mode 100644 index 0000000000..e46ed9f53f --- /dev/null +++ b/.changeset/scaffold-pnpm-floor.md @@ -0,0 +1,37 @@ +--- +"@objectstack/cli": patch +"create-objectstack": patch +--- + +Declare a pnpm floor (`engines.pnpm: ">=10.15"`) in the `package.json` both +scaffolders write, so an unsupported pnpm reports its own version instead of an +error about a file the user never wrote. + +Both scaffold paths emit a settings-only `pnpm-workspace.yaml` with no +`packages:` key. Early pnpm 10 refuses that file 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. Measured on +the rendered shape, one clean install per pnpm version, each with its own store: + +| pnpm | before | after | +| --- | --- | --- | +| 10.0.0 – 10.4.0 | `packages field missing or empty` | unchanged — see below | +| 10.5.0 – 10.14.0 | `packages field missing or empty` | `ERR_PNPM_UNSUPPORTED_ENGINE`, naming the expected range | +| >= 10.15.0 | installs | installs | + +The floor is a diagnosis, not a repair: pnpm 10.0.0–10.4.0 parse +`pnpm-workspace.yaml` *before* they read `engines`, so they still print the raw +workspace error. Closing that remaining sliver requires deciding what a +single-package scaffold should declare under `packages:`, which is tracked +separately and deliberately not decided here. + +`engines.pnpm` rather than a `packageManager` stamp: npm, yarn and bun ignore +`engines.pnpm` entirely, so the scaffold keeps working for all four package +managers `objectstack init` hands off to. A `packageManager: "pnpm@x.y.z"` stamp +would declare the project pnpm-only (corepack-driven yarn refuses to run in such +a project) and pin one exact version that goes stale on every pnpm release — and +it buys nothing on 10.0–10.4, which reach the workspace error before reading +that field either. + +No existing project is affected; this only changes what a newly scaffolded +`package.json` contains. diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 50abfc0873..3a9cadee55 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -112,6 +112,69 @@ export const SCAFFOLD_ALLOWED_PEER_VERSIONS: Record = { '@better-auth/scim>better-call': '1.4.0', }; +/** + * Lowest pnpm that can actually install this scaffold, declared as + * `engines.pnpm` in the generated `package.json`. + * + * The rendered `pnpm-workspace.yaml` is a settings-only file with no + * `packages:` key (see `renderPnpmWorkspaceYaml` below). Early pnpm 10 refuses + * that file outright: `pnpm install` exits 1 with "ERROR packages field + * missing or empty" before it resolves a single dependency, so a brand-new + * project cannot be installed at all. pnpm 10.15.0 and everything above accept + * the keyless file. + * + * Declaring the floor does not repair those pnpm versions — it makes them + * report a cause the user can act on instead of a workspace error about a file + * they did not write. Measured on the rendered shape, one clean install per + * pnpm version, each with its own store: + * + * pnpm 10.0.0 – 10.4.0 pnpm parses `pnpm-workspace.yaml` BEFORE it reads + * `engines`, so these still print the raw "packages + * field missing or empty". The floor cannot reach + * this sliver; only a decision about the `packages:` + * key itself closes it. + * pnpm 10.5.0 – 10.14.0 refused as ERR_PNPM_UNSUPPORTED_ENGINE — "Your + * pnpm version is incompatible with . + * Expected version: >=10.15". + * pnpm >= 10.15.0 installs; unchanged by this declaration. + * + * `engines.pnpm` rather than a `packageManager` stamp, on purpose. npm, yarn + * and bun ignore `engines.pnpm` entirely, so the scaffold keeps working for all + * four package managers `objectstack init` can hand off to (see + * `detectPackageManager`). `packageManager: "pnpm@x.y.z"` would instead declare + * the project pnpm-only — corepack-driven yarn refuses to run in such a project + * — and pin one exact version that goes stale on every pnpm release. It also + * buys nothing on 10.0–10.4, which reach the workspace error before they read + * that field either. + */ +export const SCAFFOLD_PNPM_RANGE = '>=10.15'; + +/** + * Render the `package.json` written into a freshly scaffolded project. + * + * Exported so the shape is asserted directly rather than re-declared by a test + * that only claims to mirror it — a hand-copied mirror silently stops tracking + * this function the moment a field is added here. + */ +export function renderScaffoldPackageJson( + projectName: string, + template: { scripts: Record; dependencies: Record; devDependencies: Record }, +): Record { + return { + name: projectName, + version: '0.1.0', + private: true, + type: 'module', + // Not a build-script allowlist (that lives in pnpm-workspace.yaml, which + // current pnpm reads instead of the package.json `pnpm` field) — this is + // the minimum pnpm that accepts that file at all. + engines: { pnpm: SCAFFOLD_PNPM_RANGE }, + scripts: template.scripts, + dependencies: template.dependencies, + devDependencies: template.devDependencies, + }; +} + /** * Render the `pnpm-workspace.yaml` that allowlists native build scripts and * declares the two known-benign peer skews. @@ -582,15 +645,7 @@ export default class Init extends Command { // 1. Create package.json if missing const pkgPath = path.join(targetDir, 'package.json'); if (!fs.existsSync(pkgPath)) { - const pkg = { - name: projectName, - version: '0.1.0', - private: true, - type: 'module', - scripts: template.scripts, - dependencies: template.dependencies, - devDependencies: template.devDependencies, - }; + const pkg = renderScaffoldPackageJson(projectName, template); fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); createdFiles.push('package.json'); } else { diff --git a/packages/cli/test/init.test.ts b/packages/cli/test/init.test.ts index bb6a1a345f..d97a1efcf0 100644 --- a/packages/cli/test/init.test.ts +++ b/packages/cli/test/init.test.ts @@ -6,7 +6,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; -import { TEMPLATES, getCliVersion, detectPackageManager, sanitizeNamespace, SCAFFOLD_BUILT_DEPENDENCIES, SCAFFOLD_ALLOWED_PEER_VERSIONS, renderPnpmWorkspaceYaml } from '../src/commands/init'; +import { TEMPLATES, getCliVersion, detectPackageManager, sanitizeNamespace, SCAFFOLD_BUILT_DEPENDENCIES, SCAFFOLD_ALLOWED_PEER_VERSIONS, renderPnpmWorkspaceYaml, renderScaffoldPackageJson, SCAFFOLD_PNPM_RANGE } from '../src/commands/init'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const pkg = JSON.parse( @@ -95,18 +95,13 @@ describe('native build allowlist (pnpm-workspace.yaml)', () => { }); it('does NOT put the allowlist in package.json (current pnpm ignores it)', () => { - const t = TEMPLATES.app; - // Mirror init.ts's package.json construction. - const pkgJson: Record = { - name: 'my-app', - version: '0.1.0', - private: true, - type: 'module', - scripts: t.scripts, - dependencies: t.dependencies, - devDependencies: t.devDependencies, - }; + // Read the real renderer rather than a hand-copied mirror of it: the + // previous version of this test re-declared the object literal inline, so + // it asserted against its own copy and would have kept passing however far + // init.ts drifted from it. + const pkgJson = renderScaffoldPackageJson('my-app', TEMPLATES.app); expect(pkgJson.pnpm).toBeUndefined(); + expect((pkgJson.engines as Record).pnpm).toBeDefined(); }); it('renders a pnpm-workspace.yaml that allowlists better-sqlite3', () => { @@ -118,6 +113,65 @@ describe('native build allowlist (pnpm-workspace.yaml)', () => { }); }); +// That missing `packages:` key is exactly what early pnpm 10 refuses: it exits +// 1 with "ERROR packages field missing or empty" before resolving a single +// dependency, so a freshly scaffolded project cannot be installed at all. +// Measured on the rendered shape, one clean install per pnpm version, each with +// its own store: +// +// 10.0.0–10.4.0 parse pnpm-workspace.yaml BEFORE reading `engines`, so a +// floor cannot reach them — still the raw workspace error. +// Closing that sliver needs a decision about the `packages:` +// key itself, which is deliberately not made here. +// 10.5.0–10.14.0 refused as ERR_PNPM_UNSUPPORTED_ENGINE, naming the range. +// >=10.15.0 install succeeds. +// +// The floor is what turns the reachable part of that band from an error about a +// file the user never wrote into "your pnpm is too old". These assertions pin +// the declared range, not pnpm's wording. +describe('pnpm floor in the rendered package.json', () => { + /** First pnpm measured to accept the keyless workspace file. */ + const FIRST_GOOD: [number, number, number] = [10, 15, 0]; + + function parseFloor(range: string): [number, number, number] { + const m = /^>=\s*(\d+)\.(\d+)(?:\.(\d+))?$/.exec(range.trim()); + if (!m) throw new Error(`expected a plain ">=" floor, got "${range}"`); + return [Number(m[1]), Number(m[2]), Number(m[3] ?? '0')]; + } + + const rank = ([maj, min, pat]: [number, number, number]) => maj * 1e6 + min * 1e3 + pat; + + it('declares engines.pnpm in every template', () => { + for (const key of Object.keys(TEMPLATES)) { + const pkgJson = renderScaffoldPackageJson('my-app', TEMPLATES[key]); + const engines = pkgJson.engines as Record | undefined; + expect(engines?.pnpm, `template "${key}"`).toBe(SCAFFOLD_PNPM_RANGE); + } + }); + + it('sets the floor at or above the first pnpm that accepts the keyless workspace file', () => { + expect(rank(parseFloor(SCAFFOLD_PNPM_RANGE))).toBeGreaterThanOrEqual(rank(FIRST_GOOD)); + }); + + it('excludes every pnpm version measured to refuse the rendered workspace file', () => { + const declared = rank(parseFloor(SCAFFOLD_PNPM_RANGE)); + const refused: [number, number, number][] = [[10, 0, 0], [10, 5, 0], [10, 14, 0]]; + for (const v of refused) { + expect(rank(v), `pnpm ${v.join('.')} must fall below the declared floor`).toBeLessThan(declared); + } + }); + + it('does NOT pin a packageManager — the scaffold also supports npm, yarn and bun', () => { + // `packageManager: "pnpm@x.y.z"` would declare the project pnpm-only + // (corepack-driven yarn refuses to run in such a project) and pin one exact + // version that goes stale on every pnpm release. `detectPackageManager` + // hands off to whichever of the four invoked the CLI, and all three others + // ignore `engines.pnpm` — so the floor costs them nothing. + const pkgJson = renderScaffoldPackageJson('my-app', TEMPLATES.app); + expect(pkgJson.packageManager).toBeUndefined(); + }); +}); + // 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 diff --git a/packages/create-objectstack/src/template-consistency.test.ts b/packages/create-objectstack/src/template-consistency.test.ts index 41b0c5b662..4f37b9d9f6 100644 --- a/packages/create-objectstack/src/template-consistency.test.ts +++ b/packages/create-objectstack/src/template-consistency.test.ts @@ -127,6 +127,53 @@ describe('bundled template declared version surfaces', () => { } }); + // The bundled `pnpm-workspace.yaml` is a settings-only file with no + // `packages:` key, and early pnpm 10 refuses such a file outright: `pnpm + // install` exits 1 with "ERROR packages field missing or empty" before it + // resolves a single dependency, so a scaffolded project cannot be installed + // at all. Measured on the bundled shape, one clean install per pnpm + // version, each with its own store: + // + // 10.0.0–10.4.0 parse pnpm-workspace.yaml BEFORE reading `engines`, so + // the floor cannot reach them — still the raw workspace + // error. Only a decision about the `packages:` key + // itself closes that sliver, and it is not made here. + // 10.5.0–10.14.0 refused as ERR_PNPM_UNSUPPORTED_ENGINE. + // >=10.15.0 install succeeds. + // + // `objectstack init` (packages/cli/src/commands/init.ts) is the other + // scaffold path and declares the same floor from `SCAFFOLD_PNPM_RANGE`. + it('package.json declares a pnpm floor at or above the version that accepts the keyless workspace file', () => { + const templatePkg = JSON.parse(readTemplateFile('package.json')); + const range: unknown = templatePkg.engines?.pnpm; + expect( + typeof range, + `${template}/package.json must declare engines.pnpm — without it, pnpm 10.5–10.14 ` + + 'hit "packages field missing or empty" on a brand-new project instead of being told ' + + 'to upgrade', + ).toBe('string'); + + const match = /^>=\s*(\d+)\.(\d+)(?:\.(\d+))?$/.exec(String(range).trim()); + expect(match, `engines.pnpm "${range}" must be a plain ">=" floor`).not.toBeNull(); + + const rank = (maj: number, min: number, pat: number) => maj * 1e6 + min * 1e3 + pat; + const declared = rank(Number(match![1]), Number(match![2]), Number(match![3] ?? '0')); + expect( + declared, + `engines.pnpm "${range}" admits pnpm versions measured to refuse this template's ` + + 'pnpm-workspace.yaml — the first accepting version is 10.15.0', + ).toBeGreaterThanOrEqual(rank(10, 15, 0)); + }); + + // `packageManager: "pnpm@x.y.z"` would declare the scaffolded project + // pnpm-only — corepack-driven yarn refuses to run in such a project — and + // pin one exact version that goes stale on every pnpm release. npm, yarn + // and bun all ignore `engines.pnpm`, so the floor above costs them nothing. + it('package.json does NOT pin a packageManager', () => { + const templatePkg = JSON.parse(readTemplateFile('package.json')); + expect(templatePkg.packageManager).toBeUndefined(); + }); + // NOTE the file: this stamp lives in `objectstack.config.ts`, inside the // `defineStack({ manifest: … })` literal. It is NOT in // `objectstack.manifest.json` — the two were conflated in this suite's own diff --git a/packages/create-objectstack/src/templates/blank/package.json b/packages/create-objectstack/src/templates/blank/package.json index 06156be9ea..d2707381b9 100644 --- a/packages/create-objectstack/src/templates/blank/package.json +++ b/packages/create-objectstack/src/templates/blank/package.json @@ -3,6 +3,9 @@ "version": "0.1.0", "private": true, "type": "module", + "engines": { + "pnpm": ">=10.15" + }, "scripts": { "dev": "objectstack dev", "start": "objectstack start",