gitnexushub 0.4.4 → 0.6.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,680 @@
1
+ /**
2
+ * `gnx install-ci` (v2) — opens a PR on the user's GitHub repo that
3
+ * adds the single Claude-led GitNexus CI workflow, using the user's
4
+ * local `gh` CLI rather than a Hub-side OAuth-write grant.
5
+ *
6
+ * v1 shipped three workflow files (gitnexus.yml + claude.yml +
7
+ * claude-code-review.yml) and a `--no-claude` opt-out. v2 collapses
8
+ * to one file and makes Claude mandatory: the workflow's only review
9
+ * mechanism is the `anthropics/claude-code-action@v1` step, primed
10
+ * with a pre-computed Context Pack from `Akon-Labs/gitnexus-check@v2`.
11
+ *
12
+ * Pivot rationale (unchanged from v1): we don't want the Hub to keep
13
+ * a write-capable GitHub token per user. Doing the GitHub write from
14
+ * the user's machine via `gh` keeps the Hub free of `workflow` /
15
+ * `repo` OAuth scopes.
16
+ *
17
+ * Flow:
18
+ * 1. Detect git repo + GitHub remote.
19
+ * 2. Resolve fullName → repoId via `GET /api/repos`.
20
+ * 3. POST install-ci-bundle (mints CI token, renders workflow YAML).
21
+ * 4. `gh` preflight (installed, authed, has `repo` scope).
22
+ * 5. Push branch + PUT workflow file + open PR.
23
+ * 6. Set GITNEXUS_TOKEN + CLAUDE_CODE_OAUTH_TOKEN secrets via stdin.
24
+ *
25
+ * The exported `runInstallCi` is the commander entry point; the
26
+ * GitHub-write helpers (`pushWorkflowAndOpenPr`, `setRepoSecret`)
27
+ * accept an injected `runGh` for unit-testability.
28
+ */
29
+ import { execFileSync } from 'child_process';
30
+ import * as fs from 'fs';
31
+ import pc from 'picocolors';
32
+ import { isGitRepo, getGitRemoteUrl, parseGitRemote, matchRepo } from './project.js';
33
+ import { warn, resolveAuth } from './cli-helpers.js';
34
+ const WORKFLOW_PATH = '.github/workflows/gitnexus.yml';
35
+ const COMMIT_MESSAGE = 'Install GitNexus PR review workflow';
36
+ const PR_TITLE = 'Install GitNexus PR review workflow';
37
+ const PR_BODY = 'Adds the GitNexus PR review workflow.\n\n' +
38
+ 'On every PR, this workflow:\n' +
39
+ '\n' +
40
+ '1. Runs `Akon-Labs/gitnexus-check@v2` to pre-compute a Context Pack from the GitNexus knowledge graph.\n' +
41
+ '2. Hands that pack to `anthropics/claude-code-action@v1`, which posts a single review comment grounded in the graph.\n' +
42
+ '\n' +
43
+ 'Merge to enable on every PR. Two repository secrets are required:\n' +
44
+ '\n' +
45
+ '- `GITNEXUS_TOKEN` — set by `gnx install-ci`\n' +
46
+ '- `CLAUDE_CODE_OAUTH_TOKEN` — set by `gnx install-ci` if a local Claude Code login is detected\n';
47
+ // ─── Entry point ─────────────────────────────────────────────────────
48
+ /**
49
+ * Entry point for `gnx install-ci`. Imported by index.ts so commander
50
+ * has a stable handle.
51
+ *
52
+ * Output is structured as 6 numbered steps with a final boxed summary.
53
+ * The styling matches the v1 install-ci output that the user explicitly
54
+ * called out as "polished" — we keep the visual contract.
55
+ */
56
+ export async function runInstallCi(opts) {
57
+ const t0 = Date.now();
58
+ const { api } = await resolveAuth(undefined, opts.hub);
59
+ // ── Step 1: Detect the local repo ──────────────────────────────────
60
+ const step1 = beginStep(1, 6, 'Detecting repository');
61
+ if (!isGitRepo()) {
62
+ step1.failExit('Not in a git repository. cd into your project root and try again.');
63
+ }
64
+ const remoteUrl = getGitRemoteUrl();
65
+ if (!remoteUrl) {
66
+ step1.failExit('No git remote named "origin". Add a GitHub remote and try again.');
67
+ }
68
+ const fullName = parseGitRemote(remoteUrl);
69
+ if (!fullName) {
70
+ step1.failExit(`Could not parse a GitHub repo from origin: ${pc.dim(remoteUrl)}`, 'Only github.com URLs are supported (HTTPS or SSH).');
71
+ }
72
+ const [owner, repoName] = fullName.split('/');
73
+ step1.done(pc.bold(fullName));
74
+ // ── Step 2: Find the repo on the Hub ───────────────────────────────
75
+ const step2 = beginStep(2, 6, 'Looking up repo on Hub');
76
+ let hubRepoId;
77
+ try {
78
+ const hubRepos = await api.listRepos();
79
+ const hubRepo = matchRepo(fullName, hubRepos);
80
+ if (!hubRepo) {
81
+ step2.failExit(`Repo ${pc.bold(fullName)} is not indexed by your Hub.`, `Index it first via the Hub UI, then re-run ${pc.cyan('gnx install-ci')}.`);
82
+ }
83
+ hubRepoId = hubRepo.id;
84
+ step2.done(`indexed (id ${pc.dim(hubRepoId.slice(0, 8))})`);
85
+ }
86
+ catch (err) {
87
+ if (err instanceof StepExited)
88
+ throw err;
89
+ step2.failExit(`Hub lookup failed: ${err instanceof Error ? err.message : String(err)}`);
90
+ return;
91
+ }
92
+ // ── Step 3: gh CLI preflight ───────────────────────────────────────
93
+ const step3 = beginStep(3, 6, 'Checking gh CLI');
94
+ if (!hasGh()) {
95
+ step3.fail('`gh` not found in PATH.');
96
+ console.error('');
97
+ console.error(` ${pc.bold('Install:')}`);
98
+ console.error(` macOS: ${pc.cyan('brew install gh')}`);
99
+ console.error(` Linux: ${pc.cyan('https://github.com/cli/cli/blob/trunk/docs/install_linux.md')}`);
100
+ console.error(` Windows: ${pc.cyan('winget install --id GitHub.cli')}`);
101
+ console.error('');
102
+ console.error(` Then authenticate: ${pc.cyan('gh auth login')}`);
103
+ console.error('');
104
+ console.error(` ${pc.dim('GitNexus uses your gh CLI to push the workflow PR + set secrets,')}`);
105
+ console.error(` ${pc.dim('so the Hub never needs OAuth-write access to your GitHub repos.')}`);
106
+ console.error('');
107
+ return process.exit(1);
108
+ }
109
+ if (!ghIsAuthed()) {
110
+ step3.failExit('`gh` is installed but not authenticated.', `Run: ${pc.cyan('gh auth login')}`);
111
+ }
112
+ const missingScope = ghMissingScope();
113
+ if (missingScope) {
114
+ step3.failExit(`\`gh\` missing the ${pc.bold(missingScope)} scope.`, `Add it with: ${pc.cyan(`gh auth refresh -h github.com -s ${missingScope}`)}`);
115
+ }
116
+ step3.done('authenticated, scope ok');
117
+ // ── Step 4: Mint CI token + render workflow YAML ───────────────────
118
+ const step4 = beginStep(4, 6, 'Minting CI token');
119
+ let bundle;
120
+ try {
121
+ bundle = await api.installCiBundle(hubRepoId);
122
+ }
123
+ catch (err) {
124
+ step4.failExit(`Hub rejected install-ci: ${err instanceof Error ? err.message : String(err)}`);
125
+ return;
126
+ }
127
+ // Hub emits both token and ciToken as the same value for back-compat;
128
+ // prefer ciToken (named for intent). Fall back to token for any future
129
+ // Hub build that drops the alias.
130
+ const ciToken = bundle.ciToken || bundle.token;
131
+ if (!ciToken) {
132
+ step4.failExit('Hub returned an install-ci-bundle with no token. Contact support.');
133
+ return;
134
+ }
135
+ if (!bundle.workflowYaml) {
136
+ step4.failExit('Hub returned an install-ci-bundle with no workflow YAML. Contact support.');
137
+ return;
138
+ }
139
+ step4.done(`token ${pc.dim(ciToken.slice(0, 12) + '...')}`);
140
+ // ── Step 5: Push branch + workflow file + open PR ──────────────────
141
+ const branchName = `gitnexus/install-ci-${Date.now()}`;
142
+ const step5 = beginStep(5, 6, 'Pushing workflow PR');
143
+ let pushResult;
144
+ try {
145
+ step5.progress(`branch ${pc.dim(branchName)}, file ${pc.dim(WORKFLOW_PATH)}`);
146
+ pushResult = await pushWorkflowAndOpenPr({
147
+ owner,
148
+ repo: repoName,
149
+ branchName,
150
+ workflowYaml: bundle.workflowYaml,
151
+ runGh: defaultGhRunner,
152
+ });
153
+ }
154
+ catch (err) {
155
+ step5.fail(`failed to push workflow: ${err instanceof Error ? err.message : String(err)}`);
156
+ console.error(` ${pc.dim('You can rerun safely — the Hub mints fresh tokens each run.')}`);
157
+ console.error('');
158
+ return process.exit(1);
159
+ }
160
+ if (pushResult.alreadyInstalled) {
161
+ step5.done(`already installed — ${pc.cyan(pushResult.prUrl)}`);
162
+ }
163
+ else {
164
+ step5.done(pc.cyan(pushResult.prUrl));
165
+ }
166
+ // ── Step 6: Set GitHub Actions secrets ─────────────────────────────
167
+ const step6 = beginStep(6, 6, 'Setting Actions secrets');
168
+ let gnxSecretSet = false;
169
+ try {
170
+ step6.progress(`writing ${pc.bold('GITNEXUS_TOKEN')}...`);
171
+ await setRepoSecret(defaultGhRunner, fullName, 'GITNEXUS_TOKEN', ciToken);
172
+ gnxSecretSet = true;
173
+ }
174
+ catch (err) {
175
+ step6.subFail(`GITNEXUS_TOKEN: ${err instanceof Error ? err.message : String(err)}`);
176
+ }
177
+ let claudeSecretSet = false;
178
+ let claudeSkipReason = null;
179
+ const claudeToken = readClaudeOAuthToken({
180
+ claudeToken: opts.claudeToken,
181
+ claudeTokenFile: opts.claudeTokenFile,
182
+ });
183
+ if (!claudeToken) {
184
+ claudeSkipReason = 'no-token-provided';
185
+ }
186
+ else {
187
+ try {
188
+ step6.progress('writing CLAUDE_CODE_OAUTH_TOKEN...');
189
+ await setRepoSecret(defaultGhRunner, fullName, 'CLAUDE_CODE_OAUTH_TOKEN', claudeToken);
190
+ claudeSecretSet = true;
191
+ }
192
+ catch (err) {
193
+ claudeSkipReason = 'gh-failed';
194
+ step6.subFail(`CLAUDE_CODE_OAUTH_TOKEN: ${err instanceof Error ? err.message : String(err)}`);
195
+ }
196
+ }
197
+ step6.done(`GITNEXUS_TOKEN ${gnxSecretSet ? '✓' : '✗'}, CLAUDE_CODE_OAUTH_TOKEN ${claudeSecretSet ? '✓' : '⚠'}`);
198
+ // Hard-fail when we couldn't set the Claude secret at all (no token
199
+ // provided AND no gh failure to recover from). v2 mandates a Claude
200
+ // review; shipping an install where the workflow can't authenticate
201
+ // is worse than asking the user to come back with a token.
202
+ if (!claudeSecretSet && claudeSkipReason === 'no-token-provided') {
203
+ console.error('');
204
+ console.error(` ${pc.red('✗')} ${pc.bold('Claude OAuth token required')}`);
205
+ console.error('');
206
+ console.error(` ${pc.dim('GitNexus CI uses Claude to post the PR review. Set up once:')}`);
207
+ console.error('');
208
+ console.error(` ${pc.bold('1.')} Run: ${pc.cyan('claude setup-token')}`);
209
+ console.error(` ${pc.bold('2.')} Copy the printed token`);
210
+ console.error(` ${pc.bold('3.')} Re-run ${pc.cyan('gnx install-ci')} with one of:`);
211
+ console.error('');
212
+ console.error(` ${pc.dim('# preferred — set once in your shell rc')}`);
213
+ console.error(` ${pc.cyan('export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...')}`);
214
+ console.error(` ${pc.cyan('gnx install-ci')}`);
215
+ console.error('');
216
+ console.error(` ${pc.dim('# or one-shot via flag (leaks to shell history)')}`);
217
+ console.error(` ${pc.cyan('gnx install-ci --claude-token=sk-ant-oat01-...')}`);
218
+ console.error('');
219
+ console.error(` ${pc.dim('# or via file (e.g. for ops scripts)')}`);
220
+ console.error(` ${pc.cyan('gnx install-ci --claude-token-file=~/.config/claude-ci-token')}`);
221
+ console.error('');
222
+ console.error(` ${pc.dim('The CI gnx_ token is already minted on the Hub; re-running is safe.')}`);
223
+ console.error('');
224
+ process.exit(1);
225
+ }
226
+ // ── Final summary box ─────────────────────────────────────────────
227
+ const elapsedSec = ((Date.now() - t0) / 1000).toFixed(1);
228
+ printSummaryBox({
229
+ repo: fullName,
230
+ branch: branchName,
231
+ prUrl: pushResult.prUrl,
232
+ gnxSecretSet,
233
+ claudeSecretSet,
234
+ claudeSkipReason,
235
+ rawCiToken: ciToken,
236
+ alreadyInstalled: pushResult.alreadyInstalled,
237
+ elapsedSec,
238
+ });
239
+ }
240
+ // ─── Output helpers ──────────────────────────────────────────────────
241
+ /**
242
+ * Sentinel thrown by `failExit` so callers can distinguish a "we
243
+ * intentionally exited" signal from real surprises. We never actually
244
+ * see the throw because `process.exit(1)` runs first — but typescript
245
+ * needs `never` and we want a real `Error` instance for any test that
246
+ * stubs `process.exit`.
247
+ */
248
+ class StepExited extends Error {
249
+ constructor() {
250
+ super('Step exited');
251
+ this.name = 'StepExited';
252
+ }
253
+ }
254
+ function beginStep(n, total, title) {
255
+ const tag = pc.dim(`[${n}/${total}]`);
256
+ console.log('');
257
+ console.log(`${tag} ${pc.bold(title)}...`);
258
+ const t0 = Date.now();
259
+ return {
260
+ progress(msg) {
261
+ console.log(` ${pc.dim('↳')} ${pc.dim(msg)}`);
262
+ },
263
+ done(suffix) {
264
+ const elapsed = `${((Date.now() - t0) / 1000).toFixed(1)}s`;
265
+ console.log(`${tag} ${pc.green('✓')} ${pc.bold(title)} ${pc.dim('— ' + suffix)} ${pc.dim(`(${elapsed})`)}`);
266
+ },
267
+ fail(msg) {
268
+ console.log(`${tag} ${pc.red('✗')} ${pc.bold(title)} ${pc.red('— ' + msg)}`);
269
+ },
270
+ failExit(msg, hint) {
271
+ console.log(`${tag} ${pc.red('✗')} ${pc.bold(title)} ${pc.red('— ' + msg)}`);
272
+ if (hint) {
273
+ console.error('');
274
+ console.error(` ${hint}`);
275
+ }
276
+ console.error('');
277
+ process.exit(1);
278
+ // unreachable in production; needed for `never` in tests that stub exit.
279
+ throw new StepExited();
280
+ },
281
+ subFail(msg) {
282
+ console.log(` ${pc.red('✗')} ${msg}`);
283
+ },
284
+ };
285
+ }
286
+ /**
287
+ * Final recap. Plain horizontal-rule layout + aligned key/value pairs;
288
+ * avoids ANSI-aware column math (which is fragile across picocolors
289
+ * versions). The "table" is rendered for the secrets list so the
290
+ * status column lines up; everything else is plain indented text that
291
+ * still reads cleanly when ANSI colour is stripped.
292
+ */
293
+ function printSummaryBox(s) {
294
+ const hr = pc.dim('─'.repeat(60));
295
+ console.log('');
296
+ console.log(hr);
297
+ if (s.alreadyInstalled) {
298
+ console.log(` ${pc.cyan('●')} ${pc.bold(`Already installed (verified in ${s.elapsedSec}s)`)}`);
299
+ }
300
+ else {
301
+ console.log(` ${pc.green('✔')} ${pc.bold(`Setup complete in ${s.elapsedSec}s`)}`);
302
+ }
303
+ console.log(hr);
304
+ console.log('');
305
+ const kv = (k, v) => ` ${pc.dim(k.padEnd(10))}${v}`;
306
+ console.log(kv('Repo', pc.bold(s.repo)));
307
+ console.log(kv('Branch', pc.dim(s.branch)));
308
+ console.log(kv('PR', pc.cyan(s.prUrl)));
309
+ console.log('');
310
+ printTable([pc.bold('Secret'), pc.bold('Status')], [
311
+ ['GITNEXUS_TOKEN', s.gnxSecretSet ? pc.green('✓ set') : pc.red('✗ failed')],
312
+ [
313
+ 'CLAUDE_CODE_OAUTH_TOKEN',
314
+ s.claudeSecretSet
315
+ ? pc.green('✓ set')
316
+ : s.claudeSkipReason === 'no-token-provided'
317
+ ? pc.yellow('⚠ creds not found')
318
+ : pc.red('✗ failed'),
319
+ ],
320
+ ]);
321
+ console.log('');
322
+ console.log(` ${pc.bold('Next:')} review and merge ${pc.cyan(s.prUrl)}.`);
323
+ console.log(` The workflow will run automatically on every PR — Claude posts a review comment grounded in your GitNexus graph.`);
324
+ console.log('');
325
+ if (!s.gnxSecretSet) {
326
+ warn('GITNEXUS_TOKEN secret not set. Run manually:');
327
+ console.error(` ${pc.cyan('gh secret set')} GITNEXUS_TOKEN ${pc.cyan('--repo')} ${s.repo}`);
328
+ console.error(` ${pc.dim(`# paste: ${s.rawCiToken}`)}`);
329
+ console.error('');
330
+ warn('Token will not be shown again — re-run `gnx install-ci` to mint a fresh one.');
331
+ console.log('');
332
+ }
333
+ if (!s.claudeSecretSet) {
334
+ if (s.claudeSkipReason === 'no-token-provided') {
335
+ console.log(` ${pc.yellow('!')} ${pc.bold('Claude review:')} ${pc.dim('no Claude Code credentials found locally.')}`);
336
+ console.log(` 1. Run ${pc.cyan('claude setup-token')} (one-time, requires Claude Pro/Max)`);
337
+ console.log(` 2. Re-run ${pc.cyan('gnx install-ci')} — token picked up automatically`);
338
+ console.log(` ${pc.dim('Or set CLAUDE_CODE_OAUTH_TOKEN manually:')} ${pc.cyan(`gh secret set CLAUDE_CODE_OAUTH_TOKEN --repo ${s.repo}`)}`);
339
+ }
340
+ else if (s.claudeSkipReason === 'gh-failed') {
341
+ console.log(` ${pc.yellow('!')} ${pc.bold('Claude review:')} ${pc.dim('CLAUDE_CODE_OAUTH_TOKEN not set. Run manually:')}`);
342
+ console.log(` ${pc.cyan('gh secret set')} CLAUDE_CODE_OAUTH_TOKEN ${pc.cyan('--repo')} ${s.repo}`);
343
+ }
344
+ console.log('');
345
+ }
346
+ }
347
+ /** Strip ANSI CSI (`\x1b[<...>m`) so column widths line up under colour. */
348
+ function visibleWidth(s) {
349
+ // eslint-disable-next-line no-control-regex
350
+ return s.replace(/\x1b\[[0-9;]*m/g, '').length;
351
+ }
352
+ function printTable(header, rows) {
353
+ const cols = header.length;
354
+ const widths = new Array(cols).fill(0);
355
+ for (let c = 0; c < cols; c++)
356
+ widths[c] = visibleWidth(header[c]);
357
+ for (const row of rows) {
358
+ for (let c = 0; c < cols; c++) {
359
+ widths[c] = Math.max(widths[c], visibleWidth(row[c] ?? ''));
360
+ }
361
+ }
362
+ const pad = (s, w) => s + ' '.repeat(Math.max(0, w - visibleWidth(s)));
363
+ const top = ' ' + pc.dim('┌─' + widths.map((w) => '─'.repeat(w)).join('─┬─') + '─┐');
364
+ const mid = ' ' + pc.dim('├─' + widths.map((w) => '─'.repeat(w)).join('─┼─') + '─┤');
365
+ const bot = ' ' + pc.dim('└─' + widths.map((w) => '─'.repeat(w)).join('─┴─') + '─┘');
366
+ const line = (cells) => ' ' +
367
+ pc.dim('│ ') +
368
+ cells.map((cell, i) => pad(cell, widths[i])).join(pc.dim(' │ ')) +
369
+ pc.dim(' │');
370
+ console.log(top);
371
+ console.log(line(header));
372
+ console.log(mid);
373
+ for (const row of rows)
374
+ console.log(line(row));
375
+ console.log(bot);
376
+ }
377
+ // ─── gh CLI helpers ──────────────────────────────────────────────────
378
+ function hasGh() {
379
+ try {
380
+ execFileSync('gh', ['--version'], { stdio: 'pipe' });
381
+ return true;
382
+ }
383
+ catch {
384
+ return false;
385
+ }
386
+ }
387
+ function ghIsAuthed() {
388
+ try {
389
+ execFileSync('gh', ['auth', 'status'], { stdio: 'pipe' });
390
+ return true;
391
+ }
392
+ catch {
393
+ return false;
394
+ }
395
+ }
396
+ /**
397
+ * Returns the missing scope name (e.g. "repo") if the active gh auth
398
+ * session lacks one we need, else null. We check `repo` only — that
399
+ * scope covers everything install-ci does:
400
+ * - push branches & file content (Contents API)
401
+ * - open PRs (Pulls API)
402
+ * - read+write Actions secrets (`repo` is sufficient for repo-level
403
+ * secrets; only org-level secrets need `admin:org`)
404
+ *
405
+ * Falls back to "no missing scope" if we can't parse `gh auth status`
406
+ * — better to let the real call surface the real error than block on
407
+ * a parser misfire across gh versions.
408
+ */
409
+ export function ghMissingScope() {
410
+ let stderr = '';
411
+ try {
412
+ execFileSync('gh', ['auth', 'status'], {
413
+ encoding: 'utf-8',
414
+ stdio: ['ignore', 'pipe', 'pipe'],
415
+ });
416
+ }
417
+ catch (err) {
418
+ const e = err;
419
+ stderr = String(e.stderr ?? '') + String(e.stdout ?? '');
420
+ }
421
+ if (!stderr) {
422
+ try {
423
+ stderr = execFileSync('gh', ['auth', 'status'], {
424
+ encoding: 'utf-8',
425
+ stdio: ['ignore', 'pipe', 'pipe'],
426
+ });
427
+ }
428
+ catch {
429
+ return null;
430
+ }
431
+ }
432
+ const m = stderr.match(/Token scopes:\s*([^\n]+)/i);
433
+ if (!m)
434
+ return null;
435
+ const scopes = m[1]
436
+ .split(/[,\s]+/)
437
+ .map((s) => s.replace(/['"]/g, '').trim())
438
+ .filter(Boolean);
439
+ if (!scopes.some((s) => s === 'repo'))
440
+ return 'repo';
441
+ return null;
442
+ }
443
+ /**
444
+ * Read the user's Claude Code OAuth token from local credentials so
445
+ * we can auto-set CLAUDE_CODE_OAUTH_TOKEN as a GitHub Actions secret
446
+ * — the same source Claude Code's own `/install-github-app` reads.
447
+ *
448
+ * Lookup order (descending preference — first hit wins):
449
+ *
450
+ * 1. `opts.claudeToken` from `--claude-token=<value>` (lowest priority,
451
+ * flagged for completeness — leaks to argv / `ps` / shell history).
452
+ * 2. `opts.claudeTokenFile` from `--claude-token-file=<path>` — reads
453
+ * the file's trimmed contents. Keeps the token off argv.
454
+ * 3. `process.env.CLAUDE_CODE_OAUTH_TOKEN` — the documented happy path.
455
+ * User runs `claude setup-token` once, exports the result via shell
456
+ * rc, then `gnx install-ci` picks it up automatically on every run.
457
+ *
458
+ * Why we no longer auto-detect from `~/.claude/.credentials.json`: that
459
+ * file's `claudeAiOauth.accessToken` is the user's *interactive session*
460
+ * token (~1h expiry, refreshed in-band). It is NOT a valid CI OAuth
461
+ * token — those are minted explicitly by `claude setup-token` and have
462
+ * the longer-lived headless scope set the GHA expects. Live testing on
463
+ * deer-flow PR #11 confirmed: shipping the session token to the GHA
464
+ * causes "Could not resolve auth credentials" inside the bundled
465
+ * Anthropic SDK. Better to fail loud with a clear instruction than to
466
+ * silently set the wrong token.
467
+ *
468
+ * Returns the trimmed token if any source yields one; null otherwise.
469
+ * The caller prints a clear "run `claude setup-token` first" instruction
470
+ * on null and exits non-zero rather than shipping a half-installed CI.
471
+ */
472
+ export function readClaudeOAuthToken(opts) {
473
+ if (opts.claudeToken && opts.claudeToken.trim().length > 0) {
474
+ return opts.claudeToken.trim();
475
+ }
476
+ if (opts.claudeTokenFile) {
477
+ try {
478
+ const raw = fs.readFileSync(opts.claudeTokenFile, 'utf-8').trim();
479
+ if (raw.length > 0)
480
+ return raw;
481
+ }
482
+ catch {
483
+ // File unreadable / missing — fall through to env var.
484
+ }
485
+ }
486
+ const envToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
487
+ if (envToken && envToken.trim().length > 0) {
488
+ return envToken.trim();
489
+ }
490
+ return null;
491
+ }
492
+ /**
493
+ * Default `gh` runner — shells out via execFileSync. Captures stdout
494
+ * + stderr separately so callers can inspect each. When `input` is set,
495
+ * pipes it to stdin (used for `gh secret set` so secret material never
496
+ * lands in argv or process listings). Translates non-zero exit into a
497
+ * thrown Error whose message is the stderr text, matching the contract
498
+ * tests expect.
499
+ */
500
+ export const defaultGhRunner = async (args, opts = {}) => {
501
+ try {
502
+ const stdout = execFileSync('gh', args, {
503
+ encoding: 'utf-8',
504
+ stdio: opts.input !== undefined ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'],
505
+ input: opts.input,
506
+ });
507
+ return { stdout, stderr: '', code: 0 };
508
+ }
509
+ catch (err) {
510
+ const e = err;
511
+ const stderr = typeof e.stderr === 'string'
512
+ ? e.stderr
513
+ : e.stderr instanceof Buffer
514
+ ? e.stderr.toString()
515
+ : '';
516
+ throw new Error(stderr.trim() || e.message || 'gh command failed');
517
+ }
518
+ };
519
+ /**
520
+ * Set a repository-level Actions secret without ever putting the value
521
+ * in argv. `gh secret set NAME --repo OWNER/REPO` reads the secret
522
+ * value from stdin when neither `--body` nor `--body-file` is set —
523
+ * documented behavior across all supported gh versions.
524
+ *
525
+ * (We deliberately avoid `--body-file -`. That flag was added later
526
+ * than our minimum supported gh version; older installs reject it as
527
+ * "unknown flag" and crash the install-ci flow even though the
528
+ * underlying API supports stdin natively.)
529
+ */
530
+ export async function setRepoSecret(runGh, fullName, name, value) {
531
+ await runGh(['secret', 'set', name, '--repo', fullName], { input: value });
532
+ }
533
+ /**
534
+ * Push the workflow file to `branchName` and open a PR.
535
+ *
536
+ * Idempotency:
537
+ * - If `branchName` already exists we keep using it (gh's create-branch
538
+ * fails with "Reference already exists" — caught and ignored).
539
+ * - If the workflow file is already present with byte-identical
540
+ * content AND a matching PR is already open from this head, we
541
+ * mark `alreadyInstalled = true` and skip the PUT + create-PR
542
+ * calls. The branch name embeds a timestamp so practical retries
543
+ * never collide, but tests inject a fixed branch name to drive
544
+ * this code path.
545
+ * - If the file exists with different content, we PUT with the
546
+ * existing sha so it's an update.
547
+ * - If a PR creation returns 422 (duplicate), we fall back to the
548
+ * `pulls?head=...&state=all` lookup and surface that URL.
549
+ *
550
+ * `runGh` is injected so tests can drive the full idempotency matrix
551
+ * without spawning a real `gh`.
552
+ */
553
+ export async function pushWorkflowAndOpenPr(args) {
554
+ const { owner, repo, branchName, workflowYaml, runGh } = args;
555
+ const ownerRepo = `${owner}/${repo}`;
556
+ // 1. Default branch + base sha.
557
+ const repoInfoRaw = await runGh(['api', `repos/${ownerRepo}`]);
558
+ const repoInfo = JSON.parse(repoInfoRaw.stdout);
559
+ const defaultBranch = repoInfo.default_branch;
560
+ const baseRefRaw = await runGh(['api', `repos/${ownerRepo}/git/ref/heads/${defaultBranch}`]);
561
+ const baseRef = JSON.parse(baseRefRaw.stdout);
562
+ const baseSha = baseRef.object.sha;
563
+ // 2. Create the branch (idempotent).
564
+ let branchAlreadyExists = false;
565
+ try {
566
+ await runGh([
567
+ 'api',
568
+ `repos/${ownerRepo}/git/refs`,
569
+ '--method',
570
+ 'POST',
571
+ '-f',
572
+ `ref=refs/heads/${branchName}`,
573
+ '-f',
574
+ `sha=${baseSha}`,
575
+ ]);
576
+ }
577
+ catch (err) {
578
+ const msg = err instanceof Error ? err.message : '';
579
+ if (!/already exists|Reference.+exists/i.test(msg))
580
+ throw err;
581
+ branchAlreadyExists = true;
582
+ }
583
+ // 3. Check existing file content. If it's byte-identical AND the branch
584
+ // pre-existed, we're in the no-op path. Otherwise we PUT (update or
585
+ // create).
586
+ let existingSha;
587
+ let existingContentMatches = false;
588
+ try {
589
+ const existingRaw = await runGh([
590
+ 'api',
591
+ `repos/${ownerRepo}/contents/${WORKFLOW_PATH}?ref=${branchName}`,
592
+ ]);
593
+ const existing = JSON.parse(existingRaw.stdout);
594
+ existingSha = existing.sha;
595
+ if (existing.encoding === 'base64' && existing.content) {
596
+ const decoded = Buffer.from(existing.content, 'base64').toString('utf-8');
597
+ if (decoded === workflowYaml)
598
+ existingContentMatches = true;
599
+ }
600
+ }
601
+ catch (err) {
602
+ // 404 — file doesn't exist on this branch yet, leave existingSha undefined.
603
+ const msg = err instanceof Error ? err.message : '';
604
+ if (!/Not Found|404/i.test(msg))
605
+ throw err;
606
+ }
607
+ // 4. PUT the file unless content matches and we're idempotent-no-oping.
608
+ let skipWrite = false;
609
+ if (branchAlreadyExists && existingContentMatches) {
610
+ skipWrite = true;
611
+ }
612
+ if (!skipWrite) {
613
+ const writeArgs = [
614
+ 'api',
615
+ `repos/${ownerRepo}/contents/${WORKFLOW_PATH}`,
616
+ '--method',
617
+ 'PUT',
618
+ '-f',
619
+ `branch=${branchName}`,
620
+ '-f',
621
+ `message=${COMMIT_MESSAGE}`,
622
+ '-f',
623
+ `content=${Buffer.from(workflowYaml).toString('base64')}`,
624
+ ];
625
+ if (existingSha) {
626
+ writeArgs.push('-f', `sha=${existingSha}`);
627
+ }
628
+ await runGh(writeArgs);
629
+ }
630
+ // 5. Open PR (or surface existing). Always look up state=all on 422 so
631
+ // a closed-but-not-merged PR doesn't trigger an unhelpful crash.
632
+ const createPrArgs = [
633
+ 'api',
634
+ `repos/${ownerRepo}/pulls`,
635
+ '--method',
636
+ 'POST',
637
+ '-f',
638
+ `title=${PR_TITLE}`,
639
+ '-f',
640
+ `head=${branchName}`,
641
+ '-f',
642
+ `base=${defaultBranch}`,
643
+ '-f',
644
+ `body=${PR_BODY}`,
645
+ ];
646
+ let prUrl;
647
+ let prAlreadyExisted = false;
648
+ try {
649
+ const createdRaw = await runGh(createPrArgs);
650
+ prUrl = JSON.parse(createdRaw.stdout).html_url;
651
+ }
652
+ catch (err) {
653
+ // Always try the fallback list query — gh's stderr formatting
654
+ // varies across versions for 422 / "duplicate", so we don't
655
+ // regex-match the message; we just look up state=all and trust
656
+ // GitHub. If no existing PR is found we re-throw the original.
657
+ try {
658
+ const listRaw = await runGh([
659
+ 'api',
660
+ `repos/${ownerRepo}/pulls?head=${owner}:${branchName}&state=all`,
661
+ ]);
662
+ const list = JSON.parse(listRaw.stdout);
663
+ if (list[0]) {
664
+ prUrl = list[0].html_url;
665
+ prAlreadyExisted = true;
666
+ }
667
+ else {
668
+ throw err;
669
+ }
670
+ }
671
+ catch (fallbackErr) {
672
+ // Surface the original error rather than the fallback's parse error.
673
+ throw err;
674
+ }
675
+ }
676
+ return {
677
+ prUrl,
678
+ alreadyInstalled: skipWrite && prAlreadyExisted,
679
+ };
680
+ }