@catalyst-cloud/cli 0.16.25 → 0.16.28

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/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
  }
@@ -0,0 +1,47 @@
1
+ import { flagString, positionals } from "./args.js";
2
+ import { requireConfig } from "./config.js";
3
+ import { CliError, UsageError } from "./errors.js";
4
+ import { bearerFor } from "./oauth.js";
5
+ import { loadHttpSdk } from "./sdk.js";
6
+ function failure(result) {
7
+ const reason = "reason" in result && typeof result.reason === "string" ? `: ${result.reason}` : "";
8
+ return `messages: ${result.outcome}${"status" in result && result.status ? ` (${result.status})` : ""}${reason}`;
9
+ }
10
+ export async function cmdMessages(args, ctx, deps = {}) {
11
+ const [action, ...extra] = positionals(args);
12
+ if (action !== "read" || extra.length)
13
+ throw new UsageError("messages takes: read --ticket CTC-N [--since SEQUENCE]");
14
+ const ticket = flagString(args, "ticket")?.trim().toUpperCase();
15
+ if (!ticket)
16
+ throw new UsageError("messages read needs --ticket CTC-N");
17
+ const rawSince = flagString(args, "since");
18
+ const since = rawSince === undefined ? undefined : Number(rawSince);
19
+ if (since !== undefined && (!Number.isSafeInteger(since) || since < 0))
20
+ throw new UsageError("--since must be a non-negative message sequence");
21
+ const cfg = requireConfig(ctx);
22
+ const createClient = deps.createClient ?? (async (options) => (await loadHttpSdk()).createTenantClient(options));
23
+ const client = await createClient({ key: await bearerFor(ctx, cfg), baseUrl: cfg.baseUrl, fetch: ctx.fetch });
24
+ if (!client.messages)
25
+ throw new CliError("the installed SDK cannot read messages; upgrade the catalyst CLI", "sdk-upgrade-required");
26
+ const result = await client.messages.read({ ticket, ...(since === undefined ? {} : { since }) });
27
+ if (result.outcome !== "ok") {
28
+ if (args.json)
29
+ ctx.stdout(JSON.stringify(result));
30
+ else
31
+ ctx.stderr(failure(result));
32
+ return 1;
33
+ }
34
+ if (args.json)
35
+ ctx.stdout(JSON.stringify({ ticket: result.ticket, messages: result.messages, next: result.next }));
36
+ else if (result.messages.length === 0)
37
+ ctx.stdout(`${result.ticket}: no messages${since === undefined ? "" : ` at or after ${since}`}`);
38
+ else
39
+ for (const message of result.messages) {
40
+ ctx.stdout(`#${message.seq} ${message.sentAt} ${message.from.label} (${message.kind})`);
41
+ for (const line of message.body.split("\n"))
42
+ ctx.stdout(` ${line}`);
43
+ }
44
+ if (result.next !== null)
45
+ ctx.stderr(`more: re-run with --since ${result.next}`);
46
+ return 0;
47
+ }
@@ -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.
@@ -8,7 +8,7 @@ import { runnerAdmission, issueRunnerOrgKey, verifyRunnerRoutes } from "./onboar
8
8
  import { verifyOnboardRoutes } from "./onboard-capabilities.js";
9
9
  import { liveTeamKey, onboardTeamAdmission } from "./onboard-capacity.js";
10
10
  import { runnerVmCapabilities } from "./onboard-runner-probes.js";
11
- import { darwinThoughtsInstallException, darwinThoughtsInstallRefusal, installDarwinThoughtsCustody } from "./onboard-runner-custody.js";
11
+ import { darwinThoughtsInstallAdvice, darwinThoughtsInstallException, darwinThoughtsInstallRefusal, installDarwinThoughtsCustody } from "./onboard-runner-custody.js";
12
12
  import { emitCustodyFailure } from "./onboard-runner-telemetry.js";
13
13
  import { canonicalDarwinThoughtsJson, parseDarwinThoughtsAuthority } from "../vendor/self-host/darwin-thoughts-custody/verifier.mjs";
14
14
  import { readExistingOnboardJson, readOnboardTeamInventory, selectedOnboardTeam, } from "./onboard-existing.js";
@@ -1161,7 +1161,9 @@ export function onboardRunnerAdapter(input = {}) {
1161
1161
  const custodyReason = darwinThoughtsInstallRefusal(error);
1162
1162
  const failureCase = custodyReturned ? "result_unverified" : "call_threw";
1163
1163
  const details = { ...darwinThoughtsInstallException(error), ...(custodyReason ? { custodyReason } : {}) };
1164
- return refuseCustody(ctx, journal, failureCase, `Setup could not verify local storage: ${custodyReturned ? "recording the result failed" : "the native check threw"} (${details.custodyErrorClass}: ${details.custodyErrorMessage}).`, details, signal);
1164
+ const advice = darwinThoughtsInstallAdvice(error);
1165
+ return refuseCustody(ctx, journal, failureCase, `Setup could not verify local storage: ${custodyReturned ? "recording the result failed" : "the native check threw"} (${details.custodyErrorClass}: ${details.custodyErrorMessage}).` +
1166
+ (advice ? " " + advice : ""), details, signal);
1165
1167
  }
1166
1168
  }
1167
1169
  if (!writeEnv(want))
@@ -5,6 +5,8 @@ import { openConsentBrowser } from "./consent-browser.js";
5
5
  import { defaultSkillsDirFor, loadConfig, normalizeBaseUrl, readManifest, } from "./config.js";
6
6
  import { CliError, UsageError } from "./errors.js";
7
7
  import { cmdLegacy, findLegacy } from "./legacy.js";
8
+ import { findLocalSyncData, localSyncInventory } from "./local-sync-data.js";
9
+ import { replicaStatus } from "./replica.js";
8
10
  import { personalConsentAdapter } from "./onboard-personal.js";
9
11
  import { boundedOnboardSignin } from "./onboard-signin.js";
10
12
  import { existingLinearAdapters } from "./onboard-existing.js";
@@ -134,8 +136,14 @@ export function createOnboardRuntime(args, ctx, hooks) {
134
136
  },
135
137
  };
136
138
  };
137
- const legacyCheck = async (stepCtx) => {
138
- const found = findLegacy(stepCtx.home, hooks.legacy?.platform ?? process.platform).filter((item) => !item.data).length;
139
+ const legacyDeps = (journal) => ({
140
+ ...hooks.legacy,
141
+ preserveReplica: journal?.localSync ?? args.flags["local-sync"] === true,
142
+ });
143
+ const legacyCheck = async (stepCtx, journal) => {
144
+ const services = findLegacy(stepCtx.home, hooks.legacy?.platform ?? process.platform, legacyDeps(journal)).filter((item) => !item.data);
145
+ const files = legacyDeps(journal).preserveReplica ? [] : await findLocalSyncData(stepCtx);
146
+ const found = services.length + files.length;
139
147
  if (!sameHome(stepCtx.home, (hooks.realHome ?? (() => userInfo().homedir))()))
140
148
  return {
141
149
  state: "skipped",
@@ -218,10 +226,40 @@ export function createOnboardRuntime(args, ctx, hooks) {
218
226
  },
219
227
  legacy: {
220
228
  check: legacyCheck,
221
- act: async (stepCtx) => {
222
- const before = await legacyCheck(stepCtx);
229
+ act: async (stepCtx, journal) => {
230
+ const before = await legacyCheck(stepCtx, journal);
223
231
  if (before.state !== "pending")
224
232
  return before;
233
+ const options = legacyDeps(journal);
234
+ const files = options.preserveReplica ? [] : await findLocalSyncData(stepCtx);
235
+ const writers = findLegacy(stepCtx.home, options.platform ?? process.platform, options)
236
+ .filter((item) => item.kind === "job" && item.localSync);
237
+ if (files.length || writers.length) {
238
+ const inventory = [
239
+ ...writers.map((writer) => `${writer.path} (${writer.size} bytes)`),
240
+ ...localSyncInventory(files),
241
+ ];
242
+ const show = (lines) => {
243
+ if (hooks.ui?.showLocalSyncInventory)
244
+ hooks.ui.showLocalSyncInventory(lines);
245
+ else
246
+ for (const line of lines)
247
+ hooks.ui ? hooks.ui.message(line) : stepCtx.stderr(line);
248
+ };
249
+ show(["Existing local sync files:", ...inventory]);
250
+ if (args.flags["remove-local-data"] !== true) {
251
+ if (!interactiveWait || (!options.prompt && !hooks.ui?.confirmLocalSyncRemoval)) {
252
+ const line = "Local data kept. Run catalyst setup --remove-local-data to stop the writer and remove the listed files.";
253
+ show([line]);
254
+ return { state: "waiting", reason: "local_sync_cleanup_confirmation_required", evidence: { files: inventory.join("\n"), requiredFlag: "--remove-local-data" } };
255
+ }
256
+ const accepted = options.prompt
257
+ ? /^(y|yes)$/i.test((await options.prompt("Stop the local writer and delete the listed local sync files, keeping login, contract and settings? [y/N] ")).trim())
258
+ : await hooks.ui.confirmLocalSyncRemoval();
259
+ if (!accepted)
260
+ return waiting("local_sync_cleanup_declined");
261
+ }
262
+ }
225
263
  // Provider/process output is not trusted to be secret-free; keep it out of the receipt/log.
226
264
  const quiet = { ...stepCtx, stdout: () => { }, stderr: () => { } };
227
265
  const code = await cmdLegacy({
@@ -229,11 +267,11 @@ export function createOnboardRuntime(args, ctx, hooks) {
229
267
  command: "legacy",
230
268
  subcommand: null,
231
269
  rest: [],
232
- flags: { remove: true, yes: true },
270
+ flags: { remove: true, data: !options.preserveReplica, yes: true },
233
271
  json: false,
234
- }, quiet, hooks.legacy);
272
+ }, quiet, { ...options, localSyncFiles: files });
235
273
  return code === 0
236
- ? legacyCheck(stepCtx)
274
+ ? legacyCheck(stepCtx, journal)
237
275
  : { state: "failed", reason: "legacy_cleanup_failed" };
238
276
  },
239
277
  },
@@ -350,13 +388,14 @@ export function createOnboardRuntime(args, ctx, hooks) {
350
388
  "linear.personal": personalAdapter("linear"),
351
389
  "github.personal": personalAdapter("github"),
352
390
  daemon: {
353
- check: async (_stepCtx, journal) => (journal.localSync ?? args.flags["local-sync"] === true)
354
- ? waiting("local_sync_capability_unavailable")
355
- : {
356
- state: "skipped",
357
- reason: "local_sync_not_selected",
358
- evidence: { provider: "cloud" },
359
- },
391
+ check: async (stepCtx, journal) => {
392
+ if (!(journal.localSync ?? args.flags["local-sync"] === true))
393
+ return { state: "skipped", reason: "local_sync_not_selected", evidence: { provider: "cloud" } };
394
+ const status = replicaStatus(stepCtx, loadConfig(stepCtx.home));
395
+ return status.verdict === "fresh"
396
+ ? { state: "done", evidence: { provider: "existing_replica", path: status.dbPath, cursor: status.cursor } }
397
+ : waiting("local_sync_capability_unavailable");
398
+ },
360
399
  },
361
400
  housekeeping: onboardHousekeepingAdapter(hooks.scheduler),
362
401
  // CTC-4744: a settings read the cloud has not finished is waited on here, on one line.
@@ -402,6 +402,35 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
402
402
  message("Use cloud reads by default. Local sync is optional for SQL or sustained local reads.");
403
403
  message("Setup reads your AI accounts without sending them a request.");
404
404
  },
405
+ showLocalSyncInventory(lines) {
406
+ stop();
407
+ for (const line of lines)
408
+ if (renderer)
409
+ renderer.detail(line);
410
+ else
411
+ prompts.log.message(line, { output: streams.output });
412
+ },
413
+ async confirmLocalSyncRemoval() {
414
+ stop();
415
+ if (abort.signal.aborted || !interactive)
416
+ return false;
417
+ if (renderer)
418
+ question();
419
+ const answer = await select({
420
+ ...options,
421
+ message: "Stop the local writer and delete the listed files? Login, contract and settings are kept.",
422
+ initialValue: "keep",
423
+ options: [
424
+ { value: "remove", label: "Yes, remove the listed local sync files" },
425
+ { value: "keep", label: "No, keep them" },
426
+ ],
427
+ });
428
+ if (prompts.isCancel(answer)) {
429
+ abort.abort();
430
+ return false;
431
+ }
432
+ return answer === "remove";
433
+ },
405
434
  async confirmPlan(localSync, signin = "unavailable") {
406
435
  if (deps.consentGiven || !interactive)
407
436
  return { proceed: true, localSync };