@catalyst-cloud/cli 0.14.9 → 0.15.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +41 -0
  3. package/dist/args.js +34 -2
  4. package/dist/capabilities.js +2 -1
  5. package/dist/cli-update-notice.js +76 -0
  6. package/dist/cli.js +267 -94
  7. package/dist/oauth.js +80 -18
  8. package/dist/onboard-accounts.js +34 -5
  9. package/dist/onboard-automation-management.js +27 -9
  10. package/dist/onboard-capacity.js +10 -1
  11. package/dist/onboard-existing.js +132 -31
  12. package/dist/onboard-github.js +171 -6
  13. package/dist/onboard-headless.js +647 -0
  14. package/dist/onboard-interrupts.js +41 -0
  15. package/dist/onboard-next.js +215 -0
  16. package/dist/onboard-permissions.js +100 -0
  17. package/dist/onboard-personal.js +46 -10
  18. package/dist/onboard-runner-cloud.js +213 -0
  19. package/dist/onboard-runner-egress.js +67 -0
  20. package/dist/onboard-runner.js +780 -0
  21. package/dist/onboard-runtime.js +48 -8
  22. package/dist/onboard-standalone-copy.js +110 -0
  23. package/dist/onboard-team-create.js +267 -0
  24. package/dist/onboard-ui.js +623 -29
  25. package/dist/onboard-workflow.js +311 -168
  26. package/dist/onboard-workspace.js +52 -5
  27. package/dist/onboard.js +359 -207
  28. package/dist/setup-events.js +100 -0
  29. package/dist/setup-onboard-copy.js +438 -0
  30. package/dist/setup-prompt-frame.js +94 -0
  31. package/dist/setup-prompts.js +108 -0
  32. package/dist/setup-render.js +339 -0
  33. package/dist/setup-signin.js +187 -0
  34. package/dist/setup.js +426 -0
  35. package/package.json +7 -3
  36. package/skills/catalyst-github/SKILL.md +1 -1
  37. package/skills/catalyst-linear/SKILL.md +1 -1
  38. package/skills/catalyst-onboard/SKILL.md +3 -2
  39. package/skills/run-this-project/SKILL.md +1 -1
  40. package/skills/unstick/SKILL.md +1 -1
  41. package/skills/what-needs-me/SKILL.md +1 -1
  42. package/skills/what-this-repo-needs/SKILL.md +1 -1
  43. package/skills/whats-happening/SKILL.md +1 -1
  44. package/vendor/README.md +4 -0
  45. package/vendor/self-host/compose.yaml +110 -0
  46. package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.service +14 -0
  47. package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.timer +10 -0
  48. package/vendor/self-host/linux-session-egress/catalyst-session-egress.service +19 -0
  49. package/vendor/self-host/linux-session-egress/session-egress.py +291 -0
  50. package/vendor/self-host/provenance.json +11 -0
package/dist/cli.js CHANGED
@@ -1,22 +1,31 @@
1
1
  // cli.ts — the `catalyst` dispatcher (npm: @catalyst-cloud/cli). Verbs live in
2
2
  // their own modules; this file keeps every export the 0.1 tests and the bin import.
3
+ import { createSetupRenderer } from "./setup-render.js";
3
4
  import { existsSync, openSync, closeSync } from "node:fs";
4
5
  import { ReadStream as TerminalInput } from "node:tty";
6
+ import { Writable } from "node:stream";
5
7
  import { parseArgs, positionals, verbHelp } from "./args.js";
6
8
  import { CONFIG_MODE, DEFAULT_BASE_URL, LEGACY_PACKAGE_NAME, PACKAGE_NAME, cliPath, configPathFor, contractPathFor, defaultCtx, defaultReplicaDbFor, defaultSkillsDirFor, formatMode, installerOwnsSkills, INSTALL_COMMAND, loadConfig, modernCliPath, normalizeBaseUrl, readManifest, requireConfig, saveConfig, writeConfig, } from "./config.js";
7
9
  import { loadContract, pickPath } from "./contract.js";
8
10
  import { CliError, MeError, UsageError } from "./errors.js";
9
11
  import { fetchMe } from "./transport.js";
10
- import { bearerFor, deviceFlowLogin } from "./oauth.js";
12
+ import { bearerFor, deviceFlowLogin, refreshSessionIfShort, } from "./oauth.js";
13
+ const ONBOARD_SESSION_MIN_MS = 20 * 60_000;
14
+ // A consent step waits up to 10 minutes, so each step starts with more than that left.
15
+ const ONBOARD_STEP_MIN_MS = 11 * 60_000;
11
16
  import { openBrowserChecked as defaultOpenBrowser } from "./browser.js";
12
17
  import { cmdAccounts, cmdExplain, cmdHistory, cmdQueue, cmdRunning, } from "./execution.js";
13
18
  import { cmdQuery } from "./query.js";
19
+ import { maybePrintPublishedUpdateNotice } from "./cli-update-notice.js";
14
20
  import { cmdReady } from "./ready.js";
15
21
  import { cmdReplica } from "./replica.js";
16
22
  import { cmdRuntime } from "./runtime-verb.js";
17
23
  import { cmdEvents } from "./events.js";
18
24
  import { stageOnboardLogin } from "./onboard-login-candidate.js";
19
25
  import { stdinIsTty } from "./prompt.js";
26
+ import { cmdSetup, SETUP_USAGE } from "./setup.js";
27
+ import { clackSetupAsk, clackSetupSpinner, openSetupTerminal, setupOnboardStreams, } from "./setup-prompts.js";
28
+ import { keepInstalledCliPath, runSetupSignin } from "./setup-signin.js";
20
29
  import { FIRST_STAMPED_VERSION, PROVENANCE_MARKER, parseProvenanceVersion, } from "./skill-shape.js";
21
30
  import { installedBundleVersion, installSkills, parseChangelogEntry, readChangelog, resolveSkillsDir, skillsSourceDir, updateNoticeLine, } from "./skills.js";
22
31
  import { cmdWatch } from "./watch.js";
@@ -38,6 +47,7 @@ import { cmdConnections } from "./connections.js";
38
47
  import { cmdOnboard, onboardErrorJournal, onboardRequiredStepsWaiting, } from "./onboard.js";
39
48
  import { createClackOnboardUi, shouldUseOnboardUi, } from "./onboard-ui.js";
40
49
  import { createOnboardRuntime } from "./onboard-runtime.js";
50
+ import { argvRequestsHeadless, headlessOnboardDeps, headlessUsageRefusal, onboardHeadlessRequested, runOnboardHeadless, } from "./onboard-headless.js";
41
51
  import { savedOnboardBaseUrl } from "./onboard-next.js";
42
52
  import { onboardingReadyReport, observeCloudOnboarding, } from "./onboard-ready.js";
43
53
  import { selectedOnboardTeam } from "./onboard-existing.js";
@@ -66,7 +76,8 @@ export function usageText() {
66
76
  " catalyst login --key <personal-key> [--base-url <url>] (or CATALYST_CLOUD_TOKEN, for a key)",
67
77
  " catalyst join ... (deprecated alias of login; removed in the next minor version)",
68
78
  " catalyst install [--skills-dir <dir>] [--force] (repair path; your agent's own command installs the skills)",
69
- " catalyst onboard [--resume-from <step>] [--only <step>] [--yes] [--dry-run] [--json]",
79
+ " catalyst onboard [--resume-from <step>] [--only <step>] [--runner|--no-runner] [--yes] [--dry-run] [--json]",
80
+ ` catalyst ${SETUP_USAGE}`,
70
81
  " catalyst status | notice | me | ready | accounts",
71
82
  " catalyst mcp add|list|remove (vault references only)",
72
83
  " catalyst contract [--refresh] [--path <a.b.c>]",
@@ -196,6 +207,10 @@ function maybePrintUpdateNotice(args, ctx) {
196
207
  saveConfig(ctx.home, cfg);
197
208
  }
198
209
  export async function main(argv, ctx = defaultCtx(), deps = {}) {
210
+ // CTC-4625: setup passes the install options through to its engine untouched, so they never meet
211
+ // this CLI's own flag tables.
212
+ if (argv[0] === "setup")
213
+ return runSetup(argv.slice(1), ctx, deps);
199
214
  let args;
200
215
  try {
201
216
  args = parseArgs(argv.map((a, i) => (i === 0 && VERB_ALIASES[a] ? VERB_ALIASES[a] : a)));
@@ -203,11 +218,18 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
203
218
  args.command = VERB_ALIASES[args.command];
204
219
  }
205
220
  catch (err) {
221
+ if (argvRequestsHeadless(argv, ctx.env))
222
+ return headlessUsageRefusal(ctx, err instanceof Error ? err.message : String(err), argv.includes("--json"), readManifest().version);
206
223
  ctx.stderr(err instanceof Error ? err.message : String(err));
207
224
  ctx.stderr(usageText());
208
225
  return 1;
209
226
  }
210
227
  const manifest = readManifest();
228
+ if (args.json &&
229
+ (args.help || args.version) &&
230
+ args.command === "onboard" &&
231
+ onboardHeadlessRequested(args, ctx.env))
232
+ return headlessUsageRefusal(ctx, "--help and --version print text, not the headless document; run them without --json", true, manifest.version);
211
233
  if (args.version) {
212
234
  ctx.stdout(`${PACKAGE_NAME} ${manifest.version} (tenant contract range: ${manifest.tenantContractRange})`);
213
235
  return 0;
@@ -220,6 +242,12 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
220
242
  ctx.stdout(verbHelp(args.command));
221
243
  return 0;
222
244
  }
245
+ await maybePrintPublishedUpdateNotice(ctx, {
246
+ interactive: deps.isTty?.() ?? process.stdout.isTTY === true,
247
+ json: args.json,
248
+ command: args.command,
249
+ version: manifest.version,
250
+ });
223
251
  try {
224
252
  if (args.command !== "onboard") {
225
253
  migrateLegacyCliPath(ctx);
@@ -231,108 +259,178 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
231
259
  case "install":
232
260
  return cmdInstall(args, ctx);
233
261
  case "onboard": {
234
- let ui;
235
- let terminal;
236
- let input = process.stdin;
237
- const originalRaw = Boolean(input.isRaw);
238
- if (shouldUseOnboardUi(args, Boolean(process.stdout.isTTY))) {
239
- if (!input.isTTY) {
240
- try {
241
- const fd = openSync("/dev/tty", "r");
262
+ // Setup may already own signal handlers; release them before any headless early exit.
263
+ if (onboardHeadlessRequested(args, ctx.env))
264
+ deps.setupUi?.dispose();
265
+ for (const flag of ["key-file"])
266
+ if (args.flags[flag] !== undefined && !onboardHeadlessRequested(args, ctx.env))
267
+ throw new UsageError(`--${flag} needs --headless`);
268
+ if (typeof args.flags.runner === "string" && !onboardHeadlessRequested(args, ctx.env))
269
+ throw new UsageError("--runner yes|no needs --headless; use --runner or --no-runner interactively");
270
+ // Headless runs drop the terminal UI, browser sign-in and every browser action (CTC-4633).
271
+ const runOnboard = async (args, ctx, headless) => {
272
+ let ui = headless ? undefined : deps.setupUi;
273
+ let terminal;
274
+ let input = process.stdin;
275
+ const originalRaw = Boolean(input.isRaw);
276
+ if (!headless && !ui && shouldUseOnboardUi(args, Boolean(process.stdout.isTTY))) {
277
+ if (!input.isTTY) {
278
+ try {
279
+ const fd = openSync("/dev/tty", "r");
280
+ try {
281
+ terminal = new TerminalInput(fd);
282
+ input = terminal;
283
+ }
284
+ catch (error) {
285
+ closeSync(fd);
286
+ throw error;
287
+ }
288
+ }
289
+ catch {
290
+ ctx.stderr("No controlling terminal is available; using the displayed defaults.");
291
+ }
292
+ }
293
+ if (input.isTTY) {
242
294
  try {
243
- terminal = new TerminalInput(fd);
244
- input = terminal;
295
+ ui = createClackOnboardUi(await import("@clack/prompts"), { input, output: process.stdout }, {
296
+ baseUrl: () => savedOnboardBaseUrl(ctx.home),
297
+ verbose: args.flags.verbose === true,
298
+ renderer: createSetupRenderer(process.stdout, ctx.env),
299
+ disposeRenderer: true,
300
+ signinTimeoutMs: deps.onboardSigninTimeoutMs,
301
+ });
245
302
  }
246
303
  catch (error) {
247
- closeSync(fd);
304
+ terminal?.destroy();
248
305
  throw error;
249
306
  }
250
307
  }
251
- catch {
252
- ctx.stderr("No controlling terminal is available; using the displayed defaults.");
253
- }
254
- }
255
- if (input.isTTY) {
256
- try {
257
- ui = createClackOnboardUi(await import("@clack/prompts"), { input, output: process.stdout }, { baseUrl: () => savedOnboardBaseUrl(ctx.home) });
258
- }
259
- catch (error) {
260
- terminal?.destroy();
261
- throw error;
262
- }
263
308
  }
264
- }
265
- try {
266
- return await cmdOnboard(args, ctx, {
267
- ...createOnboardRuntime(args, ctx, {
268
- ui,
309
+ if (!headless && !ui && !args.json && args.flags["dry-run"] !== true) {
310
+ const output = process.stdout.isTTY === true
311
+ ? process.stdout
312
+ : new Writable({
313
+ write(chunk, _encoding, callback) {
314
+ ctx.stdout(chunk.toString().replace(/\n$/, ""));
315
+ callback();
316
+ },
317
+ });
318
+ ui = createClackOnboardUi(await import("@clack/prompts"), { input: process.stdin, output }, {
319
+ baseUrl: () => savedOnboardBaseUrl(ctx.home),
320
+ renderer: createSetupRenderer(output, ctx.env),
321
+ disposeRenderer: true,
269
322
  signinTimeoutMs: deps.onboardSigninTimeoutMs,
270
- stageSignin: (signal) => {
271
- if (args.key || ctx.env.CATALYST_CLOUD_TOKEN)
272
- throw new CliError("Connect your personal key with catalyst login, then run catalyst onboard. Setup will not replace a supplied key with browser sign-in.", "onboard-person-required", 12);
273
- return stageOnboardLogin({ ...ctx, stdout: ctx.stderr }, {
274
- baseUrl: args.baseUrl,
275
- signal,
276
- timeoutMs: deps.onboardSigninTimeoutMs,
277
- device: {
278
- isTty: deps.isTty ?? (() => Boolean(process.stdout.isTTY)),
279
- openBrowser: deps.openBrowser ?? defaultOpenBrowser,
280
- sleep: deps.sleep,
281
- waitForApproval: ui
282
- ? (run) => ui.wait("Waiting for Catalyst approval", run)
283
- : undefined,
284
- },
285
- });
323
+ interactive: false,
324
+ verbose: args.flags.verbose === true,
325
+ });
326
+ }
327
+ // Start with a full access token: a session saved long ago would otherwise expire part way
328
+ // through, or send the person back through the device sign-in. A dry run changes nothing.
329
+ if (args.flags["dry-run"] !== true)
330
+ await refreshSessionIfShort(ctx, ONBOARD_SESSION_MIN_MS);
331
+ const jsonLines = [];
332
+ const onboardCtx = args.json
333
+ ? {
334
+ ...ctx,
335
+ stdout: (line) => {
336
+ jsonLines.push(line);
286
337
  },
287
- login: (stepCtx, signal) => cmdLogin({ ...args, command: "login", flags: {}, json: false }, stepCtx, {
288
- ...deps,
289
- signal,
290
- isTty: () => true,
291
- waitForApproval: ui
292
- ? (run) => ui.wait("Waiting for Catalyst approval", run)
293
- : undefined,
294
- }, true),
295
- ready: async (stepCtx, journal) => {
296
- const team = selectedOnboardTeam(journal);
297
- const report = await onboardingReadyReport(stepCtx, {
298
- teamIds: team ? [team] : undefined,
299
- localSync: journal?.localSync ?? args.flags["local-sync"] === true,
300
- observe: observeCloudOnboarding,
301
- });
302
- const failed = report.checks.some((check) => check.required && check.state === "fail") && !onboardRequiredStepsWaiting(journal);
303
- return {
304
- state: report.state === "complete"
305
- ? "done"
306
- : failed
307
- ? "failed"
308
- : "waiting",
309
- reason: report.state === "complete"
310
- ? undefined
311
- : "onboarding_checks_pending",
312
- evidence: {
313
- checks: report.checks.length,
314
- passed: report.checks.filter((check) => check.state === "pass").length,
315
- },
316
- };
338
+ }
339
+ : ctx;
340
+ let code;
341
+ try {
342
+ code = await cmdOnboard(args, onboardCtx, ((value) => (headless ? headlessOnboardDeps(value, headless, args.flags.runner === true) : value))({
343
+ ...(deps.setupApproved
344
+ ? { reviewedSetup: true, confirm: async () => true }
345
+ : {}),
346
+ beforeStep: async () => {
347
+ if (args.flags["dry-run"] !== true)
348
+ await refreshSessionIfShort(ctx, ONBOARD_STEP_MIN_MS);
317
349
  },
318
- legacy: deps.legacy,
319
- openBrowser: deps.openBrowser,
320
- sleep: deps.sleep,
321
- skillNames: CUSTOMER_SKILLS,
322
- }),
323
- ...(deps.onboardBootstrap
324
- ? { bootstrap: deps.onboardBootstrap }
325
- : {}),
326
- ...(deps.isTty ? { isTty: deps.isTty } : {}),
327
- ...(deps.signal ? { signal: deps.signal } : {}),
328
- }, readManifest().version);
329
- }
330
- finally {
331
- ui?.dispose();
332
- if (input.isTTY && input.isRaw !== originalRaw)
333
- input.setRawMode(originalRaw);
334
- terminal?.destroy();
335
- }
350
+ ...createOnboardRuntime(args, ctx, {
351
+ ui,
352
+ signinTimeoutMs: deps.onboardSigninTimeoutMs,
353
+ stageSignin: (signal) => {
354
+ if (args.key || ctx.env.CATALYST_CLOUD_TOKEN)
355
+ throw new CliError("Connect your personal key with catalyst login, then run catalyst onboard. Setup will not replace a supplied key with browser sign-in.", "onboard-person-required", 12);
356
+ return stageOnboardLogin({
357
+ ...ctx,
358
+ stdout: ui
359
+ ? (line) => ui.message(line)
360
+ : ctx.stderr,
361
+ }, {
362
+ baseUrl: args.baseUrl,
363
+ signal,
364
+ timeoutMs: deps.onboardSigninTimeoutMs,
365
+ device: {
366
+ isTty: deps.isTty ?? (() => Boolean(process.stdout.isTTY)),
367
+ openBrowser: deps.openBrowser ?? defaultOpenBrowser,
368
+ sleep: deps.sleep,
369
+ waitForApproval: ui
370
+ ? (run) => ui.wait("Waiting for Catalyst approval", run)
371
+ : undefined,
372
+ },
373
+ });
374
+ },
375
+ login: (stepCtx, signal) => cmdLogin({ ...args, command: "login", flags: {}, json: false }, stepCtx, {
376
+ ...deps,
377
+ signal,
378
+ isTty: () => true,
379
+ waitForApproval: ui
380
+ ? (run) => ui.wait("Waiting for Catalyst approval", run)
381
+ : undefined,
382
+ }, true),
383
+ ready: async (stepCtx, journal) => {
384
+ const team = selectedOnboardTeam(journal);
385
+ const report = await onboardingReadyReport(stepCtx, {
386
+ teamIds: team ? [team] : undefined,
387
+ localSync: journal?.localSync ?? args.flags["local-sync"] === true,
388
+ observe: observeCloudOnboarding,
389
+ });
390
+ const failed = report.checks.some((check) => check.required && check.state === "fail") && !onboardRequiredStepsWaiting(journal);
391
+ return {
392
+ state: report.state === "complete"
393
+ ? "done"
394
+ : failed
395
+ ? "failed"
396
+ : "waiting",
397
+ reason: report.state === "complete"
398
+ ? undefined
399
+ : "onboarding_checks_pending",
400
+ evidence: {
401
+ checks: report.checks.length,
402
+ passed: report.checks.filter((check) => check.state === "pass").length,
403
+ },
404
+ };
405
+ },
406
+ legacy: deps.legacy,
407
+ openBrowser: deps.openBrowser,
408
+ sleep: deps.sleep,
409
+ skillNames: CUSTOMER_SKILLS,
410
+ }),
411
+ ...(deps.onboardBootstrap
412
+ ? { bootstrap: deps.onboardBootstrap }
413
+ : {}),
414
+ ...(deps.isTty ? { isTty: deps.isTty } : {}),
415
+ ...(deps.signal ? { signal: deps.signal } : {}),
416
+ }), readManifest().version);
417
+ }
418
+ finally {
419
+ ui?.dispose();
420
+ if (input.isTTY && input.isRaw !== originalRaw)
421
+ input.setRawMode(originalRaw);
422
+ terminal?.destroy();
423
+ }
424
+ for (const line of jsonLines)
425
+ ctx.stdout(line);
426
+ return code;
427
+ };
428
+ if (!onboardHeadlessRequested(args, ctx.env))
429
+ return await runOnboard(args, ctx, null);
430
+ return await runOnboardHeadless(args, ctx, {
431
+ login: (key, baseUrl, loginCtx) => cmdLogin({ ...args, command: "login", key, baseUrl, flags: {}, json: false }, loginCtx, { ...deps, isTty: () => false }, true),
432
+ onboard: (onboardArgs, onboardCtx, tracker) => runOnboard(onboardArgs, onboardCtx, tracker),
433
+ }, readManifest().version);
336
434
  }
337
435
  case "notice":
338
436
  return 0;
@@ -424,6 +522,11 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
424
522
  ctx.stderr(`catalyst: ${err.message}`);
425
523
  return 2;
426
524
  }
525
+ if (args.command === "onboard" && args.json) {
526
+ ctx.stdout(JSON.stringify(onboardErrorJournal(ctx, manifest.version, 10)));
527
+ ctx.stderr("catalyst: onboarding could not finish. Run catalyst onboard to try again.");
528
+ return 10;
529
+ }
427
530
  throw err;
428
531
  }
429
532
  }
@@ -461,6 +564,74 @@ const VERB_HELP_KNOWN = Object.fromEntries([
461
564
  "legacy",
462
565
  "onboard",
463
566
  ].map((v) => [v, true]));
567
+ /** CTC-4625: `catalyst setup`, wired to this terminal, @clack/prompts and the keyless login. */
568
+ async function runSetup(argv, ctx, deps) {
569
+ if (argv[0] === "--help" || argv[0] === "-h") {
570
+ ctx.stdout(verbHelp("setup"));
571
+ return 0;
572
+ }
573
+ const clack = await import("@clack/prompts");
574
+ const terminal = openSetupTerminal(process.stdout);
575
+ const spinner = clackSetupSpinner(clack, Boolean(process.stdin.isTTY));
576
+ try {
577
+ return await cmdSetup(argv, ctx, {
578
+ stdout: process.stdout,
579
+ stderr: process.stderr,
580
+ writeJson: (chunk) => process.stdout.write(chunk),
581
+ interactive: terminal !== null,
582
+ ...(terminal ? { ask: clackSetupAsk(clack, terminal) } : {}),
583
+ spinner,
584
+ onboard: async (renderer, flags) => {
585
+ // This returns through the dispatcher without launching another CLI or frame.
586
+ const onboardArgs = [
587
+ "onboard",
588
+ ...(flags.json ? ["--json"] : []),
589
+ ...(flags.verbose ? ["--verbose"] : []),
590
+ ...(flags.localSync ? ["--local-sync"] : []),
591
+ ...(flags.yes || flags.json || !terminal ? ["--yes"] : []),
592
+ ];
593
+ let ui;
594
+ if (!flags.json) {
595
+ ui = createClackOnboardUi(clack, setupOnboardStreams(terminal), {
596
+ baseUrl: () => savedOnboardBaseUrl(ctx.home),
597
+ renderer,
598
+ logPath: () => flags.logPath,
599
+ verbose: flags.verbose,
600
+ signinTimeoutMs: deps.onboardSigninTimeoutMs,
601
+ introduced: true,
602
+ consentGiven: true,
603
+ interactive: terminal !== null && !flags.yes,
604
+ });
605
+ }
606
+ return main(onboardArgs, ctx, {
607
+ ...deps,
608
+ setupUi: ui,
609
+ setupApproved: true,
610
+ });
611
+ },
612
+ signin: (seconds, r, stop, spin) => runSetupSignin(ctx, r, seconds, {
613
+ interrupted: stop,
614
+ spinner: spin,
615
+ login: async (loginCtx, login) => {
616
+ const code = await cmdLogin(parseArgs(["login"]), loginCtx, {
617
+ ...deps,
618
+ isTty: deps.isTty ?? (() => terminal !== null),
619
+ signal: login.signal,
620
+ waitForApproval: login.waitForApproval,
621
+ presentCode: login.present,
622
+ presentBrowser: login.presentBrowser,
623
+ });
624
+ if (code === 0)
625
+ keepInstalledCliPath(ctx, cliPath());
626
+ return code;
627
+ },
628
+ }),
629
+ });
630
+ }
631
+ finally {
632
+ terminal?.close();
633
+ }
634
+ }
464
635
  async function cmdLogin(args, ctx, deps, personalOnly = false) {
465
636
  const checkCancelled = () => {
466
637
  if (deps.signal?.aborted)
@@ -481,6 +652,8 @@ async function cmdLogin(args, ctx, deps, personalOnly = false) {
481
652
  sleep: deps.sleep,
482
653
  signal: deps.signal,
483
654
  waitForApproval: deps.waitForApproval,
655
+ present: deps.presentCode,
656
+ presentBrowser: deps.presentBrowser,
484
657
  })).accessToken;
485
658
  checkCancelled();
486
659
  const me = await fetchMe(baseUrl, bearer, ctx.fetch);
package/dist/oauth.js CHANGED
@@ -126,27 +126,45 @@ export async function deviceFlowLogin(ctx, baseUrl, deps = {}) {
126
126
  cancelled();
127
127
  const auth = await deviceAuthorize(ctx, discovery);
128
128
  cancelled();
129
- if (round > 1)
130
- ctx.stdout(`That code expired. Here is a new one (${round} of ${MAX_DEVICE_CODES}):`);
131
- ctx.stdout("");
132
- ctx.stdout(`To connect this machine, visit: ${auth.verification_uri}`);
133
- ctx.stdout(`and enter the code: ${auth.user_code}`);
134
- if (auth.verification_uri_complete)
135
- ctx.stdout(`Or open this link, which fills the code in: ${auth.verification_uri_complete}`);
136
- if (round === 1 ? (deps.isTty ?? (() => false))() : browserOpened) {
137
- try {
138
- if (!deps.openBrowser)
139
- throw new Error("browser-unavailable");
140
- await deps.openBrowser(auth.verification_uri_complete ?? auth.verification_uri);
141
- browserOpened = true;
142
- ctx.stdout("Opened your browser to that page — approve there, or use the code above.");
129
+ const opensBrowser = round === 1 ? (deps.isTty ?? (() => false))() : browserOpened;
130
+ if (deps.present) {
131
+ deps.present({
132
+ verificationUri: auth.verification_uri,
133
+ userCode: auth.user_code,
134
+ ...(auth.verification_uri_complete
135
+ ? { completeUri: auth.verification_uri_complete }
136
+ : {}),
137
+ round,
138
+ rounds: MAX_DEVICE_CODES,
139
+ });
140
+ if (opensBrowser) {
141
+ browserOpened = await tryOpenBrowser(deps, auth);
142
+ deps.presentBrowser?.(browserOpened);
143
143
  }
144
- catch {
145
- // a browser that will not open is not a failure; the code and URL still work
146
- ctx.stdout("Open the link above in a browser.");
144
+ }
145
+ else {
146
+ if (round > 1)
147
+ ctx.stdout(`That code expired. Here is a new one (${round} of ${MAX_DEVICE_CODES}):`);
148
+ ctx.stdout("");
149
+ ctx.stdout(`To connect this machine, visit: ${auth.verification_uri}`);
150
+ ctx.stdout(`and enter the code: ${auth.user_code}`);
151
+ if (auth.verification_uri_complete)
152
+ ctx.stdout(`Or open this link, which fills the code in: ${auth.verification_uri_complete}`);
153
+ if (opensBrowser) {
154
+ try {
155
+ if (!deps.openBrowser)
156
+ throw new Error("browser-unavailable");
157
+ await deps.openBrowser(auth.verification_uri_complete ?? auth.verification_uri);
158
+ browserOpened = true;
159
+ ctx.stdout("Opened your browser to that page — approve there, or use the code above.");
160
+ }
161
+ catch {
162
+ // a browser that will not open is not a failure; the code and URL still work
163
+ ctx.stdout("Open the link above in a browser.");
164
+ }
147
165
  }
166
+ ctx.stdout("Waiting for you to approve… (Ctrl-C to cancel)");
148
167
  }
149
- ctx.stdout("Waiting for you to approve… (Ctrl-C to cancel)");
150
168
  const poll = () => pollDeviceCode(ctx, discovery, auth, deps.sleep, deps.signal);
151
169
  const tokens = deps.waitForApproval
152
170
  ? await deps.waitForApproval(poll)
@@ -159,6 +177,18 @@ export async function deviceFlowLogin(ctx, baseUrl, deps = {}) {
159
177
  // unchanged from before CTC-2136; only the line is new, and its wording is Ryan's.
160
178
  throw new CliError(`The sign-in code expired ${MAX_DEVICE_CODES} times. Run the same command again when you are ready to approve.`, "login-expired");
161
179
  }
180
+ /** Opens the browser at the code's page; false when it would not open (never a failure). */
181
+ async function tryOpenBrowser(deps, auth) {
182
+ try {
183
+ if (!deps.openBrowser)
184
+ throw new Error("browser-unavailable");
185
+ await deps.openBrowser(auth.verification_uri_complete ?? auth.verification_uri);
186
+ return true;
187
+ }
188
+ catch {
189
+ return false;
190
+ }
191
+ }
162
192
  /**
163
193
  * Poll one device code until it yields a token pair or expires. Terminal refusals (`access_denied`,
164
194
  * a non-transient error) throw; expiry, by the server's `expired_token` or the code's own
@@ -358,6 +388,38 @@ export async function bearerFor(ctx, cfg, deps = {}) {
358
388
  const rotated = await promise;
359
389
  return rotated.accessToken;
360
390
  }
391
+ /** A long command such as onboarding refreshes a saved OAuth session that has less than
392
+ * `minValidityMs` left, so it starts with a full access token instead of expiring part way.
393
+ * Shares bearerFor's single-flight refresh. A key config, no config or a failed refresh is left
394
+ * alone and returns false: the command's own sign-in check decides what happens next. */
395
+ export async function refreshSessionIfShort(ctx, minValidityMs, deps = {}) {
396
+ let cfg;
397
+ try {
398
+ cfg = loadConfig(ctx.home);
399
+ }
400
+ catch {
401
+ return false;
402
+ }
403
+ if (!cfg?.auth || (typeof cfg.key === "string" && cfg.key !== ""))
404
+ return false;
405
+ const msToExpiry = Date.parse(cfg.auth.expiresAt) - ctx.now().getTime();
406
+ if (Number.isFinite(msToExpiry) && msToExpiry > minValidityMs)
407
+ return false;
408
+ try {
409
+ const pending = inFlight.get(ctx.home);
410
+ if (pending)
411
+ await pending;
412
+ else {
413
+ const promise = refreshAndPersist(ctx, cfg, deps).finally(() => inFlight.delete(ctx.home));
414
+ inFlight.set(ctx.home, promise);
415
+ await promise;
416
+ }
417
+ return true;
418
+ }
419
+ catch {
420
+ return false;
421
+ }
422
+ }
361
423
  async function refreshAndPersist(ctx, cfg, deps) {
362
424
  const sleep = deps.sleep ?? defaultSleep;
363
425
  const discovery = await fetchDiscovery(ctx, cfg.baseUrl);
@@ -1,5 +1,6 @@
1
1
  import { loadConfig, normalizeBaseUrl } from "./config.js";
2
2
  import { readExistingOnboardJson } from "./onboard-existing.js";
3
+ import { pollConsent } from "./onboard-consent.js";
3
4
  import { verifyOnboardRoutes } from "./onboard-capabilities.js";
4
5
  const listPath = "/api/v1/coding-accounts";
5
6
  const validatePath = "/api/v1/coding-accounts/:slot/validate";
@@ -99,7 +100,8 @@ export function onboardAccountsAdapter(input = {}) {
99
100
  let proof = null;
100
101
  let attempted = false;
101
102
  const candidate = (slots, now) => slots
102
- .filter((row) => row.provider === "claude" &&
103
+ .filter((row) => (input.slot === undefined || row.accountSlot === input.slot) &&
104
+ row.provider === "claude" &&
103
105
  row.declaredState === "active" &&
104
106
  row.ownedByMe &&
105
107
  !row.needsCredential &&
@@ -139,7 +141,13 @@ export function onboardAccountsAdapter(input = {}) {
139
141
  };
140
142
  proof = null;
141
143
  if (!live.slots.length)
142
- return waiting("account_enrollment_required");
144
+ return input.waitForAccount &&
145
+ ["owner", "admin"].includes(live.identity.role)
146
+ ? { state: "pending" }
147
+ : waiting("account_enrollment_required");
148
+ if (input.slot !== undefined &&
149
+ !live.slots.some((row) => row.accountSlot === input.slot))
150
+ return waiting("coding_account_not_found");
143
151
  if (!selected)
144
152
  return waiting(live.slots.some((row) => row.provider === "codex")
145
153
  ? "codex_provider_access_unverified"
@@ -156,9 +164,30 @@ export function onboardAccountsAdapter(input = {}) {
156
164
  act: async (ctx, journal, external) => {
157
165
  if (attempted)
158
166
  return waiting("account_provider_access_unverified");
159
- const live = await inventory(ctx, journal, external);
160
- if ("reason" in live)
161
- return waiting(live.reason);
167
+ const initial = await inventory(ctx, journal, external);
168
+ if ("reason" in initial)
169
+ return waiting(initial.reason);
170
+ let live = initial;
171
+ if (!live.slots.length &&
172
+ input.waitForAccount &&
173
+ ["owner", "admin"].includes(live.identity.role)) {
174
+ const result = await input.waitForAccount(() => pollConsent({
175
+ timeoutMs: input.accountWaitMs,
176
+ signal: external,
177
+ sleep: input.sleep,
178
+ readStatus: async (signal) => {
179
+ const current = await inventory(ctx, journal, signal);
180
+ if ("reason" in current)
181
+ return { outcome: "waiting", reason: current.reason };
182
+ live = current;
183
+ return { outcome: current.slots.length ? "connected" : "absent" };
184
+ },
185
+ }));
186
+ if (result.state !== "done")
187
+ return waiting(result.reason === "consent_timeout"
188
+ ? "account_enrollment_required"
189
+ : (result.reason ?? "account_enrollment_required"));
190
+ }
162
191
  const selected = candidate(live.slots, ctx.now().getTime());
163
192
  if (!selected)
164
193
  return waiting("account_provider_access_unverified");