@erclx/canon 4.2.1 → 4.4.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.
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # The bound on a nested background dispatch, and the recording behind it.
4
+ #
5
+ # An arm invoked without the narration its fixture states dispatched a real
6
+ # `claude --bg` session against the machine's own process table, and nothing in
7
+ # the harness noticed. `snapshot_tree` reads the sandbox and `snapshot_root`
8
+ # reads four scratch directories, and a dispatched session writes into none of
9
+ # them, so the escape watch reported clean throughout and was right to. The run
10
+ # spent real cost until a person found it, and `SIGTERM` alone did not end it.
11
+ #
12
+ # Three mechanisms sit here rather than in `scripts/sandbox/run.sh`, which is the
13
+ # only caller. Each is reachable from a test that never launches a session:
14
+ # `src/sandbox-dispatch.test.ts` drives the shim against a stub binary and the
15
+ # reap against a `sleep`, where proving either through `run.sh` would mean
16
+ # spawning the thing the bound exists to prevent.
17
+
18
+ # The prevention. Writes a `claude` that refuses a background dispatch by flag
19
+ # name and delegates everything else, for `run.sh` to place first on the PATH of
20
+ # the session it spawns.
21
+ #
22
+ # The real binary is baked into the file rather than passed through the
23
+ # environment. An env var carrying it would sit in the spawned session's own
24
+ # environment, which hands any arm the exact string that walks around the shim.
25
+ #
26
+ # It names itself in its refusal. No arm shells to `claude` today, and one
27
+ # written tomorrow would meet this and read it as the harness being broken
28
+ # unless the text says which layer refused and why.
29
+ #
30
+ # It is not the whole bound. A dispatch reaching the binary by an absolute path
31
+ # never resolves through PATH at all, which is what `reap_process_group`
32
+ # backstops.
33
+ install_dispatch_shim() {
34
+ local dir="$1"
35
+ local real="$2"
36
+
37
+ cat >"$dir/claude" <<SHIM
38
+ #!/usr/bin/env bash
39
+ # Generated by install_dispatch_shim in scripts/lib/sandbox-dispatch.sh.
40
+ for arg in "\$@"; do
41
+ case "\$arg" in
42
+ --bg | --background)
43
+ echo "canon sandbox harness: refusing \$arg. A sandbox run may not dispatch a background session, so this shim stands first on PATH in place of the real claude binary. Drive the refusal the fixture asks for, or run this outside the harness." >&2
44
+ exit 64
45
+ ;;
46
+ esac
47
+ done
48
+ exec "$real" "\$@"
49
+ SHIM
50
+
51
+ chmod +x "$dir/claude"
52
+ }
53
+
54
+ # True while any process still carries this group id. Reads the whole table
55
+ # rather than `ps -g`, which selects by session on some builds and reports an
56
+ # empty group as a clean exit either way.
57
+ process_group_alive() {
58
+ ps -eo pgid= 2>/dev/null | tr -d ' ' | grep -qx "$1"
59
+ }
60
+
61
+ # The backstop. Signals a process group `run.sh` created, escalating rather than
62
+ # sending one signal and reporting success, since the dispatch that produced this
63
+ # row took two signals to reap.
64
+ #
65
+ # Prints its outcome and always exits zero. Every caller runs under `set -e`, and
66
+ # a survivor is something to report rather than a reason to abandon the verdict
67
+ # the run already took.
68
+ #
69
+ # The group has to be one the run created, and the refusal below is what makes
70
+ # that property travel with the function rather than living in one caller.
71
+ # `run.sh` reads the harness group once and reports `inherited` when the session
72
+ # failed to lead a group of its own, which is a diagnosis; this is the floor
73
+ # under it, and under every caller after it, because the cost of getting the
74
+ # argument wrong is the operator's own shell.
75
+ #
76
+ # What it cannot reach is a dispatch that called `setsid` on its way out, which
77
+ # leaves the group before this reads it. That is the half the shim covers.
78
+ reap_process_group() {
79
+ local pgid="$1"
80
+
81
+ if [ "$pgid" = "$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ')" ]; then
82
+ printf 'refused-own-group\n'
83
+ return 0
84
+ fi
85
+
86
+ if ! process_group_alive "$pgid"; then
87
+ printf 'clear\n'
88
+ return 0
89
+ fi
90
+
91
+ kill -TERM -"$pgid" 2>/dev/null || true
92
+ wait_for_group_exit "$pgid"
93
+
94
+ if ! process_group_alive "$pgid"; then
95
+ printf 'reaped-term\n'
96
+ return 0
97
+ fi
98
+
99
+ kill -KILL -"$pgid" 2>/dev/null || true
100
+ wait_for_group_exit "$pgid"
101
+
102
+ if process_group_alive "$pgid"; then
103
+ printf 'survived\n'
104
+ else
105
+ printf 'reaped-kill\n'
106
+ fi
107
+ return 0
108
+ }
109
+
110
+ # Five seconds per signal. A shorter window reports a survivor for a process
111
+ # that was already exiting, and a longer one stalls a run whose verdict is
112
+ # already taken.
113
+ wait_for_group_exit() {
114
+ local pgid="$1"
115
+ local waited=0
116
+
117
+ while [ "$waited" -lt 20 ] && process_group_alive "$pgid"; do
118
+ sleep 0.25
119
+ waited=$((waited + 1))
120
+ done
121
+ }
122
+
123
+ # The client's own session registry, which is the only surface a nested dispatch
124
+ # leaves a trace on that this harness can read. It is client-owned and carries no
125
+ # contract, so a client that stops writing a record per session breaks the
126
+ # detection silently. That is why what reads it reports rather than asserts.
127
+ sessions_dir() {
128
+ printf '%s/sessions\n' "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
129
+ }
130
+
131
+ # Set whenever the registry directory existed at snapshot time. An absent
132
+ # registry produces the same empty manifest a quiet run does, and the two mean
133
+ # opposite things, so the report carries this rather than letting an unwatched
134
+ # run read as a clean one. Same distinction `escape_watched` draws in `run.sh`,
135
+ # which is also the only reader, hence the exemption on the write below.
136
+ sessions_watched=0
137
+
138
+ # Record names only, never hashes. A live session rewrites its own record on
139
+ # every status change, so a content manifest would name every session running
140
+ # beside this one, which is the noise the escape watch was already narrowed to
141
+ # avoid. A record that appears between the two snapshots is a session that
142
+ # started during the run, and that is the whole question.
143
+ snapshot_sessions() {
144
+ local manifest="$1"
145
+ local dir
146
+ dir="$(sessions_dir)"
147
+
148
+ : >"$manifest"
149
+ [ -d "$dir" ] || return 0
150
+ # shellcheck disable=SC2034
151
+ sessions_watched=1
152
+
153
+ find "$dir" -maxdepth 1 -type f -name '*.json' 2>/dev/null |
154
+ sed 's|.*/||' | sort >"$manifest"
155
+ return 0
156
+ }
157
+
158
+ # The new side alone, unlike `writes_between`. A record that vanished is a
159
+ # session that ended and a record that changed is one that was already running,
160
+ # and neither is a dispatch this run made.
161
+ sessions_between() {
162
+ comm -13 "$1" "$2"
163
+ }
164
+
165
+ # Turns a record name into a line a person can act on. The file can be gone by
166
+ # the time this reads it, since a session that started and exited inside the run
167
+ # takes its record with it, so an unreadable record still reports its name rather
168
+ # than dropping out of the list.
169
+ #
170
+ # One record is one line. The name is written by whatever peer claimed the
171
+ # session, so a newline inside it would split one record across two entries in a
172
+ # list every reader takes as one line each.
173
+ describe_session() {
174
+ local record desc
175
+ record="$(sessions_dir)/$1"
176
+
177
+ desc="$(jq -r '"\(.name // "unnamed") in \(.cwd // "an unrecorded directory")"
178
+ | gsub("\\s+"; " ")' "$record" 2>/dev/null || true)"
179
+ [ -z "$desc" ] && desc="record already gone"
180
+
181
+ printf '%s: %s\n' "$1" "$desc"
182
+ }
@@ -24,4 +24,8 @@ export const MISC_CASES: readonly SkillCase[] = [
24
24
  prompt: "This test just started failing and I don't know why yet.",
25
25
  expect: 'systematic-debugging',
26
26
  },
27
+ {
28
+ prompt: 'Does our github about text still match what the readme says?',
29
+ expect: 'repo-metadata',
30
+ },
27
31
  ]
package/src/cli.ts CHANGED
@@ -37,6 +37,7 @@ import { register as deps } from '@/commands/deps'
37
37
  import { register as labels } from '@/commands/labels'
38
38
  import { register as autoship } from '@/commands/autoship'
39
39
  import { register as pr } from '@/commands/pr'
40
+ import { register as repo } from '@/commands/repo'
40
41
  import { register as census } from '@/commands/census'
41
42
  import { register as targets } from '@/commands/targets'
42
43
  import { register as upgrade } from '@/commands/upgrade'
@@ -86,6 +87,7 @@ function showHelp(): void {
86
87
  `${GREY}│${NC} labels [cmd] ${GREY}# Read a changed set against the pull request label map (audit)${NC}`,
87
88
  `${GREY}│${NC} autoship [cmd] ${GREY}# Decide whether a changed set needs the review pass (classify)${NC}`,
88
89
  `${GREY}│${NC} pr [cmd] ${GREY}# Read a pull request body against its own diff (key-changes)${NC}`,
90
+ `${GREY}│${NC} repo [cmd] ${GREY}# This repository's own remote metadata (metadata propose, apply)${NC}`,
89
91
  `${GREY}│${NC} census [path] ${GREY}# Report tracked file count, extension breakdown, and line totals${NC}`,
90
92
  `${GREY}│${NC} audits [cmd] ${GREY}# Run every health check as one set (run, list)${NC}`,
91
93
  `${GREY}│${NC} gate [cmd] ${GREY}# Run the merge gate stage by stage (run)${NC}`,
@@ -135,6 +137,7 @@ function showHelp(): void {
135
137
  `${GREY}│${NC} canon secrets scan --json`,
136
138
  `${GREY}│${NC} canon deps audit --json`,
137
139
  `${GREY}│${NC} canon labels audit --json`,
140
+ `${GREY}│${NC} canon repo metadata propose --json`,
138
141
  `${GREY}│${NC} canon census --json`,
139
142
  `${GREY}│${NC} canon audits run --json`,
140
143
  `${GREY}│${NC} canon gate run --all --no-write`,
@@ -192,6 +195,7 @@ deps(program)
192
195
  labels(program)
193
196
  autoship(program)
194
197
  pr(program)
198
+ repo(program)
195
199
  census(program)
196
200
  audits(program)
197
201
  gate(program)
@@ -0,0 +1,393 @@
1
+ import { resolve } from 'node:path'
2
+ import type { Command } from 'commander'
3
+ import { execa } from 'execa'
4
+ import { gitEnv } from '@/git-env'
5
+ import {
6
+ compareMetadata,
7
+ type CurrentMetadata,
8
+ isValidTopic,
9
+ type MetadataDiff,
10
+ proposeMetadata,
11
+ } from '@/repo/metadata'
12
+ import {
13
+ intro,
14
+ logAdd,
15
+ logInfo,
16
+ logRemove,
17
+ logStep,
18
+ logWarn,
19
+ outro,
20
+ } from '@/ui'
21
+
22
+ const GH_TIMEOUT_MS = 30_000
23
+
24
+ type SourceRefusal = 'gh-missing' | 'gh-failed'
25
+ type ApplyRefusal =
26
+ | SourceRefusal
27
+ | 'no-changes'
28
+ | 'empty-topics'
29
+ | 'invalid-topics'
30
+ | 'wrong-repo'
31
+
32
+ const REFUSALS: Record<ApplyRefusal, string> = {
33
+ 'gh-missing': 'gh is not on the path, so no remote could be read.',
34
+ 'gh-failed':
35
+ 'gh could not read this repository. Check the remote and gh auth status.',
36
+ 'no-changes':
37
+ 'Nothing to apply. Pass --description, --homepage, or --topics with the answered value.',
38
+ 'empty-topics':
39
+ '--topics carried no usable topic, and this command never reads that as a request to remove every topic the remote carries. Pass the full desired set, or omit the flag to leave topics untouched.',
40
+ 'invalid-topics':
41
+ '--topics carried an entry GitHub does not accept as a topic (lowercase letters, digits, and internal hyphens only). This command never narrows the desired set silently, since dropping it changes what the remaining diff removes.',
42
+ 'wrong-repo':
43
+ 'The remote --root resolved to is not the one --repo named. This command never writes to a repository the invocation did not explicitly confirm.',
44
+ }
45
+
46
+ interface ProposeOptions {
47
+ readonly root?: string
48
+ readonly json?: boolean
49
+ }
50
+
51
+ interface ApplyOptions {
52
+ readonly description?: string
53
+ readonly homepage?: string
54
+ readonly topics?: string
55
+ readonly repo: string
56
+ readonly root?: string
57
+ readonly json?: boolean
58
+ }
59
+
60
+ type CurrentRead =
61
+ | {
62
+ readonly kind: 'read'
63
+ readonly current: CurrentMetadata
64
+ readonly nameWithOwner: string
65
+ }
66
+ | { readonly kind: 'refused'; readonly reason: SourceRefusal }
67
+
68
+ /** Reads what the remote already carries. The one network call this domain makes to read. */
69
+ async function readCurrent(cwd: string): Promise<CurrentRead> {
70
+ if (Bun.which('gh') === null) return { kind: 'refused', reason: 'gh-missing' }
71
+
72
+ try {
73
+ const result = await execa(
74
+ 'gh',
75
+ [
76
+ 'repo',
77
+ 'view',
78
+ '--json',
79
+ 'description,homepageUrl,repositoryTopics,nameWithOwner',
80
+ ],
81
+ { cwd, timeout: GH_TIMEOUT_MS, env: gitEnv(), extendEnv: false },
82
+ )
83
+ const row = JSON.parse(result.stdout) as {
84
+ description?: string
85
+ homepageUrl?: string
86
+ repositoryTopics?: readonly { name: string }[]
87
+ nameWithOwner?: string
88
+ }
89
+ return {
90
+ kind: 'read',
91
+ current: {
92
+ description: row.description ?? '',
93
+ homepage: row.homepageUrl ?? '',
94
+ topics: (row.repositoryTopics ?? []).map((topic) => topic.name),
95
+ },
96
+ nameWithOwner: row.nameWithOwner ?? '',
97
+ }
98
+ } catch {
99
+ return { kind: 'refused', reason: 'gh-failed' }
100
+ }
101
+ }
102
+
103
+ export function register(program: Command): void {
104
+ const repo = program
105
+ .command('repo')
106
+ .description('Read and write this repository’s own remote metadata')
107
+ .helpOption('-h, --help', 'Show this help message')
108
+
109
+ const metadata = repo
110
+ .command('metadata')
111
+ .description('The About description, homepage, and topics GitHub shows')
112
+ .helpOption('-h, --help', 'Show this help message')
113
+
114
+ metadata
115
+ .command('propose')
116
+ .description(
117
+ 'Compare a locally computed description, homepage, and topic set against the remote',
118
+ )
119
+ .helpOption('-h, --help', 'Show this help message')
120
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
121
+ .option('--json', 'Add a machine-readable record on stdout')
122
+ .addHelpText(
123
+ 'after',
124
+ [
125
+ '',
126
+ "Computes from the tree alone: a description from the README's opening",
127
+ 'line past its title and badges, and a homepage and topics from',
128
+ "package.json's own homepage and keywords fields. A field neither file",
129
+ 'declares is never diffed, so an undeclared topic set never reads as a',
130
+ 'proposal to clear what the remote already carries.',
131
+ '',
132
+ 'This is the read half. It never writes. Run `canon repo metadata apply`',
133
+ 'with the answered fields once a proposal is worth taking.',
134
+ '',
135
+ 'Exit codes:',
136
+ ' 0 the remote already carries what this run computed',
137
+ ' 1 refused, with the reason on stderr or in the JSON record',
138
+ ' 2 a difference was found',
139
+ '',
140
+ 'Examples:',
141
+ ' canon repo metadata propose',
142
+ ' canon repo metadata propose --json',
143
+ '',
144
+ ].join('\n'),
145
+ )
146
+ .action(async (opts: ProposeOptions) => {
147
+ process.exitCode = await runPropose(opts)
148
+ })
149
+
150
+ metadata
151
+ .command('apply')
152
+ .description(
153
+ 'Write an explicitly supplied description, homepage, or topic set to the remote',
154
+ )
155
+ .helpOption('-h, --help', 'Show this help message')
156
+ .requiredOption(
157
+ '--repo <owner/name>',
158
+ 'Repository this run is allowed to write to, checked against what gh resolves',
159
+ )
160
+ .option('--description <text>', 'About text to write')
161
+ .option('--homepage <url>', 'Homepage URL to write')
162
+ .option(
163
+ '--topics <list>',
164
+ 'Comma-separated topic set to write, replacing what the remote carries',
165
+ )
166
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
167
+ .option('--json', 'Add a machine-readable record on stdout')
168
+ .addHelpText(
169
+ 'after',
170
+ [
171
+ '',
172
+ 'Takes the answered value for each field directly rather than',
173
+ 're-running propose, so a change this writes is always one a person or',
174
+ 'a second invocation already read and confirmed. --topics is the full',
175
+ 'desired set. gh only takes an add and a remove list, so this reads the',
176
+ 'current set once to compute both. An entry shaped like something other',
177
+ 'than a GitHub topic refuses the whole run rather than being dropped.',
178
+ '',
179
+ '--repo is required and takes no default, since --root silently',
180
+ 'resolving to the caller’s own cwd is what turned a verification run',
181
+ 'against this exact command into a live write against a public remote.',
182
+ 'The value is checked against what gh resolves for --root, so the',
183
+ 'write refuses on any repository the invocation did not name aloud.',
184
+ '',
185
+ 'Exit codes:',
186
+ ' 0 the write succeeded, or the remote already matched',
187
+ ' 1 refused, with the reason on stderr or in the JSON record',
188
+ '',
189
+ 'Examples:',
190
+ ' canon repo metadata apply --repo erclx/canon --description "One source for conventions."',
191
+ ' canon repo metadata apply --repo erclx/canon --topics cli-tool,governance,standards',
192
+ '',
193
+ ].join('\n'),
194
+ )
195
+ .action(async (opts: ApplyOptions) => {
196
+ process.exitCode = await runApply(opts)
197
+ })
198
+ }
199
+
200
+ function refuse(
201
+ reason: ApplyRefusal,
202
+ emitJson: boolean,
203
+ root: string,
204
+ detail?: string,
205
+ ): number {
206
+ const message =
207
+ detail === undefined ? REFUSALS[reason] : `${REFUSALS[reason]} ${detail}`
208
+
209
+ logStep('Refused')
210
+ logWarn(message)
211
+ outro()
212
+
213
+ if (emitJson) {
214
+ process.stdout.write(`${JSON.stringify({ root, reason, message })}\n`)
215
+ }
216
+ return 1
217
+ }
218
+
219
+ async function runPropose(opts: ProposeOptions): Promise<number> {
220
+ const root = resolve(opts.root ?? process.cwd())
221
+ const emitJson = opts.json ?? false
222
+
223
+ intro('canon repo metadata propose')
224
+
225
+ const [proposal, currentRead] = await Promise.all([
226
+ proposeMetadata(root),
227
+ readCurrent(root),
228
+ ])
229
+
230
+ if (currentRead.kind === 'refused') {
231
+ return refuse(currentRead.reason, emitJson, root)
232
+ }
233
+
234
+ const diff: MetadataDiff = compareMetadata(currentRead.current, proposal)
235
+ const changed = Object.keys(diff).length > 0
236
+
237
+ logStep('Repository')
238
+ logInfo(currentRead.nameWithOwner)
239
+
240
+ logStep('Computed')
241
+ if (
242
+ proposal.description === undefined &&
243
+ proposal.homepage === undefined &&
244
+ proposal.topics === undefined
245
+ ) {
246
+ logInfo('nothing local resolved a description, a homepage, or topics')
247
+ } else {
248
+ if (proposal.description !== undefined) {
249
+ logInfo(`description: ${proposal.description}`)
250
+ }
251
+ if (proposal.homepage !== undefined)
252
+ logInfo(`homepage: ${proposal.homepage}`)
253
+ if (proposal.topics !== undefined) {
254
+ logInfo(`topics: ${proposal.topics.join(', ')}`)
255
+ }
256
+ }
257
+
258
+ logStep(changed ? 'Difference' : 'No difference')
259
+ if (!changed) {
260
+ logInfo('the remote already carries what this run computed')
261
+ } else {
262
+ if (diff.description !== undefined) {
263
+ logWarn(
264
+ `description: "${diff.description.current}" → "${diff.description.proposed}"`,
265
+ )
266
+ }
267
+ if (diff.homepage !== undefined) {
268
+ logWarn(
269
+ `homepage: "${diff.homepage.current}" → "${diff.homepage.proposed}"`,
270
+ )
271
+ }
272
+ if (diff.topics !== undefined) {
273
+ for (const topic of diff.topics.added) logAdd(`topic: ${topic}`)
274
+ for (const topic of diff.topics.removed) logRemove(`topic: ${topic}`)
275
+ }
276
+ logInfo(
277
+ 'Run `canon repo metadata apply` with the answered fields. This run writes nothing.',
278
+ )
279
+ }
280
+
281
+ outro()
282
+
283
+ if (emitJson) {
284
+ process.stdout.write(
285
+ `${JSON.stringify({
286
+ root,
287
+ repo: currentRead.nameWithOwner,
288
+ current: currentRead.current,
289
+ proposal,
290
+ diff,
291
+ })}\n`,
292
+ )
293
+ }
294
+
295
+ return changed ? 2 : 0
296
+ }
297
+
298
+ async function runApply(opts: ApplyOptions): Promise<number> {
299
+ const root = resolve(opts.root ?? process.cwd())
300
+ const emitJson = opts.json ?? false
301
+
302
+ intro('canon repo metadata apply')
303
+
304
+ if (
305
+ opts.description === undefined &&
306
+ opts.homepage === undefined &&
307
+ opts.topics === undefined
308
+ ) {
309
+ return refuse('no-changes', emitJson, root)
310
+ }
311
+
312
+ let desired: ReadonlySet<string> | undefined
313
+ if (opts.topics !== undefined) {
314
+ const entries = opts.topics
315
+ .split(',')
316
+ .map((topic) => topic.trim().toLowerCase())
317
+ .filter((topic) => topic !== '')
318
+ if (entries.length === 0) return refuse('empty-topics', emitJson, root)
319
+
320
+ const invalid = entries.filter((topic) => !isValidTopic(topic))
321
+ if (invalid.length > 0) {
322
+ return refuse(
323
+ 'invalid-topics',
324
+ emitJson,
325
+ root,
326
+ `Invalid: ${invalid.join(', ')}`,
327
+ )
328
+ }
329
+
330
+ desired = new Set(entries)
331
+ }
332
+
333
+ // Read ahead of every write, never only for --topics, since this is the
334
+ // one call that confirms --repo names the remote --root actually resolved
335
+ // to. A write skipping it on a description-only or homepage-only run would
336
+ // reopen the gap --repo exists to close.
337
+ const currentRead = await readCurrent(root)
338
+ if (currentRead.kind === 'refused')
339
+ return refuse(currentRead.reason, emitJson, root)
340
+ if (currentRead.nameWithOwner !== opts.repo) {
341
+ return refuse('wrong-repo', emitJson, root)
342
+ }
343
+
344
+ const args = ['repo', 'edit']
345
+ if (opts.description !== undefined)
346
+ args.push('--description', opts.description)
347
+ if (opts.homepage !== undefined) args.push('--homepage', opts.homepage)
348
+
349
+ if (desired !== undefined) {
350
+ const currentSet = new Set(currentRead.current.topics)
351
+ const toAdd = [...desired].filter((topic) => !currentSet.has(topic))
352
+ const toRemove = currentRead.current.topics.filter(
353
+ (topic) => !desired.has(topic),
354
+ )
355
+
356
+ if (toAdd.length > 0) args.push('--add-topic', toAdd.join(','))
357
+ if (toRemove.length > 0) args.push('--remove-topic', toRemove.join(','))
358
+ }
359
+
360
+ if (args.length === 2) {
361
+ logStep('Applied')
362
+ logInfo('the remote already matches every field supplied. Nothing written.')
363
+ outro()
364
+
365
+ if (emitJson) {
366
+ process.stdout.write(`${JSON.stringify({ root, written: false })}\n`)
367
+ }
368
+ return 0
369
+ }
370
+
371
+ if (Bun.which('gh') === null) return refuse('gh-missing', emitJson, root)
372
+
373
+ try {
374
+ await execa('gh', args, {
375
+ cwd: root,
376
+ timeout: GH_TIMEOUT_MS,
377
+ env: gitEnv(),
378
+ extendEnv: false,
379
+ })
380
+ } catch {
381
+ return refuse('gh-failed', emitJson, root)
382
+ }
383
+
384
+ logStep('Applied')
385
+ logInfo('gh repo edit wrote the supplied fields to the remote.')
386
+ outro()
387
+
388
+ if (emitJson) {
389
+ process.stdout.write(`${JSON.stringify({ root, written: true })}\n`)
390
+ }
391
+
392
+ return 0
393
+ }