@catalyst-cloud/cli 0.16.0 → 0.16.2

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 (49) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +9 -6
  3. package/dist/args.js +6 -0
  4. package/dist/capabilities.js +3 -1
  5. package/dist/cli.js +10 -5
  6. package/dist/environment.js +1 -1
  7. package/dist/execution.js +2 -2
  8. package/dist/hosts.js +89 -0
  9. package/dist/legacy.js +12 -2
  10. package/dist/machine-inventory.js +37 -0
  11. package/dist/onboard-capacity.js +5 -6
  12. package/dist/onboard-headless.js +2 -2
  13. package/dist/onboard-next.js +18 -10
  14. package/dist/onboard-project-cleanup.js +100 -0
  15. package/dist/onboard-returning.js +172 -0
  16. package/dist/onboard-runner.js +149 -34
  17. package/dist/onboard-runtime.js +26 -1
  18. package/dist/onboard-ui.js +61 -0
  19. package/dist/onboard.js +9 -2
  20. package/dist/ready-copy.js +30 -24
  21. package/dist/ready.js +2 -2
  22. package/dist/secret.js +2 -2
  23. package/dist/setup-onboard-copy.js +37 -32
  24. package/dist/setup.js +4 -0
  25. package/dist/transport.js +18 -2
  26. package/package.json +3 -3
  27. package/skills/catalyst-github/SKILL.md +1 -1
  28. package/skills/catalyst-linear/SKILL.md +1 -1
  29. package/skills/catalyst-onboard/SKILL.md +3 -3
  30. package/skills/catalyst-onboard/references/connecting-this-machine.md +4 -4
  31. package/skills/catalyst-onboard/references/declaring-a-repository.md +1 -1
  32. package/skills/catalyst-onboard/references/reading-ready.md +13 -13
  33. package/skills/catalyst-onboard/references/replacing-a-credential.md +1 -1
  34. package/skills/catalyst-onboard/references/the-one-path.md +5 -5
  35. package/skills/catalyst-onboard/references/what-a-phase-needs.md +4 -4
  36. package/skills/catalyst-onboard/references/what-the-browser-owns.md +20 -10
  37. package/skills/catalyst-onboard/scripts/where-am-i.mjs +49 -26
  38. package/skills/run-this-project/SKILL.md +1 -1
  39. package/skills/run-this-project/references/stalls-and-escalation.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-needs-me/references/raising-a-decision.md +1 -1
  43. package/skills/what-needs-me/references/reading-the-inbox.md +1 -1
  44. package/skills/what-this-repo-needs/SKILL.md +1 -1
  45. package/skills/whats-happening/SKILL.md +3 -2
  46. package/skills/whats-happening/references/coding-accounts.md +1 -1
  47. package/skills/whats-happening/references/machines.md +9 -0
  48. package/skills/whats-happening/references/settings-and-where-they-live.md +8 -8
  49. package/skills/whats-happening/references/stages-and-mapping.md +1 -1
@@ -0,0 +1,100 @@
1
+ import { loadConfig, normalizeBaseUrl } from "./config.js";
2
+ import { readExistingOnboardJson } from "./onboard-existing.js";
3
+ import { readReturningProjects } from "./onboard-returning.js";
4
+ const waiting = (reason) => ({ state: "waiting", reason });
5
+ const signature = (project) => JSON.stringify([...(project.repositories ?? [])].sort((a, b) => a.id.localeCompare(b.id)));
6
+ /** Archiving uses the existing reversible project action. Only the explicitly reviewed scope
7
+ * is changed, after fresh inventory and identity checks. A failed write is never assumed absent. */
8
+ export async function archiveReturningProject(project, ctx, journal, confirm, signal) {
9
+ const cfg = loadConfig(ctx.home);
10
+ const sameIdentity = () => {
11
+ const now = loadConfig(ctx.home);
12
+ return cfg?.user && now?.user && cfg.account === journal.account &&
13
+ cfg.user.id === journal.membershipId && now.account === cfg.account &&
14
+ now.user.id === cfg.user.id && ["owner", "admin"].includes(now.user.role) &&
15
+ normalizeBaseUrl(now.baseUrl) === journal.baseUrl && normalizeBaseUrl(cfg.baseUrl) === journal.baseUrl;
16
+ };
17
+ if (!sameIdentity() || !confirm)
18
+ return waiting("returning_project_cleanup_unverified");
19
+ const fresh = await readReturningProjects(ctx, journal, signal);
20
+ const selected = "projects" in fresh ? fresh.projects.find(p => p.id === project.id) : undefined;
21
+ if (!selected?.repositories?.length || selected.repositories.length > 64 || !sameIdentity())
22
+ return waiting("returning_project_cleanup_unverified");
23
+ const linked = async () => {
24
+ const names = new Set();
25
+ const boundedRead = signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000);
26
+ for (const row of selected.repositories) {
27
+ const read = await readExistingOnboardJson(ctx, `/api/v1/me/projects/${encodeURIComponent(row.id)}/repositories`, boundedRead);
28
+ if ("reason" in read || !read.body || typeof read.body !== "object")
29
+ return null;
30
+ const body = read.body;
31
+ if (!body.project || typeof body.project !== "object" ||
32
+ !("id" in body.project) || body.project.id !== row.id ||
33
+ !("teamId" in body.project) || body.project.teamId !== selected.id ||
34
+ !Array.isArray(body.repositories) || !body.repositories.length || body.repositories.length > 10_000)
35
+ return null;
36
+ const returnedNames = new Set();
37
+ for (const repository of body.repositories) {
38
+ if (!repository || typeof repository !== "object" || !("fullName" in repository) ||
39
+ typeof repository.fullName !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/.test(repository.fullName))
40
+ return null;
41
+ names.add(repository.fullName);
42
+ returnedNames.add(repository.fullName);
43
+ }
44
+ if (!returnedNames.has(row.name))
45
+ return null;
46
+ }
47
+ return [...names].sort();
48
+ };
49
+ const linkedNames = await linked();
50
+ if (!linkedNames || !sameIdentity())
51
+ return waiting("returning_project_cleanup_unverified");
52
+ const lines = [
53
+ `Archive ${selected.name} (${selected.key}) in Catalyst.`,
54
+ ...linkedNames.map(name => `Repository affected: ${name}.`),
55
+ "Archiving removes this project’s repository registrations. Mirroring may stop where no other registration keeps a repository active.",
56
+ "The project and its repository links are kept so it can be reactivated. Linear tickets and GitHub repositories are kept.",
57
+ "New work may be held when these repositories are no longer registered for this project.",
58
+ ];
59
+ if (!(await confirm(selected, lines)))
60
+ return { state: "done", reason: "returning_project_cleanup_declined" };
61
+ if (signal?.aborted || !sameIdentity())
62
+ return waiting("returning_project_cleanup_unverified");
63
+ const rechecked = await readReturningProjects(ctx, journal, signal);
64
+ const current = "projects" in rechecked ? rechecked.projects.find(p => p.id === selected.id) : undefined;
65
+ if (!current || signature(current) !== signature(selected) || !sameIdentity())
66
+ return waiting("returning_project_cleanup_changed");
67
+ const recheckedLinks = await linked();
68
+ if (!recheckedLinks || JSON.stringify(recheckedLinks) !== JSON.stringify(linkedNames) || !sameIdentity())
69
+ return waiting("returning_project_cleanup_changed");
70
+ const bearer = cfg?.key || (cfg?.auth && Date.parse(cfg.auth.expiresAt) - ctx.now().getTime() > 30_000
71
+ ? cfg.auth.accessToken : undefined);
72
+ if (!bearer)
73
+ return waiting("returning_project_cleanup_unverified");
74
+ const bounded = signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000);
75
+ for (const row of selected.repositories) {
76
+ if (bounded.aborted || !sameIdentity())
77
+ return waiting("returning_project_cleanup_unverified");
78
+ try {
79
+ const response = await ctx.fetch(`${journal.baseUrl}/api/v1/me/repositories/${encodeURIComponent(row.id)}/archive`, {
80
+ method: "POST", redirect: "error", signal: bounded,
81
+ headers: { authorization: `Bearer ${bearer}`, accept: "application/json", "content-type": "application/json" },
82
+ body: "{}",
83
+ });
84
+ if (response.status !== 200)
85
+ return waiting("returning_project_cleanup_unverified");
86
+ const body = await response.json();
87
+ const returned = body && typeof body === "object" && "repository" in body ? body.repository : null;
88
+ if (!returned || typeof returned !== "object" || !("id" in returned) || returned.id !== row.id ||
89
+ !("linearTeamId" in returned) || returned.linearTeamId !== selected.id ||
90
+ !("status" in returned) || returned.status !== "archived")
91
+ return waiting("returning_project_cleanup_unverified");
92
+ journal.changes.push({ kind: "project-archive", label: `Archived ${selected.name}: ${row.name}`,
93
+ undo: "Reactivate the project from its Catalyst project page." });
94
+ }
95
+ catch {
96
+ return waiting("returning_project_cleanup_unverified");
97
+ }
98
+ }
99
+ return { state: "done" };
100
+ }
@@ -0,0 +1,172 @@
1
+ import { loadConfig, normalizeBaseUrl } from "./config.js";
2
+ import { readExistingOnboardJson, readOnboardTeamInventory } from "./onboard-existing.js";
3
+ import { teamCheckCopy } from "./ready-copy.js";
4
+ const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v) ? v : null;
5
+ const repository = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/;
6
+ /** Count configured teams across their repositories, rather than every visible Linear team. */
7
+ export async function readReturningProjects(ctx, journal, signal) {
8
+ const cfg = loadConfig(ctx.home);
9
+ if (!cfg?.user || cfg.account !== journal.account || cfg.user.id !== journal.membershipId ||
10
+ normalizeBaseUrl(cfg.baseUrl) !== journal.baseUrl)
11
+ return { reason: "returning_inventory_unverified" };
12
+ const contract = await readExistingOnboardJson(ctx, "/api/v1/agent/contract", signal);
13
+ if ("reason" in contract)
14
+ return { reason: "returning_inventory_unverified" };
15
+ const doc = object(contract.body);
16
+ if (object(doc?.account)?.id !== cfg.account || !Array.isArray(doc?.routes))
17
+ return { reason: "returning_inventory_unverified" };
18
+ const routes = doc.routes.map(object).filter(r => r?.method === "GET" && typeof r.path === "string" &&
19
+ /^\/api\/v1\/[A-Za-z0-9_/-]+\/tenant\/repositories$/.test(r.path));
20
+ if (routes.length !== 1)
21
+ return { reason: "returning_inventory_unverified" };
22
+ // The contract's mapped teams omit projects that have not adopted a workflow yet. Use the
23
+ // complete project inventory, including paused and archived rows, and group active rows by team.
24
+ const read = await readExistingOnboardJson(ctx, routes[0].path, signal);
25
+ if ("reason" in read)
26
+ return { reason: "returning_inventory_unverified" };
27
+ const list = object(read.body);
28
+ if (list?.canManage !== true || !Array.isArray(list.repositories) || list.repositories.length > 10_000)
29
+ return { reason: "returning_inventory_unverified" };
30
+ const inventory = await readOnboardTeamInventory(ctx, signal);
31
+ const configured = [];
32
+ const seen = new Set();
33
+ const clean = (text) => text.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").slice(0, 120);
34
+ for (const value of list.repositories) {
35
+ const row = object(value);
36
+ if (typeof row?.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/.test(row.id) || seen.has(row.id) ||
37
+ typeof row.linearTeamId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(row.linearTeamId) ||
38
+ typeof row.name !== "string" || !row.name.trim() ||
39
+ !(row.linearTeamKey === null || typeof row.linearTeamKey === "string") ||
40
+ !["active", "paused", "archived"].includes(String(row.status)) ||
41
+ typeof row.githubRepoOwner !== "string" || typeof row.githubRepoName !== "string" ||
42
+ !repository.test(`${row.githubRepoOwner}/${row.githubRepoName}`))
43
+ return { reason: "returning_inventory_unverified" };
44
+ seen.add(row.id);
45
+ if (row.status === "archived")
46
+ continue;
47
+ const savedProject = configured.find(p => p.id === row.linearTeamId);
48
+ const linked = { id: row.id, name: `${row.githubRepoOwner}/${row.githubRepoName}`, status: row.status };
49
+ if (savedProject) {
50
+ savedProject.repositories.push(linked);
51
+ continue;
52
+ }
53
+ const team = "reason" in inventory ? undefined : inventory.teams.find(t => t.id === row.linearTeamId);
54
+ configured.push({ id: row.linearTeamId, key: team?.key ?? clean(row.linearTeamKey ?? ""),
55
+ name: team?.name ?? clean(row.name), status: "Readiness could not be checked. Run catalyst ready to check it.", repositories: [linked] });
56
+ }
57
+ // Bound the whole readiness summary, including a workspace with many projects.
58
+ const deadline = new AbortController();
59
+ const timer = setTimeout(() => deadline.abort(), 30_000);
60
+ const bounded = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal;
61
+ try {
62
+ for (let start = 0; start < configured.length && !bounded.aborted; start += 4) {
63
+ await Promise.all(configured.slice(start, start + 4).map(async (project) => {
64
+ const read = await readExistingOnboardJson(ctx, `/api/v1/agent/tenant/readiness?team=${encodeURIComponent(project.id)}`, bounded);
65
+ if ("reason" in read)
66
+ return;
67
+ const row = object(object(read.body)?.readiness);
68
+ if (row?.teamId !== project.id || !["ready", "degraded", "blocked"].includes(String(row.status)) ||
69
+ typeof row.checkedAt !== "number" || !Number.isFinite(row.checkedAt) || row.checkedAt < 0 || row.checkedAt > ctx.now().getTime() ||
70
+ !Array.isArray(row.checks) || !row.checks.length || row.checks.length > 100)
71
+ return;
72
+ const checks = row.checks.map(object);
73
+ if (!checks.every(c => c && typeof c.id === "string" && /^[a-z][a-z0-9_]{0,79}$/.test(c.id) &&
74
+ ["pass", "fail", "unknown"].includes(String(c.state))) ||
75
+ new Set(checks.map(c => c.id)).size !== checks.length)
76
+ return;
77
+ const unfinished = checks.filter(c => c.state !== "pass");
78
+ if (row.status === "ready" && !unfinished.length)
79
+ project.status = "Ready";
80
+ else if (unfinished.length)
81
+ project.status = unfinished.map(c => teamCheckCopy(cfg.baseUrl, project.key, c.id, c.state, null).line
82
+ .replace(`team ${project.key}: `, "")).join(" ");
83
+ }));
84
+ }
85
+ }
86
+ finally {
87
+ clearTimeout(timer);
88
+ }
89
+ for (const project of configured) {
90
+ const paused = project.repositories?.filter(r => r.status === "paused") ?? [];
91
+ if (paused.length)
92
+ project.status = `Paused: ${paused.map(r => r.name).join(", ")}. ${project.status === "Ready" ? "Readiness checks pass." : project.status}`;
93
+ }
94
+ return { projects: configured };
95
+ }
96
+ const PROJECT_STEPS = new Set([
97
+ "linear.workspace", "linear.personal", "linear.team", "linear.adopt", "linear.automations",
98
+ "github.install", "github.personal", "github.repos", "projects", "settings", "values", "capacity", "first-ticket", "ready",
99
+ ]);
100
+ /** Scope is enforced in the adapters and receipt, so moving on cannot perform project writes. */
101
+ export function returningWorkspaceAdapters(adapters, args, review, message, newProject, active = () => true, cleanup) {
102
+ if (args.flags.team !== undefined || args.flags.only !== undefined || args.flags.headless === true)
103
+ return;
104
+ let mode = "unread";
105
+ let count = 0;
106
+ let cleanupProject;
107
+ const scope = async (ctx, journal, signal, act = false) => {
108
+ if (!active())
109
+ return null;
110
+ if (mode === "unread") {
111
+ if (!["owner", "admin"].includes(loadConfig(ctx.home)?.user?.role ?? "")) {
112
+ mode = "continue";
113
+ return null;
114
+ }
115
+ const inventory = await readReturningProjects(ctx, journal, signal);
116
+ if ("reason" in inventory)
117
+ mode = "unknown";
118
+ else {
119
+ count = inventory.projects.length;
120
+ if (!count)
121
+ mode = "continue";
122
+ else {
123
+ message(`This workspace already has ${count} Catalyst project${count === 1 ? "" : "s"}.`);
124
+ for (const p of inventory.projects)
125
+ message(`${p.name} (${p.key}): ${p.status}`);
126
+ const choice = review ? await review(inventory.projects) : "move-on";
127
+ if (!choice || signal?.aborted)
128
+ return { state: "waiting", reason: "interrupted" };
129
+ if (choice === "move-on")
130
+ mode = "move-on";
131
+ else if (choice === "new") {
132
+ mode = "continue";
133
+ newProject();
134
+ }
135
+ else if ("cleanup" in choice) {
136
+ cleanupProject = inventory.projects.find(p => p.id === choice.cleanup);
137
+ mode = cleanupProject ? "cleanup" : "unknown";
138
+ }
139
+ else if (inventory.projects.some(p => p.id === choice.repair)) {
140
+ args.flags.team = choice.repair;
141
+ mode = "continue";
142
+ }
143
+ else
144
+ mode = "unknown";
145
+ }
146
+ }
147
+ }
148
+ if (mode === "cleanup") {
149
+ if (!act)
150
+ return { state: "pending", reason: "returning_project_cleanup_requested" };
151
+ if (!cleanup || !cleanupProject)
152
+ return { state: "waiting", reason: "returning_project_cleanup_unverified" };
153
+ const result = await cleanup(cleanupProject, ctx, journal, signal);
154
+ if (result.state !== "done")
155
+ return result;
156
+ mode = "move-on";
157
+ }
158
+ if (mode === "unknown")
159
+ return { state: "waiting", reason: "returning_inventory_unverified" };
160
+ return mode === "move-on" ? { state: "skipped", reason: "returning_workspace_move_on", evidence: { projectCount: count } } : null;
161
+ };
162
+ for (const id of PROJECT_STEPS) {
163
+ const adapter = adapters[id];
164
+ if (!adapter)
165
+ continue;
166
+ adapters[id] = {
167
+ ...adapter,
168
+ check: async (ctx, journal, signal) => (mode === "unread" && id !== "linear.workspace" ? null : await scope(ctx, journal, signal)) ?? adapter.check(ctx, journal, signal),
169
+ ...(adapter.act ? { act: async (ctx, journal, signal) => (mode === "unread" && id !== "linear.workspace" ? null : await scope(ctx, journal, signal, true)) ?? adapter.act(ctx, journal, signal) } : {}),
170
+ };
171
+ }
172
+ }
@@ -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,