@catalyst-cloud/cli 0.15.6 → 0.16.1

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 (54) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +9 -6
  3. package/dist/args.js +26 -1
  4. package/dist/ask.js +56 -0
  5. package/dist/capabilities.js +3 -1
  6. package/dist/cli.js +10 -5
  7. package/dist/environment.js +1 -1
  8. package/dist/execution.js +2 -2
  9. package/dist/hosts.js +89 -0
  10. package/dist/legacy.js +12 -2
  11. package/dist/machine-inventory.js +37 -0
  12. package/dist/onboard-capacity.js +5 -6
  13. package/dist/onboard-github-copy.js +2 -0
  14. package/dist/onboard-github.js +2 -0
  15. package/dist/onboard-headless.js +2 -2
  16. package/dist/onboard-next.js +21 -12
  17. package/dist/onboard-project-cleanup.js +100 -0
  18. package/dist/onboard-returning.js +172 -0
  19. package/dist/onboard-runner.js +149 -34
  20. package/dist/onboard-runtime.js +26 -1
  21. package/dist/onboard-ui.js +67 -1
  22. package/dist/onboard-workflow.js +32 -7
  23. package/dist/onboard.js +9 -2
  24. package/dist/ready-copy.js +30 -24
  25. package/dist/ready.js +2 -2
  26. package/dist/secret.js +2 -2
  27. package/dist/setup-onboard-copy.js +37 -32
  28. package/dist/setup.js +4 -0
  29. package/dist/transport.js +18 -2
  30. package/package.json +8 -5
  31. package/skills/catalyst-github/SKILL.md +1 -1
  32. package/skills/catalyst-linear/SKILL.md +1 -1
  33. package/skills/catalyst-onboard/SKILL.md +3 -3
  34. package/skills/catalyst-onboard/references/connecting-this-machine.md +4 -4
  35. package/skills/catalyst-onboard/references/declaring-a-repository.md +1 -1
  36. package/skills/catalyst-onboard/references/reading-ready.md +14 -14
  37. package/skills/catalyst-onboard/references/replacing-a-credential.md +1 -1
  38. package/skills/catalyst-onboard/references/the-one-path.md +5 -5
  39. package/skills/catalyst-onboard/references/what-a-phase-needs.md +4 -4
  40. package/skills/catalyst-onboard/references/what-the-browser-owns.md +20 -10
  41. package/skills/catalyst-onboard/scripts/where-am-i.mjs +50 -27
  42. package/skills/run-this-project/SKILL.md +1 -1
  43. package/skills/run-this-project/references/stalls-and-escalation.md +1 -1
  44. package/skills/unstick/SKILL.md +1 -1
  45. package/skills/what-needs-me/SKILL.md +2 -2
  46. package/skills/what-needs-me/references/raising-a-decision.md +12 -1
  47. package/skills/what-needs-me/references/reading-the-inbox.md +1 -1
  48. package/skills/what-needs-me/scripts/raise.mjs +10 -1
  49. package/skills/what-this-repo-needs/SKILL.md +1 -1
  50. package/skills/whats-happening/SKILL.md +3 -2
  51. package/skills/whats-happening/references/coding-accounts.md +1 -1
  52. package/skills/whats-happening/references/machines.md +9 -0
  53. package/skills/whats-happening/references/settings-and-where-they-live.md +8 -8
  54. package/skills/whats-happening/references/stages-and-mapping.md +1 -1
@@ -1,14 +1,13 @@
1
1
  import { spawn } from "node:child_process";
2
- import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
- import { randomBytes } from "node:crypto";
2
+ import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
4
3
  import { hostname, tmpdir } from "node:os";
5
- import { join } from "node:path";
4
+ import { isAbsolute, join, relative } from "node:path";
6
5
  import { loadConfig, normalizeBaseUrl, packageRoot } from "./config.js";
7
6
  import { nativeEgressReady, NATIVE_EGRESS_DIR } from "./onboard-runner-egress.js";
8
7
  import { runnerAdmission, issueRunnerOrgKey, verifyRunnerRoutes } from "./onboard-runner-cloud.js";
9
8
  import { verifyOnboardRoutes } from "./onboard-capabilities.js";
10
9
  import { liveTeamKey, onboardTeamAdmission } from "./onboard-capacity.js";
11
- import { readExistingOnboardJson, selectedOnboardTeam, } from "./onboard-existing.js";
10
+ import { readExistingOnboardJson, readOnboardTeamInventory, selectedOnboardTeam, } from "./onboard-existing.js";
12
11
  import { onboardStateRoot, } from "./onboard.js";
13
12
  // "Run Catalyst's work on this machine": the self-hosted host from catalyst-cloud's deploy/self-host
14
13
  // (a supervisor and a deadline watchdog under Compose), enrolled with the person's own owner or
@@ -133,6 +132,54 @@ export function dockerRunnerEngine(deps = {}) {
133
132
  const supportedVm = platform === "darwin" && vm && host?.startsWith("unix:///");
134
133
  return { arch, vm, ...(!nativeLocal && !supportedVm ? { unsupported: true } : {}) };
135
134
  },
135
+ async installation(home, supervisorImage, signal) {
136
+ const listed = await docker(["ps", "--all", "--filter", `label=com.docker.compose.project=${RUNNER_PROJECT}`,
137
+ "--filter", "label=com.docker.compose.service=supervisor", "--format", "{{.ID}}"], { signal });
138
+ if (listed.code !== 0)
139
+ return "unverified";
140
+ const ids = listed.stdout.trim().split(/\s+/).filter(Boolean);
141
+ if (ids.length === 0)
142
+ return "missing";
143
+ if (ids.length !== 1 || !/^[0-9a-f]{12,64}$/.test(ids[0]))
144
+ return "unverified";
145
+ const read = await docker(["inspect", ids[0]], { signal });
146
+ if (read.code !== 0)
147
+ return "unverified";
148
+ try {
149
+ const rows = JSON.parse(read.stdout);
150
+ if (!Array.isArray(rows) || rows.length !== 1)
151
+ return "unverified";
152
+ const config = rows[0]?.Config;
153
+ const labels = config?.Labels;
154
+ const dir = labels?.["com.docker.compose.project.working_dir"];
155
+ if (config?.Image !== supervisorImage || labels?.["com.docker.compose.project"] !== RUNNER_PROJECT ||
156
+ labels?.["com.docker.compose.service"] !== "supervisor" || typeof dir !== "string" || !isAbsolute(dir) ||
157
+ labels?.["com.docker.compose.project.config_files"] !== join(dir, "compose.yaml"))
158
+ return "unverified";
159
+ const inHome = relative(realpathSync(home), realpathSync(dir));
160
+ if (inHome === ".." || inHome.startsWith("../") || isAbsolute(inHome) || !lstatSync(dir).isDirectory())
161
+ return "unverified";
162
+ for (const path of [join(dir, "compose.yaml"), join(dir, ".env")]) {
163
+ const stat = lstatSync(path);
164
+ if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o022) !== 0)
165
+ return "unverified";
166
+ }
167
+ if (!Array.isArray(config.Env) || !config.Env.every((entry) => typeof entry === "string"))
168
+ return "unverified";
169
+ const names = config.Env.filter((entry) => entry.startsWith("CATALYST_HOST_NAME="));
170
+ const origins = config.Env.filter((entry) => entry.startsWith("CATALYST_MIRROR_URL="));
171
+ if (names.length !== 1 || origins.length !== 1)
172
+ return "unverified";
173
+ const hostName = names[0].slice("CATALYST_HOST_NAME=".length);
174
+ const baseUrl = origins[0].slice("CATALYST_MIRROR_URL=".length);
175
+ if (!HOST_NAME.test(hostName))
176
+ return "unverified";
177
+ return { dir, hostName, baseUrl: normalizeBaseUrl(baseUrl) };
178
+ }
179
+ catch {
180
+ return "unverified";
181
+ }
182
+ },
136
183
  async imageArch(ref, signal) {
137
184
  const read = await docker(["image", "inspect", "--format", "{{.Architecture}}", ref], { signal });
138
185
  return read.code === 0 ? read.stdout.trim() || null : null;
@@ -148,7 +195,7 @@ export function dockerRunnerEngine(deps = {}) {
148
195
  const anonymousEnv = { ...env, DOCKER_CONFIG: config };
149
196
  delete anonymousEnv.DOCKER_CONTEXT;
150
197
  delete anonymousEnv.DOCKER_AUTH_CONFIG;
151
- return (await docker(["--host", host, "--config", config, "pull", "--quiet", ref], { env: anonymousEnv, signal, timeoutMs: 900_000 })).code === 0;
198
+ return ((await docker(["--host", host, "--config", config, "pull", "--quiet", ref], { env: anonymousEnv, signal, timeoutMs: 900_000 })).code === 0);
152
199
  }
153
200
  catch {
154
201
  return false;
@@ -197,7 +244,9 @@ export function dockerRunnerEngine(deps = {}) {
197
244
  ];
198
245
  return (await docker(args, { signal })).code === 0;
199
246
  },
200
- async nativeEgressStatus(nowMs, signal) { return nativeLocal && await nativeEgressReady(nowMs, signal); },
247
+ async nativeEgressStatus(nowMs, signal) {
248
+ return nativeLocal && (await nativeEgressReady(nowMs, signal));
249
+ },
201
250
  async socketGid() {
202
251
  if (!nativeLocal)
203
252
  return null;
@@ -226,7 +275,8 @@ export function dockerRunnerEngine(deps = {}) {
226
275
  RUNNER_UID,
227
276
  ...paths,
228
277
  ];
229
- return (await docker(args, { cwd: dir, signal, timeoutMs: 120_000 })).code === 0;
278
+ return ((await docker(args, { cwd: dir, signal, timeoutMs: 120_000 })).code ===
279
+ 0);
230
280
  },
231
281
  async hasVolumeFile(dir, variable, signal) {
232
282
  return ((await docker(supervisorShell(dir, `test -s "$${variable}"`), {
@@ -316,7 +366,8 @@ export function dockerRunnerEngine(deps = {}) {
316
366
  },
317
367
  async composeRunning(dir, signal) {
318
368
  const read = await docker([...compose(dir), "ps", "--status", "running", "--services"], { cwd: dir, signal });
319
- return read.code === 0 && read.stdout.split("\n").some((line) => line.trim() === "supervisor");
369
+ return (read.code === 0 &&
370
+ read.stdout.split("\n").some((line) => line.trim() === "supervisor"));
320
371
  },
321
372
  };
322
373
  }
@@ -350,7 +401,9 @@ function parseHosts(body) {
350
401
  hostName: host.hostName,
351
402
  team: host.team,
352
403
  revoked: host.revokedAtMs !== null,
353
- capacity: capability ? (capability.runtimeLive ? capability.placeableCapacity : 0) : null,
404
+ capacity: capability ? capability.runtimeLive
405
+ ? capability.placeableCapacity
406
+ : 0 : null,
354
407
  failing: capability ? capability.failingRequired : [],
355
408
  });
356
409
  }
@@ -393,16 +446,15 @@ function writePrivate(path, text) {
393
446
  /** Enrollment uses the stored host id and exact team/name. Two machines with one hostname must not
394
447
  * share it: a random suffix, kept in `.env` from the first write on. */
395
448
  export function runnerDefaultHostName() {
396
- const suffix = randomBytes(3).toString("hex");
397
- const base = `catalyst-${hostname().split(".")[0] ?? "host"}`
449
+ const base = (hostname().split(".")[0] ?? "machine")
398
450
  .replace(/[^A-Za-z0-9._-]/g, "-")
399
- .slice(0, 55);
400
- const name = `${base}-${suffix}`;
401
- return HOST_NAME.test(name) ? name : `catalyst-host-${suffix}`;
451
+ .slice(0, 63);
452
+ return HOST_NAME.test(base) ? base : "machine";
402
453
  }
403
454
  export function onboardRunnerAdapter(input = {}) {
404
455
  const engine = input.engine ?? dockerRunnerEngine();
405
- const sleep = input.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
456
+ const sleep = input.sleep ??
457
+ ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
406
458
  const waitMs = input.waitMs ?? 120_000;
407
459
  const pollMs = input.pollMs ?? 5_000;
408
460
  // One question per run: the engine checks again after acting, and the receipt row is "running"
@@ -412,6 +464,7 @@ export function onboardRunnerAdapter(input = {}) {
412
464
  // only reports on it, so an unattended check never restarts a runner the person stopped.
413
465
  let mayAct = false;
414
466
  let acted = false;
467
+ let chosenName;
415
468
  const selected = { selected: true };
416
469
  async function decide(journal) {
417
470
  if (decided !== undefined)
@@ -460,14 +513,6 @@ export function onboardRunnerAdapter(input = {}) {
460
513
  ], signal);
461
514
  if ("reason" in support)
462
515
  return waiting(support.reason, selected);
463
- const teamId = selectedOnboardTeam(journal);
464
- const teamKey = teamId ? await liveTeamKey(ctx, teamId, signal ?? new AbortController().signal) : null;
465
- if (!teamId || !teamKey)
466
- return waiting("runner_context_unverified", selected);
467
- const runnerSupport = await verifyRunnerRoutes(ctx, journal, [{ method: "GET", path: "/api/v1/agent/runner-admission" }], signal);
468
- if ("reason" in runnerSupport && runnerSupport.reason !== "cloud_capability_unavailable")
469
- return waiting(runnerSupport.reason, selected);
470
- const cloud = !("reason" in runnerSupport);
471
516
  let state;
472
517
  try {
473
518
  state = onboardStateRoot(ctx.home, ctx.env);
@@ -475,7 +520,41 @@ export function onboardRunnerAdapter(input = {}) {
475
520
  catch {
476
521
  return waiting("runner_directory_unavailable", selected);
477
522
  }
478
- const dir = join(state, "runner");
523
+ let dir = join(state, "runner");
524
+ const installation = await engine.installation?.(ctx.home, ctx.env.CATALYST_SUPERVISOR_IMAGE ?? RUNNER_HOST_IMAGES.supervisor, signal);
525
+ if (installation === "unverified")
526
+ return waiting("runner_installation_unverified", selected);
527
+ let credential = null;
528
+ if (installation && installation !== "missing") {
529
+ if (installation.baseUrl !== normalizeBaseUrl(cfg.baseUrl))
530
+ return waiting("runner_identity_unverified", selected);
531
+ dir = installation.dir;
532
+ credential = await engine.enrollment(dir, signal);
533
+ if (!credential)
534
+ return waiting("runner_credential_unverified", selected);
535
+ if (!isUnenrolled(credential) && (credential.tenant !== account || credential.enrollmentKind !== "self_hosted"))
536
+ return waiting("runner_identity_unverified", selected);
537
+ }
538
+ let teamId = selectedOnboardTeam(journal);
539
+ if (!teamId && credential && !isUnenrolled(credential)) {
540
+ // Moving on retains the verified installation's project scope. Never choose the first team.
541
+ const teams = await readOnboardTeamInventory(ctx, signal);
542
+ if (!("reason" in teams)) {
543
+ const installedTeam = credential.team;
544
+ const matching = teams.teams.filter(team => team.key === installedTeam);
545
+ if (matching.length === 1)
546
+ teamId = matching[0].id;
547
+ }
548
+ }
549
+ const teamKey = teamId ? await liveTeamKey(ctx, teamId, signal ?? new AbortController().signal) : null;
550
+ if (!teamId || !teamKey)
551
+ return waiting("runner_context_unverified", selected);
552
+ if (credential && !isUnenrolled(credential) && credential.team !== teamKey)
553
+ return waiting("runner_enrolled_for_other_team", selected);
554
+ const runnerSupport = await verifyRunnerRoutes(ctx, journal, [{ method: "GET", path: "/api/v1/agent/runner-admission" }], signal);
555
+ if ("reason" in runnerSupport && runnerSupport.reason !== "cloud_capability_unavailable")
556
+ return waiting(runnerSupport.reason, selected);
557
+ const cloud = !("reason" in runnerSupport);
479
558
  const saved = readEnvFile(join(dir, ".env"));
480
559
  const images = {
481
560
  supervisor: ctx.env.CATALYST_SUPERVISOR_IMAGE ?? RUNNER_HOST_IMAGES.supervisor,
@@ -486,11 +565,12 @@ export function onboardRunnerAdapter(input = {}) {
486
565
  return waiting("runner_image_unpinned", selected);
487
566
  if (!info.vm && !(await engine.nativeEgressStatus?.(ctx.now().getTime(), signal)))
488
567
  return waiting("runner_native_egress_setup_required", selected);
489
- return { account, baseUrl: normalizeBaseUrl(cfg.baseUrl), teamKey, teamId, cloud, dir, info, images };
568
+ return { account, baseUrl: normalizeBaseUrl(cfg.baseUrl), teamKey, teamId, cloud, dir, info, images,
569
+ ...(installation && installation !== "missing" ? { installedName: installation.hostName } : {}) };
490
570
  }
491
571
  async function desiredEnv(p, joinToken) {
492
572
  const saved = readEnvFile(join(p.dir, ".env")) ?? {};
493
- const name = saved.CATALYST_HOST_NAME || (input.hostName ?? runnerDefaultHostName)();
573
+ const name = p.installedName || saved.CATALYST_HOST_NAME || chosenName || (input.hostName ?? runnerDefaultHostName)();
494
574
  const gid = p.info.vm ? 0 : await engine.socketGid();
495
575
  if (!HOST_NAME.test(name) || gid === null)
496
576
  return null;
@@ -582,11 +662,22 @@ export function onboardRunnerAdapter(input = {}) {
582
662
  return waiting("runner_docker_socket_unreadable", selected);
583
663
  const name = want.CATALYST_HOST_NAME;
584
664
  let enrolled = null;
585
- const live = (hosts) => hosts?.find((host) => host.hostId === enrolled?.hostId && host.hostName === name && host.team === p.teamKey && !host.revoked);
665
+ const live = (hosts) => hosts?.find((host) => host.hostId === enrolled?.hostId && host.team === p.teamKey && !host.revoked);
586
666
  let hosts = await listHosts(ctx, p, signal);
587
667
  if (!hosts)
588
668
  return waiting("runner_enrollment_unavailable", selected);
589
- if (hosts.some((host) => host.hostName === name && !host.revoked && host.team !== p.teamKey))
669
+ const existingCredential = await engine.enrollment(p.dir, signal);
670
+ if ((!existingCredential || isUnenrolled(existingCredential)) &&
671
+ hosts.some((host) => host.hostName === name && !host.revoked && host.team !== p.teamKey))
672
+ return waiting("runner_enrolled_for_other_team", {
673
+ ...selected,
674
+ hostName: name,
675
+ });
676
+ if (existingCredential &&
677
+ !isUnenrolled(existingCredential) &&
678
+ hosts.some((host) => host.hostId === existingCredential.hostId &&
679
+ !host.revoked &&
680
+ host.team !== p.teamKey))
590
681
  return waiting("runner_enrolled_for_other_team", { ...selected, hostName: name });
591
682
  const running = await engine.composeRunning(p.dir, signal);
592
683
  const changed = !saved || Object.entries(want).some(([key, value]) => saved[key] !== value);
@@ -607,8 +698,13 @@ export function onboardRunnerAdapter(input = {}) {
607
698
  return waiting("runner_identity_unverified", hostEvidence);
608
699
  if (enrolled && enrolled.team !== p.teamKey)
609
700
  return waiting("runner_enrolled_for_other_team", hostEvidence);
610
- if (enrolled && !hosts.some((host) => host.hostId === enrolled.hostId && host.hostName === name && !host.revoked))
611
- return waiting("runner_enrollment_stale", hostEvidence);
701
+ if (enrolled) {
702
+ const exact = hosts.find((host) => host.hostId === enrolled.hostId);
703
+ if (exact?.revoked)
704
+ return waiting("runner_enrollment_revoked", { ...hostEvidence, hostId: enrolled.hostId });
705
+ if (!exact || exact.team !== p.teamKey)
706
+ return waiting("runner_enrollment_unverified", hostEvidence);
707
+ }
612
708
  const host = live(hosts);
613
709
  if (host && host.capacity !== null)
614
710
  break;
@@ -619,7 +715,7 @@ export function onboardRunnerAdapter(input = {}) {
619
715
  await sleep(pollMs);
620
716
  }
621
717
  const host = live(hosts);
622
- const evidence = { ...hostEvidence, hostId: host.hostId, capacity: host.capacity };
718
+ const evidence = { ...hostEvidence, hostName: host.hostName, hostId: host.hostId, capacity: host.capacity };
623
719
  const orgKey = orgKeyFromFile(ctx);
624
720
  if (orgKey === null)
625
721
  return waiting("runner_org_key_file_invalid", evidence);
@@ -682,6 +778,17 @@ export function onboardRunnerAdapter(input = {}) {
682
778
  catch {
683
779
  return { state: "failed", reason: "runner_directory_unavailable", evidence: selected };
684
780
  }
781
+ if (!p.installedName &&
782
+ !readEnvFile(join(p.dir, ".env"))?.CATALYST_HOST_NAME &&
783
+ chosenName === undefined) {
784
+ const suggested = (input.hostName ?? runnerDefaultHostName)();
785
+ const name = input.chooseName
786
+ ? await input.chooseName(suggested)
787
+ : suggested;
788
+ if (name === null || !HOST_NAME.test(name))
789
+ return waiting("runner_name_required", selected);
790
+ chosenName = name;
791
+ }
685
792
  let want = await desiredEnv(p);
686
793
  if (!want)
687
794
  return waiting("runner_docker_socket_unreadable", selected);
@@ -725,14 +832,22 @@ export function onboardRunnerAdapter(input = {}) {
725
832
  const hosts = await listHosts(ctx, p, signal);
726
833
  if (!hosts)
727
834
  return waiting("runner_enrollment_unavailable", selected);
728
- if (hosts.some((host) => host.hostName === name && !host.revoked && host.team !== p.teamKey))
835
+ if (!enrolled &&
836
+ hosts.some((host) => host.hostName === name && !host.revoked && host.team !== p.teamKey))
729
837
  return waiting("runner_enrolled_for_other_team", { ...selected, hostName: name });
730
838
  if (enrolled && (enrolled.tenant !== p.account || enrolled.enrollmentKind !== "self_hosted"))
731
839
  return waiting("runner_identity_unverified", { ...selected, hostName: name });
732
840
  if (enrolled && enrolled.team !== p.teamKey)
733
841
  return waiting("runner_enrolled_for_other_team", { ...selected, hostName: name });
734
- if (hasCredential && !isUnenrolled(credential) && (!enrolled || !hosts.some((host) => host.hostId === enrolled.hostId && host.hostName === name && host.team === p.teamKey && !host.revoked)))
735
- return waiting("runner_enrollment_stale", { ...selected, hostName: name });
842
+ if (hasCredential && !isUnenrolled(credential)) {
843
+ if (!enrolled)
844
+ return waiting("runner_credential_unverified", { ...selected, hostName: name });
845
+ const exact = hosts.find((host) => host.hostId === enrolled.hostId);
846
+ if (exact?.revoked)
847
+ return waiting("runner_enrollment_revoked", { ...selected, hostName: name, hostId: enrolled.hostId });
848
+ if (!exact || exact.team !== p.teamKey)
849
+ return waiting("runner_enrollment_unverified", { ...selected, hostName: name });
850
+ }
736
851
  // A lost volume also lost the secret that redeemed the old token. Mint a new token and match
737
852
  // the new host id from the supervisor's credential, never an old advertisement with this name.
738
853
  // A lost reply must retry the same token and retained secret: a new token would be
@@ -8,6 +8,9 @@ import { cmdLegacy, findLegacy } from "./legacy.js";
8
8
  import { personalConsentAdapter } from "./onboard-personal.js";
9
9
  import { boundedOnboardSignin } from "./onboard-signin.js";
10
10
  import { existingLinearAdapters } from "./onboard-existing.js";
11
+ import { returningWorkspaceAdapters } from "./onboard-returning.js";
12
+ import { archiveReturningProject } from "./onboard-project-cleanup.js";
13
+ import { CREATE_TEAM_CHOICE } from "./onboard-team-create.js";
11
14
  import { linearWorkspaceAdapter } from "./onboard-workspace.js";
12
15
  import { firstProjectAdapters } from "./onboard-projects.js";
13
16
  import { onboardCapacityAdapter } from "./onboard-capacity.js";
@@ -175,8 +178,15 @@ export function createOnboardRuntime(args, ctx, hooks) {
175
178
  ? (message, run) => waitFor(message, run)
176
179
  : undefined,
177
180
  });
181
+ let newProjectRequested = false;
178
182
  const linear = existingLinearAdapters(args, hooks.ui?.chooseTeam
179
- ? (teams, create) => hooks.ui.chooseTeam(teams.map((team) => ({ ...team })), create)
183
+ ? async (teams, create) => {
184
+ if (newProjectRequested) {
185
+ newProjectRequested = false;
186
+ return CREATE_TEAM_CHOICE;
187
+ }
188
+ return hooks.ui.chooseTeam(teams.map(team => ({ ...team })), create);
189
+ }
180
190
  : undefined, hooks.ui?.nameNewTeam
181
191
  ? (question) => hooks.ui.nameNewTeam(question)
182
192
  : undefined, (text) => (hooks.ui ? hooks.ui.message(text) : ctx.stderr(text)));
@@ -314,6 +324,14 @@ export function createOnboardRuntime(args, ctx, hooks) {
314
324
  ? () => hooks.ui.chooseRunner()
315
325
  : undefined,
316
326
  engine: hooks.runnerEngine,
327
+ hostName: typeof args.flags["machine-name"] === "string"
328
+ ? () => String(args.flags["machine-name"])
329
+ : undefined,
330
+ chooseName: interactiveWait &&
331
+ hooks.ui?.nameMachine &&
332
+ args.flags["machine-name"] === undefined
333
+ ? (name) => hooks.ui.nameMachine(name)
334
+ : undefined,
317
335
  sleep: hooks.sleep,
318
336
  message: (text) => (hooks.ui ? hooks.ui.message(text) : ctx.stderr(text)),
319
337
  }),
@@ -473,7 +491,14 @@ export function createOnboardRuntime(args, ctx, hooks) {
473
491
  }
474
492
  // Public auth discovery and the existing /me sign-in bootstrap establish identity before the
475
493
  // tenant-bound step guards can run. Unsupported/local-only steps make no endpoint requests.
494
+ let reviewActive = false;
495
+ returningWorkspaceAdapters(adapters, args, hooks.ui?.reviewProjects?.bind(hooks.ui), text => hooks.ui ? hooks.ui.message(text) : ctx.stderr(text), () => { newProjectRequested = true; }, () => reviewActive, (project, stepCtx, journal, signal) => archiveReturningProject(project, stepCtx, journal, hooks.ui?.confirmProjectCleanup?.bind(hooks.ui), signal));
476
496
  return {
497
+ beforeStep: async (id) => {
498
+ await hooks.beforeStep?.(id);
499
+ if (id === "linear.workspace")
500
+ reviewActive = true;
501
+ },
477
502
  identity: (journal) => identity(ctx, journal),
478
503
  ...(hooks.stageSignin ? { stageSignin: hooks.stageSignin } : {}),
479
504
  bindSignals: true,
@@ -1,3 +1,4 @@
1
+ import { GITHUB_CONFIRM_ACCESS } from "./onboard-github-copy.js";
1
2
  import { COMPUTER_CHECKS, standalonePlan, standalonePlanNotes, pendingContinuation, } from "./onboard-standalone-copy.js";
2
3
  import { trackedPrompt } from "./setup-prompt-frame.js";
3
4
  import { createOnboardInterrupts } from "./onboard-interrupts.js";
@@ -157,11 +158,13 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
157
158
  return;
158
159
  if (renderer &&
159
160
  currentStep === "linear.adopt" &&
160
- text.endsWith("already has every state and label"))
161
+ text.endsWith("has every required state and label"))
161
162
  return;
162
163
  if (renderer) {
163
164
  if (currentStep &&
164
165
  setupBrowserInstruction(currentStep, deps.baseUrl?.())) {
166
+ if (currentStep === "github.install" && text === GITHUB_CONFIRM_ACCESS)
167
+ renderer.detail(text);
165
168
  if (/no browser opened|could not open a browser/i.test(text))
166
169
  browserOpened = false;
167
170
  return;
@@ -441,6 +444,49 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
441
444
  ...(answer === "signin" ? { signin: true } : {}),
442
445
  };
443
446
  },
447
+ async reviewProjects(projects) {
448
+ stop();
449
+ if (abort.signal.aborted)
450
+ return null;
451
+ const answer = await select({
452
+ ...options,
453
+ message: "What should setup do with this workspace's projects?",
454
+ initialValue: "move-on",
455
+ options: [
456
+ { value: "move-on", label: "Move on and finish this computer's setup", hint: "default" },
457
+ ...projects.map(p => ({ value: `repair:${p.id}`, label: `Review or repair ${p.name} (${p.key})` })),
458
+ ...projects.map(p => ({ value: `cleanup:${p.id}`, label: `Preview archiving ${p.name} (${p.key})` })),
459
+ { value: "new", label: "Create a new Catalyst project" },
460
+ ],
461
+ });
462
+ if (prompts.isCancel(answer)) {
463
+ abort.abort();
464
+ return null;
465
+ }
466
+ if (answer === "move-on" || answer === "new")
467
+ return answer;
468
+ if (typeof answer === "string" && answer.startsWith("cleanup:"))
469
+ return { cleanup: answer.slice(8) };
470
+ return typeof answer === "string" && answer.startsWith("repair:") ? { repair: answer.slice(7) } : null;
471
+ },
472
+ async confirmProjectCleanup(project, lines) {
473
+ stop();
474
+ for (const line of lines)
475
+ message(line);
476
+ if (abort.signal.aborted)
477
+ return false;
478
+ const answer = await select({ ...options,
479
+ message: `Archive ${project.name} after reviewing these changes?`,
480
+ initialValue: "keep",
481
+ options: [{ value: "keep", label: "Keep the project and finish this computer's setup" },
482
+ { value: "archive", label: "Archive this project" }],
483
+ });
484
+ if (prompts.isCancel(answer)) {
485
+ abort.abort();
486
+ return false;
487
+ }
488
+ return answer === "archive";
489
+ },
444
490
  async chooseTeam(teams, create) {
445
491
  stop();
446
492
  // Creating needs a name prompt; a port without one offers only the existing teams.
@@ -668,6 +714,23 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
668
714
  }
669
715
  return typeof answer === "string" ? answer : null;
670
716
  },
717
+ async nameMachine(defaultName) {
718
+ if (abort.signal.aborted)
719
+ return null;
720
+ const answer = await text({
721
+ ...options,
722
+ message: "Name for this machine",
723
+ initialValue: defaultName,
724
+ validate: (value) => /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/.test(value ?? "")
725
+ ? undefined
726
+ : "Use 1 to 63 letters, digits, dots, underscores or hyphens, starting with a letter or digit.",
727
+ });
728
+ if (typeof answer !== "string") {
729
+ abort.abort();
730
+ return null;
731
+ }
732
+ return answer;
733
+ },
671
734
  async chooseRunner() {
672
735
  if (abort.signal.aborted)
673
736
  return null;
@@ -1028,6 +1091,8 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
1028
1091
  ? status()
1029
1092
  : "waiting for you, up to 10 minutes");
1030
1093
  renderer.action(browser.instruction);
1094
+ if (currentStep === "github.install")
1095
+ renderer.detail(GITHUB_CONFIRM_ACCESS);
1031
1096
  if (browser.url)
1032
1097
  renderer.detail(renderer.link(browser.url));
1033
1098
  renderer.detail(renderer.dim(interactive ? "Ctrl-C skips this step." : "Ctrl-C stops setup."));
@@ -1109,6 +1174,7 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
1109
1174
  delete ui.chooseFirstRepository;
1110
1175
  delete ui.reviewSettings;
1111
1176
  delete ui.chooseRunner;
1177
+ delete ui.nameMachine;
1112
1178
  delete ui.chooseFirstTicket;
1113
1179
  delete ui.retryTimedOut;
1114
1180
  delete ui.confirmWorkflowAdoption;
@@ -145,13 +145,36 @@ export function observeOnboardWorkflow(value, team, now) {
145
145
  : {}),
146
146
  };
147
147
  }
148
- const label = (text) => text
148
+ const plainText = (text) => text
149
149
  .replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff]/g, "")
150
- .trim()
151
- .slice(0, 80);
150
+ .trim();
151
+ const label = (text) => plainText(text).slice(0, 80);
152
152
  const listed = (names) => names.length > 10
153
153
  ? `${names.slice(0, 10).join(", ")}, and ${names.length - 10} more`
154
154
  : names.join(", ");
155
+ function unfinishedLabelLines(plan) {
156
+ return [
157
+ ...plan.labels.filter((row) => row.outcome === "refused").map((row) => {
158
+ const name = plainText(row.name);
159
+ const reason = plainText(row.reason ?? "Linear did not supply a reason.");
160
+ const guidance = name === "review-followup"
161
+ ? "Optional for the cloud workflow; no action is needed to finish setup."
162
+ : [
163
+ name === "catalyst-not-an-ask"
164
+ ? "Required for the cloud workflow. Without this label, work held by the ask-shape check cannot be released."
165
+ : "Only missing required labels block workflow readiness.",
166
+ "Ask a Linear workspace administrator to resolve the reported refusal and make this label available to the team, then run catalyst onboard.",
167
+ ].join(" ");
168
+ return `Linear refused label: ${name}. ${reason} ${guidance}`;
169
+ }),
170
+ ...(plan.labelsNotCreated ?? []).map((row) => {
171
+ const entry = object(row);
172
+ const name = typeof entry?.name === "string" ? plainText(entry.name) : "";
173
+ const reason = typeof entry?.reason === "string" ? plainText(entry.reason) : "";
174
+ return `Label not added: ${name || "Unnamed label"}. ${reason || "The server did not supply a reason; run catalyst onboard again to retry."}`;
175
+ }),
176
+ ];
177
+ }
155
178
  /** The adopt preview in plain words: stages and labels it creates or keeps. Linear's names are data. */
156
179
  export function adoptPlanLines(plan) {
157
180
  const group = (rows, outcome) => rows.filter((row) => row.outcome === outcome).map((row) => label(row.name));
@@ -161,11 +184,10 @@ export function adoptPlanLines(plan) {
161
184
  ["Linear refused stages", group(plan.stages, "refused")],
162
185
  ["Create labels", group(plan.labels, "would-create")],
163
186
  ["Labels already present", group(plan.labels, "already-present")],
164
- ["Linear refused labels", group(plan.labels, "refused")],
165
187
  ]
166
188
  .filter(([, names]) => names.length > 0)
167
189
  .map(([title, names]) => `${title}: ${listed(names)}`)
168
- .concat(plan.unfilledLoadBearing?.length ? [`Required work stages still missing: ${plan.unfilledLoadBearing.length}`] : [], (plan.provenanceGaps?.length ?? 0) + (plan.labelProvenanceGaps?.length ?? 0) ? ["Some existing states or labels have no recorded creator; setup keeps them"] : [], plan.labelsNotCreated?.length ? [`Labels that cannot be added: ${plan.labelsNotCreated.length}`] : []);
190
+ .concat(plan.unfilledLoadBearing?.length ? [`Required work stages still missing: ${plan.unfilledLoadBearing.length}`] : [], (plan.provenanceGaps?.length ?? 0) + (plan.labelProvenanceGaps?.length ?? 0) ? ["Some existing states or labels have no recorded creator; setup keeps them"] : [], unfinishedLabelLines(plan));
169
191
  }
170
192
  /** The only adopt bodies setup sends: this team, and the approved mode and hash. */
171
193
  export function adoptRequestAllowed(body, team, allow) {
@@ -391,7 +413,7 @@ export function onboardWorkflowVerificationAdapter(input = {}) {
391
413
  : {}),
392
414
  },
393
415
  };
394
- }, () => input.message?.(`${onboardTeamKey(journal) ?? "This team"} already has every state and label`));
416
+ }, () => input.message?.(`${onboardTeamKey(journal) ?? "This team"} has every required state and label`));
395
417
  if (!adopts)
396
418
  return { check };
397
419
  // A local non-admin never gets here; a 403 means the seat changed after login.
@@ -440,7 +462,10 @@ export function onboardWorkflowVerificationAdapter(input = {}) {
440
462
  }
441
463
  const created = (rows) => rows.filter((row) => row.outcome === "created").length;
442
464
  const stages = created(reply.stages), labels = created(reply.labels);
443
- summary = `Applied the Catalyst workflow to ${key}: created ${stages} ${stages === 1 ? "stage" : "stages"} and ${labels} ${labels === 1 ? "label" : "labels"}.`;
465
+ summary = [
466
+ `Applied the Catalyst workflow to ${key}: created ${stages} ${stages === 1 ? "stage" : "stages"} and ${labels} ${labels === 1 ? "label" : "labels"}.`,
467
+ ...unfinishedLabelLines(reply),
468
+ ].join("\n");
444
469
  return { state: "done" };
445
470
  }, () => input.message?.(summary));
446
471
  // Only the server's own plan-stale answer plans again; a later identity refusal does not.
package/dist/onboard.js CHANGED
@@ -268,6 +268,7 @@ function normalizeStep(value, fallbackAt) {
268
268
  "membershipId",
269
269
  "checkedAt",
270
270
  "project",
271
+ "projectCount",
271
272
  "repository",
272
273
  "installation",
273
274
  "ticket",
@@ -673,6 +674,7 @@ export function stepSatisfied(step) {
673
674
  // Optional: a computer without the daily update still runs work.
674
675
  step.id === "housekeeping" ||
675
676
  step.reason === "member_scope" ||
677
+ step.reason === "returning_workspace_move_on" ||
676
678
  (step.id === "daemon" && step.reason === "local_sync_not_selected") ||
677
679
  (step.id === "runner" && step.reason === "runner_not_selected") ||
678
680
  (step.id === "first-ticket" && step.reason === "first_ticket_skipped") ||
@@ -711,7 +713,8 @@ export function onboardRequiredStepsWaiting(journal) {
711
713
  }
712
714
  /** A full run that exited 0 with deferred steps still open: ready for work, not complete. */
713
715
  export function onboardReadyForWork(journal, only) {
714
- return !only && journal.exit === 0 && !journal.complete;
716
+ return !only && journal.exit === 0 && !journal.complete &&
717
+ !journal.steps.some(step => step.reason === "returning_workspace_move_on");
715
718
  }
716
719
  /** A recorded step's next action; a prerequisite wait names the first unfinished step it needs. */
717
720
  export function onboardStepAction(journal, step, baseUrl) {
@@ -1143,7 +1146,11 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
1143
1146
  else if (deps.ui)
1144
1147
  deps.ui.finish(journal, only);
1145
1148
  else {
1146
- if (journal.complete)
1149
+ if (journal.complete && journal.steps.some(step => step.reason === "returning_workspace_move_on")) {
1150
+ ctx.stdout("This computer is set up. Existing projects were kept in place.");
1151
+ ctx.stdout("Next: run catalyst ready to review their checks.");
1152
+ }
1153
+ else if (journal.complete)
1147
1154
  ctx.stdout("Onboarding complete.");
1148
1155
  else if (onboardReadyForWork(journal, only) &&
1149
1156
  (deps.requiredSteps ?? []).every((id) => journalStep(journal, id)?.state === "done")) {