@catalyst-cloud/cli 0.16.25 → 0.16.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.27
4
+
5
+ A large workspace's local replica now finishes its first download instead of stopping after 30 seconds: it waits as long as data keeps arriving, and gives up only after 150 seconds with no progress. New commands: `catalyst repo status` lists your repositories and whether each is paused, and `catalyst repo pause|resume` pauses or resumes one (owners and admins). `catalyst runner status|stop|start|uninstall` stops a self-hosted machine taking work, starts it again with the same registration, or removes it and revokes its registration. Setup with local sync off now lists an old local replica's files with their sizes and removes them only after you say yes. Setup with local sync on keeps using the replica you already have. On a Mac where Docker Desktop does not share your home folder, setup now says which setting to change (Settings, then Resources, then File sharing).
6
+
7
+ ## 0.16.26
8
+
9
+ Not published. Its changes ship in 0.16.27.
10
+
3
11
  ## 0.16.25
4
12
 
5
13
  A self-hosted machine that runs several teams' work from one `compose.json` can now take each approved release by itself. Run `catalyst runner update --fleet --dir <the folder that holds compose.json>`, by hand or on a schedule. It switches only the supervisor to the approved images and leaves work that is already running alone. If the machine's teams are not ready again within four minutes, it puts the previous images back. `--check` shows what would change without changing anything.
package/README.md CHANGED
@@ -166,6 +166,19 @@ With a person at the terminal, `catalyst onboard` starts each of these approvals
166
166
 
167
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.
168
168
 
169
+ ### Stopping or removing this machine's runner
170
+
171
+ Run these on the machine itself:
172
+
173
+ | command | what it does |
174
+ | -- | -- |
175
+ | `catalyst runner stop` | The machine takes no new work. Work it already started finishes or reaches its deadline. Its registration, settings and checkouts are kept, and `catalyst hosts` shows it as `stopped`. |
176
+ | `catalyst runner start` | Starts it again with the same registration. |
177
+ | `catalyst runner status` | Says whether it is running, stopped or not installed. |
178
+ | `catalyst runner uninstall` | Asks first (`--yes` confirms without a terminal; an owner or admin login is needed). It stops new work, revokes the machine's registration and the runner key setup issued, and switches the project's runner admission off when no other machine runs it. Then it removes the runner's containers, their volumes and the session network, and the setup files in the runner folder. On macOS it also removes the storage LaunchAgent and its folders. On Linux it also removes the session-egress units, their script and state, and the `inet catalyst_session` nftables table, through `sudo`. |
179
+
180
+ Uninstall removes only what setup created. Anything that does not match what setup wrote, such as a network with another label or a changed unit file, is left in place. Your repository and thoughts checkouts are kept unless you add `--delete-checkouts`. The command ends with a "Left behind" list naming everything it did not remove. When a step fails, such as a cloud call while you are offline, it says which one and exits 1. Run it again: it picks up where the last run stopped. Do not delete the runner folder by hand while the runner is installed. That deletes its settings while its containers keep running.
181
+
169
182
  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.
170
183
 
171
184
  | Exit | Meaning |
@@ -201,6 +214,24 @@ Open a new session and ask about your own cloud account. The skills read the con
201
214
 
202
215
  `catalyst team list` inventories visible teams without running readiness checks. `team check ENG` prints readiness for one team; `team check --all` runs checks for every team. `team map ENG`, `team adopt ENG`, and `team migrate ENG` print a plan and exit 3 before changing configuration; review the plan and rerun the chosen command with `--yes --plan-hash <hash>` from that preview. `team adopt ENG --undo` previews the exact previously created stages, and `team migrate ENG --retire` requires its own confirmation after the ticket move. `team checklist ENG` prints the same manual setup lines as the browser. These commands use the member's own login; an admin or owner seat is required for setup, and Adopt and Migrate also need that member's personal Linear connection.
203
216
 
217
+ ## Repository status and pauses
218
+
219
+ Use your existing login to list the repositories visible to you, their status and attached projects:
220
+
221
+ ```sh
222
+ catalyst repo status
223
+ catalyst repo status --json
224
+ catalyst repo pause acme/app --reason "flaky deploy"
225
+ catalyst repo resume acme/app --reason "deploy fixed"
226
+ catalyst repo resume acme/app
227
+ ```
228
+
229
+ Any workspace member can read status. Pausing or resuming requires a workspace owner or admin. Pause requires a reason; resume accepts an optional reason. A supplied reason must contain 1 to 500 characters after trimming. These commands work outside a local checkout.
230
+
231
+ Paused rows show who paused the repository (their email, or the key or operator that paused it), when and why, when those details are available. An empty list means no repositories are visible to your login. After a write, the command reads and prints the repository's current state. If that read fails, it reports that the write succeeded and status could not be read; check `catalyst repo status` before repeating a write.
232
+
233
+ `--json` preserves the list response for status and prints the refreshed repository row for a write. An older cloud reports that repository status is not served yet and needs a newer cloud. Agents can use `catalyst capabilities --json` to check route availability against the cached cloud contract.
234
+
204
235
  ## What has to be running
205
236
 
206
237
  Nothing, by default. After `login`, every read, write, ask and explanation goes to the cloud's origin-fresh API with the config file and the cached contract on disk. Two optional processes exist for people who want them:
@@ -270,6 +301,8 @@ A `customer.json` written by an older bundle is still read unchanged; it gains t
270
301
 
271
302
  ## Uninstalling
272
303
 
304
+ If this machine runs Catalyst's work, run `catalyst runner uninstall` first (see "Stopping or removing this machine's runner").
305
+
273
306
  Remove the skills the way you installed them: `/plugin uninstall catalyst@catalyst-cloud` in Claude Code, or delete the skill directories (`catalyst-github`, `catalyst-linear`, `catalyst-onboard`, `run-this-project`, `unstick`, `what-needs-me`, `what-this-repo-needs`, `whats-happening`) from wherever `npx skills add` wrote them. Skills an earlier release shipped (`catalyst-setup`, `connect-me`, `how-catalyst-works`) go the same way; the CLI removes its own stamped copies of them when it installs or refreshes skills. Then remove what the CLI wrote:
274
307
 
275
308
  ```sh
package/dist/args.js CHANGED
@@ -26,6 +26,8 @@ export const FLAG_TABLES = {
26
26
  auto: { value: false, help: "one-line output for the scheduled run" },
27
27
  dir: { value: true, help: "the runner directory (default: the one setup created)" },
28
28
  fleet: { value: false, help: "update a multi-team supervisor fleet's compose.json in --dir" },
29
+ yes: { value: false, help: "uninstall: confirm without a terminal" },
30
+ "delete-checkouts": { value: false, help: "uninstall: also delete the repository and thoughts checkouts" },
29
31
  },
30
32
  login: {
31
33
  "start-replica": {
@@ -66,6 +68,10 @@ export const FLAG_TABLES = {
66
68
  value: false,
67
69
  help: "include the optional local sync setup in this plan",
68
70
  },
71
+ "remove-local-data": {
72
+ value: false,
73
+ help: "with local sync off, stop existing local writers and delete the listed replica and event-cache files; keep login, contract and settings",
74
+ },
69
75
  runner: {
70
76
  value: false,
71
77
  optionalValue: true,
@@ -486,7 +492,7 @@ export const FLAG_TABLES = {
486
492
  },
487
493
  data: {
488
494
  value: false,
489
- help: "historical flag; shared data folders are always kept",
495
+ help: "include the listed replica DB, sidecars and local event cache; shared data folders, login, contract and settings are kept",
490
496
  },
491
497
  yes: {
492
498
  value: false,
@@ -494,6 +500,7 @@ export const FLAG_TABLES = {
494
500
  },
495
501
  },
496
502
  repo: {
503
+ reason: { value: true, help: "pause: required; resume: optional; 1 to 500 characters after trimming (owner or admin)" },
497
504
  write: {
498
505
  value: false,
499
506
  help: "agents-block: write or update the block in AGENTS.md (working tree only)",
@@ -545,10 +552,10 @@ export const FLAG_TABLES = {
545
552
  };
546
553
  export const VERB_USAGE = {
547
554
  hosts: "hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name> [--json]",
548
- runner: "runner update [--check] [--dir <runner directory>] [--fleet] | runner auto-update [status|on|off]",
555
+ runner: "runner [status] | runner stop | runner start | runner uninstall [--yes] [--delete-checkouts] | runner update [--check] [--dir <runner directory>] [--fleet] | runner auto-update [status|on|off] [--json]",
549
556
  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)",
550
557
  login: "login [--base-url <url>] [--start-replica] (keyless; or --key <personal-key> / CATALYST_CLOUD_TOKEN)",
551
- 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]",
558
+ onboard: "onboard [--team <ID|key>] [--repo <owner/name>]... [--coding-account <slot>] [--resume-from <step>] [--only <step>] [--local-sync] [--remove-local-data] [--runner|--no-runner] [--no-color] [--yes] [--dry-run] [--json] | onboard --headless [--key-file <path>] [--team ...] [--repo ...]... [--coding-account ...] [--runner yes|no] [--remove-local-data] [--json]",
552
559
  install: "install [--skills-dir <dir>] [--force]",
553
560
  status: "status",
554
561
  notice: "notice",
@@ -577,7 +584,7 @@ export const VERB_USAGE = {
577
584
  identity: "identity linear <status|options|set> [<linearUserId>] [--json]",
578
585
  capabilities: "capabilities [--json]",
579
586
  project: "project <list [--json] | wip-limit <get|set <n>|set default> [--team K] [--json]>",
580
- repo: "repo <agents-block <path> [--write]|agent-setup <path> [--apply] [--with-check]> [--json]",
587
+ repo: "repo <status|pause <owner/name> --reason <text>|resume <owner/name> [--reason <text>]|agents-block <path> [--write]|agent-setup <path> [--apply] [--with-check]> [--json] (status: visible workspace repositories; agents-block/agent-setup: local checkout)",
581
588
  legacy: "legacy [--remove [--data] [--yes]] [--json]",
582
589
  connections: "connections personal <linear|github> <start|status> [--wait <seconds>] [--json]",
583
590
  release: "release <ticket> --because <what changed> [--retry-unchanged] [--dry-run] [--json] | release --class <failure-class> --team <K> --because <what changed> [--retry-unchanged] [--dry-run] [--limit N] [--json]",
@@ -8,6 +8,8 @@ export const CAPABILITIES = [
8
8
  { verb: "hosts", does: "list this account's machines, current use and live capacity", needs: "member", routes: [], since: "0.16.0" },
9
9
  { verb: "hosts remove|rename", does: "remove or rename a self-hosted machine in this account", needs: "admin", routes: [], since: "0.16.0" },
10
10
  { verb: "runner update|auto-update", does: "move this machine's self-hosted runner to the release Catalyst approved, and check for one every 15 minutes", needs: "member", routes: [], since: "0.16.16" },
11
+ { verb: "runner status|stop|start", does: "stop this machine's self-hosted runner taking work, or start it again with the same registration", needs: "member", routes: [], since: "0.16.27" },
12
+ { verb: "runner uninstall", does: "remove this machine's self-hosted runner and revoke its registration and runner key", needs: "admin", routes: [], since: "0.16.27" },
11
13
  // CTC-4680: plain `catalyst setup` runs the onboard flow, so install.sh may hand off to it. install.sh
12
14
  // reads this flag rather than `setup --help`, which already exits 0 on 0.15.x.
13
15
  { 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 },
@@ -28,6 +30,8 @@ export const CAPABILITIES = [
28
30
  { verb: "team checklist", does: "print what adopting the workflow would do to a team", needs: "admin", routes: [agent("GET", "team-workflow")], since: "0.9.5" },
29
31
  { verb: "project wip-limit get", does: "read a project's new-start WIP limit, where it comes from, and its work in progress now", needs: "member", routes: [agent("GET", "team-wip-limit")], since: "0.13.1" },
30
32
  { verb: "project wip-limit set", does: "set a project's new-start WIP limit (0 to 9999, or back to the workspace default)", needs: "admin", routes: [agent("POST", "team-wip-limit")], since: "0.13.1" },
33
+ { verb: "repo status", does: "list visible repositories with their state, pause details and projects", needs: "member", routes: [agent("GET", "repos")], since: "0.16.27" },
34
+ { verb: "repo pause|resume", does: "pause or resume a repository as a workspace owner or admin", needs: "admin", routes: [agent("GET", "repos"), agent("POST", "repos/pause"), agent("POST", "repos/resume")], since: "0.16.27" },
31
35
  { verb: "repo agents-block", does: "add or refresh the one Catalyst block in a checkout's AGENTS.md, in the working tree, for the person to commit and open as a pull request", needs: "member", routes: [], since: "0.13.1" },
32
36
  { verb: "repo agent-setup", does: "read a checkout's agent setup (AGENTS.md, CLAUDE.md, .agents and .claude skills and rules) and, on request, make it portable in the working tree", needs: "member", routes: [], since: "0.13.1" },
33
37
  { verb: "env shell", does: "open this repository's environment on this computer: Catalyst's image, the declared toolchain and variables (needs Docker; no login for Catalyst's image)", needs: "member", routes: [], since: "0.16.20" },
package/dist/cli.js CHANGED
@@ -48,7 +48,7 @@ import { cmdRepo } from "./repo.js";
48
48
  import { cmdLegacy } from "./legacy.js";
49
49
  import { cmdIdentity } from "./identity.js";
50
50
  import { cmdConnections } from "./connections.js";
51
- import { cmdOnboard, onboardErrorJournal, } from "./onboard.js";
51
+ import { cmdOnboard, onboardErrorJournal, onboardStatePath, readOnboardJournal, } from "./onboard.js";
52
52
  import { createClackOnboardUi, shouldUseOnboardUi, } from "./onboard-ui.js";
53
53
  import { createOnboardRuntime } from "./onboard-runtime.js";
54
54
  import { argvRequestsHeadless, headlessOnboardDeps, headlessUsageRefusal, onboardHeadlessRequested, runOnboardHeadless, } from "./onboard-headless.js";
@@ -85,6 +85,7 @@ export function usageText() {
85
85
  " catalyst status | notice | me | ready | accounts",
86
86
  " catalyst seat checkout --size big|med --harness claude,codex,opencode [--session-id ID] [--stage STAGE]",
87
87
  " catalyst hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name>",
88
+ " catalyst runner [status] | runner stop | runner start | runner uninstall [--yes] [--delete-checkouts] (this machine's self-hosted runner)",
88
89
  " catalyst runner update [--check] | runner auto-update [status|on|off] (move this machine's runner to the approved release)",
89
90
  " catalyst mcp add|list|remove (vault references only) | mcp status <name> (a server's state, grants and last 50 tool calls)",
90
91
  " catalyst contract [--refresh] [--path <a.b.c>]",
@@ -106,6 +107,8 @@ export function usageText() {
106
107
  " catalyst project list [--json] (all workspace projects, including those with no stage mapping)",
107
108
  " catalyst stage <start|renew|done|fail> <ticket> <phase> (a cloud-claimed local phase)",
108
109
  " catalyst project wip-limit <get|set <n>|set default> [--team K] (a project's new-start WIP limit; members read, owners and admins set)",
110
+ " catalyst repo pause <owner/name> --reason <text> | repo resume <owner/name> [--reason <text>] (owner or admin; reason: 1 to 500 trimmed characters)",
111
+ " catalyst repo status [--json] (visible workspace repositories and their paused state; needs login)",
109
112
  " catalyst repo <agents-block <path> [--write]|agent-setup <path> [--apply] [--with-check]> (a checkout's AGENTS.md block and portable agent layout; working tree only)",
110
113
  " catalyst legacy [--remove [--data] [--yes]] (leftovers of the old local Catalyst runtime: list them; remove them only on a yes)",
111
114
  " catalyst identity linear <status|options|set> [<linearUserId>] [--json]",
@@ -131,6 +134,15 @@ export function usageText() {
131
134
  * the `catalyst` launcher beside it, once, on the next run of either name. Read and write back to
132
135
  * back, so the window for racing a concurrent OAuth refresh is as small as the update notice's.
133
136
  */
137
+ /** Setup's saved local sync choice; an unreadable record reads as off, which only adds a hint. */
138
+ function savedLocalSync(ctx) {
139
+ try {
140
+ return readOnboardJournal(onboardStatePath(ctx.home, ctx.env))?.localSync === true;
141
+ }
142
+ catch {
143
+ return false;
144
+ }
145
+ }
134
146
  function migrateLegacyCliPath(ctx) {
135
147
  let cfg;
136
148
  try {
@@ -520,7 +532,7 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
520
532
  case "repo":
521
533
  return await cmdRepo(args, ctx);
522
534
  case "legacy":
523
- return await cmdLegacy(args, ctx, deps.legacy ?? {});
535
+ return await cmdLegacy(args, ctx, { localSyncSelected: savedLocalSync(ctx), ...deps.legacy });
524
536
  case "identity":
525
537
  return await cmdIdentity(args, ctx, deps.identity ?? {});
526
538
  case "hosts":
package/dist/hosts.js CHANGED
@@ -8,7 +8,7 @@ async function confirmRemove(name) {
8
8
  return confirmYesNo(`Remove ${name} from this account? It will stop receiving work. [y/N] `);
9
9
  }
10
10
  export function machineLines(inventory) {
11
- 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()}`);
11
+ 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.stopped === true ? "stopped" : m.status} | last check-in ${m.lastCheckInAtMs === null ? "never" : new Date(m.lastCheckInAtMs).toISOString()}`);
12
12
  if (lines.length === 0)
13
13
  lines.push("No machines are enrolled for this account.");
14
14
  const c = inventory.capacity;
package/dist/legacy.js CHANGED
@@ -6,17 +6,20 @@
6
6
  //
7
7
  // The list is fixed on purpose. A prefix match would one day catch a current job (the housekeeping
8
8
  // job, the sandbox credit guard, jobs a person made); this matches names the old runtime installed,
9
- // read at the source commit below, and nothing else. Shared data folders are always kept. The historical --data flag cannot remove them.
9
+ // read at the source commit below, and nothing else. Shared data folders are always kept;
10
+ // --data separately selects the enumerated replica and event-cache files.
10
11
  import { existsSync, lstatSync, readFileSync, readdirSync, unlinkSync, } from "node:fs";
11
12
  import { spawnSync } from "node:child_process";
12
13
  import { join } from "node:path";
13
14
  import { flagBool, positionals } from "./args.js";
14
15
  import { UsageError } from "./errors.js";
15
16
  import { promptSecret, stdinIsTty } from "./prompt.js";
17
+ import { findLocalSyncData, localSyncInventory, removeLocalSyncData } from "./local-sync-data.js";
16
18
  /** The archived repository's commit the list below was read at. */
17
19
  export const LEGACY_SOURCE_COMMIT = "73bc0645252ce8be38f8c87be6b67b950b3f0b56";
18
20
  /** The old runtime's marketplace source, as Claude Code records it in known_marketplaces.json. */
19
21
  export const OLD_MARKETPLACE_REPO = "coalesce-labs/catalyst";
22
+ const OLD_REPLICA_LABEL = "ai.coalesce.catalyst-replica-sync";
20
23
  /** launchd labels the old runtime installed under ~/Library/LaunchAgents (macOS). `com.catalyst.role.<name>`
21
24
  * is the old role supervisor's per-role instance, matched by that exact prefix. Never `dev.catalystcloud.*`
22
25
  * (the current housekeeping job and runner jobs) and never `dev.catalyst.*` (the sandbox credit guard). */
@@ -36,7 +39,7 @@ export const OLD_LAUNCHD_LABELS = [
36
39
  "ai.coalesce.catalyst-usage-page",
37
40
  "ai.coalesce.catalyst-claude-update",
38
41
  "ai.coalesce.catalyst-updater",
39
- "ai.coalesce.catalyst-replica-sync",
42
+ OLD_REPLICA_LABEL,
40
43
  "ai.coalesce.catalyst-channel-watcher",
41
44
  "ai.coalesce.catalyst-event-mirror",
42
45
  "ai.coalesce.catalyst-monitor",
@@ -129,6 +132,9 @@ function findJobs(home, platform) {
129
132
  kind: "job",
130
133
  name: label,
131
134
  path: join(dir, `${label}.plist`),
135
+ ...(label === OLD_REPLICA_LABEL || label === "ai.coalesce.catalyst-event-mirror"
136
+ ? { localSync: true, size: lstatSync(join(dir, `${label}.plist`)).size }
137
+ : {}),
132
138
  }));
133
139
  }
134
140
  if (platform === "linux") {
@@ -157,12 +163,12 @@ function findFiles(home) {
157
163
  return [...bins, ...data];
158
164
  }
159
165
  /** Every old piece on this machine, in removal order: plugins, their marketplace, jobs, bins, data. Never writes. */
160
- export function findLegacy(home, platform) {
166
+ export function findLegacy(home, platform, options = {}) {
161
167
  return [
162
168
  ...findPlugins(home),
163
169
  ...findJobs(home, platform),
164
170
  ...findFiles(home),
165
- ];
171
+ ].filter((item) => !(options.preserveReplica && "localSync" in item && item.localSync));
166
172
  }
167
173
  const defaultRun = (cmd, args) => {
168
174
  const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 60_000 });
@@ -224,8 +230,9 @@ export async function cmdLegacy(args, ctx, deps = {}) {
224
230
  const platform = deps.platform ?? process.platform;
225
231
  const uid = deps.uid ?? (typeof process.getuid === "function" ? process.getuid() : 0);
226
232
  const run = deps.run ?? defaultRun;
227
- const found = findLegacy(ctx.home, platform);
228
- const legacy = found.filter((item) => !item.data);
233
+ const local = deps.preserveReplica ? [] : deps.localSyncFiles ?? await findLocalSyncData(ctx);
234
+ const found = [...findLegacy(ctx.home, platform, deps), ...local];
235
+ const legacy = found.filter((item) => !item.data && (item.kind === "job" || !item.localSync || withData));
229
236
  const shared = found.filter((item) => item.data);
230
237
  const emit = (body, lines) => {
231
238
  if (args.json)
@@ -234,24 +241,34 @@ export async function cmdLegacy(args, ctx, deps = {}) {
234
241
  for (const l of lines)
235
242
  ctx.stdout(l);
236
243
  };
244
+ const inventory = localSyncInventory(local);
237
245
  if (legacy.length === 0) {
238
246
  emit({
239
247
  sourceCommit: LEGACY_SOURCE_COMMIT,
240
248
  found: [],
241
249
  removed: [],
242
250
  remaining: [],
243
- kept: shared,
251
+ kept: [...shared, ...local],
252
+ ...(local.length ? { localSyncFiles: local } : {}),
253
+ ...(local.length && !deps.localSyncSelected ? { requiredFlag: "--remove --data" } : {}),
244
254
  }, [
245
255
  "no leftovers of the old local Catalyst runtime on this machine",
246
256
  ...(shared.length
247
257
  ? ["shared current Catalyst data folders are present and kept"]
248
258
  : []),
259
+ ...inventory,
260
+ ...(local.length
261
+ ? [deps.localSyncSelected
262
+ ? "Local sync is on, so these files are current data and kept."
263
+ : "Local sync data kept. If you no longer use local sync, run catalyst legacy --remove --data to review and remove it."]
264
+ : []),
249
265
  ]);
250
266
  return 0;
251
267
  }
252
268
  const listLines = [
253
269
  `Leftovers of the old local Catalyst runtime on this machine (${legacy.length}):`,
254
- ...legacy.map((f) => ` ${f.kind}: ${f.name}${f.kind === "job" ? ` — ${f.path}` : ""}`),
270
+ ...legacy.map((f) => ` ${f.kind}: ${f.name}${f.kind === "job" ? ` — ${f.path} (${lstatSync(f.path).size} bytes)` : ""}`),
271
+ ...inventory,
255
272
  ...(shared.length
256
273
  ? [
257
274
  "Shared current data folders (kept):",
@@ -286,14 +303,16 @@ export async function cmdLegacy(args, ctx, deps = {}) {
286
303
  }, [
287
304
  ...listLines,
288
305
  RECOMMEND,
289
- "nothing removed: no terminal to ask on; run catalyst legacy --remove --yes to remove these without a question (all data folders are kept)",
306
+ withData
307
+ ? "nothing removed: no terminal to ask on; run catalyst legacy --remove --data --yes to remove the listed local sync files (login, contract, settings and shared data folders are kept)"
308
+ : "nothing removed: no terminal to ask on; run catalyst legacy --remove --yes to remove these without a question (all data folders are kept)",
290
309
  ]);
291
310
  return 1;
292
311
  }
293
312
  for (const l of listLines)
294
313
  ctx.stdout(l);
295
314
  ctx.stdout(RECOMMEND);
296
- const answer = (await (deps.prompt ?? ((q) => promptSecret(q)))(`Remove ${legacy.length} item${legacy.length === 1 ? "" : "s"}, keeping all data folders? [y/N] `))
315
+ const answer = (await (deps.prompt ?? ((q) => promptSecret(q)))(`Remove ${legacy.length} item${legacy.length === 1 ? "" : "s"}${withData && local.length ? ", including the listed local sync files" : ", keeping all data folders"}? [y/N] `))
297
316
  .trim()
298
317
  .toLowerCase();
299
318
  go = answer === "y" || answer === "yes";
@@ -313,6 +332,11 @@ export async function cmdLegacy(args, ctx, deps = {}) {
313
332
  const kept = [];
314
333
  const lines = [];
315
334
  for (const item of found) {
335
+ if (item.localSync && item.kind === "data") {
336
+ if (!withData)
337
+ kept.push(item);
338
+ continue;
339
+ }
316
340
  if (item.data) {
317
341
  kept.push(item);
318
342
  lines.push(`kept: ${item.kind} ${item.name} (shared current state; always kept)`);
@@ -328,8 +352,25 @@ export async function cmdLegacy(args, ctx, deps = {}) {
328
352
  lines.push(`still present: ${item.kind} ${item.name} (${why})`);
329
353
  }
330
354
  }
355
+ if (withData && local.length) {
356
+ try {
357
+ if (failed.some(({ item }) => item.name === OLD_REPLICA_LABEL || item.name === "ai.coalesce.catalyst-event-mirror"))
358
+ throw new Error("local sync service did not stop; local data kept");
359
+ await removeLocalSyncData(local, deps.stopWriter);
360
+ removed.push(...local);
361
+ lines.push(...local.map((item) => `removed: local sync data ${item.path}`));
362
+ }
363
+ catch (error) {
364
+ kept.push(...local);
365
+ failed.push({ item: local[0], why: error instanceof Error ? error.message : String(error) });
366
+ lines.push("local sync cleanup failed; remaining local data kept");
367
+ }
368
+ }
331
369
  // re-check from the same list, so the report says what the machine holds now, not what was attempted
332
- const remaining = findLegacy(ctx.home, platform).filter((f) => !f.data);
370
+ const remaining = [
371
+ ...findLegacy(ctx.home, platform, deps).filter((f) => !f.data),
372
+ ...(withData ? await findLocalSyncData(ctx) : []),
373
+ ];
333
374
  const keptNote = kept.length > 0 ? "; all shared data folders were kept" : "";
334
375
  lines.push(remaining.length === 0
335
376
  ? `re-checked: nothing of the old runtime remains${keptNote}`
@@ -342,5 +383,5 @@ export async function cmdLegacy(args, ctx, deps = {}) {
342
383
  kept,
343
384
  failed,
344
385
  }, lines);
345
- return remaining.length === 0 ? 0 : 1;
386
+ return remaining.length === 0 && failed.length === 0 ? 0 : 1;
346
387
  }
@@ -0,0 +1,159 @@
1
+ import { existsSync, lstatSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { isAbsolute, join } from "node:path";
4
+ import { configPathFor, defaultReplicaDbFor, replicaDbPath } from "./config.js";
5
+ import { CliError } from "./errors.js";
6
+ import { loadMachinePaths } from "../vendor/paths/node.js";
7
+ function json(path) {
8
+ try {
9
+ const value = JSON.parse(readFileSync(path, "utf8"));
10
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
11
+ }
12
+ catch {
13
+ return {};
14
+ }
15
+ }
16
+ /** Read only known replica leaves and SDK event-cache segments, never a shared directory. */
17
+ export async function findLocalSyncData(ctx) {
18
+ const cfg = json(configPathFor(ctx.home));
19
+ const env = { ...ctx.env, HOME: ctx.home };
20
+ const machine = await loadMachinePaths({ env });
21
+ const databases = new Set([
22
+ defaultReplicaDbFor(ctx.home),
23
+ join(ctx.home, ".config", "catalyst", "replica.db"),
24
+ ]);
25
+ if (typeof cfg.replicaDb === "string") {
26
+ if (!isAbsolute(cfg.replicaDb))
27
+ throw new CliError("the saved replica path is not absolute; local data kept", "local-sync-path-invalid");
28
+ databases.add(cfg.replicaDb);
29
+ }
30
+ try {
31
+ databases.add(replicaDbPath({ replicaDb: typeof cfg.replicaDb === "string" ? cfg.replicaDb : undefined }, ctx.home, ctx.env));
32
+ }
33
+ catch (error) {
34
+ if (!(error instanceof CliError && error.code === "replica-not-configured"))
35
+ throw error;
36
+ }
37
+ const paths = new Set();
38
+ for (const db of databases) {
39
+ if (/^(customer|contract|settings|config|paths)\.(json|toml)$/.test(db.split("/").at(-1) ?? ""))
40
+ throw new CliError(`local sync cleanup cannot delete login, contract or settings: ${db}`, "local-sync-path-protected");
41
+ for (const suffix of ["", "-wal", "-shm", ".pid", ".writer.lock", ".writer.state"])
42
+ paths.add(`${db}${suffix}`);
43
+ }
44
+ const roots = new Set([
45
+ join(ctx.env.XDG_STATE_HOME ?? join(ctx.home, ".local", "state"), "catalyst", "events"),
46
+ ...(ctx.env.CATALYST_EVENTS_DIR ? [ctx.env.CATALYST_EVENTS_DIR] : []),
47
+ ...(machine?.paths.events ? [machine.paths.events] : []),
48
+ ]);
49
+ const entries = (dir) => {
50
+ try {
51
+ if (!lstatSync(dir).isDirectory())
52
+ return [];
53
+ return readdirSync(dir);
54
+ }
55
+ catch (error) {
56
+ if (error.code === "ENOENT")
57
+ return [];
58
+ throw error;
59
+ }
60
+ };
61
+ const cacheFiles = (dir) => {
62
+ for (const name of entries(dir))
63
+ if (/^\d{4}-\d{2}-\d{2}(?:-\d{3})?\.jsonl$/.test(name) || ["cursor.json", ".sync.writer.lock"].includes(name))
64
+ paths.add(join(dir, name));
65
+ };
66
+ for (const root of roots) {
67
+ cacheFiles(root);
68
+ for (const account of entries(root)) {
69
+ const dir = join(root, account);
70
+ if (lstatSync(dir).isDirectory())
71
+ cacheFiles(join(dir, "backbone"));
72
+ }
73
+ }
74
+ const files = [];
75
+ for (const path of paths) {
76
+ if (!existsSync(path))
77
+ continue;
78
+ const stat = lstatSync(path);
79
+ if (!stat.isFile())
80
+ throw new Error(`local sync cleanup requires a regular file: ${path}`);
81
+ files.push({ kind: "data", name: path, path, localSync: true, size: stat.size, dev: stat.dev, ino: stat.ino });
82
+ }
83
+ return files;
84
+ }
85
+ export function localSyncInventory(files) {
86
+ return files.map((file) => `${file.path} (${file.size} bytes)`);
87
+ }
88
+ /** Stop the replica writer a pidfile or lock names. A pid that is gone, belongs to another user
89
+ * (EPERM: a writer this person started runs as them) or runs another command is a stale record
90
+ * left by a crash or reboot: no signal is sent, and cleanup goes on. */
91
+ async function stopWriter(pid) {
92
+ if (pid === process.pid)
93
+ throw new Error("refusing to stop the cleanup process as a replica writer");
94
+ const alive = () => {
95
+ try {
96
+ process.kill(pid, 0);
97
+ return true;
98
+ }
99
+ catch (error) {
100
+ const code = error.code;
101
+ if (code === "ESRCH" || code === "EPERM")
102
+ return false;
103
+ throw error;
104
+ }
105
+ };
106
+ if (!alive())
107
+ return;
108
+ const command = spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8", timeout: 1000 });
109
+ if (command.status !== 0 || !/(?:^|[/\s])catalyst(?:-skills)?(?:\.js)?\s+replica\s+start\b|(?:^|[/\s])catalyst-replica-sync(?:\s|$)/.test(command.stdout))
110
+ return;
111
+ process.kill(pid, "SIGTERM");
112
+ const deadline = Date.now() + 5000;
113
+ while (alive()) {
114
+ if (Date.now() >= deadline)
115
+ throw new Error(`replica writer ${pid} did not stop; local data kept`);
116
+ await new Promise((resolve) => setTimeout(resolve, 50));
117
+ }
118
+ }
119
+ /** Internal test seam for the production writer stop, without a process override. */
120
+ export const stopReplicaWriterForTest = stopWriter;
121
+ /** Wait for every detached writer before unlinking. Refuse a replacement file after approval. */
122
+ export async function removeLocalSyncData(files, stop = stopWriter) {
123
+ const pids = new Set();
124
+ const present = (file) => {
125
+ let current;
126
+ try {
127
+ current = lstatSync(file.path);
128
+ }
129
+ catch (error) {
130
+ if (error.code === "ENOENT")
131
+ return false;
132
+ throw error;
133
+ }
134
+ if (!current.isFile() || current.dev !== file.dev || current.ino !== file.ino)
135
+ throw new Error(`local sync file changed after confirmation; kept: ${file.path}`);
136
+ return true;
137
+ };
138
+ // Check the whole approved inventory before reading ownership evidence or sending a signal.
139
+ for (const file of files)
140
+ present(file);
141
+ for (const file of files) {
142
+ if (!present(file))
143
+ continue;
144
+ let pid;
145
+ if (file.path.endsWith(".pid"))
146
+ pid = Number(readFileSync(file.path, "utf8").trim());
147
+ else if (file.path.endsWith(".writer.lock"))
148
+ pid = json(file.path).pid;
149
+ if (Number.isSafeInteger(pid) && Number(pid) > 0)
150
+ pids.add(Number(pid));
151
+ }
152
+ for (const pid of pids)
153
+ await stop(pid);
154
+ for (const file of files)
155
+ present(file);
156
+ for (const file of files)
157
+ if (present(file))
158
+ unlinkSync(file.path);
159
+ }
@@ -12,6 +12,7 @@ function machine(value) {
12
12
  count(value.free) &&
13
13
  (value.lastCheckInAtMs === null || count(value.lastCheckInAtMs)) &&
14
14
  (value.status === "live" || value.status === "stale") &&
15
+ (value.stopped === undefined || typeof value.stopped === "boolean") &&
15
16
  typeof value.removable === "boolean" &&
16
17
  typeof value.renameable === "boolean");
17
18
  }
@@ -19,6 +19,14 @@ class CustodyInstallError extends Error {
19
19
  export function darwinThoughtsInstallRefusal(error) {
20
20
  return error instanceof CustodyInstallError ? error.reason : undefined;
21
21
  }
22
+ /** What a person changes for a create refusal that names its cause; other refusals have no advice. */
23
+ const CREATE_REFUSAL_ADVICE = {
24
+ bootstrap_create_mount_refused: "Docker Desktop refused to share a storage folder with the check container. In Docker Desktop, open Settings, then Resources, then File sharing, make sure your home folder is listed, apply, and run setup again.",
25
+ };
26
+ export function darwinThoughtsInstallAdvice(error) {
27
+ const reason = darwinThoughtsInstallRefusal(error);
28
+ return reason === undefined ? undefined : CREATE_REFUSAL_ADVICE[reason];
29
+ }
22
30
  /** Unknown exception text can contain credentials. Keep its class, and explicitly redact it. */
23
31
  export function darwinThoughtsInstallException(error) {
24
32
  const known = error instanceof CustodyInstallError;
@@ -620,6 +628,7 @@ async function actualBootstrap(input) {
620
628
  let id;
621
629
  let cachedImage;
622
630
  let createAttempted = false;
631
+ let createRefused = false;
623
632
  const receiveIdentity = (received, acknowledgedId) => {
624
633
  if (received.code !== 0)
625
634
  fail("bootstrap_cleanup_unproved");
@@ -681,8 +690,10 @@ async function actualBootstrap(input) {
681
690
  "PATH=/usr/bin:/bin", "BUN_RUNTIME_TRANSPILER_CACHE_PATH=0", "/usr/local/bin/bun", "--config=/dev/null", "--no-env-file", "--cwd=/",
682
691
  "-e", program, canonicalDarwinThoughtsJson({ authority, deadlineMs: input.deadlineMs })]);
683
692
  // Docker's own refusal of a mount source is matched, never forwarded: stderr can carry secrets.
684
- if (created.code !== 0 && /bind source path does not exist|[Mm]ounts denied/.test(created.stderr))
693
+ if (created.code !== 0 && /bind source path does not exist|[Mm]ounts denied/.test(created.stderr)) {
694
+ createRefused = true;
685
695
  fail("bootstrap_create_mount_refused");
696
+ }
686
697
  if (created.code !== 0 || !/^[a-f0-9]{64}$/.test(created.stdout))
687
698
  fail("bootstrap_create_unreceived");
688
699
  id = created.stdout;
@@ -726,7 +737,17 @@ async function actualBootstrap(input) {
726
737
  image.stdout !== cachedImage)
727
738
  fail("bootstrap_cleanup_unproved");
728
739
  let received;
729
- for (let attempt = 0; attempt < 32; attempt++) {
740
+ // A refused create persists nothing; prove that by our random token label, and only
741
+ // fall back to recovering the exact helper when the Engine lists one anyway.
742
+ let refusedAbsent = false;
743
+ if (createRefused && id === undefined) {
744
+ const persisted = await cleanupDocker(["ps", "--all", "--no-trunc", "--quiet",
745
+ "--filter", "label=dev.catalystcloud.custody-token=" + token]);
746
+ if (persisted.code !== 0)
747
+ fail("bootstrap_cleanup_unproved");
748
+ refusedAbsent = persisted.stdout === "";
749
+ }
750
+ for (let attempt = 0; !refusedAbsent && attempt < 32; attempt++) {
730
751
  const current = await cleanupDocker(["inspect", name]);
731
752
  if (current.code === 0) {
732
753
  received = current;
@@ -736,19 +757,21 @@ async function actualBootstrap(input) {
736
757
  fail("bootstrap_cleanup_unproved");
737
758
  await new Promise(resolve => setTimeout(resolve, 25));
738
759
  }
739
- if (received === undefined)
740
- fail("bootstrap_cleanup_unproved");
741
- id = receiveIdentity(received, id);
742
- const list = ["ps", "--all", "--no-trunc", "--quiet", "--filter", "id=" + id];
743
- const present = await cleanupDocker(list);
744
- if (present.code !== 0 || present.stdout !== id)
745
- fail("bootstrap_cleanup_unproved");
746
- const removed = await cleanupDocker(["rm", "--force", id]);
747
- if (removed.code !== 0)
748
- fail("bootstrap_cleanup_unproved");
749
- const absent = await cleanupDocker(list);
750
- if (absent.code !== 0 || absent.stdout !== "")
751
- fail("bootstrap_cleanup_unproved");
760
+ if (!refusedAbsent) {
761
+ if (received === undefined)
762
+ fail("bootstrap_cleanup_unproved");
763
+ id = receiveIdentity(received, id);
764
+ const list = ["ps", "--all", "--no-trunc", "--quiet", "--filter", "id=" + id];
765
+ const present = await cleanupDocker(list);
766
+ if (present.code !== 0 || present.stdout !== id)
767
+ fail("bootstrap_cleanup_unproved");
768
+ const removed = await cleanupDocker(["rm", "--force", id]);
769
+ if (removed.code !== 0)
770
+ fail("bootstrap_cleanup_unproved");
771
+ const absent = await cleanupDocker(list);
772
+ if (absent.code !== 0 || absent.stdout !== "")
773
+ fail("bootstrap_cleanup_unproved");
774
+ }
752
775
  }
753
776
  catch {
754
777
  // Keep the refusal that started cleanup; without it every create failure reads as cleanup.