@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/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));
@@ -4,7 +4,9 @@ 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
6
  import { dockerFleetUpdateEngine, runFleetUpdate } from "./runner-update-fleet.js";
7
- const USAGE = "catalyst runner update [--check] [--dir <runner directory>] [--fleet] | runner auto-update [status|on|off]";
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"];
8
10
  function schedulerDeps(ctx, deps) {
9
11
  const cli = deps.scheduler?.cli ?? resolve(process.argv[1] ?? "catalyst");
10
12
  return {
@@ -41,6 +43,13 @@ export function enableRunnerAutoUpdate(ctx, dir) {
41
43
  export async function cmdRunner(args, ctx, deps = {}) {
42
44
  const parts = positionals(args);
43
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");
44
53
  const dirFlag = flagString(args, "dir");
45
54
  if (dirFlag !== undefined && !isAbsolute(dirFlag))
46
55
  throw new UsageError("--dir must be an absolute path");