@catalyst-cloud/cli 0.16.0 → 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 (49) hide show
  1. package/CHANGELOG.md +9 -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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.1
4
+
5
+ Setup lists your existing projects and lets you finish this computer without choosing a team again.
6
+
7
+ - Keep your saved machine name when setup finds an existing installation, even if its settings file is missing.
8
+ - Check readiness across projects that share a Linear team, with a clear next action when a check cannot finish.
9
+ - Remove recognized older background jobs automatically. Preserve shared data and explain any cleanup that needs attention.
10
+ - Recheck the existing installation before offering a replacement, and distinguish an ended enrollment from one that could not be verified.
11
+
3
12
  ## 0.16.0
4
13
 
5
14
  The CLI, skills and plugin now share the 0.16 release line with the SDK and installer. The CLI installs the matching Node replica module for local cache commands. Update the CLI and update the Catalyst plugin through your agent's plugin manager.
package/README.md CHANGED
@@ -85,7 +85,7 @@ catalyst ready
85
85
 
86
86
  `catalyst login` with no key opens a device-code login: it prints a short code and a URL, you approve it in your browser, and this machine is connected as you. On a machine with no browser (a remote box, a container) the code and URL still work — approve them from your phone. The short-lived session refreshes silently afterwards, so you log in about once a year. `npx -p @catalyst-cloud/cli catalyst login` works without the global install, and `bunx` works in place of `npx`. A non-default cloud is set with `CATALYST_CLOUD_BASE_URL` or `--base-url <url>`.
87
87
 
88
- Prefer a key? Pass one instead — mint a **personal key** at Settings → API keys in the Catalyst Cloud app (every member can; no admin needed). The environment form keeps it out of your shell history:
88
+ For a script or an unattended machine, where nobody can approve a code, pass a **personal key** instead. Minting one is a browser step: Settings → API keys in the Catalyst Cloud app (every member can; no admin needed). The environment form keeps it out of your shell history:
89
89
 
90
90
  ```sh
91
91
  CATALYST_CLOUD_TOKEN=<your-personal-key> catalyst login
@@ -98,7 +98,8 @@ After a workspace owner connects your cloud account's Linear workspace, connect
98
98
  ```sh
99
99
  catalyst connections personal linear start
100
100
  catalyst connections personal linear status
101
- # First install the workspace's GitHub App and register its repository in Settings.
101
+ # First install the workspace's GitHub App and register its repository:
102
+ # catalyst onboard --team <KEY> --repo <owner/name>
102
103
  catalyst connections personal github start
103
104
  catalyst connections personal github status
104
105
  ```
@@ -121,7 +122,7 @@ A keyless session's access token is short-lived and rotates on its own: every re
121
122
 
122
123
  `login` does not install skills; the install command above did that. Re-running `login` after a key rotation, or to switch rails, rewrites the config. `catalyst status` prints which cloud account this machine is connected to and as whom, `catalyst ready` prints one READY or NOT READY verdict with the fix for each failure and who can apply it, and `catalyst --version` prints the package version and its pinned contract range.
123
124
 
124
- The setup skill Catalyst seeds into your repository ends by pointing at this same command. That served skill lives in the Catalyst Cloud application, not here; this is the only connect step a customer runs. Your cloud account's **account key** (Settings → Account keys, admin-minted) is a different credential — for a host or daemon that runs unattended — and is not what a person connects their own agent with: it would strip your name from everything your agent writes, and `login` says so if you use one.
125
+ The setup skill Catalyst seeds into your repository ends by pointing at this same command. That served skill lives in the Catalyst Cloud application, not here; this is the only connect step a customer runs. Your cloud account's **account key** (minted by `catalyst onboard --runner`, run by an owner or admin) is a different credential — for a host or daemon that runs unattended — and is not what a person connects their own agent with: it would strip your name from everything your agent writes, and `login` says so if you use one.
125
126
 
126
127
  ## Run and resume setup
127
128
 
@@ -161,6 +162,8 @@ Before any network call, the run checks that every input is present. Each missin
161
162
  | The person's Linear | `linear_personal_grant_missing` | Settings → Connected accounts |
162
163
  | The person's GitHub | `github_personal_grant_missing` | Settings → Connected accounts |
163
164
 
165
+ With a person at the terminal, `catalyst onboard` starts each of these approvals itself and prints the exact link to approve.
166
+
164
167
  With `--runner yes`, setup starts the self-hosted runner and verifies enrollment, advertised capacity and team admission. This needs Docker with Compose, pinned host images and an organization key file (`CATALYST_RUNNER_ORG_KEY_FILE`). When images, a key or admission are missing, exit 11 names that gate. A failed setup step exits 10. Use `--runner no` to skip the machine's runner. Unlike the interactive optional runner step, an explicitly requested headless runner must finish before exit 0.
165
168
 
166
169
  With `--json`, stdout carries exactly one document: the onboarding receipt plus a `headless` block (`schema: "catalyst-onboard-headless/1"`). The block holds the resolved `inputs` (the key appears only as its source: `env`, `file` or `saved`), and lists of items under `missing`, `refused`, `failed`, `deferred` and `warnings`. Each item has `id`, `kind` (`input`, `grant` or `step`), `reason` and a one-line `text`, plus `flag`, `env` and `url` where they apply. Everything else goes to stderr.
@@ -183,7 +186,7 @@ Local sync is optional. Include it in the plan with `--local-sync` when you need
183
186
  ## Requirements
184
187
 
185
188
  - Node 22.15 or newer (Node 26 works), or bun 1.4 or newer. The bundle uses Node's built-in SQLite module (and, on bun, bun's own `node:sqlite`) for the optional local replica, so there is no native dependency to build; if `better-sqlite3` resolves on the machine it is used instead. `catalyst ready` names the exact reason when the runtime is too old, and `catalyst runtime install` installs a pinned Node under this CLI's own cache — without touching your machine's default Node — if you would rather not upgrade it.
186
- - Your personal key, minted by you at Settings → API keys. The key is the only account selector: you never type an account id. If your Linear identity is not matched yet, `login` says so; you match it yourself with `catalyst identity linear set` (an identity someone else already claims needs an admin at Settings → Members), and until then "what needs me" shows everyone's asks.
189
+ - Your own login: a keyless `catalyst login`, or for an unattended machine a personal key you mint at Settings → API keys. The credential is the only account selector: you never type an account id. If your Linear identity is not matched yet, `login` says so; you match it yourself with `catalyst identity linear set` (an identity someone else already claims needs an admin at Settings → Members), and until then "what needs me" shows everyone's asks.
187
190
  - An agent that discovers skills. Claude Code loads the plugin; Codex, Cursor, OpenCode and the rest read the `skills/<name>/SKILL.md` files the `npx skills` installer writes.
188
191
  - Bun is optional, only if you prefer `bunx` over `npx` — bun 1.4 or newer, which is when `node:sqlite` arrives; older bun cannot run this CLI at all.
189
192
 
@@ -233,7 +236,7 @@ Some of these only read, and some write: a comment, a card move, an ask, a relea
233
236
 
234
237
  ## What a key cannot see yet
235
238
 
236
- Your personal key reads everything the skills need — tickets, pull requests, the eligibility explainer, the dispatch queue, fleet activity, per-ticket execution history (`catalyst explain --history <ticket>`: phase attempts, remediation rounds, park state) and coding-account status (`catalyst accounts`: provider, declared and observed state, usage limits, walls, quarantine — never a credential; enrolling or pausing one is `<your cloud>/settings/coding-accounts`). It also releases a parked or held ticket once its cause is fixed: `catalyst release <ticket> --because <what changed>` (the `unstick` skill runs it), recorded against your name and shown in the ticket's history. And it declares what your containers need: `catalyst environment` reads the account-wide declaration, `environment propose --file <path> --approve` proposes and approves it in one compare-and-set, and the values behind the names stay in the cloud — reading needs any active seat, proposing and approving need an admin or owner one. An admin or owner can put a repository's values in from the terminal. `catalyst secret import .env --repo owner/name` stores every name in the file and lists the declared names that still have no value. `catalyst secret set NAME --repo owner/name --command 'op read op://Vault/item/field'` runs the command on your machine and stores its output; with no `--command` it reads the value from stdin, or asks for it without echoing. No value is ever printed, and the cloud's audit records the command, not its output. That is the account's own declaration — a repository's own `.catalyst/catalyst.toml`, which `catalyst-onboard` already reports on, is a separate thing. `catalyst env inventory` lists the environment variable names a repository needs — never a value; `env check` checks the TOML syntax and environment variable table in `.catalyst/catalyst.toml` offline; `env migrate [catalyst.env.json]` prints a TOML environment table from a legacy declaration, with all names optional and values omitted. These commands need no login (the `what-this-repo-needs` skill walks through them). Two things it cannot do, and the skills say so by name rather than guess:
239
+ Your personal key reads everything the skills need — tickets, pull requests, the eligibility explainer, the dispatch queue, fleet activity, per-ticket execution history (`catalyst explain --history <ticket>`: phase attempts, remediation rounds, park state) and coding-account status (`catalyst accounts`: provider, declared and observed state, usage limits, walls, quarantine — never a credential; enrolling or pausing one is `<your cloud>/settings/coding-accounts`, with no command yet). It also releases a parked or held ticket once its cause is fixed: `catalyst release <ticket> --because <what changed>` (the `unstick` skill runs it), recorded against your name and shown in the ticket's history. And it declares what your containers need: `catalyst environment` reads the account-wide declaration, `environment propose --file <path> --approve` proposes and approves it in one compare-and-set, and the values behind the names stay in the cloud — reading needs any active seat, proposing and approving need an admin or owner one. An admin or owner can put a repository's values in from the terminal. `catalyst secret import .env --repo owner/name` stores every name in the file and lists the declared names that still have no value. `catalyst secret set NAME --repo owner/name --command 'op read op://Vault/item/field'` runs the command on your machine and stores its output; with no `--command` it reads the value from stdin, or asks for it without echoing. No value is ever printed, and the cloud's audit records the command, not its output. That is the account's own declaration — a repository's own `.catalyst/catalyst.toml`, which `catalyst-onboard` already reports on, is a separate thing. `catalyst env inventory` lists the environment variable names a repository needs — never a value; `env check` checks the TOML syntax and environment variable table in `.catalyst/catalyst.toml` offline; `env migrate [catalyst.env.json]` prints a TOML environment table from a legacy declaration, with all names optional and values omitted. These commands need no login (the `what-this-repo-needs` skill walks through them). Two things it cannot do, and the skills say so by name rather than guess:
237
240
 
238
241
  - Read pull-request labels or the reviewer's reaction. The mirror does not carry them; GitHub's own page does.
239
242
  - Compute a flow number — cycle time, throughput, or how long pull requests have been open. Nothing serves those yet, so say they are not computed rather than counting something else and calling it that.
@@ -277,7 +280,7 @@ npm uninstall -g @catalyst-cloud/cli
277
280
 
278
281
  ## If login fails
279
282
 
280
- - `catalyst: GET /me failed (401): credential not accepted — mint a personal key at Settings → API keys and log in again` — the key is stale, mistyped or revoked. Mint a new one, then run `login` again.
283
+ - `catalyst: GET /me failed (401): credential not accepted. Run catalyst login to sign in again; an unattended machine needs a new personal key from Settings → API keys` — the key is stale, mistyped or revoked. Run a keyless `catalyst login`, or on an unattended machine mint a new key, then run `login` again.
281
284
  - `catalyst: GET /me failed (403): account-not-operational` — your cloud account is suspended. This is a conversation with your account's admin, not a local fix.
282
285
  - `catalyst: could not reach <url>: <detail>` — the machine cannot reach the cloud. The URL is named in the message; check `CATALYST_CLOUD_BASE_URL` or `--base-url`.
283
286
  - A line naming two contract versions after `Connected to` — your account serves a contract outside this bundle's `1.x || 2.x` range. The config is written; update the bundle before using the other skills.
package/dist/args.js CHANGED
@@ -20,6 +20,7 @@ const GLOBAL_FLAGS = {
20
20
  };
21
21
  /** Per-verb flag tables. A verb absent here accepts only the global flags. */
22
22
  export const FLAG_TABLES = {
23
+ hosts: { yes: { value: false, help: "confirm removal without a terminal" } },
23
24
  login: {
24
25
  "start-replica": {
25
26
  value: false,
@@ -41,6 +42,10 @@ export const FLAG_TABLES = {
41
42
  },
42
43
  install: {},
43
44
  onboard: {
45
+ "machine-name": {
46
+ value: true,
47
+ help: "name this self-hosted machine (default: its hostname)",
48
+ },
44
49
  verbose: { value: false, help: "show granted permissions for connected steps in interactive setup" },
45
50
  team: {
46
51
  value: true,
@@ -480,6 +485,7 @@ export const FLAG_TABLES = {
480
485
  },
481
486
  };
482
487
  export const VERB_USAGE = {
488
+ hosts: "hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name> [--json]",
483
489
  setup: "setup [onboard options] (sets up Catalyst step by step; same as catalyst onboard) | setup --engine <file> --engine-sha256 <hex> [-- <install options>] (run by install.sh; install.sh --help lists the options)",
484
490
  login: "login [--base-url <url>] [--start-replica] (keyless; or --key <personal-key> / CATALYST_CLOUD_TOKEN)",
485
491
  onboard: "onboard [--team <ID|key>] [--repo <owner/name>]... [--coding-account <slot>] [--resume-from <step>] [--only <step>] [--local-sync] [--runner|--no-runner] [--no-color] [--yes] [--dry-run] [--json] | onboard --headless [--key-file <path>] [--team ...] [--repo ...]... [--coding-account ...] [--runner yes|no] [--json]",
@@ -5,9 +5,11 @@ const agent = (method, name) => ({ method, path: `/api/v1/agent/${name}` });
5
5
  * the person, the workspace, the project, the repository, then work. A new verb is a new row here
6
6
  * in the same change, or `catalyst capabilities` lies to every guide that reads it. */
7
7
  export const CAPABILITIES = [
8
+ { verb: "hosts", does: "list this account's machines, current use and live capacity", needs: "member", routes: [], since: "0.16.0" },
9
+ { verb: "hosts remove|rename", does: "remove or rename a self-hosted machine in this account", needs: "admin", routes: [], since: "0.16.0" },
8
10
  // CTC-4680: plain `catalyst setup` runs the onboard flow, so install.sh may hand off to it. install.sh
9
11
  // reads this flag rather than `setup --help`, which already exits 0 on 0.15.x.
10
- { verb: "setup", does: "set up Catalyst step by step, or show install.sh's plan, progress and sign-in in this terminal", needs: "member", routes: [], since: "0.15.0", engineProtocol: 1, bootstrapHandoff: true },
12
+ { verb: "setup", does: "set up Catalyst step by step, or show install.sh's plan, progress and sign-in in this terminal", needs: "member", routes: [], since: "0.15.0", engineProtocol: 1, bootstrapHandoff: true, bootstrapHandoffVersion: 2 },
11
13
  { verb: "onboard", does: "run and resume Catalyst setup with a saved step record", needs: "member", routes: [], since: "0.14.0", bootstrapHandoff: true, headless: true },
12
14
  { verb: "status", does: "say whether this machine is connected, as whom, and to which cloud", needs: "member", routes: [], since: "0.1.0" },
13
15
  { verb: "me", does: "read the person's seat, role and Linear identity", needs: "member", routes: [], since: "0.1.0" },
package/dist/cli.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { cmdHosts } from "./hosts.js";
1
2
  // cli.ts — the `catalyst` dispatcher (npm: @catalyst-cloud/cli). Verbs live in
2
3
  // their own modules; this file keeps every export the 0.1 tests and the bin import.
3
4
  import { createSetupRenderer, setupBanner, } from "./setup-render.js";
@@ -79,6 +80,7 @@ export function usageText() {
79
80
  " catalyst onboard [--resume-from <step>] [--only <step>] [--runner|--no-runner] [--yes] [--dry-run] [--json]",
80
81
  ` catalyst ${SETUP_USAGE}`,
81
82
  " catalyst status | notice | me | ready | accounts",
83
+ " catalyst hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name>",
82
84
  " catalyst mcp add|list|remove (vault references only)",
83
85
  " catalyst contract [--refresh] [--path <a.b.c>]",
84
86
  " catalyst query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <cursor|head>>",
@@ -372,11 +374,11 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
372
374
  ...(deps.setupApproved
373
375
  ? { reviewedSetup: true, confirm: async () => true }
374
376
  : {}),
375
- beforeStep: async () => {
376
- if (args.flags["dry-run"] !== true)
377
- await refreshSessionIfShort(ctx, ONBOARD_STEP_MIN_MS);
378
- },
379
377
  ...createOnboardRuntime(args, ctx, {
378
+ beforeStep: async () => {
379
+ if (args.flags["dry-run"] !== true)
380
+ await refreshSessionIfShort(ctx, ONBOARD_STEP_MIN_MS);
381
+ },
380
382
  ui,
381
383
  signinTimeoutMs: deps.onboardSigninTimeoutMs,
382
384
  stageSignin: (signal) => {
@@ -511,6 +513,8 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
511
513
  return await cmdLegacy(args, ctx, deps.legacy ?? {});
512
514
  case "identity":
513
515
  return await cmdIdentity(args, ctx, deps.identity ?? {});
516
+ case "hosts":
517
+ return await cmdHosts(args, ctx, deps.hosts);
514
518
  case "connections":
515
519
  return await cmdConnections(args, ctx, deps.connections ?? {});
516
520
  default:
@@ -620,6 +624,7 @@ async function runSetup(argv, ctx, deps) {
620
624
  ...(flags.json ? ["--json"] : []),
621
625
  ...(flags.verbose ? ["--verbose"] : []),
622
626
  ...(flags.localSync ? ["--local-sync"] : []),
627
+ ...(flags.runner === undefined ? [] : [flags.runner ? "--runner" : "--no-runner"]),
623
628
  ...(flags.yes || flags.json || !terminal ? ["--yes"] : []),
624
629
  ];
625
630
  let ui;
@@ -726,7 +731,7 @@ async function cmdLogin(args, ctx, deps, personalOnly = false) {
726
731
  : ' — your Linear identity is not matched yet, so "what needs me" will show everyone\'s asks until an admin matches it in Settings'}`);
727
732
  }
728
733
  else {
729
- ctx.stderr(`[catalyst] this is your Catalyst workspace's shared key (a host credential), not your own. The skills work, but nothing your agent writes will carry your name, and "what needs me" cannot mean you. Make a personal key at Settings → API keys and sign in with that.`);
734
+ ctx.stderr(`[catalyst] this is your Catalyst workspace's shared key (a host credential), not your own. The skills work, but nothing your agent writes will carry your name, and "what needs me" cannot mean you. Run catalyst login to sign in as yourself; for an unattended machine, make a personal key at Settings → API keys and sign in with that.`);
730
735
  }
731
736
  ctx.stdout(`Config written to ${written.path} (mode ${formatMode(written.mode)}, holds your ${auth ? "login session" : "key"} and the CLI path)${written.mode === CONFIG_MODE
732
737
  ? ""
@@ -79,7 +79,7 @@ export function renderRead(r) {
79
79
  // never a refusal: the secret may be created next, and a repo may supply the name at checkout.
80
80
  const unresolved = r.unresolvedReferences ?? [];
81
81
  if (unresolved.length > 0) {
82
- lines.push(`referenced but not set on this tenant yet: ${unresolved.join(", ")} — add them in the app, or a phase that needs one will fail on it`);
82
+ lines.push(`referenced but not set in this workspace yet: ${unresolved.join(", ")}. Set them with catalyst var set or catalyst secret set, or a phase that needs one will fail on it`);
83
83
  }
84
84
  return lines;
85
85
  }
package/dist/execution.js CHANGED
@@ -51,7 +51,7 @@ export const EXCLUSION_REASONS = {
51
51
  export const UNKNOWN_REASONS = {
52
52
  ordering_never_published: "the dispatch order has never been published for this team — usually because the team has no saved stage mapping, which does not clear by itself",
53
53
  ordering_stale: "the dispatch order is stale",
54
- workflow_mapping_unknown: "the team has no saved stage mapping for dispatch, pr, done and canceled (or a mapped stage was deleted); it does not clear by itself — a tenant owner or admin maps the team in Settings → Linear teams",
54
+ workflow_mapping_unknown: "the team has no saved stage mapping for dispatch, pr, done and canceled (or a mapped stage was deleted); it does not clear by itself; a workspace owner or admin runs catalyst team map <KEY>, or catalyst team adopt <KEY> to create Catalyst's stages",
55
55
  ticket_unknown: "the ticket is not in the mirror",
56
56
  dependency_snapshot_unknown: "the dependency snapshot has never completed",
57
57
  blocker_unknown: "a blocking relation could not be resolved",
@@ -373,7 +373,7 @@ export async function cmdAccounts(args, ctx) {
373
373
  return 0;
374
374
  }
375
375
  if (accounts.length === 0) {
376
- ctx.stdout(`No coding accounts are enrolled on this tenant — enrol one at ${normalizeBaseUrl(cfg.baseUrl)}/settings/coding-accounts`);
376
+ ctx.stdout(`No coding accounts are enrolled in this workspace. A workspace owner or admin adds one at ${normalizeBaseUrl(cfg.baseUrl)}/settings/coding-accounts; there is no command for that yet.`);
377
377
  return 0;
378
378
  }
379
379
  for (const a of accounts)
package/dist/hosts.js ADDED
@@ -0,0 +1,89 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { positionals } from "./args.js";
3
+ import { requireConfig } from "./config.js";
4
+ import { CliError, UsageError } from "./errors.js";
5
+ import { apiClient } from "./transport.js";
6
+ import { isMachineInventory, isMachineMutation, } from "./machine-inventory.js";
7
+ import { stdinIsTty } from "./prompt.js";
8
+ async function confirmRemove(name) {
9
+ if (!stdinIsTty())
10
+ return false;
11
+ const prompt = createInterface({
12
+ input: process.stdin,
13
+ output: process.stderr,
14
+ });
15
+ try {
16
+ return /^(y|yes)$/i.test((await prompt.question(`Remove ${name} from this account? It will stop receiving work. [y/N] `)).trim());
17
+ }
18
+ finally {
19
+ prompt.close();
20
+ }
21
+ }
22
+ export function machineLines(inventory) {
23
+ const lines = inventory.machines.map((m) => `${m.name} | ${m.ownership === "self_hosted" ? "Self-hosted" : "Catalyst's"} | ${m.teams.join(", ")} | ${m.slots} slots, ${m.inUse} in use, ${m.free} free | ${m.status} | last check-in ${m.lastCheckInAtMs === null ? "never" : new Date(m.lastCheckInAtMs).toISOString()}`);
24
+ if (lines.length === 0)
25
+ lines.push("No machines are enrolled for this account.");
26
+ const c = inventory.capacity;
27
+ lines.push(`Live capacity: ${c.slots} slots (${c.selfHostedSlots} self-hosted, ${c.catalystSlots} from Catalyst), ${c.inUse} in use, ${c.free} free. Stale machines are excluded.`);
28
+ return lines;
29
+ }
30
+ export async function cmdHosts(args, ctx, deps = {}) {
31
+ const parts = positionals(args);
32
+ const action = parts[0] ?? "list";
33
+ if (!((action === "list" && parts.length <= 1) ||
34
+ (action === "remove" && parts.length === 2) ||
35
+ (action === "rename" && parts.length === 3)))
36
+ throw new UsageError("hosts takes: [list] | remove <name-or-id> [--yes] | rename <name-or-id> <new-name>");
37
+ if (args.flags.yes === true && action !== "remove")
38
+ throw new UsageError("--yes is only valid with hosts remove");
39
+ if (action === "rename" &&
40
+ !/^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/.test(parts[2]))
41
+ throw new UsageError("Use 1 to 63 letters, digits, dots, underscores or hyphens, starting with a letter or digit.");
42
+ const cfg = requireConfig(ctx);
43
+ const client = apiClient(cfg, ctx);
44
+ const listed = await client.getJson("/api/v1/hosts/machines", {
45
+ query: { account: cfg.account },
46
+ });
47
+ if (!isMachineInventory(listed.body) || listed.body.account !== cfg.account)
48
+ throw new CliError("The cloud returned an unreadable machine list.", "shape");
49
+ if (action === "list") {
50
+ if (args.json)
51
+ ctx.stdout(JSON.stringify(listed.body));
52
+ else
53
+ for (const line of machineLines(listed.body))
54
+ ctx.stdout(line);
55
+ return 0;
56
+ }
57
+ const matches = listed.body.machines.filter((m) => m.id === parts[1] || m.name === parts[1]);
58
+ if (matches.length !== 1)
59
+ throw new CliError(matches.length === 0
60
+ ? "No machine matches that name or ID."
61
+ : "More than one machine has that name. Use its ID.", "machine_not_unique");
62
+ const machine = matches[0];
63
+ if (!(action === "remove" ? machine.removable : machine.renameable))
64
+ throw new CliError("Catalyst manages this cloud machine. Only self-hosted machines can be changed here.", "managed_machine");
65
+ if (action === "remove" &&
66
+ args.flags.yes !== true &&
67
+ !(await (deps.confirm ?? confirmRemove)(machine.name))) {
68
+ ctx.stdout(args.json
69
+ ? JSON.stringify({ removed: false, reason: "confirmation_required" })
70
+ : "Machine kept. Use --yes to confirm removal without a terminal.");
71
+ return 2;
72
+ }
73
+ const path = `/api/v1/hosts/${encodeURIComponent(machine.id)}${action === "rename" ? "/name" : ""}?account=${encodeURIComponent(cfg.account)}`;
74
+ const result = action === "remove"
75
+ ? await client.deleteJson(path, { accept: [403] })
76
+ : await client.postJson(path, { name: parts[2] }, { accept: [403] });
77
+ if (result.status === 403)
78
+ throw new CliError("Admin access is required to change machines in this account.", "admin_required", 2, 403);
79
+ if (!isMachineMutation(result.body) ||
80
+ result.body.hostId !== machine.id ||
81
+ (action === "rename" && result.body.name !== parts[2]))
82
+ throw new CliError("The cloud did not confirm the machine change.", "shape");
83
+ ctx.stdout(args.json
84
+ ? JSON.stringify(result.body)
85
+ : action === "remove"
86
+ ? `Removed ${machine.name}.`
87
+ : `Renamed ${machine.name} to ${result.body.name}.`);
88
+ return 0;
89
+ }
package/dist/legacy.js CHANGED
@@ -189,8 +189,18 @@ function remove(item, run, platform, uid) {
189
189
  const why = platform === "darwin"
190
190
  ? tool("launchctl", ["bootout", `gui/${uid}/${item.name}`], "launchctl bootout")
191
191
  : tool("systemctl", ["--user", "disable", "--now", item.name], "systemctl --user disable --now");
192
- if (why !== null)
193
- return why;
192
+ if (why !== null) {
193
+ if (platform !== "darwin")
194
+ return why;
195
+ // A nonzero bootout may mean the exact owned service was already unloaded.
196
+ // Prove the scheduler domain works before interpreting its absence response.
197
+ const domain = run("launchctl", ["print", `gui/${uid}`]);
198
+ const service = run("launchctl", ["print", `gui/${uid}/${item.name}`]);
199
+ if (domain.status !== 0 ||
200
+ service.status !== 113 ||
201
+ !/could not find service|could not find specified service/i.test(service.stderr || service.stdout))
202
+ return `${why}. Check launchctl print gui/${uid}/${item.name}, fix the reported scheduler error, then run catalyst legacy --remove.`;
203
+ }
194
204
  unlinkSync(item.path);
195
205
  return null;
196
206
  }
@@ -0,0 +1,37 @@
1
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
2
+ const count = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
3
+ function machine(value) {
4
+ return (object(value) &&
5
+ typeof value.id === "string" &&
6
+ typeof value.name === "string" &&
7
+ (value.ownership === "self_hosted" || value.ownership === "catalyst") &&
8
+ Array.isArray(value.teams) &&
9
+ value.teams.every((team) => typeof team === "string") &&
10
+ count(value.slots) &&
11
+ count(value.inUse) &&
12
+ count(value.free) &&
13
+ (value.lastCheckInAtMs === null || count(value.lastCheckInAtMs)) &&
14
+ (value.status === "live" || value.status === "stale") &&
15
+ typeof value.removable === "boolean" &&
16
+ typeof value.renameable === "boolean");
17
+ }
18
+ export function isMachineInventory(value) {
19
+ if (!object(value) ||
20
+ typeof value.account !== "string" ||
21
+ !count(value.readAtMs) ||
22
+ !Array.isArray(value.machines) ||
23
+ !value.machines.every(machine) ||
24
+ !object(value.capacity))
25
+ return false;
26
+ return (count(value.capacity.slots) &&
27
+ count(value.capacity.inUse) &&
28
+ count(value.capacity.free) &&
29
+ count(value.capacity.selfHostedSlots) &&
30
+ count(value.capacity.catalystSlots));
31
+ }
32
+ export function isMachineMutation(value) {
33
+ return (object(value) &&
34
+ value.ok === true &&
35
+ typeof value.hostId === "string" &&
36
+ (value.name === undefined || typeof value.name === "string"));
37
+ }
@@ -163,7 +163,7 @@ export function onboardCapacityAdapter(input = {}) {
163
163
  const expected = binding(ctx, journal), selected = selection(journal), teamId = selectedOnboardTeam(journal), repos = selectedOnboardRepositories(journal);
164
164
  if (!expected)
165
165
  return waiting("capacity_identity_unverified");
166
- if (!teamId || repos.length !== 1 || repos[0].teamId !== teamId)
166
+ if (!teamId || repos.length === 0 || repos.some(r => r.teamId !== teamId))
167
167
  return waiting("capacity_context_unverified");
168
168
  const controller = new AbortController(), signal = external
169
169
  ? AbortSignal.any([external, controller.signal])
@@ -191,10 +191,9 @@ export function onboardCapacityAdapter(input = {}) {
191
191
  const inventory = await readOnboardRepositoryInventory(ctx, journal, signal);
192
192
  if ("reason" in inventory ||
193
193
  !current() ||
194
- !inventory.repositories.some((r) => r.repoId === repos[0].repoId &&
195
- r.teamId === teamId &&
196
- r.owner.toLowerCase() === repos[0].owner.toLowerCase() &&
197
- r.name.toLowerCase() === repos[0].name.toLowerCase()))
194
+ !repos.every(selectedRepo => inventory.repositories.some(r => r.repoId === selectedRepo.repoId && r.teamId === teamId &&
195
+ r.owner.toLowerCase() === selectedRepo.owner.toLowerCase() &&
196
+ r.name.toLowerCase() === selectedRepo.name.toLowerCase())))
198
197
  return pause("capacity_context_unverified");
199
198
  const read = await readExistingOnboardJson(ctx, route, signal);
200
199
  if ("reason" in read || !current())
@@ -202,7 +201,7 @@ export function onboardCapacityAdapter(input = {}) {
202
201
  if (!(await liveIdentity(ctx, journal, signal)) || !current())
203
202
  return pause("capacity_identity_unverified");
204
203
  const observed = snapshot(read.body, ctx.now().getTime());
205
- const bucket = observed?.buckets.find((b) => b.repo === repos[0].repoId), mapped = bucket?.teams.find((t) => t.key === teamKey);
204
+ const bucket = observed?.buckets.find(b => b.teams.some(t => t.key === teamKey) && repos.some(r => r.repoId === b.repo)), mapped = bucket?.teams.find((t) => t.key === teamKey);
206
205
  if (!observed || !bucket || !mapped)
207
206
  return pause("capacity_context_unverified");
208
207
  if (!mapped.admission || mapped.remaining === null)
@@ -29,8 +29,8 @@ const pages = (baseUrl) => {
29
29
  keys: `${web}/settings/api-keys`,
30
30
  connections: `${web}/a/account/connections`,
31
31
  personal: `${web}/settings/connected-accounts`,
32
- teams: `${web}/settings/linear-teams`,
33
- repos: `${web}/settings/repositories`,
32
+ teams: `${web}/settings/projects`,
33
+ repos: `${web}/settings/projects`,
34
34
  codingAccounts: `${web}/settings/coding-accounts`,
35
35
  };
36
36
  };
@@ -5,6 +5,7 @@ import { githubInstallationPage } from "./onboard-permissions.js";
5
5
  import { valuesFacts, valuesFix } from "./ready-copy.js";
6
6
  /** What a person reads for a step that is not done. Each line ends in an action they can take. */
7
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.",
8
9
  account_identity_unverified: "Your login changed while AI accounts were checked. Run catalyst onboard again to verify your person and workspace.",
9
10
  account_inventory_unavailable: "AI accounts could not be read. Run catalyst onboard again to retry.",
10
11
  account_inventory_unverified: "The server did not return a fresh supported AI account list. Run catalyst onboard after the server update.",
@@ -53,6 +54,11 @@ const REASONS = {
53
54
  local_sync_not_selected: "Using cloud reads. Local sync was not selected.",
54
55
  local_sync_capability_unavailable: "Local sync was selected but could not be verified.",
55
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.",
56
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.",
57
63
  onboarding_checks_pending: "Some required checks are still unverified.",
58
64
  interrupted: "Setup paused. Run the same command to resume.",
@@ -73,7 +79,7 @@ const REASONS = {
73
79
  team_read_login_refresh_required: "Renew your login with catalyst login, then run catalyst onboard to resume.",
74
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.",
75
81
  team_create_unverified: "Setup could not confirm whether the Linear team was created. Check Linear for it before creating it again.",
76
- 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.",
77
83
  team_create_identity_unverified: "Your login changed while setup was creating a team. Resume with the original workspace and person.",
78
84
  team_choice_required: "Choose an existing team with catalyst onboard --team <team ID or key>.",
79
85
  team_selection_unverified: "Your selected Linear team could not be verified. Run catalyst onboard --team <team ID or key> to choose again.",
@@ -101,13 +107,15 @@ const REASONS = {
101
107
  verification_pending: "Setup made the change but could not confirm it yet. Run catalyst onboard again to check.",
102
108
  automations_compatible: "Nothing to change.",
103
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.",
104
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.",
105
113
  capacity_currently_full: "Every runner slot for this repository is in use. Run catalyst onboard again after current work finishes.",
106
114
  capacity_unavailable: "Setup could not read runner capacity. Run catalyst onboard again to retry.",
107
115
  runner_not_selected: "This setup does not start a runner on this machine. To start one, run catalyst onboard --runner.",
108
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.",
109
117
  runner_identity_unverified: "A workspace owner or administrator enrolls a runner. Sign in as one with catalyst login, then run catalyst onboard --runner.",
110
- 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.",
111
119
  runner_directory_unavailable: "Setup could not prepare the runner folder in Catalyst's state directory. Check its permissions, then run catalyst onboard again.",
112
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.",
113
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.",
@@ -115,9 +123,12 @@ const REASONS = {
115
123
  runner_session_network_failed: "Docker could not create the catalyst-session-v1 network. Check that Docker is running, then run catalyst onboard again.",
116
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.",
117
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.",
118
127
  runner_enrollment_unavailable: "Setup could not read this workspace's runner hosts. Run catalyst onboard again to retry.",
119
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.",
120
- 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.",
121
132
  runner_login_refresh_required: "Renew your login with catalyst login, then run catalyst onboard.",
122
133
  runner_join_token_unavailable: "Catalyst did not issue a join token for this machine. Run catalyst onboard again to retry.",
123
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.",
@@ -337,13 +348,10 @@ export function onboardReasonText(step, context = {}) {
337
348
  // A member is told which admin action is left and where an admin takes it.
338
349
  if (reason === "member_scope") {
339
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>"}.`;
340
353
  const adminOnly = {
341
354
  "linear.workspace": ["connect Linear", "/settings/connections", " and connect it there"],
342
- "linear.adopt": [
343
- "set up the team's Catalyst workflow",
344
- key ? `/settings/linear-teams/${encodeURIComponent(key)}/adopt` : "/settings/linear-teams",
345
- "",
346
- ],
347
355
  };
348
356
  const admin = adminOnly[step.id];
349
357
  if (admin) {
@@ -374,7 +382,7 @@ export function onboardReasonText(step, context = {}) {
374
382
  const where = context.baseUrl
375
383
  ? `at ${normalizeBaseUrl(context.baseUrl)}/settings/coding-accounts`
376
384
  : "in the web app on the Settings → AI accounts page";
377
- 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.`;
378
386
  }
379
387
  if (reason === "account_enrollment_required") {
380
388
  const where = context.baseUrl
@@ -425,7 +433,7 @@ export function onboardReasonText(step, context = {}) {
425
433
  const where = context.baseUrl
426
434
  ? `at ${normalizeBaseUrl(context.baseUrl)}/settings/account-keys`
427
435
  : "on the web app's Account API keys page";
428
- 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.`;
429
437
  }
430
438
  if (reason === "runner_host_not_ready") {
431
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
+ }