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
13 changes: 13 additions & 0 deletions .changeset/22162-scaffold-workspace-minimal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"create-objectstack": patch
"@objectstack/cli": patch
---

A new project's `pnpm-workspace.yaml` is now a short settings file: one comment line above each block instead of 57 to 66 lines of version history and measurements. `npm create objectstack`, `objectstack init` and a standalone `objectstack create` now write the same 21-line file.

Clause-②: no

- The settings are unchanged: `packages: []`, the same `onlyBuiltDependencies` and `allowBuilds` lists, and the same `peerDependencyRules.allowedVersions` entries, in the same order. A project installs exactly as before.
- Each block keeps one line that says why it is there: which pnpm versions read each build-approval key, and that the peer rules only silence a warning that was measured harmless.
- The measurements behind each setting now live in the `@objectstack/cli` source next to the values they explain, so they are kept up to date there instead of in every project.
- Already-scaffolded projects are not touched. Their longer comments can be deleted or kept; the settings are identical.
104 changes: 43 additions & 61 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ export function manifestIdSlug(name: string): string {
* default standalone SQLite store, via knex) ships uncompiled and `serve`
* fails with "Could not locate the bindings file".
*
* Why each entry is here: `better-sqlite3` is the native SQLite driver, an
* `optionalDependencies` entry of `@objectstack/driver-sql` (and of this
* package, for its own sqlite tooling); `esbuild` is what compiles
* `objectstack.config.ts` when the CLI loads it through `bundle-require`.
*
* Current pnpm reads this from `pnpm-workspace.yaml`, NOT the `pnpm` field in
* package.json (that field is now ignored and emits a deprecation warning).
* npm/yarn/bun build native modules by default and ignore this file.
Expand Down Expand Up @@ -147,7 +152,10 @@ export const SCAFFOLD_BUILT_DEPENDENCIES = ['better-sqlite3', 'esbuild'];
* hands it to Kysely, and its own sqlite test path uses node's built-in
* `node:sqlite` `DatabaseSync`. There is therefore no better-auth call site
* that could touch an API moved between better-sqlite3 12 and 13 — the
* range is a statement about an instance we never supply.
* range is a statement about an instance we never supply. Re-read
* 2026-09-11 on the 1.7.3 pin (the rendered file's prose carried this
* reading until it shrank to one line per block): the published package
* still names better-sqlite3 only in that peer declaration.
*
* ⚠️ TWO CORRECTIONS to what this entry used to say, both measured:
* • the 13.x copy better-auth binds to is `@objectstack/cli`'s OWN
Expand Down Expand Up @@ -486,85 +494,59 @@ export function renderScaffoldPackageJson(
* Emitting only the older key is what made a freshly scaffolded project fail
* its very first `pnpm install` for every user on pnpm 11. Both lists come
* from the same `builtDeps` argument, so the two populations can never be
* granted different build permission. This is the shape
* `packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml`
* already ships for the other scaffold path.
* granted different build permission.
*
* ## One comment line per block — the reasons live here, not in the file
*
* The rendered file is the first thing a newcomer opens beside a README that
* calls the project a clean slate, and none of the measurements behind it is
* that reader's decision. So each block carries ONE comment line naming its
* reason, and everything else is kept beside the value it explains:
*
* packages: [] this docblock (above) and `SCAFFOLD_PNPM_RANGE`.
* onlyBuiltDependencies, this docblock (the per-pnpm-version table above)
* allowBuilds and `SCAFFOLD_BUILT_DEPENDENCIES` (why each
* package needs its build).
* peerDependencyRules `SCAFFOLD_ALLOWED_PEER_VERSIONS`: one measured
* paragraph per entry, its retirement condition,
* and the retired `@better-auth/scim>better-call`.
*
* The file carried 57 comment lines here, and 66 in the template, until the
* maintainer asked for it to stay minimal with the rationale moved into the
* scaffolder source (#22162). A later change to any value meets its reason in
* this file, which is where the change is made.
*
* `packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml`, the
* other scaffold path, ships these exact bytes: that package cannot import this
* renderer (see the shared emission policy block above), so
* `test/scaffold-workspace-consistency.test.ts` holds the two files equal and
* holds the comment budget, and an edit to one is made to both.
*/
export function renderPnpmWorkspaceYaml(
builtDeps: string[] = SCAFFOLD_BUILT_DEPENDENCIES,
allowedPeerVersions: Record<string, string> = SCAFFOLD_ALLOWED_PEER_VERSIONS,
): string {
const peerEntries = Object.entries(allowedPeerVersions);

// One comment line per block, directly above its key and inside 80 columns
// (held by test/scaffold-workspace-consistency.test.ts). The reasons behind
// each line live in the docblocks named above, not in the user's file.
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.',
'# Older pnpm needs this key; empty because this project is not a monorepo.',
'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',
'# error. Without the build, better-sqlite3 can ship without a usable',
'# binding and `objectstack serve` fails with "Could not locate the',
'# bindings file".',
'#',
'# Both keys are needed; no single pnpm version range reads both:',
'# allowBuilds pnpm >= 10.26, and the ONLY key pnpm 11 reads',
'# — with onlyBuiltDependencies alone pnpm 11',
'# exits 1 exactly as if nothing were approved.',
'# onlyBuiltDependencies pnpm 10.0–10.25, which ignore allowBuilds.',
'# pnpm 11 ignores this key.',
'#',
'# npm, yarn and bun ignore this file and build native modules anyway.',
'# Build-script approvals for pnpm 10.0–10.25, which do not read allowBuilds.',
'onlyBuiltDependencies:',
...builtDeps.map((d) => ` - ${d}`),
'',
'# The same approvals for pnpm 10.26 and later; pnpm 11 reads only this key.',
'allowBuilds:',
...builtDeps.map((d) => ` ${d}: true`),
// No rules, no header: a bare `peerDependencyRules:` would advertise a
// declaration that is not there.
...(peerEntries.length === 0 ? [] : [
'',
'# Third-party peer ranges that resolve outside what their declaring',
'# package states, and that pnpm reports on a first install. None is a',
'# real incompatibility:',
'#',
'# better-auth peers better-sqlite3 ^12.0.0 while the tree resolves 13.x',
'# (the copy @objectstack/cli declares for its own sqlite tooling). That',
'# peer is optional and covers handing better-auth a raw better-sqlite3',
'# `Database`; ObjectStack hands it an ObjectQL adapter instead, so',
'# nothing here goes down that path. Re-measured 2026-09-11 on',
'# better-auth 1.7.3: nothing in the published package references',
'# better-sqlite3 except that peer declaration itself — it only accepts',
'# a Database you construct — so there is no call site that could',
'# depend on what changed between 12 and 13. The upstream',
'# range is stale; pinning back to 12 would just install a second,',
'# unused native copy.',
'#',
'# (The \'@better-auth/scim>better-call\' entry that used to sit here is',
'# retired. The rc-era scim pin peered an exact better-call 1.3.7 against',
'# better-auth\'s own 1.4.0 and this map declared that skew away; the pin',
'# is a stable release now, and @better-auth/scim 1.7.3 peers an exact',
'# better-call 1.4.0 — the one copy better-auth depends on. Re-measured',
'# 2026-09-12 off the installed manifest: the skew is gone.)',
'#',
'# @better-auth/core, /oauth-provider, /scim and /sso each peer an exact',
'# @better-auth/utils 0.4.2, while better-call (better-auth\'s own HTTP',
'# layer) depends on ^0.5.0 and is what the tree resolves them against.',
'# 0.5.0 keeps every symbol those four import — base64/base64Url,',
'# createHash, createRandomStringGenerator — with the same signatures',
'# and the same values on the inputs they pass, so the report is the',
'# only difference. Pinning utils back instead would drag better-call',
'# off its own declared range, which is a real violation rather than a',
'# reported one.',
'#',
'# These suppress the report only — no resolution moves, and the lockfile',
'# is byte-identical with and without this block.',
'# Upstream peer ranges measured harmless; this only silences the warning.',
'peerDependencyRules:',
' allowedVersions:',
...peerEntries.map(([k, v]) => ` '${k}': '${v}'`),
Expand Down
114 changes: 91 additions & 23 deletions packages/cli/test/scaffold-workspace-consistency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,27 @@
// What is compared is the rendered `allowedVersions` MAP — the keys each file
// widens and the version each key is widened to — for the same reason the
// build limb compares rendered grants: no expected value is restated here, so
// each producer's expectation is the other producer. The prose above each
// block is NOT compared: the two files explain these skews in their own words
// on purpose, and the measurement they describe is the same either way.
// each producer's expectation is the other producer.
//
// ── One file, one comment line per block (#22162) ───────────────────────────
//
// The two files used to explain these settings in their own words, 57 and 66
// comment lines of measurements and version history, and the prose was not
// compared. That left a retired entry's explanation shipping in one of them
// with nothing to fail on it (#17093). The maintainer then asked for
// the file to stay minimal, with the rationale in the scaffolder source, and
// triage ruled that both scaffold paths render the same file. So two more
// limbs now hold it:
//
// * the two producers render the SAME BYTES — comments included, which is
// what closes the drift above: there is one text left to keep true;
// * each file stays a settings file — at most one comment line per block,
// directly above its key, inside 80 columns. The measurements live in the
// docblocks of `renderPnpmWorkspaceYaml`, `SCAFFOLD_BUILT_DEPENDENCIES`
// and `SCAFFOLD_ALLOWED_PEER_VERSIONS` in `src/commands/init.ts`.
//
// The limbs above stay as they were: when the bytes do differ, they say WHICH
// declaration or version claim differs, which a byte comparison cannot.

import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
Expand Down Expand Up @@ -141,31 +159,52 @@ function grantedBuilds(yaml: string): Record<(typeof APPROVAL_KEYS)[number], str
/**
* The prose each file attaches to each approval key, as one string per key.
*
* Both files write it as a definition list inside the header comment — the key
* at the start of a comment line, its explanation column-aligned after it, and
* continuation lines indented under that. The two wordings differ and are meant
* to; only the VERSION CLAIMS inside them are compared.
* Each file writes it as the one comment line directly above the key (the
* budget the last limb below holds). Only the VERSION CLAIMS inside it are
* compared, so the limb still says which boundary differs when the bytes do.
*/
function keyProse(yaml: string): Map<string, string> {
const prose = new Map<string, string>();
let current: string | null = null;
for (const line of yaml.split('\n')) {
const definition = /^#[ \t]{2,}(allowBuilds|onlyBuiltDependencies)[ \t]{2,}(\S.*)$/.exec(line);
if (definition) {
current = definition[1];
prose.set(current, definition[2].trim());
continue;
const lines = yaml.split('\n');
lines.forEach((line, i) => {
const key = /^(allowBuilds|onlyBuiltDependencies):/.exec(line)?.[1];
const above = i > 0 ? /^#[ \t]*(\S.*)$/.exec(lines[i - 1]) : null;
if (key && above) prose.set(key, above[1].trim());
});
return prose;
}

/** The widest comment line the rendered file may carry. */
const MAX_COMMENT_COLUMNS = 80;

/**
* Every way a file breaks its comment budget, one entry per offending line.
*
* The budget is at most one comment line per top-level block: a full-line
* comment, directly above its key, inside {@link MAX_COMMENT_COLUMNS}. A line
* count alone would let one block take every line the others save, and a line
* rule without a width would let one line hold a paragraph; this is the
* smallest rule that bounds both, and it adds up to a few hundred bytes of
* comment however the file grows. No value in this file can contain `#` (they
* are package names, versions and `true`), so any `#` is a comment.
*/
function commentBudgetBreaches(yaml: string): string[] {
const lines = yaml.split('\n');
const breaches: string[] = [];
lines.forEach((line, i) => {
if (!line.includes('#')) return;
const where = `line ${i + 1} ${JSON.stringify(line)}`;
if (!line.startsWith('#')) {
breaches.push(`${where} puts a comment inside or after a setting`);
} else if (!/^[A-Za-z][\w-]*:/.test(lines[i + 1] ?? '')) {
breaches.push(`${where} is not the one comment line directly above a top-level key`);
}
// A continuation is an indented comment line that starts no new key; a bare
// `#` (or anything that is not an indented comment) closes the entry.
const continuation = current === null ? null : /^#[ \t]{2,}(\S.*)$/.exec(line);
if (continuation) {
prose.set(current!, `${prose.get(current!)} ${continuation[1].trim()}`);
continue;
const columns = [...line].length;
if (columns > MAX_COMMENT_COLUMNS) {
breaches.push(`${where} is ${columns} columns wide, over ${MAX_COMMENT_COLUMNS}`);
}
current = null;
}
return prose;
});
return breaches;
}

/**
Expand Down Expand Up @@ -274,3 +313,32 @@ describe('the two scaffold paths render the same pnpm build approvals (#10499)',
).toEqual(template);
});
});

describe('the two scaffold paths render one minimal file (#22162)', () => {
it('renders the same bytes in both scaffold paths', () => {
expect(
RENDERED[CLI],
`${CLI} and ${TEMPLATE} write different pnpm-workspace.yaml files. Both scaffold ` +
'paths ship ONE file: make the same edit to renderPnpmWorkspaceYaml() and to the ' +
"template, and put any new reasoning in init.ts's docblocks rather than the file.",
).toBe(RENDERED[TEMPLATE]);
});

it.each(Object.keys(RENDERED) as Producer[])(
'%s carries at most one short comment line per block',
(producer) => {
const yaml = RENDERED[producer];
// Non-vacuity: a file with no top-level key has no block to comment, and
// an empty breach list over it would read as a file within budget.
expect(yaml).toMatch(/^[A-Za-z][\w-]*:/m);
expect(
commentBudgetBreaches(yaml),
`${producer} writes more comment than its settings need. A scaffolded project ` +
'opens with this file, and the measurements behind each block belong in the ' +
"docblocks of renderPnpmWorkspaceYaml, SCAFFOLD_BUILT_DEPENDENCIES and " +
'SCAFFOLD_ALLOWED_PEER_VERSIONS in src/commands/init.ts: one comment line per ' +
`block, directly above its key, at most ${MAX_COMMENT_COLUMNS} columns.`,
).toEqual([]);
},
);
});
9 changes: 7 additions & 2 deletions packages/create-objectstack/src/template-consistency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,9 +410,14 @@ describe('templates survive npm packing', () => {
// of every AI-written app on the platform, so a line that reads as "add member
// packages here" is the expensive half of that choice.
//
// `objectstack init` renders the second copy of this rule
// `objectstack init` renders the second copy of this file
// (`renderPnpmWorkspaceYaml` in packages/cli/src/commands/init.ts) and is
// ratcheted there; both paths carry it until the two renderers are unified.
// ratcheted there. The two are one file (#22162): the same bytes, one comment
// line per block, held by packages/cli/test/scaffold-workspace-consistency.test.ts.
// So the measurements behind every block of this file — the pnpm version table,
// why each build is approved, each peer skew and when it retires — live in
// init.ts's docblocks (`renderPnpmWorkspaceYaml`, `SCAFFOLD_BUILT_DEPENDENCIES`,
// `SCAFFOLD_ALLOWED_PEER_VERSIONS`), not in the file a new project opens.
describe('blank template explicit empty workspace', () => {
const wsPath = path.join(pkgRoot, 'src', 'templates', 'blank', 'pnpm-workspace.yaml');
// Same comment-stripping as the blocks below: the prose above the key names
Expand Down
Loading
Loading