@catalyst-cloud/cli 0.16.24 → 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.
@@ -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 };
package/dist/replica.js CHANGED
@@ -253,6 +253,11 @@ export async function engineFor(sdk, dbPath, ctx, deps = {}) {
253
253
  return deps.readonly ? sdk.nodeSqliteReadonlyEngine(dbPath) : sdk.nodeSqliteEngine(dbPath);
254
254
  }
255
255
  }
256
+ /** How long a snapshot pull may go with no progress before the SDK aborts it (CTC-6569). The mirror
257
+ * can spend up to 120 s preparing a large workspace's snapshot before its first byte, and the SDK's
258
+ * old 30 s default aborted nearly every such pull, so the replica never synced. host-sync and the web
259
+ * app pass the same 150 s. Still an idle bound: it re-arms on every chunk. */
260
+ export const SNAPSHOT_IDLE_TIMEOUT_MS = 150_000;
256
261
  export const SNAPSHOT_BASE_BACKOFF_MS = 30_000;
257
262
  export const SNAPSHOT_MAX_BACKOFF_MS = 900_000; // 15 minutes
258
263
  export const SNAPSHOT_MAX_FAILURES = 5;
@@ -354,7 +359,7 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
354
359
  ctx.stdout(`replica writer started in the background (pid ${pid}, db ${dbPath}); check with: catalyst replica status`);
355
360
  return 0;
356
361
  }
357
- const sdk = await loadSdk();
362
+ const sdk = await (deps.loadSdk ?? loadSdk)();
358
363
  const sleep = deps.sleep ?? defaultSleep;
359
364
  const random = deps.random ?? Math.random;
360
365
  const baseMs = deps.snapshotRetry?.baseBackoffMs ?? SNAPSHOT_BASE_BACKOFF_MS;
@@ -404,6 +409,7 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
404
409
  engine,
405
410
  fetchImpl: ctx.fetch,
406
411
  wsFactory: deps.wsFactory,
412
+ snapshotIdleTimeoutMs: deps.snapshotIdleTimeoutMs ?? SNAPSHOT_IDLE_TIMEOUT_MS,
407
413
  log: (level, msg, extra) => ctx.stderr(`[replica ${level}] ${msg}${extra ? ` ${safeJson(extra)}` : ""}`),
408
414
  onStatus: (s) => {
409
415
  ctx.stderr(`[replica] status=${s}`);
@@ -0,0 +1,156 @@
1
+ // Repository lifecycle commands use the key routes under /api/v1/agent/repos, independent of local checkouts.
2
+ import { flagString, positionals } from "./args.js";
3
+ import { requireConfig } from "./config.js";
4
+ import { MeError, UsageError } from "./errors.js";
5
+ import { apiClient } from "./transport.js";
6
+ const LIST_ROUTE = "/api/v1/agent/repos";
7
+ function object(value) {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9
+ }
10
+ function text(value) {
11
+ return typeof value === "string" && value.trim().length > 0;
12
+ }
13
+ function repository(value) {
14
+ return object(value) && text(value.id) && text(value.owner) && text(value.name) && text(value.status);
15
+ }
16
+ function decodeList(value) {
17
+ if (!object(value) || !Array.isArray(value.repositories) || !value.repositories.every(repository) || typeof value.canManage !== "boolean") {
18
+ throw new MeError(`GET ${LIST_ROUTE} returned an unexpected repository list shape`, "shape");
19
+ }
20
+ return { ...value, repositories: value.repositories, canManage: value.canManage };
21
+ }
22
+ function elapsedTime(value, now) {
23
+ if (typeof value !== "string" && typeof value !== "number")
24
+ return undefined;
25
+ const timestamp = new Date(value).getTime();
26
+ if (!Number.isFinite(timestamp))
27
+ return undefined;
28
+ const minutes = Math.max(0, Math.floor((now.getTime() - timestamp) / 60_000));
29
+ if (minutes === 0)
30
+ return "just now";
31
+ const [amount, unit] = minutes < 60 ? [minutes, "minute"] : minutes < 1440 ? [Math.floor(minutes / 60), "hour"] : [Math.floor(minutes / 1440), "day"];
32
+ return `${amount} ${unit}${amount === 1 ? "" : "s"} ago`;
33
+ }
34
+ function renderRow(row, ctx) {
35
+ let state = row.status;
36
+ if (state === "paused") {
37
+ const actor = text(row.pausedBy) ? row.pausedBy : object(row.pausedBy)
38
+ ? [row.pausedBy.email, row.pausedBy.actor, row.pausedBy.name, row.pausedBy.label].find(text) ?? "an operator"
39
+ : "an operator";
40
+ const time = elapsedTime(row.pausedAt, ctx.now());
41
+ state += ` by ${actor}${time ? ` ${time}` : ""}${text(row.pauseReason) ? `: ${row.pauseReason}` : ""}`;
42
+ }
43
+ ctx.stdout(`${row.owner}/${row.name}: ${state}`);
44
+ const projects = Array.isArray(row.projects) ? row.projects.filter(object).filter((p) => text(p.name)).map((p) => `${p.name}${text(p.teamKey) ? ` (${p.teamKey})` : ""}`) : [];
45
+ ctx.stdout(` projects: ${projects.length ? projects.join(", ") : "none"}`);
46
+ }
47
+ function refuse(args, ctx, message, status, body = {}) {
48
+ if (args.json)
49
+ ctx.stdout(JSON.stringify({ ...(object(body) ? body : {}), ...(status === undefined ? {} : { status }), message }));
50
+ else
51
+ ctx.stdout(`refused${status === undefined ? "" : ` (${status})`}: ${message}`);
52
+ return 1;
53
+ }
54
+ function serverDetail(body) {
55
+ if (!object(body))
56
+ return "";
57
+ const details = [body.error, body.code, body.message, body.reason].filter(text);
58
+ return details.length ? ` (${[...new Set(details)].join(": ")})` : "";
59
+ }
60
+ export async function cmdRepoStatus(args, ctx) {
61
+ const [action, wanted, ...extra] = positionals(args);
62
+ if (action !== "status" && action !== "pause" && action !== "resume")
63
+ throw new UsageError("repo needs status, pause or resume");
64
+ if (["write", "apply", "with-check"].some((flag) => args.flags[flag] !== undefined)) {
65
+ throw new UsageError(`repo ${action} does not take local write flags`);
66
+ }
67
+ const rawReason = flagString(args, "reason");
68
+ const reason = rawReason?.trim();
69
+ if (action === "status") {
70
+ if (wanted !== undefined || extra.length || rawReason !== undefined)
71
+ throw new UsageError("repo status takes no arguments or --reason");
72
+ }
73
+ else {
74
+ if (!wanted || !/^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9_.-]+$/.test(wanted) || extra.length) {
75
+ throw new UsageError(`repo ${action} needs one repository as owner/name`);
76
+ }
77
+ if (action === "pause" && reason === undefined)
78
+ throw new UsageError("repo pause needs --reason with 1 to 500 characters");
79
+ if (reason !== undefined && (reason.length === 0 || reason.length > 500))
80
+ throw new UsageError("--reason must contain 1 to 500 characters after trimming");
81
+ }
82
+ const cfg = requireConfig(ctx);
83
+ const client = apiClient(cfg, ctx);
84
+ const accept = [400, 403, 404, 409];
85
+ const response = await client.getJson(LIST_ROUTE, { accept, parseAccepted: true });
86
+ if (response.status >= 400) {
87
+ const message = response.status === 404
88
+ ? "this Catalyst Cloud does not serve repository status yet (needs a newer cloud)"
89
+ : response.status === 403 ? "your login cannot read repository status" : "repository status request failed";
90
+ return refuse(args, ctx, message + serverDetail(response.body), response.status, response.body);
91
+ }
92
+ const result = decodeList(response.body);
93
+ const role = cfg.user?.role ? `; your role is ${cfg.user.role}` : "";
94
+ if (action === "status") {
95
+ if (args.json)
96
+ ctx.stdout(JSON.stringify(result));
97
+ else {
98
+ for (const row of result.repositories)
99
+ renderRow(row, ctx);
100
+ if (result.repositories.length === 0)
101
+ ctx.stdout("No repositories are visible to your login.");
102
+ ctx.stdout(result.canManage
103
+ ? "You can pause or resume repositories as a workspace owner or admin."
104
+ : `Pause or resume requires a workspace owner or admin${role}.`);
105
+ }
106
+ return 0;
107
+ }
108
+ const roleRefusal = `pausing or resuming a repository requires a workspace owner or admin${role}`;
109
+ if (!result.canManage)
110
+ return refuse(args, ctx, roleRefusal, 403, { error: "forbidden" });
111
+ const matches = result.repositories.filter((row) => `${row.owner}/${row.name}`.toLowerCase() === wanted?.toLowerCase());
112
+ const selected = matches[0];
113
+ if (!selected)
114
+ return refuse(args, ctx, `no repository ${wanted} is visible to your login; check catalyst repo status`, undefined, { error: "repository-not-visible" });
115
+ if (matches.length !== 1)
116
+ return refuse(args, ctx, `repository ${wanted} is ambiguous; check catalyst repo status`, undefined, { error: "repository-ambiguous" });
117
+ if (selected.status === "archived")
118
+ return refuse(args, ctx, `${wanted} is archived and cannot be paused or resumed`, 409, { error: "repository-archived" });
119
+ const written = await client.postJson(`${LIST_ROUTE}/${action}`, reason === undefined ? { repoId: selected.id } : { repoId: selected.id, reason }, { accept });
120
+ if (written.status >= 400) {
121
+ const detail = serverDetail(written.body);
122
+ let message = `could not ${action} ${wanted}`;
123
+ if (written.status === 403)
124
+ message = roleRefusal;
125
+ else if (written.status === 404) {
126
+ // Use descriptive evidence rather than requiring an undocumented server code spelling.
127
+ if (/route|endpoint/i.test(detail))
128
+ message = `this Catalyst Cloud does not serve repository ${action} yet (needs a newer cloud)`;
129
+ else if (/repo(sitory)?/i.test(detail))
130
+ message = `repository ${wanted} is no longer available; check catalyst repo status`;
131
+ else
132
+ message = `${wanted}: the repository or this route is no longer available`;
133
+ }
134
+ return refuse(args, ctx, message + detail, written.status, written.body);
135
+ }
136
+ // POST envelopes are not established by this fixture. Only a fresh read establishes the row.
137
+ // Never repeat a successful POST if verification fails.
138
+ let refreshed;
139
+ try {
140
+ const after = await client.getJson(LIST_ROUTE);
141
+ const found = decodeList(after.body).repositories.find((row) => row.id === selected.id);
142
+ if (!found)
143
+ throw new Error("the repository is no longer visible to your login");
144
+ refreshed = found;
145
+ }
146
+ catch (error) {
147
+ return refuse(args, ctx, `the ${action} write succeeded, but repository status could not be read: ${error instanceof Error ? error.message : String(error)}`, undefined, {
148
+ error: "repository-status-unavailable", writeSucceeded: true, repoId: selected.id,
149
+ });
150
+ }
151
+ if (args.json)
152
+ ctx.stdout(JSON.stringify(refreshed));
153
+ else
154
+ renderRow(refreshed, ctx);
155
+ return 0;
156
+ }
package/dist/repo.js CHANGED
@@ -15,6 +15,7 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, renameSyn
15
15
  import { dirname, join, relative, resolve } from "node:path";
16
16
  import { flagBool, positionals } from "./args.js";
17
17
  import { UsageError } from "./errors.js";
18
+ import { cmdRepoStatus } from "./repo-status.js";
18
19
  export const BLOCK_START = "<!-- catalyst:start -->";
19
20
  export const BLOCK_END = "<!-- catalyst:end -->";
20
21
  /** The two skills the block names. ONE place each: the packs are being consolidated, and a rename
@@ -248,8 +249,12 @@ function checkoutRoot(args, sub) {
248
249
  }
249
250
  export async function cmdRepo(args, ctx) {
250
251
  const [sub] = positionals(args);
252
+ if (sub === "status" || sub === "pause" || sub === "resume")
253
+ return cmdRepoStatus(args, ctx);
251
254
  if (sub !== "agents-block" && sub !== "agent-setup")
252
- throw new UsageError("repo needs agents-block <path> [--write] or agent-setup <path> [--apply] [--with-check]");
255
+ throw new UsageError("repo needs status, pause <owner/name> --reason <text>, resume <owner/name> [--reason <text>], agents-block <path> [--write] or agent-setup <path> [--apply] [--with-check]");
256
+ if (args.flags.reason !== undefined)
257
+ throw new UsageError("--reason belongs to repo pause or resume");
253
258
  const root = checkoutRoot(args, sub);
254
259
  const emit = (body, lines) => { if (args.json)
255
260
  ctx.stdout(JSON.stringify(body));
@@ -3,7 +3,10 @@ import { flagBool, flagString, positionals } from "./args.js";
3
3
  import { UsageError } from "./errors.js";
4
4
  import { autoUpdateStatus, disableAutoUpdate, enableAutoUpdate, scheduledPath, } from "./runner-auto-update.js";
5
5
  import { dockerRunnerUpdateEngine, readReleaseState, runnerDir, runRunnerUpdate, updateResultLines, } from "./runner-update.js";
6
- const USAGE = "catalyst runner update [--check] [--dir <runner directory>] | runner auto-update [status|on|off]";
6
+ import { dockerFleetUpdateEngine, runFleetUpdate } from "./runner-update-fleet.js";
7
+ import { cmdRunnerLifecycle, RUNNER_LIFECYCLE_ACTIONS } from "./runner.js";
8
+ const USAGE = "catalyst runner [status] | stop | start | uninstall [--yes] [--delete-checkouts] | runner update [--check] [--dir <runner directory>] [--fleet] | runner auto-update [status|on|off]";
9
+ const UPDATE_FLAGS = ["check", "auto", "dir", "fleet"];
7
10
  function schedulerDeps(ctx, deps) {
8
11
  const cli = deps.scheduler?.cli ?? resolve(process.argv[1] ?? "catalyst");
9
12
  return {
@@ -40,6 +43,13 @@ export function enableRunnerAutoUpdate(ctx, dir) {
40
43
  export async function cmdRunner(args, ctx, deps = {}) {
41
44
  const parts = positionals(args);
42
45
  const action = parts[0];
46
+ if (action === undefined || RUNNER_LIFECYCLE_ACTIONS.includes(action)) {
47
+ if (UPDATE_FLAGS.some(flag => args.flags[flag] !== undefined))
48
+ throw new UsageError("--check, --auto, --dir and --fleet are only valid with runner update or auto-update");
49
+ return cmdRunnerLifecycle(args, ctx, deps);
50
+ }
51
+ if (args.flags.yes !== undefined || args.flags["delete-checkouts"] !== undefined)
52
+ throw new UsageError("--yes and --delete-checkouts are only valid with runner uninstall");
43
53
  const dirFlag = flagString(args, "dir");
44
54
  if (dirFlag !== undefined && !isAbsolute(dirFlag))
45
55
  throw new UsageError("--dir must be an absolute path");
@@ -47,8 +57,12 @@ export async function cmdRunner(args, ctx, deps = {}) {
47
57
  if (action === "update" && parts.length === 1) {
48
58
  const check = flagBool(args, "check");
49
59
  const auto = flagBool(args, "auto");
50
- const engine = deps.engine ?? dockerRunnerUpdateEngine({ env: ctx.env });
51
- const result = await runRunnerUpdate(engine, { dir: dir(), check });
60
+ const fleet = flagBool(args, "fleet");
61
+ if (fleet && dirFlag === undefined)
62
+ throw new UsageError("--fleet needs --dir, the directory that holds compose.json");
63
+ const result = fleet
64
+ ? await runFleetUpdate(deps.fleetEngine ?? dockerFleetUpdateEngine({ env: ctx.env }), { dir: dir(), check })
65
+ : await runRunnerUpdate(deps.engine ?? dockerRunnerUpdateEngine({ env: ctx.env }), { dir: dir(), check });
52
66
  if (args.json)
53
67
  ctx.stdout(JSON.stringify(result));
54
68
  else if (auto)