@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,9 +1,11 @@
1
1
  import { nativeEgressInstallCommands } from "./onboard-runner-egress.js";
2
+ import { GITHUB_CONFIRM_ACCESS } from "./onboard-github-copy.js";
2
3
  import { INSTALL_COMMAND, loadConfig, normalizeBaseUrl } from "./config.js";
3
4
  import { githubInstallationPage } from "./onboard-permissions.js";
4
5
  import { valuesFacts, valuesFix } from "./ready-copy.js";
5
6
  /** What a person reads for a step that is not done. Each line ends in an action they can take. */
6
7
  const REASONS = {
8
+ legacy_cleanup_failed: "An earlier Catalyst installation could not be removed. Run catalyst legacy --remove to see the exact item and its blocker, fix that blocker, then run catalyst setup again. Shared data folders are kept.",
7
9
  account_identity_unverified: "Your login changed while AI accounts were checked. Run catalyst onboard again to verify your person and workspace.",
8
10
  account_inventory_unavailable: "AI accounts could not be read. Run catalyst onboard again to retry.",
9
11
  account_inventory_unverified: "The server did not return a fresh supported AI account list. Run catalyst onboard after the server update.",
@@ -52,6 +54,11 @@ const REASONS = {
52
54
  local_sync_not_selected: "Using cloud reads. Local sync was not selected.",
53
55
  local_sync_capability_unavailable: "Local sync was selected but could not be verified.",
54
56
  member_scope: "Your workspace administrator handles this step.",
57
+ returning_workspace_move_on: "Existing projects were left in place while this computer was set up. Run catalyst ready to review their checks, or catalyst setup --team <TEAM KEY> to repair one.",
58
+ returning_inventory_unverified: "Setup could not verify this workspace's existing projects. Run catalyst ready to check the workspace, then run catalyst setup again. Its projects have been kept in place.",
59
+ returning_project_cleanup_requested: "Review the selected project's archive preview before making any change.",
60
+ returning_project_cleanup_unverified: "Setup could not confirm this project's archive state. Run catalyst project list to inspect it, then run catalyst setup again to review any remaining changes. Do not repeat an archive based on this message alone.",
61
+ returning_project_cleanup_changed: "This project's repositories changed after the preview. Run catalyst setup again to review its current project list before archiving.",
55
62
  member_team_required: "Setup needs your Linear team to check what Catalyst can work on. Run catalyst setup again with --team <KEY>, using your team's key from Linear.",
56
63
  onboarding_checks_pending: "Some required checks are still unverified.",
57
64
  interrupted: "Setup paused. Run the same command to resume.",
@@ -72,7 +79,7 @@ const REASONS = {
72
79
  team_read_login_refresh_required: "Renew your login with catalyst login, then run catalyst onboard to resume.",
73
80
  team_create_unavailable: "The Linear team could not be created, and nothing was created. Run catalyst onboard to try again, or create the team in Linear and pick it.",
74
81
  team_create_unverified: "Setup could not confirm whether the Linear team was created. Check Linear for it before creating it again.",
75
- team_create_grant_required: "Your own Linear connection is missing or has lapsed. Reconnect it in Settings → Connected accounts, then run catalyst onboard. Nothing was created.",
82
+ team_create_grant_required: "Your own Linear connection is missing or has lapsed. Reconnect it with catalyst connections personal linear start and approve the link it prints, then run catalyst onboard. Nothing was created.",
76
83
  team_create_identity_unverified: "Your login changed while setup was creating a team. Resume with the original workspace and person.",
77
84
  team_choice_required: "Choose an existing team with catalyst onboard --team <team ID or key>.",
78
85
  team_selection_unverified: "Your selected Linear team could not be verified. Run catalyst onboard --team <team ID or key> to choose again.",
@@ -100,13 +107,15 @@ const REASONS = {
100
107
  verification_pending: "Setup made the change but could not confirm it yet. Run catalyst onboard again to check.",
101
108
  automations_compatible: "Nothing to change.",
102
109
  workflow_unavailable: "Setup could not read the team's workflow. Run catalyst onboard again to retry.",
110
+ capacity_context_unverified: "Setup could not confirm this project's registered repositories and its default repository for capacity. Run catalyst ready to see the project checks, then run catalyst setup again. Starting a ticket checks its own repository's admission.",
111
+ capacity_identity_unverified: "Setup could not confirm your workspace and administrator role for the capacity check. Run catalyst login, then run catalyst setup again.",
103
112
  capacity_admission_unverified: "No runner is allowed to take work for this workspace yet. Enroll a self-hosted runner host, or ask Catalyst to enable cloud runners for it. Then run catalyst onboard.",
104
113
  capacity_currently_full: "Every runner slot for this repository is in use. Run catalyst onboard again after current work finishes.",
105
114
  capacity_unavailable: "Setup could not read runner capacity. Run catalyst onboard again to retry.",
106
115
  runner_not_selected: "This setup does not start a runner on this machine. To start one, run catalyst onboard --runner.",
107
116
  runner_docker_missing: "A runner needs Docker with Compose (Docker Desktop, OrbStack or Docker Engine), and setup could not reach it. Start Docker, or install it, then run catalyst onboard --runner.",
108
117
  runner_identity_unverified: "A workspace owner or administrator enrolls a runner. Sign in as one with catalyst login, then run catalyst onboard --runner.",
109
- runner_context_unverified: "Setup could not confirm the selected Linear team. Run catalyst onboard again.",
118
+ runner_context_unverified: "Setup could not confirm a project for this machine. Keep its existing installation and credentials. Run catalyst setup --runner --team <TEAM KEY> for the project this machine should serve.",
110
119
  runner_directory_unavailable: "Setup could not prepare the runner folder in Catalyst's state directory. Check its permissions, then run catalyst onboard again.",
111
120
  runner_image_unpinned: "Each runner image must be pinned as name@sha256:<digest>, and Catalyst's runner image is not public yet. Ask Catalyst support to load it here and set CATALYST_RUNNER_IMAGE to the reference they give you. A CATALYST_SUPERVISOR_IMAGE or CATALYST_WATCHDOG_IMAGE override takes the same form. Then run catalyst onboard --runner.",
112
121
  runner_image_emulated: "A runner image here is built for another processor, and Catalyst does not run work under emulation. Ask Catalyst support for the image built for this processor, then run catalyst onboard again.",
@@ -114,9 +123,12 @@ const REASONS = {
114
123
  runner_session_network_failed: "Docker could not create the catalyst-session-v1 network. Check that Docker is running, then run catalyst onboard again.",
115
124
  runner_docker_socket_unreadable: "Setup could not read the group of the Docker socket at /var/run/docker.sock. Check that Docker is running, then run catalyst onboard again.",
116
125
  runner_engine_unsupported: "Runner setup needs local Docker Desktop or OrbStack on Mac, or native Linux Docker at /var/run/docker.sock. Select a supported local engine, then run catalyst onboard --runner.",
126
+ runner_installation_unverified: "Setup found a possible existing machine installation but could not verify its Compose files and identity. Keep its files and credentials. Run docker compose -p catalyst-host logs supervisor to check it, then run catalyst setup --runner again.",
117
127
  runner_enrollment_unavailable: "Setup could not read this workspace's runner hosts. Run catalyst onboard again to retry.",
118
128
  runner_enrolled_for_other_team: "This runner is enrolled for another Linear team. Keep using that team, or ask your administrator to revoke the old enrollment before setting up this machine for the selected team.",
119
- runner_enrollment_stale: "This machine still holds the credential of a runner enrollment that was revoked. Stop the runner with docker compose -p catalyst-host down, remove the credential with docker volume rm catalyst-host_host-credential (this also removes the organization key), then run catalyst onboard --runner.",
129
+ runner_enrollment_revoked: "Catalyst confirms that this machine's saved enrollment was revoked. Keep its credential and organization key. Ask your workspace administrator to review the enrollment before replacing it, then run catalyst setup --runner.",
130
+ runner_credential_unverified: "Setup could not read this machine's saved enrollment. Keep its credential and organization key. Run docker compose -p catalyst-host logs supervisor to check the error, then run catalyst setup --runner.",
131
+ runner_enrollment_stale: "Setup could not match this machine's saved enrollment with Catalyst. Keep its credential and organization key, then run catalyst setup --runner to check again.",
120
132
  runner_login_refresh_required: "Renew your login with catalyst login, then run catalyst onboard.",
121
133
  runner_join_token_unavailable: "Catalyst did not issue a join token for this machine. Run catalyst onboard again to retry.",
122
134
  runner_session_network_unavailable: "Docker could not inspect the runner session network. Check engine access and run the same setup command again. Setup has not created a replacement network.",
@@ -138,7 +150,7 @@ const REASONS = {
138
150
  runner_needs_runner_flag: "This machine was set up to run Catalyst's work, but its runner is not running or not enrolled. To bring it up, run catalyst onboard --runner.",
139
151
  runner_capability_pending: "The runner enrolled but has not reported its capacity yet. Run catalyst onboard again in a minute.",
140
152
  runner_admission_unverified: "Setup could not read whether the team's pool admits runner hosts. Run catalyst onboard again to retry.",
141
- github_app_permissions_outdated: "GitHub App needs updated permissions. An organization owner on GitHub accepts the pending request on the App's installation page, then run catalyst onboard.",
153
+ github_app_permissions_outdated: `The existing GitHub App installation needs updated permissions accepted. ${GITHUB_CONFIRM_ACCESS} An organization owner on GitHub opens the App's installation page, chooses Review permissions, then Accept new permissions. Run catalyst onboard afterward.`,
142
154
  github_app_repository_missing: "The GitHub App installation cannot reach a repository a project registers. An organization owner on GitHub adds it under the installation's Repository access, then run catalyst onboard.",
143
155
  github_app_repository_not_installed: "No GitHub App installation can reach a repository a project registers. A workspace owner or administrator installs the GitHub App on its owner: run catalyst onboard.",
144
156
  github_app_permissions_unverified: "The GitHub App installation works, but its granted permissions could not be checked. Run catalyst onboard to try again.",
@@ -246,7 +258,7 @@ function permissionText(step, baseUrl) {
246
258
  case "github_app_permissions_outdated":
247
259
  if (!url)
248
260
  return undefined;
249
- return `GitHub App needs updated permissions${org ? ` on ${org}` : ""}${missing ? `: ${missing}` : ""}. ${github} reviews and accepts the request at ${url}, then run catalyst onboard.`;
261
+ return `The existing GitHub App installation${org ? ` on ${org}` : ""} needs updated permissions accepted${missing ? `: ${missing}` : ""}. ${GITHUB_CONFIRM_ACCESS} ${github} chooses Review permissions, then Accept new permissions at ${url}. Run catalyst onboard afterward.`;
250
262
  case "github_app_repository_missing":
251
263
  if (!url || !repository)
252
264
  return undefined;
@@ -336,13 +348,10 @@ export function onboardReasonText(step, context = {}) {
336
348
  // A member is told which admin action is left and where an admin takes it.
337
349
  if (reason === "member_scope") {
338
350
  const key = onboardTeamKey(context.journal);
351
+ if (step.id === "linear.adopt")
352
+ return `Only a Catalyst owner or admin can set up the team's Catalyst workflow. Ask one to run catalyst team adopt ${key ?? "<TEAM KEY>"}.`;
339
353
  const adminOnly = {
340
354
  "linear.workspace": ["connect Linear", "/settings/connections", " and connect it there"],
341
- "linear.adopt": [
342
- "set up the team's Catalyst workflow",
343
- key ? `/settings/linear-teams/${encodeURIComponent(key)}/adopt` : "/settings/linear-teams",
344
- "",
345
- ],
346
355
  };
347
356
  const admin = adminOnly[step.id];
348
357
  if (admin) {
@@ -373,7 +382,7 @@ export function onboardReasonText(step, context = {}) {
373
382
  const where = context.baseUrl
374
383
  ? `at ${normalizeBaseUrl(context.baseUrl)}/settings/coding-accounts`
375
384
  : "in the web app on the Settings → AI accounts page";
376
- return `No AI account in this workspace can take work yet. A workspace owner or administrator checks them ${where}: one may need its key replaced, or may have reached its usage limit. Then run catalyst onboard.`;
385
+ return `No AI account in this workspace can take work yet. Run catalyst accounts to see which one needs work. A workspace owner or administrator fixes it ${where}, since no command does that yet: one may need its key replaced, or may have reached its usage limit. Then run catalyst onboard.`;
377
386
  }
378
387
  if (reason === "account_enrollment_required") {
379
388
  const where = context.baseUrl
@@ -424,7 +433,7 @@ export function onboardReasonText(step, context = {}) {
424
433
  const where = context.baseUrl
425
434
  ? `at ${normalizeBaseUrl(context.baseUrl)}/settings/account-keys`
426
435
  : "on the web app's Account API keys page";
427
- return `The runner is enrolled, but it takes no work without an organization key. A workspace owner or administrator creates one with the mirror:read, mirror:write and mirror:feed scopes ${where} and saves it in a file only they can read. Then run catalyst onboard --runner with CATALYST_RUNNER_ORG_KEY_FILE set to that file.`;
436
+ return `The runner is enrolled, but it takes no work without an organization key. A workspace owner or administrator runs catalyst onboard --runner on this machine; it mints the key and stores it for the runner. If their cloud cannot mint one yet, they create one with the mirror:read, mirror:write and mirror:feed scopes ${where}, save it in a file only they can read, and run catalyst onboard --runner with CATALYST_RUNNER_ORG_KEY_FILE set to that file.`;
428
437
  }
429
438
  if (reason === "runner_host_not_ready") {
430
439
  const failing = step.evidence?.failing;
@@ -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
+ }