@erclx/canon 5.59.0 → 5.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.60.0](https://github.com/erclx/canon/compare/v5.59.0...v5.60.0) (2026-10-24)
4
+
5
+
6
+ ### Features
7
+
8
+ * **design:** add canon design audit verb ([#2661](https://github.com/erclx/canon/issues/2661)) ([303a52a](https://github.com/erclx/canon/commit/303a52ada294d9740205885336b6f1ce006ddbba))
9
+ * **skills:** reach design-taste in planner and later review passes ([#2657](https://github.com/erclx/canon/issues/2657)) ([dedc351](https://github.com/erclx/canon/commit/dedc35132a57cf8f82e9a25603e6128e0c77d6ed))
10
+ * **tooling:** bound the dev dependency install ([#2666](https://github.com/erclx/canon/issues/2666)) ([09017a6](https://github.com/erclx/canon/commit/09017a6bfcf11d3feffa1eccfa1ec99f8a0ff3ae))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **sync:** drop retired domains when reading the stamp ([#2664](https://github.com/erclx/canon/issues/2664)) ([21e0a1c](https://github.com/erclx/canon/commit/21e0a1cccfcab13dba5e50e3a55ea620d0d77907))
16
+
3
17
  ## [5.59.0](https://github.com/erclx/canon/compare/v5.58.0...v5.59.0) (2026-10-24)
4
18
 
5
19
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
4
- "version": "5.59.0",
4
+ "version": "5.60.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -17,7 +17,7 @@ Length, placement, and staleness answer to different evidence, and one list hand
17
17
  - Propose a split or an owner as a structure line for each overgrown entry and doubled or unnamed coverage finding, with the headings and the reason
18
18
  - Ship the staleness axis as a reading rather than cutting it for lacking a verb
19
19
  - Report a refusal, an absent key, or a missing subcommand as an unread axis naming the cause, and say which layer answered the classifier sweep
20
- - Run standalone, read the requirements record for identity drift when the scope reaches it, and print the `canon records stale canonical` reminder
20
+ - Run standalone, reading the requirements record for drift with its reminder, and the design record through `canon design audit --json`, routing its findings to `design-record`
21
21
 
22
22
  ## Must not
23
23
 
@@ -60,6 +60,16 @@ A refusal, an absent key, and a subcommand the project's binary does not carry e
60
60
  - `canon context audit` refuses with `reason: "no-folders"` where the layout is not the expected one. Reading that honest refusal as an empty finding set reports a healthy corpus nothing looked at.
61
61
  - A narrower read is not an unread one. The sweep running on its regex layer alone still answered, so report it with the layer named.
62
62
 
63
+ ### The design record
64
+
65
+ `canon/DESIGN.md` reads outside both verbs above, since neither measures its prose budget or its tokens. When the scope reaches it, read it through its own verb:
66
+
67
+ ```bash
68
+ canon design audit --json
69
+ ```
70
+
71
+ The record carries `prose`, the lines past the budget with their counts, and `tokens`, the custom properties the stylesheet does not declare. `${CLAUDE_SKILL_DIR}/references/axes.md` holds how each reads and where it routes. Skip the verb when the scope excludes the record or the record is `null`.
72
+
63
73
  ## Step 3: read the staleness axis
64
74
 
65
75
  No verb answers whether a document still describes the tree it was written about. Read each document against the surfaces it names and report where the two have parted, taking the file paths, commands, and counts it states as the claims to test.
@@ -121,3 +131,12 @@ Structure `canon/context/surfaces/`: `cli.md` (300 lines) splits into `cli.md` t
121
131
  The line is a proposal for the operator, with the headings each entry takes and why. Health applies nothing, and `document-repair` offers the proposal before it rewrites.
122
132
 
123
133
  Keep the evidence line under the table on every run. A table read without it flattens a judgment into a measurement, which is the one shape the three classes exist to prevent.
134
+
135
+ ### The design record row
136
+
137
+ When Step 2 read the design record, add a row for `canon/DESIGN.md`, with both findings `measured`.
138
+
139
+ - Put the `prose` findings in the Length cell and the `tokens` findings in the Placement cell
140
+ - Write `unread: <reason>` in the Placement cell when `tokens.status` reads `unchecked`, since a check that could not run is no pass
141
+ - Write `n/a: <reason>` there when it reads `not-applicable`, a values record with no property to compare
142
+ - Route every finding to `design-record`, never to `document-repair`
@@ -73,6 +73,24 @@ Both verbs resolve their folders by surface root rather than by a path written i
73
73
  - The audit refuses with `reason: "no-folders"`, naming the folders it looked for. Report that refusal as an unread axis.
74
74
  - The sweep with the model layer off answers from its regex layer alone, a narrower read rather than a refusal, so say which layer answered
75
75
 
76
+ ## The design record
77
+
78
+ | Field | Value |
79
+ | -------------- | ------------------------------------------------------------------------------------------------ |
80
+ | Measures | Whether the design record holds to its prose budget and names only properties its stylesheet has |
81
+ | Verb | `canon design audit --json` |
82
+ | Evidence class | `measured` |
83
+
84
+ The record is exempt from the length and placement verbs above, so this is the only measured read it gets.
85
+
86
+ - `prose`: each place the record passes the budget, with the count and the cap it was read against. Take the caps from the finding rather than from a number written here.
87
+ - `tokens.missing`: a custom property a table names that the stylesheet the lead names does not declare
88
+ - `tokens.status: "unchecked"`: the lead names no stylesheet, or the one it names does not resolve. Report the axis as unread with the `reason`, since nothing was compared.
89
+ - `tokens.status: "not-applicable"`: a values record, which carries `Value` where an intent record carries `Property`. Report it as not applicable with the `reason`.
90
+ - `record: null`: the project holds no design record, so there is nothing to report
91
+
92
+ **Routes to:** `design-record`, which owns the record's shape. `document-repair` rewrites a context folder and does not know the budget.
93
+
76
94
  ## Staleness
77
95
 
78
96
  | Field | Value |
@@ -38,7 +38,7 @@ Read these in parallel from the project root, skipping any that do not exist:
38
38
 
39
39
  Also read `canon/DESIGN.md` when the feature touches code or UI, for visual intent and component rules, with values read from the stylesheet its lead names. Skip it for prose, docs, catalog, or config-only changes. For a UI feature, read `canon/design/index.md` too, if it exists, for the surfaces with an accepted design.
40
40
 
41
- Coding standards live in `.claude/rules/`. Claude Code loads them automatically, and path-scoped rules apply to the files they match.
41
+ Coding standards live in `.claude/rules/`. Claude Code loads them automatically, and path-scoped rules apply to the files they match. For a UI feature, load `canon:design-taste` whether or not `canon/DESIGN.md` exists.
42
42
 
43
43
  ### Read the domain's context entry
44
44
 
@@ -78,7 +78,7 @@ Read these in parallel from the project root, skipping any that do not exist:
78
78
  - `.canon/tasks/priority.md`: where this feature sits on the board and what each neighbor waits on, resolved at the main root the way `session-worktree` does, since a linked worktree holds no copy
79
79
  - `.canon/plans/feature-<slug>.md` for the branch, when present: the intent the PR should satisfy, and its `**Review focus:**` section as the inputs this pass must confirm the diff handles
80
80
 
81
- Coding standards from `.claude/rules/` are auto-loaded by Claude Code.
81
+ Coding standards from `.claude/rules/` are auto-loaded by Claude Code for the files a first pass reads. A path-scoped rule loads only when a session reads a matching file, so a later pass opens each changed file through the Read tool per `references/scope.md`, which loads the rules its trunk path matches and names the gap for an added file.
82
82
 
83
83
  ## Step 2: scope the read
84
84
 
@@ -117,3 +117,13 @@ Carry `<prior-heading>` into Step 4 and run Step 3 unchanged. This guard turns o
117
117
  ## The read
118
118
 
119
119
  Read each changed file in scope. Skip deleted files. Run reads in parallel.
120
+
121
+ ### Load the path-scoped rules on a later pass
122
+
123
+ A path-scoped rule loads when a session uses the Read tool on a matching file, and a Bash call such as `git show` loads none. A later pass reads only `<prior-oid>..<headRefOid>`, so the head copy of each changed file has to reach the Read tool.
124
+
125
+ - Open the trunk path with the Read tool when it exists, as the trigger, and take the content from `git show <headRefOid>:<path>`, since trunk content is stale
126
+ - For an added file, write its head blob to `.canon/tmp/pr/review/head-read/<number>-<short-sha>/<path>`, in the review body folder, then open that copy with the Read tool
127
+ - The copy loads only `**/` globs, since a root-anchored glob such as `canon/design/**` misses its prefix, so an added file under one loads no rule
128
+ - Read only files the range changed, never the whole tree, so the cost stays bounded by the range
129
+ - A range whose only change is a deletion has no file to read, so skip it and carry on
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.59.0",
4
+ "version": "5.60.0",
5
5
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -1,7 +1,9 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { join, relative } from 'node:path'
3
3
  import type { Command } from 'commander'
4
+ import { auditDesign } from '@/design/audit'
4
5
  import { buildDesignCss } from '@/design/css'
6
+ import { currentWorktreeRoot } from '@/git/worktree'
5
7
  import { HAND_DRAWN_FONT_FACES } from '@/design/fonts'
6
8
  import { DESIGN_DOCUMENT, regenDesign } from '@/design/regen'
7
9
  import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
@@ -11,7 +13,7 @@ export function register(program: Command): void {
11
13
  const design = program
12
14
  .command('design')
13
15
  .helpOption('-h, --help', 'Show this help message')
14
- .description('Design system commands (regen, css)')
16
+ .description('Design system commands (regen, css, audit)')
15
17
 
16
18
  design
17
19
  .command('regen')
@@ -77,4 +79,58 @@ export function register(program: Command): void {
77
79
  }),
78
80
  )
79
81
  })
82
+
83
+ design
84
+ .command('audit')
85
+ .helpOption('-h, --help', 'Show this help message')
86
+ .description(
87
+ 'Measure the design record against the prose budget and check its custom properties against the stylesheet',
88
+ )
89
+ .option('--json', 'Add a machine-readable record on stdout')
90
+ .option('--root <path>', 'Project root, defaulting to the current worktree')
91
+ .addHelpText(
92
+ 'after',
93
+ [
94
+ '',
95
+ 'Reads canon/DESIGN.md. Prose findings carry their counts against the',
96
+ 'budget the design standard sets. Each custom property in the Colors and',
97
+ 'Shapes tables is checked against the stylesheet the lead names. A lead',
98
+ 'naming none reads as `tokens.status: "unchecked"`, never as a pass.',
99
+ '',
100
+ 'Exit codes:',
101
+ ' 0 the reading completed, whatever it found',
102
+ '',
103
+ 'It gates nothing and writes nothing.',
104
+ '',
105
+ 'Examples:',
106
+ ' canon design audit',
107
+ ' canon design audit --json',
108
+ '',
109
+ ].join('\n'),
110
+ )
111
+ .action(async (opts: { json?: boolean; root?: string }) => {
112
+ const root = opts.root ?? (await currentWorktreeRoot())
113
+ const audit = auditDesign(root)
114
+
115
+ if (opts.json ?? false) {
116
+ process.stdout.write(`${JSON.stringify(audit)}\n`)
117
+ return
118
+ }
119
+
120
+ intro('canon design audit')
121
+ if (audit.record === null) {
122
+ logInfo('No design record to audit')
123
+ } else {
124
+ for (const finding of audit.prose) logWarn(finding)
125
+ if (audit.tokens?.status === 'unchecked') {
126
+ logWarn(`Tokens unchecked: ${audit.tokens.reason}`)
127
+ } else if (audit.tokens?.status === 'checked') {
128
+ for (const property of audit.tokens.missing) {
129
+ logWarn(`${property} is not declared in ${audit.tokens.stylesheet}`)
130
+ }
131
+ }
132
+ if (audit.clean) logInfo(`${audit.record} is within budget`)
133
+ }
134
+ outro()
135
+ })
80
136
  }
@@ -107,6 +107,9 @@ export function register(program: Command): void {
107
107
  'A target below its git root syncs as a subfolder. It withholds',
108
108
  '.github/, which GitHub reads only at the root.',
109
109
  '',
110
+ 'The dependency install is bounded at 120 seconds. A stalled or failed',
111
+ 'install refuses with exit 1, and the stamp stays unwritten.',
112
+ '',
110
113
  'To gate CI on tooling drift, run headlessly with neither flag. That',
111
114
  'exits 1 when a file would be replaced and 0 when none would, which is',
112
115
  'what `canon sync --check --exit-code` spells with a flag. Pass --check',
@@ -444,7 +447,14 @@ async function runSync(
444
447
  }
445
448
 
446
449
  await injectSeeds(prepared.chain, prepared.target)
447
- await injectManifest(prepared.chain, prepared.target)
450
+ const manifest = await injectManifest(prepared.chain, prepared.target)
451
+ if (!manifest.ok) {
452
+ return refuse(
453
+ opts,
454
+ manifest.reason,
455
+ `${manifest.message}\nConfigs and seeds above were written. Scripts, gitignore entries, and the stamp were not, so the next sync retries.`,
456
+ )
457
+ }
448
458
 
449
459
  await stampChain(prepared.chain, prepared.target)
450
460
 
@@ -553,7 +563,12 @@ async function runInject(
553
563
  await injectSeeds(prepared.chain, prepared.target)
554
564
  }
555
565
  if (opts.manifest || applyAll) {
556
- await injectManifest(prepared.chain, prepared.target)
566
+ const manifest = await injectManifest(prepared.chain, prepared.target)
567
+ if (!manifest.ok) {
568
+ logWarn(manifest.message)
569
+ if (framed) outro()
570
+ return 1
571
+ }
557
572
  } else if (opts.gitignore) {
558
573
  await injectGitignore(prepared.chain, prepared.target)
559
574
  }
@@ -0,0 +1,109 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import { measureDesignProse } from '@/design/budget'
4
+ import { parseDesignDoc } from '@/design/parse'
5
+ import { DESIGN_DOCUMENT } from '@/design/regen'
6
+
7
+ /** The custom property a table row names, such as `--color-accent`. */
8
+ const PROPERTY = /--[A-Za-z0-9_-]+/g
9
+
10
+ /** A custom property declaration, which a stylesheet and a Tailwind `@theme` block share. */
11
+ const DECLARATION = /(--[A-Za-z0-9_-]+)\s*:/g
12
+
13
+ /** A backticked path to a stylesheet. */
14
+ const STYLESHEET = /`([^`\s]+\.css)`/
15
+
16
+ export type TokenCheck =
17
+ | {
18
+ readonly status: 'checked'
19
+ readonly stylesheet: string
20
+ readonly properties: number
21
+ readonly missing: readonly string[]
22
+ }
23
+ | { readonly status: 'unchecked'; readonly reason: string }
24
+ | { readonly status: 'not-applicable'; readonly reason: string }
25
+
26
+ export interface DesignAudit {
27
+ readonly ok: true
28
+ /** The record read, or null where the project holds none. */
29
+ readonly record: string | null
30
+ readonly prose: readonly string[]
31
+ readonly tokens: TokenCheck | null
32
+ /** True only when the prose is within budget and the token check ran clean. */
33
+ readonly clean: boolean
34
+ }
35
+
36
+ /**
37
+ * Reads a project's design record against the prose budget and checks each
38
+ * custom property its tables name against the stylesheet its lead names. It
39
+ * writes nothing.
40
+ *
41
+ * A token check that cannot run says why under `unchecked` and leaves `clean`
42
+ * false, since a check that did not look is no pass.
43
+ */
44
+ export function auditDesign(root: string): DesignAudit {
45
+ const path = join(root, DESIGN_DOCUMENT)
46
+ if (!existsSync(path)) {
47
+ return { ok: true, record: null, prose: [], tokens: null, clean: true }
48
+ }
49
+
50
+ const source = readFileSync(path, 'utf8')
51
+ const prose = measureDesignProse(source)
52
+ const tokens = checkTokens(root, path, source)
53
+ const clean =
54
+ prose.length === 0 &&
55
+ tokens.status !== 'unchecked' &&
56
+ (tokens.status !== 'checked' || tokens.missing.length === 0)
57
+
58
+ return { ok: true, record: DESIGN_DOCUMENT, prose, tokens, clean }
59
+ }
60
+
61
+ function checkTokens(root: string, path: string, source: string): TokenCheck {
62
+ const doc = parseDesignDoc(path)
63
+ const rows = [...doc.color, ...doc.borders]
64
+
65
+ // A values record renders from a token module and carries `Value` where an
66
+ // intent record carries `Property`, so it has no property to compare.
67
+ if (!rows.some((row) => 'Property' in row)) {
68
+ return {
69
+ status: 'not-applicable',
70
+ reason:
71
+ 'The record has no Property column, so it holds no property to compare.',
72
+ }
73
+ }
74
+
75
+ const lead = source.split(/^##\s/m)[0]
76
+ const named = lead.match(STYLESHEET)?.[1]
77
+ if (named === undefined) {
78
+ return {
79
+ status: 'unchecked',
80
+ reason: 'The lead names no stylesheet, so no property could be checked.',
81
+ }
82
+ }
83
+
84
+ const stylesheet = resolve(root, named)
85
+ if (!stylesheet.startsWith(resolve(root)) || !existsSync(stylesheet)) {
86
+ return {
87
+ status: 'unchecked',
88
+ reason: `The lead names ${named}, which does not resolve in the project.`,
89
+ }
90
+ }
91
+
92
+ const css = readFileSync(stylesheet, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
93
+ const declared = new Set([...css.matchAll(DECLARATION)].map((m) => m[1]))
94
+
95
+ const properties = [
96
+ ...new Set(
97
+ rows.flatMap((row) =>
98
+ [...(row['Property']?.value.matchAll(PROPERTY) ?? [])].map((m) => m[0]),
99
+ ),
100
+ ),
101
+ ]
102
+
103
+ return {
104
+ status: 'checked',
105
+ stylesheet: named,
106
+ properties: properties.length,
107
+ missing: properties.filter((property) => !declared.has(property)),
108
+ }
109
+ }
package/src/design/css.ts CHANGED
@@ -154,8 +154,6 @@ export function fontFaceBlock(
154
154
  }
155
155
 
156
156
  export interface CssOptions {
157
- /** Prepended as a comment, naming what wrote the file and from where. */
158
- readonly banner?: string
159
157
  /**
160
158
  * Component rules ride along by default; a token-only consumer opts out
161
159
  * with `false`. Pass an explicit list to emit those instead of the generic default.
@@ -173,8 +171,6 @@ export function buildDesignCss(
173
171
  tokens: DesignTokens = TOKENS,
174
172
  options: CssOptions = {},
175
173
  ): string {
176
- const banner =
177
- options.banner === undefined ? '' : `/* ${options.banner} */\n\n`
178
174
  const root = [':root {', ...tokenProperties(tokens), '}'].join('\n')
179
175
  const faces =
180
176
  options.embedFonts === true
@@ -193,5 +189,5 @@ export function buildDesignCss(
193
189
  : COMPONENTS
194
190
  if (components) parts.push(componentBlock(components))
195
191
 
196
- return `${banner}${parts.join('\n\n')}\n`
192
+ return `${parts.join('\n\n')}\n`
197
193
  }
package/src/sync/stamp.ts CHANGED
@@ -16,9 +16,10 @@ import { readInstalled } from '@/version/installed'
16
16
  *
17
17
  * A stamp written before the standards or snippets install channel closed
18
18
  * still carries a `standards` or `snippets` record, and one written before the
19
- * design install retired carries a `design` record. `isStamp` ignores the key
20
- * and `sortDomains` drops it on the next write, so the target loses a domain
21
- * nothing can refresh rather than losing the whole file.
19
+ * design install retired carries a `design` record. `isStamp` ignores the key,
20
+ * `readStamp` drops it from what it returns, and `sortDomains` drops it from
21
+ * the file on the next write, so the target loses a domain nothing can refresh
22
+ * rather than losing the whole file.
22
23
  */
23
24
  export const STAMP_DOMAINS = ['governance', 'tooling'] as const
24
25
 
@@ -183,12 +184,31 @@ function readStampFile(path: string): Stamp | undefined {
183
184
 
184
185
  try {
185
186
  const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
186
- return isStamp(parsed) ? parsed : undefined
187
+ return isStamp(parsed) ? withoutRetired(parsed) : undefined
187
188
  } catch {
188
189
  return undefined
189
190
  }
190
191
  }
191
192
 
193
+ /**
194
+ * A stamp written before a domain retired still names it until the next write.
195
+ * Filtering here keeps a report on a target that has not synced since from
196
+ * listing a domain nothing can refresh. The file itself is left as it is.
197
+ */
198
+ function withoutRetired(stamp: Stamp): Stamp {
199
+ const domains: Partial<Record<StampDomain, DomainStamp>> = {}
200
+ for (const domain of STAMP_DOMAINS) {
201
+ const record = stamp.domains[domain]
202
+ if (record !== undefined) domains[domain] = record
203
+ }
204
+
205
+ const covers = Array.isArray(stamp.covers)
206
+ ? stamp.covers.filter((domain) => STAMP_DOMAINS.includes(domain))
207
+ : []
208
+
209
+ return { covers, domains }
210
+ }
211
+
192
212
  /** The newest canon release that has written this domain, when one was recorded. */
193
213
  export function stampedVersion(
194
214
  stamp: Stamp | undefined,
@@ -1,8 +1,8 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
3
3
  import { dirname, join, relative } from 'node:path'
4
- import { $ } from 'bun'
5
4
  import { copyPreservingMode } from '@/sync/copy'
5
+ import { type InstallResult, installDevDependencies } from '@/tooling/install'
6
6
  import { rewritesOnInstall, stripSeedMarker } from '@/tooling/seed-marker'
7
7
  import { resolveSurfacePath } from '@/roots/surface'
8
8
  import { mergeSections, pruneSections } from '@/tooling/gitignore'
@@ -165,7 +165,7 @@ export async function pruneGitignore(
165
165
  export async function injectManifest(
166
166
  chain: readonly Manifest[],
167
167
  target: string,
168
- ): Promise<void> {
168
+ ): Promise<InstallResult> {
169
169
  const packagePath = join(target, 'package.json')
170
170
 
171
171
  if (!existsSync(packagePath) && declaresInstalls(chain)) {
@@ -183,7 +183,8 @@ export async function injectManifest(
183
183
 
184
184
  if (missing.length > 0) {
185
185
  logStep('Installing missing dependencies')
186
- await $`bun add -D ${missing}`.cwd(target)
186
+ const installed = await installDevDependencies(missing, target)
187
+ if (!installed.ok) return installed
187
188
  for (const spec of missing) logAdd(spec)
188
189
  }
189
190
 
@@ -202,6 +203,8 @@ export async function injectManifest(
202
203
  await injectGitignore(chain, target)
203
204
 
204
205
  if (!pkg) warnUnreadablePackage(chain, target)
206
+
207
+ return { ok: true }
205
208
  }
206
209
 
207
210
  /**
@@ -0,0 +1,55 @@
1
+ import { execa } from 'execa'
2
+
3
+ export const INSTALL_TIMEOUT_MS = 120_000
4
+ const TAIL_LINES = 20
5
+
6
+ export type InstallResult =
7
+ | { ok: true }
8
+ | {
9
+ ok: false
10
+ reason: 'install-timeout' | 'install-failed'
11
+ message: string
12
+ }
13
+
14
+ /**
15
+ * Runs `bun add -D` for the specs and bounds it, since a registry that accepts
16
+ * a connection and never answers would otherwise hang the caller for good. A
17
+ * stall or a failure comes back as a refusal naming the packages and the tail
18
+ * of the output, never as a thrown error.
19
+ */
20
+ export async function installDevDependencies(
21
+ specs: readonly string[],
22
+ cwd: string,
23
+ timeoutMs: number = INSTALL_TIMEOUT_MS,
24
+ ): Promise<InstallResult> {
25
+ const tried = specs.join(', ')
26
+ const result = await execa('bun', ['add', '-D', ...specs], {
27
+ cwd,
28
+ all: true,
29
+ reject: false,
30
+ stdin: 'ignore',
31
+ timeout: timeoutMs,
32
+ })
33
+
34
+ if (!result.failed) return { ok: true }
35
+
36
+ if (result.timedOut) {
37
+ return {
38
+ ok: false,
39
+ reason: 'install-timeout',
40
+ message: `Install timed out after ${Math.round(timeoutMs / 1000)}s. Tried: ${tried}`,
41
+ }
42
+ }
43
+
44
+ const detail = tail(result.all) || String(result.shortMessage ?? '')
45
+ return {
46
+ ok: false,
47
+ reason: 'install-failed',
48
+ message: `Install failed. Tried: ${tried}${detail === '' ? '' : `\n${detail}`}`,
49
+ }
50
+ }
51
+
52
+ function tail(output: unknown): string {
53
+ if (typeof output !== 'string') return ''
54
+ return output.replace(/\n$/, '').split('\n').slice(-TAIL_LINES).join('\n')
55
+ }