Skip to content

Commit afe1c4e

Browse files
Elon Muskclaude
andauthored
fix(cli): declare the four @better-auth/utils peer skews a fresh scaffold reports (#10931) (#11128)
@better-auth/core, /oauth-provider, /scim and /sso each peer an exact @better-auth/utils@0.4.2 while a scaffolded tree hands them 0.5.0, so a brand-new project's first `pnpm install` opened with four unmet-peer lines the scaffold's own peerDependencyRules block exists to prevent. Measured compatible before widening: the four import three symbols in total (base64/base64Url, createHash, and createRandomStringGenerator in core), 0.5.0 declares all three unchanged, and both versions return identical values on the inputs those call sites pass -- confirmed end to end through better-auth with the sso, oauth-provider and scim plugins. A resolution change was measured and rejected: pinning utils back clears the lines only by dragging better-call@1.4.0 off its own declared ^0.5.0. Both scaffold paths change together, and the cross-file consistency gate gains a peer limb -- the reservation it carried for this card is discharged. Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent f0e7d64 commit afe1c4e

6 files changed

Lines changed: 305 additions & 17 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/cli": patch
3+
"create-objectstack": patch
4+
---
5+
6+
fix(cli): declare the four `@better-auth/utils` peer skews a freshly scaffolded project reports (#10931)
7+
8+
Both scaffold paths emit a `peerDependencyRules.allowedVersions` block whose
9+
stated purpose is that a brand-new project's first `pnpm install` does not open
10+
with a peer-skew report. It declared two skews and left four showing:
11+
12+
```
13+
├─┬ @better-auth/core 1.7.1
14+
│ └── ✕ unmet peer @better-auth/utils@0.4.2: found 0.5.0
15+
├─┬ @better-auth/scim 1.7.0-rc.1
16+
│ └── ✕ unmet peer @better-auth/utils@0.4.2: found 0.5.0
17+
├─┬ @better-auth/oauth-provider 1.7.1
18+
│ └── ✕ unmet peer @better-auth/utils@0.4.2: found 0.5.0
19+
└─┬ @better-auth/sso 1.7.1
20+
└── ✕ unmet peer @better-auth/utils@0.4.2: found 0.5.0
21+
```
22+
23+
`@better-auth/core`, `/oauth-provider`, `/scim` and `/sso` each peer an **exact**
24+
`@better-auth/utils@0.4.2`. The 0.5.0 they are handed comes from
25+
`better-call@1.4.0` — better-auth's own HTTP layer — which *depends* on
26+
`^0.5.0`; `@objectstack/plugin-auth` names the four as direct dependencies
27+
without naming utils, so pnpm satisfies their peer from better-call's copy
28+
instead of better-auth's own exact 0.4.2 dependency.
29+
30+
**Measured compatible before widening, not assumed.** Those four import three
31+
symbols in total: `base64`/`base64Url` (`@better-auth/utils/base64`),
32+
`createHash` (`/hash`) and, in core only, `createRandomStringGenerator`
33+
(`/random`). 0.5.0 declares all three with identical signatures; `/random` is
34+
unchanged apart from formatting, `/base64` swaps `new Uint8Array(data)` for a
35+
helper that *is* `new Uint8Array(data)` on non-strings, and `/hash` only widens
36+
its input coercion for views not backed by a plain `ArrayBuffer`. Run against
37+
the input shapes those call sites actually pass, the two versions agree on every
38+
value; run end to end — better-auth with the `sso`, `oauth-provider` and `scim`
39+
plugins — a tree where the four resolve 0.5.0 and one where they resolve 0.4.2
40+
produce the same transcript: sign-up, sign-in, session, both OAuth metadata
41+
documents, the RFC 7636 PKCE challenge, and the SCIM and SSO endpoint outcomes.
42+
43+
A resolution change was measured too, and rejected: pinning utils back to 0.4.2
44+
clears the four lines only by dragging `better-call@1.4.0` off its own declared
45+
`^0.5.0` — manufacturing one real range violation to silence four benign ones.
46+
47+
Four scoped entries, one per declaring package, matching the block's convention
48+
that each rule widens exactly one declaration. `allowedVersions` suppresses the
49+
report only: the lockfile a scaffold resolves is byte-identical with and without
50+
the block. The version is spelled `0.5.0` exactly rather than `0.5`, so a future
51+
`0.6.0` reports again instead of inheriting this finding.
52+
53+
Both scaffold paths — `objectstack init` (rendered by the CLI) and
54+
`npx create-objectstack` (a copied template file) — are changed together, and
55+
`packages/cli/test/scaffold-workspace-consistency.test.ts` gains a limb that
56+
compares the peer maps the two produce, so they cannot drift apart again.

packages/cli/src/commands/init.ts

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,10 @@ export const SCAFFOLD_BUILT_DEPENDENCIES = ['better-sqlite3', 'esbuild'];
8282
* states, keyed `<declaring package>><peer>` — pnpm's scoped `allowedVersions`
8383
* spelling, so each entry widens exactly one declaration and nothing else.
8484
*
85-
* Both are reported by `pnpm install` on a brand-new scaffold, and neither is a
86-
* real incompatibility. They are declared here because that report is the first
87-
* thing a newcomer sees, on the one screen where they are deciding whether this
88-
* project is solid, and there is nothing they did to cause it.
85+
* Every one of them is reported by `pnpm install` on a brand-new scaffold, and
86+
* none is a real incompatibility. They are declared here because that report is
87+
* the first thing a newcomer sees, on the one screen where they are deciding
88+
* whether this project is solid, and there is nothing they did to cause it.
8989
*
9090
* - `better-auth>better-sqlite3` — better-auth 1.7.1 peers `^12.0.0` while the
9191
* tree resolves 13.x (`@objectstack/driver-sql`'s optional dependency). The
@@ -105,12 +105,62 @@ export const SCAFFOLD_BUILT_DEPENDENCIES = ['better-sqlite3', 'esbuild'];
105105
* HOST's better-call instance, so the single 1.4.0 copy every install
106106
* already resolves is the correct tree, not a skew to repair.
107107
* ⚠️ This entry retires together with the SCIM rc pin — delete both at once.
108+
* Stable `@better-auth/scim@1.7.1` peers `better-call@1.4.0`, so the skew
109+
* this line covers is genuinely gone the moment the pin moves.
108110
*
109-
* `allowedVersions` suppresses the report ONLY; it moves no resolution.
111+
* - `<four>@better-auth/utils` — `@better-auth/core`, `/oauth-provider`,
112+
* `/scim` and `/sso` each peer an EXACT `@better-auth/utils@0.4.2`, while a
113+
* scaffolded tree hands them 0.5.0. The 0.5.0 comes from `better-call@1.4.0`
114+
* (better-auth's own HTTP layer), which DEPENDS on `^0.5.0`;
115+
* `@objectstack/plugin-auth` names the four packages as direct dependencies
116+
* without naming utils, so pnpm satisfies their peer from better-call's copy
117+
* rather than from better-auth's own exact 0.4.2 dependency.
118+
*
119+
* Measured compatible rather than assumed. Those four import exactly three
120+
* symbols across two subpaths — `base64`/`base64Url` (`/base64`),
121+
* `createHash` (`/hash`) and, in core only, `createRandomStringGenerator`
122+
* (`/random`). 0.5.0 exports all three with identical signatures; `/random`
123+
* is unchanged apart from formatting, `/base64` swaps `new Uint8Array(data)`
124+
* for a helper that IS `new Uint8Array(data)` on non-strings, and `/hash`
125+
* only widens its input coercion for views not backed by a plain
126+
* ArrayBuffer. Run against the input shapes those call sites actually pass,
127+
* 0.4.2 and 0.5.0 agree on every value; run end to end (better-auth with the
128+
* sso, oauth-provider and scim plugins), a tree where the four resolve 0.5.0
129+
* and one where they resolve 0.4.2 produce the same transcript — sign-up,
130+
* sign-in, session, both OAuth metadata documents, the RFC 7636 PKCE
131+
* challenge, and the SCIM and SSO endpoint outcomes.
132+
*
133+
* ⛔ A resolution change is the WRONG remedy here, and was measured too:
134+
* pinning utils back to 0.4.2 clears the four lines only by dragging
135+
* `better-call@1.4.0` off its own declared `^0.5.0` — manufacturing one real
136+
* range violation to silence four benign ones.
137+
*
138+
* Spelled `0.5.0` exactly, not `0.5`: 0.5.0 is the version that was
139+
* measured, and a future 0.6.0 SHOULD report again rather than inherit this
140+
* finding.
141+
*
142+
* ⚠️ These four do NOT retire with the SCIM rc pin, even though one of them
143+
* names scim. Stable `@better-auth/scim@1.7.1` still peers
144+
* `@better-auth/utils@0.4.2`, so this skew outlives that pin. They retire
145+
* when the four packages accept 0.5.0 upstream, or when
146+
* `SCAFFOLD_PNPM_RANGE` reaches `>=10.31` — pnpm 10.31 changed peer
147+
* resolution so that all four land on 0.4.2 by themselves. Measured on the
148+
* rendered scaffold, one clean resolve per pnpm version:
149+
*
150+
* pnpm 10.15.0 – 10.30.0 all four reported as unmet peers.
151+
* pnpm >= 10.31.0 resolved to 0.4.2; nothing to report.
152+
*
153+
* `allowedVersions` suppresses the report ONLY; it moves no resolution — the
154+
* lockfile a scaffold resolves is byte-identical with and without this block
155+
* (verified by digest on pnpm 10.15.0 and 10.30.0).
110156
*/
111157
export const SCAFFOLD_ALLOWED_PEER_VERSIONS: Record<string, string> = {
112158
'better-auth>better-sqlite3': '13',
113159
'@better-auth/scim>better-call': '1.4.0',
160+
'@better-auth/core>@better-auth/utils': '0.5.0',
161+
'@better-auth/oauth-provider>@better-auth/utils': '0.5.0',
162+
'@better-auth/scim>@better-auth/utils': '0.5.0',
163+
'@better-auth/sso>@better-auth/utils': '0.5.0',
114164
};
115165

116166
/**
@@ -186,7 +236,7 @@ export function renderScaffoldPackageJson(
186236

187237
/**
188238
* Render the `pnpm-workspace.yaml` that allowlists native build scripts and
189-
* declares the two known-benign peer skews.
239+
* declares the known-benign peer skews.
190240
* Declares an explicit empty `packages: []`: a workspace root with no member
191241
* packages, which is what a single-package scaffold is — the file stays purely
192242
* a settings file. Spelling the key out is what lets pnpm 10.0–10.4 (and 9.x)
@@ -257,9 +307,9 @@ export function renderPnpmWorkspaceYaml(
257307
// declaration that is not there.
258308
...(peerEntries.length === 0 ? [] : [
259309
'',
260-
'# Two third-party peer ranges resolve outside what their declaring package',
261-
'# states, and pnpm reports both on a first install. Neither is a real',
262-
'# incompatibility:',
310+
'# Third-party peer ranges that resolve outside what their declaring',
311+
'# package states, and that pnpm reports on a first install. None is a',
312+
'# real incompatibility:',
263313
'#',
264314
'# better-auth peers better-sqlite3 ^12.0.0 while the tree resolves 13.x.',
265315
'# That peer is optional and covers handing better-auth a raw',
@@ -272,7 +322,18 @@ export function renderPnpmWorkspaceYaml(
272322
'# better-auth plugin has to share the host\'s better-call instance, so',
273323
'# the single 1.4.0 copy is the correct resolution.',
274324
'#',
275-
'# These suppress the report only — no resolution moves.',
325+
'# @better-auth/core, /oauth-provider, /scim and /sso each peer an exact',
326+
'# @better-auth/utils 0.4.2, while better-call (better-auth\'s own HTTP',
327+
'# layer) depends on ^0.5.0 and is what the tree resolves them against.',
328+
'# 0.5.0 keeps every symbol those four import — base64/base64Url,',
329+
'# createHash, createRandomStringGenerator — with the same signatures',
330+
'# and the same values on the inputs they pass, so the report is the',
331+
'# only difference. Pinning utils back instead would drag better-call',
332+
'# off its own declared range, which is a real violation rather than a',
333+
'# reported one.',
334+
'#',
335+
'# These suppress the report only — no resolution moves, and the lockfile',
336+
'# is byte-identical with and without this block.',
276337
'peerDependencyRules:',
277338
' allowedVersions:',
278339
...peerEntries.map(([k, v]) => ` '${k}': '${v}'`),

packages/cli/test/init.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,59 @@ describe('benign peer-skew declarations (#10326)', () => {
342342
expect(settings).toMatch(/^ {4}'@better-auth\/scim>better-call': '1\.4\.0'$/m);
343343
});
344344

345+
it.each([
346+
['@better-auth/core'],
347+
['@better-auth/oauth-provider'],
348+
['@better-auth/scim'],
349+
['@better-auth/sso'],
350+
])('widens %s\'s exact @better-auth/utils peer to the version the tree resolves', (declaring) => {
351+
// Each of the four peers an EXACT `@better-auth/utils@0.4.2`, while the
352+
// tree hands them 0.5.0 — better-call (better-auth's own HTTP layer)
353+
// depends on `^0.5.0`, and that is the copy plugin-auth's direct
354+
// dependencies resolve their peer against. Measured on the surface the
355+
// range governs: the four import three symbols in total (base64/base64Url,
356+
// createHash, and createRandomStringGenerator in core), 0.5.0 declares all
357+
// three unchanged, and both versions return identical values on the inputs
358+
// those call sites pass — confirmed end to end through better-auth with the
359+
// sso, oauth-provider and scim plugins. Pinning utils back instead would
360+
// drag better-call off its own `^0.5.0`, trading four reported skews for a
361+
// real one.
362+
const key = `${declaring}>@better-auth/utils`;
363+
expect(SCAFFOLD_ALLOWED_PEER_VERSIONS[key]).toBe('0.5.0');
364+
expect(settings).toMatch(
365+
new RegExp(`^ {4}'${key.replace(/[/*+?^${}()|[\]\\]/g, '\\$&')}': '0\\.5\\.0'$`, 'm'),
366+
);
367+
});
368+
369+
it('covers every declaring package that peers @better-auth/utils, not some of them', () => {
370+
// The defect this replaces was PARTIAL coverage: two skews were declared
371+
// and four more were not, so the first screen was clean for a third of the
372+
// report. A set assertion is what fails when a fifth declaration appears
373+
// and nobody measures it, or when one of these four is dropped while the
374+
// others stay.
375+
const declaring = Object.keys(SCAFFOLD_ALLOWED_PEER_VERSIONS)
376+
.filter((k) => k.endsWith('>@better-auth/utils'))
377+
.map((k) => k.slice(0, -'>@better-auth/utils'.length))
378+
.sort();
379+
expect(declaring).toEqual([
380+
'@better-auth/core',
381+
'@better-auth/oauth-provider',
382+
'@better-auth/scim',
383+
'@better-auth/sso',
384+
]);
385+
});
386+
387+
it('keeps the @better-auth/utils widening separate from the retiring better-call pin', () => {
388+
// @better-auth/scim appears in TWO entries for two unrelated reasons, and
389+
// they retire on different days: the better-call one goes when scim leaves
390+
// the rc (stable 1.7.1 peers better-call 1.4.0), while the utils one
391+
// outlives it (stable 1.7.1 still peers @better-auth/utils 0.4.2). Deleting
392+
// both together — the obvious move when the rc pin lifts — would silently
393+
// put the utils report back on a newcomer's first screen.
394+
expect(SCAFFOLD_ALLOWED_PEER_VERSIONS['@better-auth/scim>better-call']).toBe('1.4.0');
395+
expect(SCAFFOLD_ALLOWED_PEER_VERSIONS['@better-auth/scim>@better-auth/utils']).toBe('0.5.0');
396+
});
397+
345398
it('renders the rules under peerDependencyRules.allowedVersions', () => {
346399
expect(settings).toMatch(/^peerDependencyRules:$/m);
347400
expect(settings).toMatch(/^ {2}allowedVersions:$/m);

packages/cli/test/scaffold-workspace-consistency.test.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,26 @@
5252
// `@objectstack/cli#test` inputs; without that declaration a template-only diff
5353
// could not reach this suite and its cache would replay a stale green.
5454
//
55-
// ⛔ `peerDependencyRules` is deliberately NOT compared here. The peer-warning
56-
// skew between the two files is #10931, open on this same surface; a limb
57-
// added here would either duplicate that card or pre-empt its ruling.
55+
// ── `peerDependencyRules`, and why it is compared here NOW ──────────────────
56+
//
57+
// This limb was deliberately absent while #10931 was open: that card was the
58+
// ruling on WHICH peer skews the scaffold should declare, and a limb written
59+
// before it would have either duplicated the card or pre-empted its answer.
60+
// #10931 is answered (the four `@better-auth/utils` declarations landed with
61+
// it), so the reservation is discharged and the drift risk is what remains —
62+
// and it is the same two-producer risk the rest of this file exists for. The
63+
// peer block is, if anything, the more fragile of the two: build approvals are
64+
// one flat package list, while a peer rule is a `<declaring>><peer>` key whose
65+
// value has to be RE-MEASURED per entry, so a copy that lands in one file and
66+
// not the other is both easy to make and invisible to either package's own
67+
// tests.
68+
//
69+
// What is compared is the rendered `allowedVersions` MAP — the keys each file
70+
// widens and the version each key is widened to — for the same reason the
71+
// build limb compares rendered grants: no expected value is restated here, so
72+
// each producer's expectation is the other producer. The prose above each
73+
// block is NOT compared: the two files explain these skews in their own words
74+
// on purpose, and the measurement they describe is the same either way.
5875

5976
import { describe, it, expect } from 'vitest';
6077
import { readFileSync } from 'node:fs';
@@ -87,6 +104,21 @@ const [CLI, TEMPLATE] = Object.keys(RENDERED) as [Producer, Producer];
87104
/** The two keys that grant a dependency's build script permission to run. */
88105
const APPROVAL_KEYS = ['allowBuilds', 'onlyBuiltDependencies'] as const;
89106

107+
/**
108+
* The peer skews each file declares, as `<declaring package>><peer>` → version.
109+
*
110+
* Read out of the settings with the prose stripped first, exactly as the build
111+
* grants are: the comments above the block NAME these packages and versions,
112+
* and must never be what satisfies an assertion about the declarations.
113+
*/
114+
function declaredPeerSkews(yaml: string): Record<string, string> {
115+
const settings = yaml.replace(/^\s*#.*$/gm, '');
116+
const block = /^peerDependencyRules:\n[ \t]+allowedVersions:\n((?:[ \t]+.*\n?)*)/m.exec(settings)?.[1] ?? '';
117+
const out: Record<string, string> = {};
118+
for (const m of block.matchAll(/^[ \t]+'([^']+)':[ \t]*'([^']*)'[ \t]*$/gm)) out[m[1]] = m[2];
119+
return out;
120+
}
121+
90122
/**
91123
* The packages each key actually grants a build to, read out of the settings
92124
* with the prose stripped first — the comments below each key NAME these
@@ -214,4 +246,31 @@ describe('the two scaffold paths render the same pnpm build approvals (#10499)',
214246
).toEqual(claimed[TEMPLATE]);
215247
}
216248
});
249+
250+
it('declares the same peer skews, widened to the same versions', () => {
251+
const cli = declaredPeerSkews(RENDERED[CLI]);
252+
const template = declaredPeerSkews(RENDERED[TEMPLATE]);
253+
254+
// Non-vacuity: two empty maps compare equal while declaring nothing, which
255+
// is the state that puts an unmet-peer report on a newcomer's first screen.
256+
for (const [producer, skews] of [[CLI, cli], [TEMPLATE, template]] as const) {
257+
expect(
258+
Object.keys(skews).length,
259+
`${producer} declares no peer skew at all — a scaffolded project's first ` +
260+
'`pnpm install` then opens with an unmet-peer report the user did not cause',
261+
).toBeGreaterThan(0);
262+
}
263+
264+
expect(
265+
cli,
266+
'the two scaffold paths declare different peer skews: ' +
267+
`${CLI} widens {${Object.entries(cli).map(([k, v]) => `${k}=${v}`).join(', ')}} and ` +
268+
`${TEMPLATE} widens {${Object.entries(template).map(([k, v]) => `${k}=${v}`).join(', ')}}. ` +
269+
'Each entry is a per-declaration judgement backed by its own measurement, so a ' +
270+
'key present in one file and missing from the other means half of users see a ' +
271+
'report the other half does not — and a key widened to DIFFERENT versions means ' +
272+
'one of the two is silencing a skew nobody measured. Re-measure before moving ' +
273+
'either file; never copy a value across just to make this pass.',
274+
).toEqual(template);
275+
});
217276
});

0 commit comments

Comments
 (0)