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
37 changes: 37 additions & 0 deletions .changeset/scaffold-pnpm-floor.md
Original file line numberDiff line numberDiff line change
@@ -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.
73 changes: 64 additions & 9 deletions packages/cli/src/commands/init.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,6 +112,69 @@ export const SCAFFOLD_ALLOWED_PEER_VERSIONS: Record<string, string> = {
'@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 <project>.
* 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<string, string>; dependencies: Record<string, string>; devDependencies: Record<string, string> },
): Record<string, unknown> {
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.
Expand DownExpand Up@@ -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 {
Expand Down
78 changes: 66 additions & 12 deletions packages/cli/test/init.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand DownExpand Up@@ -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<string, unknown> = {
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<string, string>).pnpm).toBeDefined();
});

it('renders a pnpm-workspace.yaml that allowlists better-sqlite3', () => {
Expand All@@ -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<string, string> | 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
Expand Down
47 changes: 47 additions & 0 deletions packages/create-objectstack/src/template-consistency.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/create-objectstack/src/templates/blank/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,9 @@
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"pnpm": ">=10.15"
},
"scripts": {
"dev": "objectstack dev",
"start": "objectstack start",
Expand Down
Loading