@catalyst-cloud/cli 0.16.16 → 0.16.18

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.18
4
+
5
+ A self-hosted machine now returns to the approved release by itself after setup runs again on it. Before, re-running setup could put older images back while the machine still counted itself current, so it stopped updating. On Linux, the 15-minute update schedule now keeps running after you enable lingering for your user. Install this version, then run `catalyst runner auto-update on` to rewrite the schedule.
6
+
7
+ ## 0.16.17
8
+
9
+ A self-hosted machine now keeps its own images current. Every 15 minutes it checks for the release Catalyst approved, waits until no work is running on it, switches to the new images, and goes back to the previous ones if the machine is not ready within five minutes. `catalyst runner update --check` shows what would change, and `catalyst runner auto-update status|on|off` controls the schedule. Setup turns it on and now starts new machines on the current approved images. To turn it on for a machine you already set up, run `catalyst runner auto-update on`.
10
+
3
11
  ## 0.16.16
4
12
 
5
13
  Publishing a browser verification recording now saves its proof card with the required attachment details. Update the CLI before using `browser-verify --publish`.
package/dist/args.js CHANGED
@@ -21,6 +21,11 @@ const GLOBAL_FLAGS = {
21
21
  /** Per-verb flag tables. A verb absent here accepts only the global flags. */
22
22
  export const FLAG_TABLES = {
23
23
  hosts: { yes: { value: false, help: "confirm removal without a terminal" } },
24
+ runner: {
25
+ check: { value: false, help: "read the approved release and say what would change, without changing it" },
26
+ auto: { value: false, help: "one-line output for the scheduled run" },
27
+ dir: { value: true, help: "the runner directory (default: the one setup created)" },
28
+ },
24
29
  login: {
25
30
  "start-replica": {
26
31
  value: false,
@@ -499,6 +504,7 @@ export const FLAG_TABLES = {
499
504
  };
500
505
  export const VERB_USAGE = {
501
506
  hosts: "hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name> [--json]",
507
+ runner: "runner update [--check] [--dir <runner directory>] | runner auto-update [status|on|off]",
502
508
  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)",
503
509
  login: "login [--base-url <url>] [--start-replica] (keyless; or --key <personal-key> / CATALYST_CLOUD_TOKEN)",
504
510
  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]",
@@ -7,6 +7,7 @@ const agent = (method, name) => ({ method, path: `/api/v1/agent/${name}` });
7
7
  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
+ { 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" },
10
11
  // CTC-4680: plain `catalyst setup` runs the onboard flow, so install.sh may hand off to it. install.sh
11
12
  // reads this flag rather than `setup --help`, which already exits 0 on 0.15.x.
12
13
  { 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 },
package/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { cmdHosts } from "./hosts.js";
2
+ import { cmdRunner } from "./runner-command.js";
2
3
  import { cmdSeat } from "./seat.js";
3
4
  // cli.ts — the `catalyst` dispatcher (npm: @catalyst-cloud/cli). Verbs live in
4
5
  // their own modules; this file keeps every export the 0.1 tests and the bin import.
@@ -84,6 +85,7 @@ export function usageText() {
84
85
  " catalyst status | notice | me | ready | accounts",
85
86
  " catalyst seat checkout --size big|med --harness claude,codex,opencode [--session-id ID] [--stage STAGE]",
86
87
  " catalyst hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name>",
88
+ " catalyst runner update [--check] | runner auto-update [status|on|off] (move this machine's runner to the approved release)",
87
89
  " catalyst mcp add|list|remove (vault references only)",
88
90
  " catalyst contract [--refresh] [--path <a.b.c>]",
89
91
  " catalyst query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <cursor|head>>",
@@ -523,6 +525,8 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
523
525
  return await cmdIdentity(args, ctx, deps.identity ?? {});
524
526
  case "hosts":
525
527
  return await cmdHosts(args, ctx, deps.hosts);
528
+ case "runner":
529
+ return await cmdRunner(args, ctx, deps.runner);
526
530
  case "connections":
527
531
  return await cmdConnections(args, ctx, deps.connections ?? {});
528
532
  default:
@@ -20,13 +20,14 @@ import { onboardStateRoot, } from "./onboard.js";
20
20
  /** The Compose project name the vendored file declares. */
21
21
  export const RUNNER_PROJECT = "catalyst-host";
22
22
  export const RUNNER_SESSION_NETWORK = "catalyst-session-v1";
23
- /** Supervisor from ee729b12 (Host images 38138827750); runner from
24
- * 91a82214 (Runner image 38133282600). The watchdog retains its reviewed pin.
23
+ /** Catalyst's approved host release, generation 2 (hr-a19f7b0859a98147-9a96426c93e32173-63a9b20c3be3dc3e):
24
+ * runner, supervisor and watchdog all built from catalyst-cloud 2ec4a551. A machine set up from these
25
+ * pins then follows newer approved releases itself (`catalyst runner update`, CTC-4646).
25
26
  * Explicit environment values override these pins; a saved runner image is preserved. */
26
27
  export const RUNNER_HOST_IMAGES = {
27
- supervisor: "ghcr.io/coalesce-labs/catalyst-supervisor@sha256:3331ebc4204ae3975a4efb2c870c941f9c0f5ab435735e91dc9977941c91e9a8",
28
- watchdog: "ghcr.io/coalesce-labs/catalyst-deadline-watchdog@sha256:a5999dbf229ac432b83c2ed3fe43fa251366baceb73af2f57590890a6a758f0d",
29
- runner: "ghcr.io/coalesce-labs/catalyst-runner@sha256:9db2bf2c5580e2a1459fd076fa32c9af34456cc639ec3ab92b57b6ad98da67fb",
28
+ supervisor: "ghcr.io/coalesce-labs/catalyst-supervisor@sha256:9a96426c93e32173a67e849aa5a462e9f5466b872e4117f9a8a7df1972b2a136",
29
+ watchdog: "ghcr.io/coalesce-labs/catalyst-deadline-watchdog@sha256:63a9b20c3be3dc3ea328d77aa68dece942a40be46cb3126d9a7868e082828feb",
30
+ runner: "ghcr.io/coalesce-labs/catalyst-runner@sha256:a19f7b0859a9814740935f7aadd8fe5c5c9c5974f9a5956a057b983ada16ea01",
30
31
  };
31
32
  const IMAGE_REF = /^[a-z0-9][a-z0-9._:/-]{0,255}@sha256:[0-9a-f]{64}$/;
32
33
  const HOST_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/;
@@ -42,7 +43,7 @@ const CUSTODY_KEYS = [
42
43
  "CATALYST_THOUGHTS_CUSTODY_RESPONSES_DIR",
43
44
  ];
44
45
  /** A persisted authority selects bindings, never substitutes for a fresh native proof. */
45
- function persistedCustody(dir, values) {
46
+ export function persistedCustody(dir, values) {
46
47
  const present = CUSTODY_KEYS.map(key => values?.[key] ?? "");
47
48
  if (present.every(value => !value))
48
49
  return "absent";
@@ -70,7 +71,8 @@ const DISK_POLICY_KEYS = [
70
71
  function isUnenrolled(value) {
71
72
  return value !== null && "unenrolled" in value;
72
73
  }
73
- const spawnDocker = (args, options) => new Promise((resolve, reject) => {
74
+ /** The docker CLI as a child process. Exported for `catalyst runner update`. */
75
+ export const spawnDocker = (args, options) => new Promise((resolve, reject) => {
74
76
  const child = spawn("docker", args, {
75
77
  env: options.env,
76
78
  cwd: options.cwd,
@@ -103,6 +105,23 @@ function composeErrorText(raw) {
103
105
  const text = (errors.length > 0 ? errors : lines).slice(0, 3).join(" | ").slice(0, 480);
104
106
  return text !== "" ? text : undefined;
105
107
  }
108
+ /** The Compose arguments for the installation in `dir`, with the Darwin custody file when its
109
+ * persisted authority is installed. Null when that authority is only partly present. */
110
+ export function runnerComposeArgs(dir) {
111
+ const custody = persistedCustody(dir, readEnvFile(join(dir, ".env")));
112
+ if (custody === "invalid")
113
+ return null;
114
+ return [
115
+ "compose",
116
+ "--project-name",
117
+ RUNNER_PROJECT,
118
+ "--file",
119
+ join(dir, "compose.yaml"),
120
+ ...(custody === "installed" ? ["--file", join(dir, "compose.darwin-thoughts.yaml")] : []),
121
+ "--env-file",
122
+ join(dir, ".env"),
123
+ ];
124
+ }
106
125
  /** The real engine, through the docker CLI. Compose reads `.env` only: the caller's CATALYST_ and
107
126
  * DOCKER_SOCKET_ variables are removed, because Compose prefers an exported value over the file. */
108
127
  export function dockerRunnerEngine(deps = {}) {
@@ -116,21 +135,7 @@ export function dockerRunnerEngine(deps = {}) {
116
135
  return { code: 1, stdout: "" };
117
136
  }
118
137
  };
119
- const compose = (dir) => {
120
- const custody = persistedCustody(dir, readEnvFile(join(dir, ".env")));
121
- if (custody === "invalid")
122
- return null;
123
- return [
124
- "compose",
125
- "--project-name",
126
- RUNNER_PROJECT,
127
- "--file",
128
- join(dir, "compose.yaml"),
129
- ...(custody === "installed" ? ["--file", join(dir, "compose.darwin-thoughts.yaml")] : []),
130
- "--env-file",
131
- join(dir, ".env"),
132
- ];
133
- };
138
+ const compose = runnerComposeArgs;
134
139
  const supervisorShell = (dir, script) => {
135
140
  const files = compose(dir);
136
141
  if (!files)
@@ -583,7 +588,7 @@ function parseHosts(body) {
583
588
  return parsed;
584
589
  }
585
590
  /** `KEY=value` lines; a single-quoted value is unquoted. Anything else is ignored. */
586
- function readEnvFile(path) {
591
+ export function readEnvFile(path) {
587
592
  let text;
588
593
  try {
589
594
  text = readFileSync(path, "utf8");
@@ -591,6 +596,10 @@ function readEnvFile(path) {
591
596
  catch {
592
597
  return null;
593
598
  }
599
+ return parseEnvText(text);
600
+ }
601
+ /** `KEY=value` lines of `.env` text, as readEnvFile reads them. */
602
+ export function parseEnvText(text) {
594
603
  const values = {};
595
604
  for (const line of text.split("\n")) {
596
605
  const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
@@ -1176,6 +1185,14 @@ export function onboardRunnerAdapter(input = {}) {
1176
1185
  return { state: "failed", reason: "runner_compose_failed", evidence: { ...selected,
1177
1186
  project: RUNNER_PROJECT, ...(up.error ? { composeError: up.error } : {}) } };
1178
1187
  acted = true;
1188
+ if (input.autoUpdate) {
1189
+ try {
1190
+ input.message?.(input.autoUpdate(p.dir));
1191
+ }
1192
+ catch {
1193
+ input.message?.("Automatic runner updates could not be turned on. Run catalyst runner auto-update on to try again.");
1194
+ }
1195
+ }
1179
1196
  return { state: "done" };
1180
1197
  },
1181
1198
  };
@@ -27,6 +27,7 @@ import { guardOnboardCapabilities, } from "./onboard-capabilities.js";
27
27
  import { onboardSettingsAdapter, } from "./onboard-settings.js";
28
28
  import { TIMED_OUT_REASONS } from "./setup-onboard-copy.js";
29
29
  import { fetchMe } from "./transport.js";
30
+ import { enableRunnerAutoUpdate } from "./runner-command.js";
30
31
  const HOLD_ON_TIMEOUT = [
31
32
  "linear.workspace",
32
33
  "linear.personal",
@@ -334,6 +335,7 @@ export function createOnboardRuntime(args, ctx, hooks) {
334
335
  : undefined,
335
336
  sleep: hooks.sleep,
336
337
  message: (text) => (hooks.ui ? hooks.ui.message(text) : ctx.stderr(text)),
338
+ autoUpdate: hooks.runnerAutoUpdate ?? (hooks.runnerEngine ? undefined : (dir) => enableRunnerAutoUpdate(ctx, dir)),
337
339
  }),
338
340
  "linear.adopt": onboardWorkflowVerificationAdapter({
339
341
  message: (text) => (hooks.ui ? hooks.ui.message(text) : ctx.stderr(text)),
@@ -0,0 +1,176 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ // The schedule that runs `catalyst runner update --auto` every 15 minutes (CTC-4646): a LaunchAgent
5
+ // on macOS, a systemd user timer on Linux. It runs as the person who set the runner up, with the
6
+ // docker command they use, and only ever calls this CLI. Turning it off removes exactly these files.
7
+ export const AUTO_UPDATE_LABEL = "dev.catalystcloud.runner-update";
8
+ export const AUTO_UPDATE_UNIT = "catalyst-runner-update";
9
+ export const AUTO_UPDATE_INTERVAL_SECONDS = 900;
10
+ /** The minutes past each hour the macOS schedule runs at: every 15 minutes. */
11
+ const CALENDAR_MINUTES = [0, 15, 30, 45];
12
+ /** `su` and many ssh sessions leave XDG_RUNTIME_DIR unset, so `systemctl --user` cannot find a
13
+ * running user manager. Its socket is at /run/user/<uid>; use that when it exists. */
14
+ function schedulerEnv() {
15
+ const uid = process.getuid?.();
16
+ const runtime = uid === undefined ? undefined : `/run/user/${uid}`;
17
+ return !process.env.XDG_RUNTIME_DIR && runtime && existsSync(runtime)
18
+ ? { ...process.env, XDG_RUNTIME_DIR: runtime }
19
+ : process.env;
20
+ }
21
+ const defaultRun = (cmd, args) => {
22
+ const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 15_000, env: schedulerEnv() });
23
+ return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.error ? r.error.message : (r.stderr ?? "") };
24
+ };
25
+ const xml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
26
+ /** systemd's own quoting for one ExecStart/Environment word. */
27
+ const unitWord = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
28
+ export function launchAgentPath(home) {
29
+ return join(home, "Library", "LaunchAgents", `${AUTO_UPDATE_LABEL}.plist`);
30
+ }
31
+ export function systemdUnitPaths(home) {
32
+ const base = join(home, ".config", "systemd", "user");
33
+ return { service: join(base, `${AUTO_UPDATE_UNIT}.service`), timer: join(base, `${AUTO_UPDATE_UNIT}.timer`) };
34
+ }
35
+ export function launchAgentPlist(deps, dir) {
36
+ const args = [deps.cli, "runner", "update", "--auto", "--dir", dir];
37
+ return [
38
+ '<?xml version="1.0" encoding="UTF-8"?>',
39
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
40
+ '<plist version="1.0"><dict>',
41
+ `<key>Label</key><string>${AUTO_UPDATE_LABEL}</string>`,
42
+ `<key>ProgramArguments</key><array>${args.map((a) => `<string>${xml(a)}</string>`).join("")}</array>`,
43
+ `<key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(deps.path)}</string><key>HOME</key><string>${xml(deps.home)}</string></dict>`,
44
+ // A calendar schedule, not StartInterval: on a busy Mac launchd held a StartInterval job as
45
+ // "pended nondemand spawn" for hours (mini-2, 2026-10-17), and RunAtLoad was pended the same
46
+ // way. The calendar entries fired on time on that same machine.
47
+ `<key>StartCalendarInterval</key><array>${CALENDAR_MINUTES.map((m) => `<dict><key>Minute</key><integer>${m}</integer></dict>`).join("")}</array>`,
48
+ "<key>ProcessType</key><string>Standard</string>",
49
+ `<key>StandardOutPath</key><string>${xml(join(dir, "update.log"))}</string>`,
50
+ `<key>StandardErrorPath</key><string>${xml(join(dir, "update.log"))}</string>`,
51
+ "</dict></plist>",
52
+ "",
53
+ ].join("\n");
54
+ }
55
+ export function systemdUnits(deps, dir) {
56
+ return {
57
+ service: [
58
+ "[Unit]",
59
+ "Description=Catalyst runner update",
60
+ "",
61
+ "[Service]",
62
+ "Type=oneshot",
63
+ `Environment=${unitWord(`PATH=${deps.path}`)}`,
64
+ `ExecStart=${[deps.cli, "runner", "update", "--auto", "--dir", dir].map(unitWord).join(" ")}`,
65
+ "",
66
+ ].join("\n"),
67
+ timer: [
68
+ "[Unit]",
69
+ "Description=Check for an approved Catalyst runner release every 15 minutes",
70
+ "",
71
+ "[Timer]",
72
+ // Wall-clock, not OnBootSec/OnUnitActiveSec: those are relative to boot and to the last run in
73
+ // THIS user manager, so a manager that restarts without a reboot (enabling linger, a logout and
74
+ // login) left the timer "elapsed" with no next run, forever (fresh Ubuntu 24.04, 2026-10-17).
75
+ `OnCalendar=*:0/${AUTO_UPDATE_INTERVAL_SECONDS / 60}`,
76
+ "RandomizedDelaySec=60",
77
+ "Persistent=true",
78
+ "",
79
+ "[Install]",
80
+ "WantedBy=timers.target",
81
+ "",
82
+ ].join("\n"),
83
+ };
84
+ }
85
+ function writeFileAtomic(path, text) {
86
+ mkdirSync(dirname(path), { recursive: true });
87
+ const temp = `${path}.${process.pid}.tmp`;
88
+ writeFileSync(temp, text, { mode: 0o644 });
89
+ renameSync(temp, path);
90
+ }
91
+ export function autoUpdateStatus(deps) {
92
+ const platform = deps.platform ?? process.platform;
93
+ const run = deps.run ?? defaultRun;
94
+ if (platform === "darwin") {
95
+ const path = launchAgentPath(deps.home);
96
+ if (!existsSync(path))
97
+ return { state: "off", detail: "no_launch_agent" };
98
+ const loaded = run("launchctl", ["print", `gui/${deps.uid ?? process.getuid?.() ?? 0}/${AUTO_UPDATE_LABEL}`]).status === 0;
99
+ return loaded ? { state: "on", detail: "launch_agent_loaded", path } : { state: "off", detail: "launch_agent_not_loaded" };
100
+ }
101
+ if (platform === "linux") {
102
+ const { timer } = systemdUnitPaths(deps.home);
103
+ if (!existsSync(timer))
104
+ return { state: "off", detail: "no_timer" };
105
+ const enabled = run("systemctl", ["--user", "is-enabled", `${AUTO_UPDATE_UNIT}.timer`]);
106
+ return enabled.status === 0 ? { state: "on", detail: "timer_enabled", path: timer } : { state: "off", detail: "timer_not_enabled" };
107
+ }
108
+ return { state: "unsupported", detail: `platform_${platform}` };
109
+ }
110
+ /** Install and start the schedule. Rewrites the files every time, so a moved CLI or PATH is picked up. */
111
+ export function enableAutoUpdate(deps, dir) {
112
+ const platform = deps.platform ?? process.platform;
113
+ const run = deps.run ?? defaultRun;
114
+ if (platform === "darwin") {
115
+ const uid = deps.uid ?? process.getuid?.() ?? 0;
116
+ const path = launchAgentPath(deps.home);
117
+ const text = launchAgentPlist(deps, dir);
118
+ const unchanged = existsSync(path) && readFileSync(path, "utf8") === text;
119
+ const domain = `gui/${uid}`;
120
+ const loaded = run("launchctl", ["print", `${domain}/${AUTO_UPDATE_LABEL}`]).status === 0;
121
+ if (unchanged && loaded)
122
+ return { state: "on", detail: "launch_agent_loaded", path };
123
+ if (loaded)
124
+ run("launchctl", ["bootout", `${domain}/${AUTO_UPDATE_LABEL}`]);
125
+ writeFileAtomic(path, text);
126
+ const boot = run("launchctl", ["bootstrap", domain, path]);
127
+ return boot.status === 0
128
+ ? { state: "on", detail: "launch_agent_loaded", path }
129
+ : { state: "off", detail: `launchctl_bootstrap_failed:${boot.stderr.trim().slice(0, 160)}` };
130
+ }
131
+ if (platform === "linux") {
132
+ if (run("systemctl", ["--user", "show-environment"]).status !== 0)
133
+ return { state: "unsupported", detail: "no_systemd_user_manager" };
134
+ const paths = systemdUnitPaths(deps.home);
135
+ const units = systemdUnits(deps, dir);
136
+ writeFileAtomic(paths.service, units.service);
137
+ writeFileAtomic(paths.timer, units.timer);
138
+ run("systemctl", ["--user", "daemon-reload"]);
139
+ const enabled = run("systemctl", ["--user", "enable", "--now", `${AUTO_UPDATE_UNIT}.timer`]);
140
+ if (enabled.status !== 0)
141
+ return { state: "off", detail: `systemctl_enable_failed:${enabled.stderr.trim().slice(0, 160)}` };
142
+ // Without lingering, a user timer stops when this person logs out of a headless machine.
143
+ const linger = run("loginctl", ["show-user", String(deps.uid ?? process.getuid?.() ?? 0), "--property=Linger"]);
144
+ return { state: "on", detail: /Linger=yes/.test(linger.stdout) ? "timer_enabled" : "timer_enabled_without_linger", path: paths.timer };
145
+ }
146
+ return { state: "unsupported", detail: `platform_${platform}` };
147
+ }
148
+ /** Stop and remove the schedule's own files. Nothing else. */
149
+ export function disableAutoUpdate(deps) {
150
+ const platform = deps.platform ?? process.platform;
151
+ const run = deps.run ?? defaultRun;
152
+ if (platform === "darwin") {
153
+ const uid = deps.uid ?? process.getuid?.() ?? 0;
154
+ run("launchctl", ["bootout", `gui/${uid}/${AUTO_UPDATE_LABEL}`]);
155
+ const path = launchAgentPath(deps.home);
156
+ if (existsSync(path))
157
+ unlinkSync(path);
158
+ return { state: "off", detail: "launch_agent_removed" };
159
+ }
160
+ if (platform === "linux") {
161
+ run("systemctl", ["--user", "disable", "--now", `${AUTO_UPDATE_UNIT}.timer`]);
162
+ const paths = systemdUnitPaths(deps.home);
163
+ for (const path of [paths.timer, paths.service])
164
+ if (existsSync(path))
165
+ unlinkSync(path);
166
+ run("systemctl", ["--user", "daemon-reload"]);
167
+ return { state: "off", detail: "timer_removed" };
168
+ }
169
+ return { state: "unsupported", detail: `platform_${platform}` };
170
+ }
171
+ /** PATH for the scheduled run: the CLI's and node's directories first, then the usual docker homes. */
172
+ export function scheduledPath(cli, nodeExecutable, current) {
173
+ const dirs = [dirname(cli), dirname(nodeExecutable), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin",
174
+ ...(current ?? "").split(":")];
175
+ return [...new Set(dirs.filter((d) => d.startsWith("/")))].join(":");
176
+ }
@@ -0,0 +1,73 @@
1
+ import { isAbsolute, resolve } from "node:path";
2
+ import { flagBool, flagString, positionals } from "./args.js";
3
+ import { UsageError } from "./errors.js";
4
+ import { autoUpdateStatus, disableAutoUpdate, enableAutoUpdate, scheduledPath, } from "./runner-auto-update.js";
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]";
7
+ function schedulerDeps(ctx, deps) {
8
+ const cli = deps.scheduler?.cli ?? resolve(process.argv[1] ?? "catalyst");
9
+ return {
10
+ home: ctx.home,
11
+ cli,
12
+ path: scheduledPath(cli, process.execPath, ctx.env.PATH),
13
+ ...deps.scheduler,
14
+ };
15
+ }
16
+ function statusLine(status) {
17
+ if (status.state === "on")
18
+ return status.detail === "timer_enabled_without_linger"
19
+ ? "Automatic runner updates are on, every 15 minutes while you are logged in. To keep them running after you log out, run: sudo loginctl enable-linger $USER"
20
+ : "Automatic runner updates are on: this machine checks for an approved release every 15 minutes.";
21
+ if (status.state === "off")
22
+ return `Automatic runner updates are off (${status.detail}). Turn them on with: catalyst runner auto-update on`;
23
+ if (status.detail === "no_systemd_user_manager")
24
+ return "Automatic runner updates need your systemd user manager, which is not running for you. Run: sudo loginctl enable-linger $USER, then catalyst runner auto-update on";
25
+ return `Automatic runner updates are not available on this machine (${status.detail}). Run catalyst runner update yourself.`;
26
+ }
27
+ /** Setup's call: turn the schedule on for `dir` and say what happened in one line. */
28
+ export function enableRunnerAutoUpdate(ctx, dir) {
29
+ return statusLine(enableAutoUpdate(schedulerDeps(ctx, {}), dir));
30
+ }
31
+ export async function cmdRunner(args, ctx, deps = {}) {
32
+ const parts = positionals(args);
33
+ const action = parts[0];
34
+ const dirFlag = flagString(args, "dir");
35
+ if (dirFlag !== undefined && !isAbsolute(dirFlag))
36
+ throw new UsageError("--dir must be an absolute path");
37
+ const dir = () => dirFlag ?? runnerDir(ctx);
38
+ if (action === "update" && parts.length === 1) {
39
+ const check = flagBool(args, "check");
40
+ const auto = flagBool(args, "auto");
41
+ const engine = deps.engine ?? dockerRunnerUpdateEngine({ env: ctx.env });
42
+ const result = await runRunnerUpdate(engine, { dir: dir(), check });
43
+ if (args.json)
44
+ ctx.stdout(JSON.stringify(result));
45
+ else if (auto)
46
+ ctx.stdout(`${ctx.now().toISOString()} ${result.outcome} ${result.detail} generation=${result.generation ?? "none"}`);
47
+ else
48
+ for (const line of updateResultLines(result))
49
+ ctx.stdout(line);
50
+ return result.exitCode;
51
+ }
52
+ if (action === "auto-update" && parts.length <= 2) {
53
+ const mode = parts[1] ?? "status";
54
+ const scheduler = schedulerDeps(ctx, deps);
55
+ let status;
56
+ if (mode === "on")
57
+ status = enableAutoUpdate(scheduler, dir());
58
+ else if (mode === "off")
59
+ status = disableAutoUpdate(scheduler);
60
+ else if (mode === "status")
61
+ status = autoUpdateStatus(scheduler);
62
+ else
63
+ throw new UsageError(USAGE);
64
+ if (args.json) {
65
+ const state = mode === "status" && dirFlag !== undefined ? readReleaseState(dirFlag) : undefined;
66
+ ctx.stdout(JSON.stringify({ ...status, ...(state ? { release: state } : {}) }));
67
+ }
68
+ else
69
+ ctx.stdout(statusLine(status));
70
+ return status.state === "on" || mode !== "on" ? 0 : 1;
71
+ }
72
+ throw new UsageError(USAGE);
73
+ }
@@ -0,0 +1,506 @@
1
+ import { closeSync, existsSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CliError } from "./errors.js";
4
+ import { onboardStateRoot } from "./onboard.js";
5
+ import { dockerRunnerEngine, parseEnvText, persistedCustody, prepareRunnerImages, readEnvFile, runnerComposeArgs, spawnDocker, } from "./onboard-runner.js";
6
+ // `catalyst runner update`: move this machine's self-hosted runner to the release Catalyst approved
7
+ // (CTC-4646). The cloud names one approved runner, supervisor and watchdog by immutable digest with
8
+ // a monotonic generation (catalyst-cloud CTC-5926, GET /api/v1/hosts/desired-release, read with the
9
+ // host's own credential). This command reads it from inside a one-off supervisor container, so the
10
+ // host secret never leaves Docker, then moves only when every check passes:
11
+ //
12
+ // - the release parses strictly: three digests from the three Catalyst repositories, never a
13
+ // tag or `latest`, and a generation newer than the one this machine last applied;
14
+ // - no phase container exists for this host;
15
+ // - all three images are pulled by digest, natively for this engine, before anything changes;
16
+ // - on a Mac with private thoughts storage, that storage is re-attested on the new supervisor;
17
+ // - with the supervisor stopped, so it admits no new work, there is still no phase container.
18
+ // If one started in between, the supervisor comes back as it was and the update waits.
19
+ //
20
+ // Then it rewrites the three image lines in `.env`, keeps the previous file beside it, and runs
21
+ // `docker compose up`. The release counts as applied only when the running containers are its
22
+ // images and the supervisor logged `host_ready` since it started; a `.env` that already names the
23
+ // release is reconciled the same way, never trusted. Otherwise it restores the previous file, the
24
+ // thoughts storage attestation and the previous images. The outcome is kept in
25
+ // `release-state.json` for `--check` and status readers.
26
+ export const RELEASE_STATE_FILE = "release-state.json";
27
+ export const PREVIOUS_ENV_FILE = ".env.release-previous";
28
+ const LOCK_FILE = "update.lock";
29
+ const READY_DEADLINE_MS = 5 * 60_000;
30
+ const READY_POLL_MS = 5_000;
31
+ const ATTEMPT_HOST_LABEL = "dev.catalystcloud.attempt.host-id";
32
+ const DESIRED_RELEASE_PATH = "/api/v1/hosts/desired-release";
33
+ export const RELEASE_COMPONENTS = ["runner", "supervisor", "watchdog"];
34
+ /** The only repositories a release may name, exactly as catalyst-cloud's HOST_RELEASE_REPOSITORIES. */
35
+ export const RELEASE_REPOSITORIES = {
36
+ runner: "ghcr.io/coalesce-labs/catalyst-runner",
37
+ supervisor: "ghcr.io/coalesce-labs/catalyst-supervisor",
38
+ watchdog: "ghcr.io/coalesce-labs/catalyst-deadline-watchdog",
39
+ };
40
+ export const RELEASE_ENV_KEYS = {
41
+ runner: "CATALYST_RUNNER_IMAGE",
42
+ supervisor: "CATALYST_SUPERVISOR_IMAGE",
43
+ watchdog: "CATALYST_WATCHDOG_IMAGE",
44
+ };
45
+ const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
46
+ const posInt = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
47
+ const DIGEST = /^sha256:[0-9a-f]{64}$/;
48
+ const SHA40 = /^[0-9a-f]{40}$/;
49
+ /** A reference this machine may pull for `component`: its repository, at a digest, with no tag. */
50
+ export function releaseReference(component, value) {
51
+ if (typeof value !== "string" || value.length > 300)
52
+ return null;
53
+ const at = value.indexOf("@");
54
+ if (at === -1 || value.slice(0, at) !== RELEASE_REPOSITORIES[component])
55
+ return null;
56
+ return DIGEST.test(value.slice(at + 1)) ? value : null;
57
+ }
58
+ const shortDigest = (reference) => reference.slice(reference.indexOf("@sha256:") + 8).slice(0, 16);
59
+ /** The host-readable release, parsed as strictly as catalyst-cloud's parseHostDesiredRelease. */
60
+ export function parseDesiredRelease(input) {
61
+ if (!isRecord(input) || input.version !== 1)
62
+ return null;
63
+ if (typeof input.releaseId !== "string" || !posInt(input.generation) || !posInt(input.approvedAtMs))
64
+ return null;
65
+ const images = {};
66
+ for (const component of RELEASE_COMPONENTS) {
67
+ const value = input[component];
68
+ if (!isRecord(value))
69
+ return null;
70
+ const reference = releaseReference(component, value.reference);
71
+ if (!reference || value.digest !== reference.slice(reference.indexOf("@") + 1))
72
+ return null;
73
+ if (typeof value.sourceSha !== "string" || !SHA40.test(value.sourceSha))
74
+ return null;
75
+ const tag = `${RELEASE_REPOSITORIES[component]}:${value.sourceSha}${component === "runner" ? "-multiarch" : ""}`;
76
+ if (value.tag !== tag)
77
+ return null;
78
+ images[component] = { reference, sourceSha: value.sourceSha };
79
+ }
80
+ const id = `hr-${shortDigest(images.runner.reference)}-${shortDigest(images.supervisor.reference)}-${shortDigest(images.watchdog.reference)}`;
81
+ if (input.releaseId !== id)
82
+ return null;
83
+ return { releaseId: id, generation: input.generation, approvedAtMs: input.approvedAtMs, images };
84
+ }
85
+ /** Whether to move. Every answer except a valid, strictly newer generation keeps the current images. */
86
+ export function decideRelease(appliedGeneration, read) {
87
+ const retain = (reason, terminal = false) => ({ action: "retain", reason, terminal });
88
+ if (read.kind === "no_credential")
89
+ return retain("no_credential");
90
+ if (read.kind === "unreachable")
91
+ return retain("read_unreachable");
92
+ if (read.status === 401 || read.status === 403)
93
+ return retain("credential_refused", isRecord(read.body) && read.body.refusal === "host_revoked" && read.body.terminal === true);
94
+ if (read.status !== 200)
95
+ return retain("release_unavailable");
96
+ const release = parseDesiredRelease(isRecord(read.body) && read.body.ok === true ? read.body.release : null);
97
+ if (!release)
98
+ return retain("response_invalid");
99
+ if (appliedGeneration !== null && release.generation < appliedGeneration)
100
+ return retain("not_newer");
101
+ if (appliedGeneration !== null && release.generation === appliedGeneration)
102
+ return { action: "verify", release };
103
+ return { action: "update", release };
104
+ }
105
+ export function readReleaseState(dir) {
106
+ const empty = { version: 1, generation: null, releaseId: null, appliedAtMs: null, previous: null, last: null };
107
+ try {
108
+ const parsed = JSON.parse(readFileSync(join(dir, RELEASE_STATE_FILE), "utf8"));
109
+ if (!isRecord(parsed) || parsed.version !== 1)
110
+ return empty;
111
+ return {
112
+ ...empty,
113
+ generation: posInt(parsed.generation) ? parsed.generation : null,
114
+ releaseId: typeof parsed.releaseId === "string" ? parsed.releaseId : null,
115
+ appliedAtMs: posInt(parsed.appliedAtMs) ? parsed.appliedAtMs : null,
116
+ previous: isRecord(parsed.previous) ? parsed.previous : null,
117
+ last: isRecord(parsed.last) ? parsed.last : null,
118
+ };
119
+ }
120
+ catch {
121
+ return empty;
122
+ }
123
+ }
124
+ /** Write a private file by rename, so a reader never sees half of it. */
125
+ function writePrivateAtomic(path, text) {
126
+ const temp = `${path}.${process.pid}.tmp`;
127
+ writeFileSync(temp, text, { mode: 0o600 });
128
+ renameSync(temp, path);
129
+ }
130
+ function writeReleaseState(dir, state) {
131
+ writePrivateAtomic(join(dir, RELEASE_STATE_FILE), `${JSON.stringify(state, null, 2)}\n`);
132
+ }
133
+ /** `.env` with each `KEY=` line for `values` replaced in place (or appended), every other line kept. */
134
+ export function rewriteEnvText(text, values) {
135
+ const pending = new Map(Object.entries(values));
136
+ const lines = text.split("\n").map((line) => {
137
+ const key = /^([A-Z][A-Z0-9_]*)=/.exec(line)?.[1];
138
+ if (key === undefined || !pending.has(key))
139
+ return line;
140
+ const value = pending.get(key);
141
+ pending.delete(key);
142
+ return `${key}=${value}`;
143
+ });
144
+ if (pending.size > 0) {
145
+ if (lines.length > 0 && lines[lines.length - 1] === "")
146
+ lines.pop();
147
+ for (const [key, value] of pending)
148
+ lines.push(`${key}=${value}`);
149
+ lines.push("");
150
+ }
151
+ return lines.join("\n");
152
+ }
153
+ /** One serialized update run. A second concurrent run returns `retained` with `update_running`. */
154
+ export async function runRunnerUpdate(engine, options) {
155
+ const { dir } = options;
156
+ const now = options.now ?? Date.now;
157
+ const envPath = join(dir, ".env");
158
+ if (!existsSync(envPath) || !existsSync(join(dir, "compose.yaml")))
159
+ return { outcome: "failed", detail: "runner_not_installed", generation: null, exitCode: 1 };
160
+ const lock = options.check ? null : takeLock(dir, now());
161
+ if (!options.check && lock === null)
162
+ return { outcome: "retained", detail: "update_running", generation: readReleaseState(dir).generation, exitCode: 0 };
163
+ try {
164
+ const result = await update(engine, options, now);
165
+ if (!options.check) {
166
+ const state = readReleaseState(dir);
167
+ writeReleaseState(dir, { ...state, last: { atMs: now(), outcome: result.outcome, detail: result.detail, generation: result.generation } });
168
+ }
169
+ return result;
170
+ }
171
+ finally {
172
+ if (lock !== null)
173
+ releaseLock(dir, lock);
174
+ }
175
+ }
176
+ const imagesOf = (env) => Object.fromEntries(RELEASE_COMPONENTS.map((c) => [c, env[RELEASE_ENV_KEYS[c]] ?? ""]));
177
+ /** The running containers are these images, and the supervisor reported ready since it started. */
178
+ async function running(engine, dir, images, signal) {
179
+ const live = await engine.services(dir, signal);
180
+ if (!live?.supervisor?.running || live.supervisor.image !== images.supervisor)
181
+ return false;
182
+ if (!live.watchdog?.running || live.watchdog.image !== images.watchdog)
183
+ return false;
184
+ return engine.supervisorReadySinceStart(dir, signal);
185
+ }
186
+ async function update(engine, options, now) {
187
+ const { dir, signal } = options;
188
+ const envPath = join(dir, ".env");
189
+ const state = readReleaseState(dir);
190
+ const done = (outcome, detail, extra = {}) => ({ outcome, detail, generation: state.generation, exitCode: 0, ...extra });
191
+ const read = await engine.desiredRelease(dir, signal);
192
+ const decision = decideRelease(state.generation, read);
193
+ if (decision.action === "retain")
194
+ return decision.reason === "not_newer"
195
+ ? done("current", "not_newer")
196
+ : done("retained", decision.reason, { exitCode: decision.reason === "credential_refused" || decision.reason === "no_credential" ? 1 : 0 });
197
+ const { release } = decision;
198
+ const target = Object.fromEntries(RELEASE_COMPONENTS.map((c) => [c, release.images[c].reference]));
199
+ const envText = readFileSync(envPath, "utf8");
200
+ const env = readEnvFile(envPath) ?? {};
201
+ const current = imagesOf(env);
202
+ const changed = RELEASE_COMPONENTS.filter((c) => current[c] !== target[c]);
203
+ // Already on this generation: cheap check that `.env` and the running containers are its images
204
+ // (no log read). A match is current; any drift is moved back below like any other update.
205
+ if (decision.action === "verify" && changed.length === 0) {
206
+ const live = await engine.services(dir, signal);
207
+ if (live?.supervisor?.running && live.supervisor.image === target.supervisor && live.watchdog?.image === target.watchdog)
208
+ return done("current", "not_newer");
209
+ }
210
+ const tagged = { releaseId: release.releaseId };
211
+ const record = (outcome, detail, previous) => {
212
+ writeReleaseState(dir, { ...state, generation: release.generation, releaseId: release.releaseId, appliedAtMs: now(), previous });
213
+ return { outcome, detail, generation: release.generation, exitCode: 0, ...tagged };
214
+ };
215
+ if (options.check)
216
+ return done("planned", changed.length > 0 ? `would_update:${changed.join(",")}` : "already_configured", { ...tagged, generation: release.generation });
217
+ // `.env` naming the release proves nothing on its own: an earlier run may have stopped after writing
218
+ // it. Only the running containers, ready, prove the release is applied.
219
+ if (changed.length === 0 && (await running(engine, dir, target, signal)))
220
+ return record("recorded", "already_running_release", state.previous);
221
+ // What a failure goes back to: today's file, or for a half-applied file the one saved before it.
222
+ let rollbackText = envText;
223
+ if (changed.length === 0) {
224
+ try {
225
+ rollbackText = readFileSync(join(dir, PREVIOUS_ENV_FILE), "utf8");
226
+ }
227
+ catch {
228
+ rollbackText = null;
229
+ }
230
+ }
231
+ const rollbackImages = rollbackText === null ? null : imagesOf(parseEnvText(rollbackText));
232
+ const hostId = read.kind === "answered" ? read.hostId : "";
233
+ const busy = await engine.attemptContainers(dir, hostId, signal);
234
+ if (busy === null)
235
+ return done("retained", "docker_unavailable");
236
+ if (busy > 0)
237
+ return done("deferred_busy", `attempt_containers:${busy}`, tagged);
238
+ const unprepared = await engine.prepare(RELEASE_COMPONENTS.map((c) => target[c]), signal);
239
+ if (unprepared !== null)
240
+ return done("retained", unprepared, tagged);
241
+ // Mac private thoughts storage records the supervisor image its helper runs. Every path that does
242
+ // not end on the new release attests it back onto the images the machine keeps.
243
+ const custodyInstalled = persistedCustody(dir, env) === "installed";
244
+ const restoreCustody = async (images) => !custodyInstalled || images === null || (await engine.custody(dir, images, signal)) === env.CATALYST_THOUGHTS_CUSTODY_AUTHORITY;
245
+ if (custodyInstalled) {
246
+ const authority = await engine.custody(dir, target, signal);
247
+ // Re-attestation keeps the installed authority. A different one would need setup, not an update.
248
+ if (authority === null || authority !== env.CATALYST_THOUGHTS_CUSTODY_AUTHORITY) {
249
+ const restored = await restoreCustody(rollbackImages ?? current);
250
+ return done(restored ? "retained" : "failed", restored ? "custody_reattest_failed" : "custody_restore_failed", { ...tagged, exitCode: restored ? 0 : 1 });
251
+ }
252
+ }
253
+ // The boundary between phases: stop the supervisor so it admits nothing, then look again. A phase
254
+ // that started since the first look keeps running, the supervisor comes back, and the update waits.
255
+ if (!(await engine.stopSupervisor(dir, signal))) {
256
+ await engine.composeUp(dir, signal);
257
+ await restoreCustody(rollbackImages ?? current);
258
+ return done("retained", "supervisor_stop_failed", tagged);
259
+ }
260
+ const busyAfterStop = await engine.attemptContainers(dir, hostId, signal);
261
+ if (busyAfterStop !== 0) {
262
+ await engine.composeUp(dir, signal);
263
+ await restoreCustody(rollbackImages ?? current);
264
+ return busyAfterStop === null
265
+ ? done("retained", "docker_unavailable", tagged)
266
+ : done("deferred_busy", `attempt_containers:${busyAfterStop}`, tagged);
267
+ }
268
+ // From here the machine changes. The previous file is the rollback.
269
+ if (changed.length > 0)
270
+ writePrivateAtomic(join(dir, PREVIOUS_ENV_FILE), envText);
271
+ writePrivateAtomic(envPath, rewriteEnvText(envText, Object.fromEntries(RELEASE_COMPONENTS.map((c) => [RELEASE_ENV_KEYS[c], target[c]]))));
272
+ if ((await engine.composeUp(dir, signal)) && (await waitReady(engine, dir, target, options, now))) {
273
+ // Only once the new release is running and ready: the replaced images free their disk (a runner
274
+ // image is several GB, against a free-space floor that decides whether the machine takes work).
275
+ // A rollback or failure never reaches here, so the previous images stay for it.
276
+ if (changed.length > 0)
277
+ await engine.removeImages(changed.map((c) => current[c]).filter((ref) => ref !== ""), signal);
278
+ return record("updated", changed.length > 0 ? `updated:${changed.join(",")}` : "reconciled", changed.length > 0 ? current : state.previous);
279
+ }
280
+ if (rollbackText === null || rollbackImages === null)
281
+ return done("failed", "new_supervisor_not_ready_no_previous", { ...tagged, exitCode: 1 });
282
+ writePrivateAtomic(envPath, rollbackText);
283
+ const custodyBack = await restoreCustody(rollbackImages);
284
+ const restored = (await engine.composeUp(dir, signal)) && (await waitReady(engine, dir, rollbackImages, options, now));
285
+ return done(restored && custodyBack ? "rolled_back" : "failed", !restored ? "rollback_not_ready" : custodyBack ? "new_supervisor_not_ready" : "custody_restore_failed", { ...tagged, exitCode: 1 });
286
+ }
287
+ async function waitReady(engine, dir, images, options, now) {
288
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
289
+ const deadline = now() + (options.readyDeadlineMs ?? READY_DEADLINE_MS);
290
+ for (;;) {
291
+ if (options.signal?.aborted)
292
+ return false;
293
+ if (await running(engine, dir, images, options.signal))
294
+ return true;
295
+ if (now() >= deadline)
296
+ return false;
297
+ await sleep(READY_POLL_MS);
298
+ }
299
+ }
300
+ const alive = (pid) => {
301
+ try {
302
+ process.kill(pid, 0);
303
+ return true;
304
+ }
305
+ catch (error) {
306
+ // EPERM: the process exists but belongs to someone else.
307
+ return error.code === "EPERM";
308
+ }
309
+ };
310
+ /** An exclusive lock file holding the owner's pid. It is taken over only when that process is gone,
311
+ * never by age: one run can legitimately take longer than any fixed limit (three image pulls). */
312
+ function takeLock(dir, nowMs) {
313
+ const path = join(dir, LOCK_FILE);
314
+ const token = `${process.pid} ${nowMs}\n`;
315
+ for (let attempt = 0; attempt < 2; attempt++) {
316
+ try {
317
+ const fd = openSync(path, "wx", 0o600);
318
+ writeFileSync(fd, token);
319
+ closeSync(fd);
320
+ return token;
321
+ }
322
+ catch {
323
+ try {
324
+ const owner = Number(readFileSync(path, "utf8").split(" ")[0]);
325
+ if (Number.isSafeInteger(owner) && owner > 0 && alive(owner))
326
+ return null;
327
+ unlinkSync(path);
328
+ }
329
+ catch {
330
+ return null;
331
+ }
332
+ }
333
+ }
334
+ return null;
335
+ }
336
+ function releaseLock(dir, token) {
337
+ const path = join(dir, LOCK_FILE);
338
+ try {
339
+ if (readFileSync(path, "utf8") === token)
340
+ unlinkSync(path);
341
+ }
342
+ catch {
343
+ // Already gone.
344
+ }
345
+ }
346
+ /** Runs inside the supervisor image with its own environment. Prints one JSON object, never the
347
+ * secret: the bearer is built and sent from inside the container. */
348
+ const DESIRED_RELEASE_SCRIPT = `(async () => {
349
+ const out = (v) => process.stdout.write(JSON.stringify(v));
350
+ let state;
351
+ try { state = JSON.parse(require("node:fs").readFileSync(process.env.CATALYST_HOST_CREDENTIAL_FILE, "utf8")); }
352
+ catch { return out({ kind: "no_credential" }); }
353
+ const e = state && state.enrollment;
354
+ const base = process.env.CATALYST_MIRROR_URL;
355
+ if (!state || state.version !== 1 || typeof state.secret !== "string" || !e ||
356
+ typeof e.tenant !== "string" || typeof e.hostId !== "string" || !base) return out({ kind: "no_credential" });
357
+ const tenant = Buffer.from(e.tenant, "utf8").toString("base64url");
358
+ try {
359
+ const response = await fetch(new URL(${JSON.stringify(DESIRED_RELEASE_PATH)}, base).href, {
360
+ redirect: "error", signal: AbortSignal.timeout(15000),
361
+ headers: { authorization: "Bearer ctc_host_" + tenant + "." + e.hostId + "." + state.secret, accept: "application/json" },
362
+ });
363
+ let body = null;
364
+ try { body = await response.json(); } catch {}
365
+ out({ kind: "answered", status: response.status, body, hostId: e.hostId });
366
+ } catch (error) {
367
+ out({ kind: "unreachable", detail: String((error && error.name) || "fetch failed"), hostId: e.hostId });
368
+ }
369
+ })();`;
370
+ const HOST_ID = /^[A-Za-z0-9._:-]{1,128}$/;
371
+ /** The real engine: the docker CLI and the setup engine's own pull, custody and Compose steps. */
372
+ export function dockerRunnerUpdateEngine(deps = {}) {
373
+ const exec = deps.exec ?? spawnDocker;
374
+ const env = Object.fromEntries(Object.entries(deps.env ?? process.env)
375
+ .filter(([key]) => !key.startsWith("CATALYST_") && !key.startsWith("DOCKER_SOCKET_")));
376
+ const setup = deps.setup ?? dockerRunnerEngine({ exec, env: deps.env });
377
+ const docker = async (args, options = {}) => {
378
+ try {
379
+ return await exec(args, { env, ...options });
380
+ }
381
+ catch {
382
+ return { code: 1, stdout: "" };
383
+ }
384
+ };
385
+ return {
386
+ async desiredRelease(dir, signal) {
387
+ const compose = runnerComposeArgs(dir);
388
+ if (!compose)
389
+ return { kind: "no_credential" };
390
+ const read = await docker([...compose, "run", "--rm", "-T", "--no-deps", "--entrypoint", "bun", "supervisor", "-e", DESIRED_RELEASE_SCRIPT], { cwd: dir, signal, timeoutMs: 120_000 });
391
+ if (read.code !== 0)
392
+ return { kind: "no_credential" };
393
+ try {
394
+ const parsed = JSON.parse(read.stdout);
395
+ if (!isRecord(parsed))
396
+ return { kind: "no_credential" };
397
+ const hostId = typeof parsed.hostId === "string" && HOST_ID.test(parsed.hostId) ? parsed.hostId : null;
398
+ if (parsed.kind === "answered" && hostId && typeof parsed.status === "number")
399
+ return { kind: "answered", status: parsed.status, body: parsed.body ?? null, hostId };
400
+ if (parsed.kind === "unreachable" && hostId)
401
+ return { kind: "unreachable", detail: String(parsed.detail ?? "fetch failed").slice(0, 80), hostId };
402
+ return { kind: "no_credential" };
403
+ }
404
+ catch {
405
+ return { kind: "no_credential" };
406
+ }
407
+ },
408
+ async attemptContainers(_dir, hostId, signal) {
409
+ if (!HOST_ID.test(hostId))
410
+ return null;
411
+ const listed = await docker(["ps", "--all", "--quiet", "--filter", `label=${ATTEMPT_HOST_LABEL}=${hostId}`], { signal });
412
+ return listed.code === 0 ? listed.stdout.split("\n").filter((line) => line.trim() !== "").length : null;
413
+ },
414
+ async prepare(images, signal) {
415
+ const info = await setup.info(signal);
416
+ if (!info)
417
+ return "docker_unavailable";
418
+ const refused = await prepareRunnerImages(setup, images, info.arch, signal);
419
+ return refused === null ? null : `${refused.reason}${refused.image ? `:${refused.image}` : ""}`;
420
+ },
421
+ async custody(dir, images, signal) {
422
+ if (!setup.nativeThoughtsCustody)
423
+ return null;
424
+ try {
425
+ const result = await setup.nativeThoughtsCustody({ dir, supervisorImage: images.supervisor, runnerImage: images.runner }, signal);
426
+ return result.envAuthority;
427
+ }
428
+ catch {
429
+ return null;
430
+ }
431
+ },
432
+ async services(dir, signal) {
433
+ const compose = runnerComposeArgs(dir);
434
+ if (!compose)
435
+ return null;
436
+ const service = async (name) => {
437
+ const listed = await docker([...compose, "ps", "--all", "--quiet", name], { cwd: dir, signal });
438
+ if (listed.code !== 0)
439
+ return undefined;
440
+ const id = listed.stdout.trim();
441
+ if (id === "")
442
+ return null;
443
+ if (!/^[0-9a-f]{12,64}$/.test(id))
444
+ return undefined;
445
+ const inspected = await docker(["inspect", "--format", "{{.State.Running}}|{{.Config.Image}}|{{.State.StartedAt}}", id], { signal });
446
+ const [isRunning, image, started] = inspected.stdout.trim().split("|");
447
+ const startedAtMs = Date.parse(started ?? "");
448
+ return inspected.code === 0 && image && Number.isFinite(startedAtMs)
449
+ ? { running: isRunning === "true", image, startedAtMs } : undefined;
450
+ };
451
+ const [supervisor, watchdog] = [await service("supervisor"), await service("deadline-watchdog")];
452
+ return supervisor === undefined || watchdog === undefined ? null : { supervisor, watchdog };
453
+ },
454
+ async stopSupervisor(dir, signal) {
455
+ const compose = runnerComposeArgs(dir);
456
+ if (!compose)
457
+ return false;
458
+ return (await docker([...compose, "stop", "supervisor"], { cwd: dir, signal, timeoutMs: 120_000 })).code === 0;
459
+ },
460
+ async composeUp(dir, signal) {
461
+ return (await setup.composeUp(dir, signal)).ok;
462
+ },
463
+ async removeImages(images, signal) {
464
+ for (const ref of images)
465
+ await docker(["image", "rm", ref], { signal, timeoutMs: 120_000 });
466
+ },
467
+ async supervisorReadySinceStart(dir, signal) {
468
+ const compose = runnerComposeArgs(dir);
469
+ if (!compose)
470
+ return false;
471
+ const id = (await docker([...compose, "ps", "--quiet", "supervisor"], { cwd: dir, signal })).stdout.trim();
472
+ if (!/^[0-9a-f]{12,64}$/.test(id))
473
+ return false;
474
+ const started = (await docker(["inspect", "--format", "{{.State.StartedAt}}", id], { signal })).stdout.trim();
475
+ if (!Number.isFinite(Date.parse(started)))
476
+ return false;
477
+ const logs = await docker(["logs", "--since", started, id], { signal, timeoutMs: 30_000 });
478
+ return `${logs.stdout}\n${logs.stderr ?? ""}`.includes('"event":"host_ready"');
479
+ },
480
+ };
481
+ }
482
+ /** The runner directory setup uses. */
483
+ export function runnerDir(ctx) {
484
+ try {
485
+ return join(onboardStateRoot(ctx.home, ctx.env), "runner");
486
+ }
487
+ catch {
488
+ throw new CliError("The runner directory could not be found. Run catalyst setup --runner first.", "runner_directory_unavailable", 1);
489
+ }
490
+ }
491
+ const OUTCOME_TEXT = {
492
+ current: "The runner is on the approved release.",
493
+ recorded: "The runner was already on the approved release; recorded it.",
494
+ updated: "The runner moved to the approved release.",
495
+ planned: "Checked the approved release; nothing was changed.",
496
+ deferred_busy: "A phase is running here, so the update waits for the next check.",
497
+ retained: "Kept the current images.",
498
+ rolled_back: "The new supervisor did not become ready, so the previous images were restored.",
499
+ failed: "The update could not finish. Look at the runner with docker compose ps.",
500
+ };
501
+ export function updateResultLines(result) {
502
+ return [
503
+ OUTCOME_TEXT[result.outcome],
504
+ `Release: ${result.releaseId ?? "none"}, generation ${result.generation ?? "none"} (${result.detail}).`,
505
+ ];
506
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/cli",
3
- "version": "0.16.16",
3
+ "version": "0.16.18",
4
4
  "description": "Set up Catalyst Cloud, check your tickets and manage your account from the command line.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -4,7 +4,7 @@ description: >-
4
4
  Catalyst's GitHub: show a ticket's pull request with its checks, reviews and review threads, say whether it is mergeable under this repository's policy, and explain what a PR accumulates as the ticket moves (the branch, the draft, the rewrite, the force-pushes, the labels, the queue). Use when someone asks "show me the PR", "what are the checks saying", "why hasn't it merged", "what does the queue need", "what does that label mean", or wants the branch and merge conventions Catalyst follows.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.16 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.18 — written in this repository for customer accounts -->
8
8
 
9
9
  # Catalyst's GitHub
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Catalyst's view of Linear on the customer's own cloud account. Reads a ticket with its comments, relations, labels, linked pull requests and agent sessions inline, from the cloud API by default, with local replica reads available as an explicit opt-in, always naming the source; searches tickets, PRs, projects and initiatives; writes comments, card moves, labels and new tickets through the account's agent proxy as the app actor, with every route, stage id, label id and marker read from the account's contract. Knows what a ticket accumulates as Catalyst works it (phase-outcome comments, the document per phase, the agent session, the labels, the single blocks relation, the bookkeeping marker, the eyes acknowledgement). Use when a person says "show me the ticket", "what did Catalyst write on it", "comment on it", "move it", "label it" or "file a ticket". Not for raising a decision for a human (what-needs-me) and not for pull requests (catalyst-github).
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.16 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.18 — written in this repository for customer accounts -->
8
8
 
9
9
  # Catalyst Linear
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Set up Catalyst Cloud and say whether setup is working. Walks a person from nothing to their first ticket running, one step at a time: the coding account, the project, the integrations and connected accounts, each repository's settings file, then one real ticket moving. Also logs this machine in, reads the readiness verdict with who can fix each failure, and checks the optional local replica. Use when someone says "set me up", "onboard me", "I just signed up", "get me started", "what do I do first", "log me in", "which account is this machine on", "am I set up", "what is missing", "why does nothing happen" or "is the replica running", when a login expired, or when a skill script exits 2 saying this machine is not connected. Does every step a key can do through the catalyst CLI, hands over the exact page for the steps only a browser can do, and never claims a step it did not watch succeed.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.16 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.18 — written in this repository for customer accounts -->
8
8
 
9
9
  # Onboard me
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Own one Catalyst Cloud project end to end until it closes. Use when the person says "run this project for me", "own this until it ships", "keep this moving", or hands you a project id or a set of tickets to drive. Subscribes to the account's event stream for the scope through the catalyst CLI, reacts to each change in the same turn, makes tickets ready and moves them to dispatch, parks what should stop, chases stalls, escalates inward, and keeps one status summary current. Writes to Linear as the app actor; never polls.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.16 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.18 — written in this repository for customer accounts -->
8
8
 
9
9
  # Run this project
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Get a stuck Catalyst Cloud ticket moving again. Use when the person asks "why is this parked and can you release it?", "unpark this", "get things flowing again", "the outage is over, retry what failed", or hands you a ticket that is not moving. Reads why nothing runs and what holds the ticket through the catalyst CLI, decides whether the recorded cause is fixed, previews the release, releases every governor holding the ticket the right way (or one failure class across a team), and raises an ask only for what a person has to do. Never releases a cause it cannot show changed without saying so.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.16 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.18 — written in this repository for customer accounts -->
8
8
 
9
9
  # Unstick
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  The human's decision inbox on Catalyst Cloud, and the one way an agent raises a decision on their behalf. Use when the person asks "what needs me?", "what am I blocking?", or when active work is gated on a choice only they can make. Lists open asks ranked by how much open work each one holds, files an ask through the cloud's ask route with the account's own template, and records the answer so the held work releases. Never answers as the human, never duplicates an open ask.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.16 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.18 — written in this repository for customer accounts -->
8
8
 
9
9
  # What needs me
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  What environment variable names does this repository need, and where does each one come from? Scans the repository offline (no login, no network) and lists the names in three groups (build/test, deploy-only, bindings), each with where it was found, what uses it, and where a local value would come from. Never reads a value or prints one. Also checks the environment variable table in `.catalyst/catalyst.toml` offline. Use when someone asks "what does catalyst.env.json mean", "what env vars does this repo need", "why does the container need this", or before reviewing or writing the declaration.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.16 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.18 — written in this repository for customer accounts -->
8
8
 
9
9
  # What this repository needs
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Use for "what's happening?", "what machines do we have?", "how much capacity do we have?", "where are we?", "why is that stuck?", "what closed?", "what's next?", "how does this work?", "why did it do that?", "how does it prioritise?" and "what does this setting do, where is it set?" in Catalyst Cloud, or asks for something to be done rather than known. Reads the contract via catalyst CLI. Read-only: route work/decisions, never answer as the human.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.16 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.18 — written in this repository for customer accounts -->
8
8
 
9
9
  # What's happening
10
10