@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.
- package/CHANGELOG.md +13 -0
- package/README.md +41 -0
- package/dist/args.js +34 -2
- package/dist/capabilities.js +2 -1
- package/dist/cli-update-notice.js +76 -0
- package/dist/cli.js +267 -94
- package/dist/oauth.js +80 -18
- package/dist/onboard-accounts.js +34 -5
- package/dist/onboard-automation-management.js +27 -9
- package/dist/onboard-capacity.js +10 -1
- package/dist/onboard-existing.js +132 -31
- package/dist/onboard-github.js +171 -6
- package/dist/onboard-headless.js +647 -0
- package/dist/onboard-interrupts.js +41 -0
- package/dist/onboard-next.js +215 -0
- package/dist/onboard-permissions.js +100 -0
- package/dist/onboard-personal.js +46 -10
- package/dist/onboard-runner-cloud.js +213 -0
- package/dist/onboard-runner-egress.js +67 -0
- package/dist/onboard-runner.js +780 -0
- package/dist/onboard-runtime.js +48 -8
- package/dist/onboard-standalone-copy.js +110 -0
- package/dist/onboard-team-create.js +267 -0
- package/dist/onboard-ui.js +623 -29
- package/dist/onboard-workflow.js +311 -168
- package/dist/onboard-workspace.js +52 -5
- package/dist/onboard.js +359 -207
- package/dist/setup-events.js +100 -0
- package/dist/setup-onboard-copy.js +438 -0
- package/dist/setup-prompt-frame.js +94 -0
- package/dist/setup-prompts.js +108 -0
- package/dist/setup-render.js +339 -0
- package/dist/setup-signin.js +187 -0
- package/dist/setup.js +426 -0
- package/package.json +7 -3
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +3 -2
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +1 -1
- package/vendor/README.md +4 -0
- package/vendor/self-host/compose.yaml +110 -0
- package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.service +14 -0
- package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.timer +10 -0
- package/vendor/self-host/linux-session-egress/catalyst-session-egress.service +19 -0
- package/vendor/self-host/linux-session-egress/session-egress.py +291 -0
- 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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
browserOpened =
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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);
|
package/dist/onboard-accounts.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
160
|
-
if ("reason" in
|
|
161
|
-
return waiting(
|
|
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");
|