@catalyst-cloud/cli 0.14.10 → 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 +9 -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 +263 -101
  7. package/dist/oauth.js +48 -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 -212
  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,7 +1,9 @@
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";
@@ -14,12 +16,16 @@ const ONBOARD_STEP_MIN_MS = 11 * 60_000;
14
16
  import { openBrowserChecked as defaultOpenBrowser } from "./browser.js";
15
17
  import { cmdAccounts, cmdExplain, cmdHistory, cmdQueue, cmdRunning, } from "./execution.js";
16
18
  import { cmdQuery } from "./query.js";
19
+ import { maybePrintPublishedUpdateNotice } from "./cli-update-notice.js";
17
20
  import { cmdReady } from "./ready.js";
18
21
  import { cmdReplica } from "./replica.js";
19
22
  import { cmdRuntime } from "./runtime-verb.js";
20
23
  import { cmdEvents } from "./events.js";
21
24
  import { stageOnboardLogin } from "./onboard-login-candidate.js";
22
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";
23
29
  import { FIRST_STAMPED_VERSION, PROVENANCE_MARKER, parseProvenanceVersion, } from "./skill-shape.js";
24
30
  import { installedBundleVersion, installSkills, parseChangelogEntry, readChangelog, resolveSkillsDir, skillsSourceDir, updateNoticeLine, } from "./skills.js";
25
31
  import { cmdWatch } from "./watch.js";
@@ -41,6 +47,7 @@ import { cmdConnections } from "./connections.js";
41
47
  import { cmdOnboard, onboardErrorJournal, onboardRequiredStepsWaiting, } from "./onboard.js";
42
48
  import { createClackOnboardUi, shouldUseOnboardUi, } from "./onboard-ui.js";
43
49
  import { createOnboardRuntime } from "./onboard-runtime.js";
50
+ import { argvRequestsHeadless, headlessOnboardDeps, headlessUsageRefusal, onboardHeadlessRequested, runOnboardHeadless, } from "./onboard-headless.js";
44
51
  import { savedOnboardBaseUrl } from "./onboard-next.js";
45
52
  import { onboardingReadyReport, observeCloudOnboarding, } from "./onboard-ready.js";
46
53
  import { selectedOnboardTeam } from "./onboard-existing.js";
@@ -69,7 +76,8 @@ export function usageText() {
69
76
  " catalyst login --key <personal-key> [--base-url <url>] (or CATALYST_CLOUD_TOKEN, for a key)",
70
77
  " catalyst join ... (deprecated alias of login; removed in the next minor version)",
71
78
  " catalyst install [--skills-dir <dir>] [--force] (repair path; your agent's own command installs the skills)",
72
- " 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}`,
73
81
  " catalyst status | notice | me | ready | accounts",
74
82
  " catalyst mcp add|list|remove (vault references only)",
75
83
  " catalyst contract [--refresh] [--path <a.b.c>]",
@@ -199,6 +207,10 @@ function maybePrintUpdateNotice(args, ctx) {
199
207
  saveConfig(ctx.home, cfg);
200
208
  }
201
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);
202
214
  let args;
203
215
  try {
204
216
  args = parseArgs(argv.map((a, i) => (i === 0 && VERB_ALIASES[a] ? VERB_ALIASES[a] : a)));
@@ -206,11 +218,18 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
206
218
  args.command = VERB_ALIASES[args.command];
207
219
  }
208
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);
209
223
  ctx.stderr(err instanceof Error ? err.message : String(err));
210
224
  ctx.stderr(usageText());
211
225
  return 1;
212
226
  }
213
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);
214
233
  if (args.version) {
215
234
  ctx.stdout(`${PACKAGE_NAME} ${manifest.version} (tenant contract range: ${manifest.tenantContractRange})`);
216
235
  return 0;
@@ -223,6 +242,12 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
223
242
  ctx.stdout(verbHelp(args.command));
224
243
  return 0;
225
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
+ });
226
251
  try {
227
252
  if (args.command !== "onboard") {
228
253
  migrateLegacyCliPath(ctx);
@@ -234,116 +259,178 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
234
259
  case "install":
235
260
  return cmdInstall(args, ctx);
236
261
  case "onboard": {
237
- let ui;
238
- let terminal;
239
- let input = process.stdin;
240
- const originalRaw = Boolean(input.isRaw);
241
- if (shouldUseOnboardUi(args, Boolean(process.stdout.isTTY))) {
242
- if (!input.isTTY) {
243
- try {
244
- 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) {
245
294
  try {
246
- terminal = new TerminalInput(fd);
247
- 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
+ });
248
302
  }
249
303
  catch (error) {
250
- closeSync(fd);
304
+ terminal?.destroy();
251
305
  throw error;
252
306
  }
253
307
  }
254
- catch {
255
- ctx.stderr("No controlling terminal is available; using the displayed defaults.");
256
- }
257
- }
258
- if (input.isTTY) {
259
- try {
260
- ui = createClackOnboardUi(await import("@clack/prompts"), { input, output: process.stdout }, { baseUrl: () => savedOnboardBaseUrl(ctx.home) });
261
- }
262
- catch (error) {
263
- terminal?.destroy();
264
- throw error;
265
- }
266
308
  }
267
- }
268
- // Start with a full access token: a session saved long ago would otherwise expire part way
269
- // through, or send the person back through the device sign-in. A dry run changes nothing.
270
- if (args.flags["dry-run"] !== true)
271
- await refreshSessionIfShort(ctx, ONBOARD_SESSION_MIN_MS);
272
- try {
273
- return await cmdOnboard(args, ctx, {
274
- beforeStep: async () => {
275
- if (args.flags["dry-run"] !== true)
276
- await refreshSessionIfShort(ctx, ONBOARD_STEP_MIN_MS);
277
- },
278
- ...createOnboardRuntime(args, ctx, {
279
- 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,
280
322
  signinTimeoutMs: deps.onboardSigninTimeoutMs,
281
- stageSignin: (signal) => {
282
- if (args.key || ctx.env.CATALYST_CLOUD_TOKEN)
283
- 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);
284
- return stageOnboardLogin({ ...ctx, stdout: ctx.stderr }, {
285
- baseUrl: args.baseUrl,
286
- signal,
287
- timeoutMs: deps.onboardSigninTimeoutMs,
288
- device: {
289
- isTty: deps.isTty ?? (() => Boolean(process.stdout.isTTY)),
290
- openBrowser: deps.openBrowser ?? defaultOpenBrowser,
291
- sleep: deps.sleep,
292
- waitForApproval: ui
293
- ? (run) => ui.wait("Waiting for Catalyst approval", run)
294
- : undefined,
295
- },
296
- });
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);
297
337
  },
298
- login: (stepCtx, signal) => cmdLogin({ ...args, command: "login", flags: {}, json: false }, stepCtx, {
299
- ...deps,
300
- signal,
301
- isTty: () => true,
302
- waitForApproval: ui
303
- ? (run) => ui.wait("Waiting for Catalyst approval", run)
304
- : undefined,
305
- }, true),
306
- ready: async (stepCtx, journal) => {
307
- const team = selectedOnboardTeam(journal);
308
- const report = await onboardingReadyReport(stepCtx, {
309
- teamIds: team ? [team] : undefined,
310
- localSync: journal?.localSync ?? args.flags["local-sync"] === true,
311
- observe: observeCloudOnboarding,
312
- });
313
- const failed = report.checks.some((check) => check.required && check.state === "fail") && !onboardRequiredStepsWaiting(journal);
314
- return {
315
- state: report.state === "complete"
316
- ? "done"
317
- : failed
318
- ? "failed"
319
- : "waiting",
320
- reason: report.state === "complete"
321
- ? undefined
322
- : "onboarding_checks_pending",
323
- evidence: {
324
- checks: report.checks.length,
325
- passed: report.checks.filter((check) => check.state === "pass").length,
326
- },
327
- };
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);
328
349
  },
329
- legacy: deps.legacy,
330
- openBrowser: deps.openBrowser,
331
- sleep: deps.sleep,
332
- skillNames: CUSTOMER_SKILLS,
333
- }),
334
- ...(deps.onboardBootstrap
335
- ? { bootstrap: deps.onboardBootstrap }
336
- : {}),
337
- ...(deps.isTty ? { isTty: deps.isTty } : {}),
338
- ...(deps.signal ? { signal: deps.signal } : {}),
339
- }, readManifest().version);
340
- }
341
- finally {
342
- ui?.dispose();
343
- if (input.isTTY && input.isRaw !== originalRaw)
344
- input.setRawMode(originalRaw);
345
- terminal?.destroy();
346
- }
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);
347
434
  }
348
435
  case "notice":
349
436
  return 0;
@@ -435,6 +522,11 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
435
522
  ctx.stderr(`catalyst: ${err.message}`);
436
523
  return 2;
437
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
+ }
438
530
  throw err;
439
531
  }
440
532
  }
@@ -472,6 +564,74 @@ const VERB_HELP_KNOWN = Object.fromEntries([
472
564
  "legacy",
473
565
  "onboard",
474
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
+ }
475
635
  async function cmdLogin(args, ctx, deps, personalOnly = false) {
476
636
  const checkCancelled = () => {
477
637
  if (deps.signal?.aborted)
@@ -492,6 +652,8 @@ async function cmdLogin(args, ctx, deps, personalOnly = false) {
492
652
  sleep: deps.sleep,
493
653
  signal: deps.signal,
494
654
  waitForApproval: deps.waitForApproval,
655
+ present: deps.presentCode,
656
+ presentBrowser: deps.presentBrowser,
495
657
  })).accessToken;
496
658
  checkCancelled();
497
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
@@ -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");