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
44 changes: 44 additions & 0 deletions .changeset/scaffold-explicit-empty-workspace.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
---
"@objectstack/cli": patch
"create-objectstack": patch
---

Scaffolded projects declare an explicit empty `packages: []` in their
`pnpm-workspace.yaml` (#10933). Both scaffold paths render it —
`renderPnpmWorkspaceYaml` in `objectstack init`, and the bundled `blank`
template `npx create-objectstack` copies.

The file was deliberately keyless so it would act purely as a settings file.
That intent is now written down rather than inferred from a missing key, and
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 and giving no hint that the cause is their
pnpm version — and no `engines.pnpm` floor could reach them, because they never
got as far as the engines check. Measured, one clean install per pnpm version,
each with its own store:

| pnpm | before | after |
|---|---|---|
| 9.15.9, 10.0.0, 10.4.0 | `ERROR packages field missing or empty` | `ERR_PNPM_UNSUPPORTED_ENGINE`, naming `>=10.15` |
| 10.5.0–10.14.0 | `ERR_PNPM_UNSUPPORTED_ENGINE` | unchanged |
| 10.15.0, 10.34.5, 11.22.0 | installs | installs, byte-identical `pnpm-lock.yaml` |

So every unsupported pnpm now reports the same actionable cause, and supported
pnpm is unaffected: the empty key was measured equivalent to omission on
10.15.0, 10.34.5 and 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`, and an identical second-install
"Already up to date".

The declaration 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 (human or AI) as an invitation to add member packages to an app.

`engines.pnpm` is unchanged at `>=10.15`.
63 changes: 43 additions & 20 deletions packages/cli/src/commands/init.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -117,36 +117,44 @@ export const SCAFFOLD_ALLOWED_PEER_VERSIONS: Record<string, string> = {
* 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.
* The rendered `pnpm-workspace.yaml` declares an explicit empty `packages: []`
* (see `renderPnpmWorkspaceYaml` below). It did not always, and that history is
* why this floor is reachable at all: while the key was omitted, pnpm 10.0–10.4
* refused the file outright — `pnpm install` exited 1 with "ERROR packages
* field missing or empty" before resolving a single dependency — and those
* versions parse `pnpm-workspace.yaml` BEFORE they read `engines`, so no floor
* value could ever be consulted on that band.
*
* Declaring the floor does not repair those pnpm versions — it makes them
* Declaring the floor does not repair the versions below it — 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 9.15.9, 10.0.0, refused as ERR_PNPM_UNSUPPORTED_ENGINE — "Your
* 10.4.0, 10.5.0–10.14.0 pnpm version is incompatible with PROJECT.
* Expected version: >=10.15". With the key omitted,
* 9.x and 10.0–10.4 printed the raw workspace error
* here instead, naming a file the user never wrote.
* pnpm >= 10.15.0 installs; unchanged by this declaration.
*
* ⚠️ So this floor, not the workspace file, is now what stops pnpm 10.0–10.4:
* with the floor lowered they install (measured, exit 0). Admitting them is a
* support decision rather than an edit — measured on 10.0.0 and 10.4.0, 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 a scaffold installed there is
* quietly missing its native builds. Do not move this floor as a side effect;
* whether to admit that band at all is #11048.
*
* `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.
* — and pin one exact version that goes stale on every pnpm release. Those two
* reasons carry the choice on their own: the third one recorded when the stamp
* was rejected ("it buys nothing on 10.0–10.4") was measured against the
* keyless file, and the explicit `packages:` key retires it.
*/
export const SCAFFOLD_PNPM_RANGE = '>=10.15';

Expand DownExpand Up@@ -179,8 +187,14 @@ export function renderScaffoldPackageJson(
/**
* Render the `pnpm-workspace.yaml` that allowlists native build scripts and
* 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.
* Declares an explicit empty `packages: []`: a workspace root with no member
* packages, which is what a single-package scaffold is — the file stays purely
* a settings file. Spelling the key out is what lets pnpm 10.0–10.4 (and 9.x)
* parse the file at all; they read it before `engines` and refuse a file
* without the key outright. ⛔ Never `packages: ['.']`: that declares the
* project root a workspace MEMBER, i.e. a monorepo root, which this is not —
* and it is the shape an AI reader would take as licence to add member packages
* to a scaffolded app.
*
* 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
Expand DownExpand Up@@ -210,6 +224,15 @@ export function renderPnpmWorkspaceYaml(
const peerEntries = Object.entries(allowedPeerVersions);

return [
'# An explicit EMPTY workspace: this project has no member packages, so',
'# this file is settings-only. The key is not decoration — pnpm 9.x and',
'# 10.0–10.4 parse this file BEFORE they read `engines`, and refuse a file',
'# without a `packages:` key outright ("ERROR packages field missing or',
'# empty") before resolving a single dependency.',
'# Not `packages: [\'.\']`: that would declare this project a workspace',
'# MEMBER — a monorepo root, which it is not.',
'packages: []',
'',
'# 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',
Expand Down
110 changes: 90 additions & 20 deletions packages/cli/test/init.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -108,29 +108,93 @@ describe('native build allowlist (pnpm-workspace.yaml)', () => {
const yaml = renderPnpmWorkspaceYaml();
expect(yaml).toMatch(/^onlyBuiltDependencies:/m);
expect(yaml).toMatch(/^ {2}- better-sqlite3$/m);
// No `packages:` key — this is a settings file, not a workspace declaration.
expect(yaml).not.toMatch(/^packages:/m);
});
});

// 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:
// A scaffolded project is a workspace root with NO member packages, and the
// rendered file says so in one line rather than leaving it to be inferred from
// the absence of a key. That is not cosmetic: pnpm 9.x and 10.0–10.4 parse
// `pnpm-workspace.yaml` BEFORE they read `engines`, and a file without the key
// is refused outright — `pnpm install` exits 1 with "ERROR packages field
// missing or empty" before it resolves a single dependency, naming a file the
// user never wrote. 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.
// 9.15.9 / 10.0.0 / 10.4.0 raw workspace error BEFORE → the floor's own
// ERR_PNPM_UNSUPPORTED_ENGINE AFTER.
// 10.15.0 / 10.34.5 / 11.22.0 install succeeds either way, and the two
// renders are equivalent: byte-identical
// `pnpm-lock.yaml`, `.modules.yaml` identical once
// run-local `prunedAt`/`storeDir` are dropped, and
// `pnpm ls -r --depth -1` reporting one project.
//
// 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
// ⛔ NOT `packages: ['.']`, which satisfies the same parsers but declares the
// project root a workspace MEMBER — a monorepo root. The scaffold's output is
// the start of every AI-written app on this platform, so a line that reads as
// "add member packages here" is the expensive half of that choice.
describe('explicit empty workspace declaration in the rendered file', () => {
// Comments are stripped first: the prose above the key names it, and must
// not be what satisfies an assertion about the declaration itself.
const settings = renderPnpmWorkspaceYaml().replace(/^\s*#.*$/gm, '');

it('declares `packages:` — the key early pnpm refuses the file without', () => {
expect(
/^packages:/m.test(settings),
'the rendered pnpm-workspace.yaml must declare `packages:` — without it pnpm 9.x ' +
'and 10.0–10.4 exit 1 with "packages field missing or empty" before reading engines',
).toBe(true);
});

it('declares it EMPTY — a workspace root with no member packages', () => {
const inline = /^packages:[ \t]*(.*)$/m.exec(settings);
expect(inline, '`packages:` must be declared inline').not.toBeNull();
expect(
inline![1].trim(),
"`packages:` must be an empty list; `['.']` would declare the project root a " +
'workspace MEMBER (a monorepo root), which a single-package scaffold is not',
).toBe('[]');
});

it('declares no member in any spelling', () => {
// Covers the inline form (`['.']`, `["packages/*"]`) and the block form
// (`packages:` followed by ` - …`), so neither can arrive unnoticed.
expect(settings).not.toMatch(/^packages:[ \t]*\[[ \t]*[^\]\s]/m);
expect(settings).not.toMatch(/^packages:[ \t]*\n[ \t]*-/m);
});

it('adds no other top-level setting to the rendered file', () => {
// The rest of the file is what it was: the same four keys, same order. A
// "restore the packages key" edit that also drags a setting in fails here.
const keys = [...settings.matchAll(/^([A-Za-z][\w-]*):/gm)].map((m) => m[1]);
expect(keys).toEqual(['packages', 'onlyBuiltDependencies', 'allowBuilds', 'peerDependencyRules']);
});
});

// The explicit `packages:` key above is what makes this floor reachable at all.
// While the key was omitted, pnpm 9.x and 10.0–10.4 never got as far as
// `engines` — they parse the workspace file first and refused it outright — so
// no floor value could reach them. With the key present the entire band below
// the floor reports the same actionable cause instead. Measured on the rendered
// shape, one clean install per pnpm version, each with its own store:
//
// 9.15.9, 10.0.0, 10.4.0, ERR_PNPM_UNSUPPORTED_ENGINE naming ">=10.15".
// 10.5.0–10.14.0 (9.x and 10.0–10.4 printed the raw workspace
// error here before the key existed.)
// >=10.15.0 install succeeds, byte-identical lockfile.
//
// ⚠️ So the floor, not the workspace file, is now what stops 10.0–10.4: with the
// floor lowered they install (exit 0, measured) — but they read neither the
// build allowlist nor the peer rules out of `pnpm-workspace.yaml`, so a scaffold
// there is quietly missing its native builds. Admitting that band is a support
// decision (#11048), not a value this suite should drift. 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. */
/**
* Lowest pnpm measured to install the rendered shape AND honour the workspace
* file's settings — its build allowlist actually runs there (`node-gyp
* rebuild` for better-sqlite3). 10.0.0 and 10.4.0 install too, now that
* `packages:` is explicit, but skip those builds with only a warning.
*/
const FIRST_GOOD: [number, number, number] = [10, 15, 0];

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

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

it('excludes every pnpm version measured to refuse the rendered workspace file', () => {
it('excludes every pnpm version this scaffold is not supported on', () => {
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) {
// 10.0.0 and 10.4.0 were measured to install and then IGNORE the workspace
// file's build allowlist ("The following dependencies have build scripts
// that were ignored: better-sqlite3, esbuild"). 10.5.0 and 10.14.0 are
// refused by the floor itself and were never measured past it — they stay
// listed because nothing has shown them to honour the file, and dropping
// them would silently widen what the scaffold claims to support.
const unsupported: [number, number, number][] = [[10, 0, 0], [10, 4, 0], [10, 5, 0], [10, 14, 0]];
for (const v of unsupported) {
expect(rank(v), `pnpm ${v.join('.')} must fall below the declared floor`).toBeLessThan(declared);
}
});
Expand Down
Loading
Loading