diff --git a/.changeset/22162-scaffold-workspace-minimal.md b/.changeset/22162-scaffold-workspace-minimal.md new file mode 100644 index 0000000000..9c16fae311 --- /dev/null +++ b/.changeset/22162-scaffold-workspace-minimal.md @@ -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. diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 929642fba1..ccd65107b0 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -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. @@ -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 @@ -486,9 +494,33 @@ 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, @@ -496,75 +528,25 @@ export function renderPnpmWorkspaceYaml( ): 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}'`), diff --git a/packages/cli/test/scaffold-workspace-consistency.test.ts b/packages/cli/test/scaffold-workspace-consistency.test.ts index dfd45bea70..3aa34de739 100644 --- a/packages/cli/test/scaffold-workspace-consistency.test.ts +++ b/packages/cli/test/scaffold-workspace-consistency.test.ts @@ -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'; @@ -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 { const prose = new Map(); - 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; } /** @@ -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([]); + }, + ); +}); diff --git a/packages/create-objectstack/src/template-consistency.test.ts b/packages/create-objectstack/src/template-consistency.test.ts index bf3706864a..d32c819cf8 100644 --- a/packages/create-objectstack/src/template-consistency.test.ts +++ b/packages/create-objectstack/src/template-consistency.test.ts @@ -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 diff --git a/packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml b/packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml index ef9209b74d..ef6296429b 100644 --- a/packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml +++ b/packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml @@ -1,81 +1,17 @@ -# 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 install scripts unless they are approved here. -# Without this file a fresh `pnpm install` on pnpm 11 exits 1 with -# ERR_PNPM_IGNORED_BUILDS — pnpm 10 only warned, pnpm 11 made it a hard error. -# -# Both keys are needed; they are read by different pnpm versions: -# allowBuilds pnpm >= 10.26 and pnpm 11+. pnpm 11 reads ONLY this -# one — onlyBuiltDependencies alone still errors. -# onlyBuiltDependencies pnpm 10.0–10.25, which do not understand allowBuilds. -# -# better-sqlite3 is the native sqlite driver (@objectstack/driver-sql's optional -# dependency); esbuild compiles objectstack.config.ts. Both ship prebuilt -# binaries, so an unapproved build degrades rather than breaks — but pnpm 11 -# fails the install before it gets that far. -# -# npm and yarn ignore this file; it costs them nothing. - +# Build-script approvals for pnpm 10.0–10.25, which do not read allowBuilds. onlyBuiltDependencies: - better-sqlite3 - esbuild +# The same approvals for pnpm 10.26 and later; pnpm 11 reads only this key. allowBuilds: better-sqlite3: true esbuild: true -# Some third-party peer ranges resolve outside what their declaring package -# states. `pnpm install` reports each one as an unmet peer — which would be the -# first thing a brand-new project shows you — and 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, and the one pnpm -# names in the report. 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. First measured behaviourally -# on 1.7.1 — better-auth's own Kysely dialect, running its migrations, -# sign-up, sign-in and adapter find/update/delete, behaves identically on -# better-sqlite3 13.0.3 and on 12.11.1. Re-measured on the pinned 1.7.2 and -# now structural: no file in the published package references better-sqlite3 -# at all, so no better-auth call site can depend on what moved between 12 and -# 13. The upstream range is stale. Pinning our own declaration back to 12 -# would clear the report only by installing a second, unused native copy. -# -# (The '@better-auth/scim>better-call' entry that used to sit here retired -# with the scim release-candidate pin — stable @better-auth/scim 1.7.3 peers -# better-call 1.4.0 exactly, so the skew it declared away is gone. First -# measured on 1.7.1 when the entry retired; re-read 2026-09-12 on the 1.7.3 -# pin, off the installed manifest.) -# -# @better-auth/core, @better-auth/oauth-provider, @better-auth/scim and -# @better-auth/sso each peer an exact @better-auth/utils 0.4.2, while the -# tree hands them 0.5.0 — because better-call, better-auth's own HTTP layer, -# depends on ^0.5.0, and that is the copy your project's plugins resolve -# their peer against. Measured on the surface the range governs: those four -# import three symbols in total (base64/base64Url, createHash and, in core, -# createRandomStringGenerator), 0.5.0 declares all three unchanged, and on -# the inputs those call sites pass the two versions return identical values — -# checked again end to end through better-auth with the sso, oauth-provider -# and scim plugins, where both resolutions produce the same sign-up, sign-in, -# session, OAuth metadata, PKCE challenge and SCIM/SSO responses. Forcing -# utils back to 0.4.2 instead would push better-call off its own declared -# ^0.5.0 — trading four reported skews for one real one. -# Reported by pnpm 10.15–10.30; pnpm 10.31 changed peer resolution so all -# four land on 0.4.2 on their own. These four entries retire when the pnpm -# floor reaches 10.31, or when the four packages accept 0.5.0 upstream — -# NOT with the SCIM rc pin above, which the utils skew outlives. -# -# 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: 'better-auth>better-sqlite3': '13'