@catalyst-cloud/cli 0.16.30 → 0.16.31

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.
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
+ import { CLAUDE_PATH_ENV } from "./update-plugin.js";
4
5
  // The schedule that runs `catalyst runner update --auto` every 15 minutes (CTC-4646): a LaunchAgent
5
6
  // on macOS, a systemd user timer on Linux. It runs as the person who set the runner up, with the
6
7
  // docker command they use, and only ever calls this CLI. Turning it off removes exactly these files.
@@ -40,7 +41,7 @@ export function launchAgentPlist(deps, dir) {
40
41
  '<plist version="1.0"><dict>',
41
42
  `<key>Label</key><string>${AUTO_UPDATE_LABEL}</string>`,
42
43
  `<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
+ `<key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(deps.path)}</string><key>HOME</key><string>${xml(deps.home)}</string>${deps.claude ? `<key>${CLAUDE_PATH_ENV}</key><string>${xml(deps.claude)}</string>` : ""}</dict>`,
44
45
  // A calendar schedule, not StartInterval: on a busy Mac launchd held a StartInterval job as
45
46
  // "pended nondemand spawn" for hours (mini-2, 2026-10-17), and RunAtLoad was pended the same
46
47
  // way. The calendar entries fired on time on that same machine.
@@ -61,6 +62,7 @@ export function systemdUnits(deps, dir) {
61
62
  "[Service]",
62
63
  "Type=oneshot",
63
64
  `Environment=${unitWord(`PATH=${deps.path}`)}`,
65
+ ...(deps.claude ? [`Environment=${unitWord(`${CLAUDE_PATH_ENV}=${deps.claude}`)}`] : []),
64
66
  `ExecStart=${[deps.cli, "runner", "update", "--auto", "--dir", dir].map(unitWord).join(" ")}`,
65
67
  "",
66
68
  ].join("\n"),
@@ -1,9 +1,12 @@
1
1
  import { isAbsolute, resolve } from "node:path";
2
2
  import { flagBool, flagString, positionals } from "./args.js";
3
- import { UsageError } from "./errors.js";
3
+ import { CliError, UsageError } from "./errors.js";
4
4
  import { autoUpdateStatus, disableAutoUpdate, enableAutoUpdate, scheduledPath, } from "./runner-auto-update.js";
5
- import { dockerRunnerUpdateEngine, readReleaseState, runnerDir, runRunnerUpdate, updateResultLines, } from "./runner-update.js";
5
+ import { dockerRunnerUpdateEngine, readReleaseState, RELEASE_COMPONENTS, runnerDir, runRunnerUpdate, updateResultLines, } from "./runner-update.js";
6
6
  import { dockerFleetUpdateEngine, runFleetUpdate } from "./runner-update-fleet.js";
7
+ import { runUpdate, sharedReleaseRead, updateReportJson, updateReportLines } from "./update.js";
8
+ import { effectivePolicy, readUpdatePolicy, setComponentPolicy } from "./update-policy.js";
9
+ import { resolveClaude } from "./update-plugin.js";
7
10
  import { cmdRunnerLifecycle, RUNNER_LIFECYCLE_ACTIONS } from "./runner.js";
8
11
  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
12
  const UPDATE_FLAGS = ["check", "auto", "dir", "fleet"];
@@ -13,6 +16,7 @@ function schedulerDeps(ctx, deps) {
13
16
  home: ctx.home,
14
17
  cli,
15
18
  path: scheduledPath(cli, process.execPath, ctx.env.PATH),
19
+ claude: resolveClaude(ctx.home, ctx.env),
16
20
  ...deps.scheduler,
17
21
  };
18
22
  }
@@ -36,6 +40,17 @@ function statusLine(status) {
36
40
  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";
37
41
  return `Automatic runner updates are not available on this machine (${status.detail}). Run catalyst runner update yourself.`;
38
42
  }
43
+ /** Why this machine's images must not move now: an owner's hold, or settings that cannot be read
44
+ * (which never count as "everything on"). Null when every image may move. */
45
+ function imageHold(ctx) {
46
+ try {
47
+ const policy = readUpdatePolicy(ctx.home);
48
+ return RELEASE_COMPONENTS.some((id) => !effectivePolicy(policy, id).enabled) ? "owner_setting" : null;
49
+ }
50
+ catch {
51
+ return "update_policy_unreadable";
52
+ }
53
+ }
39
54
  /** Setup's call: turn the schedule on for `dir` and say what happened in one line. */
40
55
  export function enableRunnerAutoUpdate(ctx, dir) {
41
56
  return statusLine(enableAutoUpdate(schedulerDeps(ctx, {}), dir));
@@ -60,17 +75,78 @@ export async function cmdRunner(args, ctx, deps = {}) {
60
75
  const fleet = flagBool(args, "fleet");
61
76
  if (fleet && dirFlag === undefined)
62
77
  throw new UsageError("--fleet needs --dir, the directory that holds compose.json");
78
+ // One desired-release read serves the image check and the component report (CTC-6708).
79
+ const engine = sharedReleaseRead(deps.engine ?? dockerRunnerUpdateEngine({ env: ctx.env }));
80
+ // The 15-minute schedule runs `runner update --auto`. After the images it carries on with every
81
+ // other component (the CLI, the Cloud pack, the replica, opted-in SDK projects), on the same
82
+ // release read, so one schedule keeps the whole install on the approved release (CTC-6708). An
83
+ // image hold keeps only the images. A check never applies; with --json the components join the
84
+ // one JSON result, otherwise they get one line, and only when a release was read.
85
+ const components = async (imageResult) => {
86
+ if (!auto || fleet || check)
87
+ return null;
88
+ const report = await runUpdate(ctx, { mode: "apply", imagesDir: dir(), engine: () => engine, ...(imageResult ? { imageResult } : {}), ...deps.components });
89
+ if (report.approved === null)
90
+ return null;
91
+ const rows = report.components.filter((c) => !RELEASE_COMPONENTS.some((id) => id === c.id));
92
+ return {
93
+ rows,
94
+ line: `${ctx.now().toISOString()} components ${rows.map((c) => `${c.id}=${c.status}${c.status === "failed" || c.status === "pending" ? `(${c.reason})` : ""}`).join(" ")}`,
95
+ exitCode: rows.some((c) => c.status === "failed") || report.projects.some((p) => p.applied?.outcome === "failed") ? 1 : 0,
96
+ };
97
+ };
98
+ const print = (result, extra) => {
99
+ if (args.json)
100
+ ctx.stdout(JSON.stringify(extra ? { ...result, components: extra.rows } : result));
101
+ else if (auto) {
102
+ ctx.stdout(`${ctx.now().toISOString()} ${result.outcome} ${result.detail} generation=${result.generation ?? "none"}`);
103
+ if (extra)
104
+ ctx.stdout(extra.line);
105
+ }
106
+ else
107
+ for (const line of updateResultLines(result))
108
+ ctx.stdout(line);
109
+ return Math.max(result.exitCode, extra?.exitCode ?? 0);
110
+ };
111
+ // An owner's image hold stops this command's image work too, including the schedule's (CTC-6708).
112
+ const hold = check ? null : imageHold(ctx);
113
+ if (hold !== null) {
114
+ const retained = { outcome: "retained", detail: hold, generation: readReleaseState(dir()).generation, exitCode: hold === "owner_setting" ? 0 : 1 };
115
+ // Unreadable settings move nothing; a hold leaves the other components to the release check.
116
+ return print(retained, hold === "owner_setting" ? await components(undefined) : null);
117
+ }
63
118
  const result = fleet
64
119
  ? await runFleetUpdate(deps.fleetEngine ?? dockerFleetUpdateEngine({ env: ctx.env }), { dir: dir(), check })
65
- : await runRunnerUpdate(deps.engine ?? dockerRunnerUpdateEngine({ env: ctx.env }), { dir: dir(), check });
66
- if (args.json)
67
- ctx.stdout(JSON.stringify(result));
68
- else if (auto)
69
- ctx.stdout(`${ctx.now().toISOString()} ${result.outcome} ${result.detail} generation=${result.generation ?? "none"}`);
70
- else
71
- for (const line of updateResultLines(result))
72
- ctx.stdout(line);
73
- return result.exitCode;
120
+ : await runRunnerUpdate(engine, { dir: dir(), check });
121
+ // `runner update --check` also lists every installed component against the approved release, as
122
+ // `catalyst update --check` does. The image fields stay where scripts already read them.
123
+ if (check && !fleet && !auto) {
124
+ let report;
125
+ try {
126
+ report = await runUpdate(ctx, { mode: "check", imagesDir: dir(), engine: () => engine, imageResult: result });
127
+ }
128
+ catch (err) {
129
+ // Unreadable update settings cost the component report only; the image result still prints.
130
+ if (!(err instanceof CliError))
131
+ throw err;
132
+ ctx.stderr(`catalyst: ${err.message}`);
133
+ if (args.json)
134
+ ctx.stdout(JSON.stringify(result));
135
+ else
136
+ for (const line of updateResultLines(result))
137
+ ctx.stdout(line);
138
+ return result.exitCode;
139
+ }
140
+ if (args.json) {
141
+ const { schemaVersion, release, components, projects } = updateReportJson(report);
142
+ ctx.stdout(JSON.stringify({ ...result, schemaVersion, release, components, projects }));
143
+ }
144
+ else
145
+ for (const line of [...updateResultLines(result), ...updateReportLines(report)])
146
+ ctx.stdout(line);
147
+ return result.exitCode;
148
+ }
149
+ return print(result, await components(result));
74
150
  }
75
151
  if (action === "auto-update" && parts.length <= 2) {
76
152
  const mode = parts[1] ?? "status";
@@ -84,6 +160,11 @@ export async function cmdRunner(args, ctx, deps = {}) {
84
160
  status = autoUpdateStatus(scheduler);
85
161
  else
86
162
  throw new UsageError(USAGE);
163
+ // The owner's explicit on/off is also their setting for the three images, so a release check
164
+ // reports them as held (CTC-6708). Setup's own call (enableRunnerAutoUpdate) never lifts a hold.
165
+ if (mode === "off" || (mode === "on" && status.state === "on"))
166
+ for (const id of RELEASE_COMPONENTS)
167
+ setComponentPolicy(ctx.home, id, mode === "on");
87
168
  if (args.json) {
88
169
  const state = mode === "status" && dirFlag !== undefined ? readReleaseState(dirFlag) : undefined;
89
170
  ctx.stdout(JSON.stringify({ ...status, ...(state ? { release: state } : {}) }));
@@ -11,12 +11,19 @@ import { dockerRunnerEngine, parseEnvText, persistedCustody, prepareRunnerImages
11
11
  //
12
12
  // - the release parses strictly: three digests from the three Catalyst repositories, never a
13
13
  // tag or `latest`, and a generation newer than the one this machine last applied;
14
- // - no phase container exists for this host;
14
+ // - no phase container exists for this host, unless the update is in place (below);
15
15
  // - all three images are pulled by digest, natively for this engine, before anything changes;
16
16
  // - on a Mac with private thoughts storage, that storage is re-attested on the new supervisor;
17
17
  // - with the supervisor stopped, so it admits no new work, there is still no phase container.
18
18
  // If one started in between, the supervisor comes back as it was and the update waits.
19
19
  //
20
+ // In place (CTC-6709): when the release names its supervisor's journal format and it equals the
21
+ // format of the supervisor a failure would go back to, as recorded when that supervisor was applied,
22
+ // running phases are not waited out. The supervisor is recreated and re-adopts them, and they finish
23
+ // on the image they were granted. A rollback lands on a supervisor that reads the same journal. A
24
+ // format that differs, or is unknown on either side, waits for an idle machine as above, because an
25
+ // older supervisor that meets a newer journal refuses to start.
26
+ //
20
27
  // Then it rewrites the three image lines in `.env`, keeps the previous file beside it, and runs
21
28
  // `docker compose up`. The release counts as applied only when the running containers are its
22
29
  // images and the supervisor logged `host_ready` since it started; a `.env` that already names the
@@ -26,6 +33,7 @@ import { dockerRunnerEngine, parseEnvText, persistedCustody, prepareRunnerImages
26
33
  export const RELEASE_STATE_FILE = "release-state.json";
27
34
  export const PREVIOUS_ENV_FILE = ".env.release-previous";
28
35
  const LOCK_FILE = "update.lock";
36
+ const PENDING_REMOVAL_KEPT = 20;
29
37
  const READY_DEADLINE_MS = 5 * 60_000;
30
38
  const READY_POLL_MS = 5_000;
31
39
  const ATTEMPT_HOST_LABEL = "dev.catalystcloud.attempt.host-id";
@@ -77,10 +85,18 @@ export function parseDesiredRelease(input) {
77
85
  return null;
78
86
  images[component] = { reference, sourceSha: value.sourceSha };
79
87
  }
88
+ // Only the supervisor carries a journal format, a positive integer, as catalyst-cloud's parser.
89
+ const supervisor = input.supervisor;
90
+ if (supervisor.journalFormat !== undefined && !posInt(supervisor.journalFormat))
91
+ return null;
92
+ for (const component of ["runner", "watchdog"])
93
+ if (input[component].journalFormat !== undefined)
94
+ return null;
95
+ const journalFormat = supervisor.journalFormat === undefined ? null : supervisor.journalFormat;
80
96
  const id = `hr-${shortDigest(images.runner.reference)}-${shortDigest(images.supervisor.reference)}-${shortDigest(images.watchdog.reference)}`;
81
97
  if (input.releaseId !== id)
82
98
  return null;
83
- return { releaseId: id, generation: input.generation, approvedAtMs: input.approvedAtMs, images };
99
+ return { releaseId: id, generation: input.generation, approvedAtMs: input.approvedAtMs, images, journalFormat };
84
100
  }
85
101
  /** Whether to move. Every answer except a valid, strictly newer generation keeps the current images. */
86
102
  export function decideRelease(appliedGeneration, read) {
@@ -103,7 +119,7 @@ export function decideRelease(appliedGeneration, read) {
103
119
  return { action: "update", release };
104
120
  }
105
121
  export function readReleaseState(dir) {
106
- const empty = { version: 1, generation: null, releaseId: null, appliedAtMs: null, previous: null, last: null };
122
+ const empty = { version: 1, generation: null, releaseId: null, appliedAtMs: null, previous: null, journal: null, pendingRemoval: [], last: null };
107
123
  try {
108
124
  const parsed = JSON.parse(readFileSync(join(dir, RELEASE_STATE_FILE), "utf8"));
109
125
  if (!isRecord(parsed) || parsed.version !== 1)
@@ -114,6 +130,12 @@ export function readReleaseState(dir) {
114
130
  releaseId: typeof parsed.releaseId === "string" ? parsed.releaseId : null,
115
131
  appliedAtMs: posInt(parsed.appliedAtMs) ? parsed.appliedAtMs : null,
116
132
  previous: isRecord(parsed.previous) ? parsed.previous : null,
133
+ journal: isRecord(parsed.journal) && posInt(parsed.journal.format) && typeof parsed.journal.supervisor === "string"
134
+ ? { format: parsed.journal.format, supervisor: parsed.journal.supervisor }
135
+ : null,
136
+ pendingRemoval: Array.isArray(parsed.pendingRemoval)
137
+ ? parsed.pendingRemoval.filter((ref) => typeof ref === "string").slice(0, PENDING_REMOVAL_KEPT)
138
+ : [],
117
139
  last: isRecord(parsed.last) ? parsed.last : null,
118
140
  };
119
141
  }
@@ -161,6 +183,8 @@ export async function runRunnerUpdate(engine, options) {
161
183
  if (!options.check && lock === null)
162
184
  return { outcome: "retained", detail: "update_running", generation: readReleaseState(dir).generation, exitCode: 0 };
163
185
  try {
186
+ if (!options.check)
187
+ await retryPendingRemoval(engine, dir, options.signal);
164
188
  const result = await update(engine, options, now);
165
189
  if (!options.check) {
166
190
  const state = readReleaseState(dir);
@@ -173,6 +197,19 @@ export async function runRunnerUpdate(engine, options) {
173
197
  releaseLock(dir, lock);
174
198
  }
175
199
  }
200
+ /** Try again to remove images an earlier update replaced but could not remove, never one `.env`
201
+ * names now (a setup run or a person may have put it back). */
202
+ async function retryPendingRemoval(engine, dir, signal) {
203
+ const state = readReleaseState(dir);
204
+ if (state.pendingRemoval.length === 0)
205
+ return;
206
+ const inUse = new Set(Object.values(imagesOf(readEnvFile(join(dir, ".env")) ?? {})));
207
+ const removable = state.pendingRemoval.filter((ref) => !inUse.has(ref));
208
+ if (removable.length === 0)
209
+ return;
210
+ const kept = await engine.removeImages(removable, signal);
211
+ writeReleaseState(dir, { ...state, pendingRemoval: [...new Set([...state.pendingRemoval.filter((ref) => inUse.has(ref)), ...kept])] });
212
+ }
176
213
  const imagesOf = (env) => Object.fromEntries(RELEASE_COMPONENTS.map((c) => [c, env[RELEASE_ENV_KEYS[c]] ?? ""]));
177
214
  /** The running containers are these images, and the supervisor reported ready since it started. */
178
215
  async function running(engine, dir, images, signal) {
@@ -208,8 +245,10 @@ async function update(engine, options, now) {
208
245
  return done("current", "not_newer");
209
246
  }
210
247
  const tagged = { releaseId: release.releaseId };
211
- const record = (outcome, detail, previous) => {
212
- writeReleaseState(dir, { ...state, generation: release.generation, releaseId: release.releaseId, appliedAtMs: now(), previous });
248
+ const record = (outcome, detail, previous, unremoved = []) => {
249
+ const journal = release.journalFormat === null ? null : { format: release.journalFormat, supervisor: target.supervisor };
250
+ const pendingRemoval = [...new Set([...state.pendingRemoval, ...unremoved])].slice(-PENDING_REMOVAL_KEPT);
251
+ writeReleaseState(dir, { ...state, generation: release.generation, releaseId: release.releaseId, appliedAtMs: now(), previous, journal, pendingRemoval });
213
252
  return { outcome, detail, generation: release.generation, exitCode: 0, ...tagged };
214
253
  };
215
254
  if (options.check)
@@ -229,12 +268,17 @@ async function update(engine, options, now) {
229
268
  }
230
269
  }
231
270
  const rollbackImages = rollbackText === null ? null : imagesOf(parseEnvText(rollbackText));
271
+ // In place only when the supervisor a failure goes back to reads the release's journal format.
272
+ const inPlace = release.journalFormat !== null && state.journal !== null && rollbackImages !== null
273
+ && state.journal.format === release.journalFormat && state.journal.supervisor === rollbackImages.supervisor;
232
274
  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);
275
+ if (!inPlace) {
276
+ const busy = await engine.attemptContainers(dir, hostId, signal);
277
+ if (busy === null)
278
+ return done("retained", "docker_unavailable");
279
+ if (busy > 0)
280
+ return done("deferred_busy", `attempt_containers:${busy}`, tagged);
281
+ }
238
282
  const unprepared = await engine.prepare(RELEASE_COMPONENTS.map((c) => target[c]), signal);
239
283
  if (unprepared !== null)
240
284
  return done("retained", unprepared, tagged);
@@ -257,7 +301,7 @@ async function update(engine, options, now) {
257
301
  await restoreCustody(rollbackImages ?? current);
258
302
  return done("retained", "supervisor_stop_failed", tagged);
259
303
  }
260
- const busyAfterStop = await engine.attemptContainers(dir, hostId, signal);
304
+ const busyAfterStop = inPlace ? 0 : await engine.attemptContainers(dir, hostId, signal);
261
305
  if (busyAfterStop !== 0) {
262
306
  await engine.composeUp(dir, signal);
263
307
  await restoreCustody(rollbackImages ?? current);
@@ -273,9 +317,12 @@ async function update(engine, options, now) {
273
317
  // Only once the new release is running and ready: the replaced images free their disk (a runner
274
318
  // image is several GB, against a free-space floor that decides whether the machine takes work).
275
319
  // 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);
320
+ // An image still in use, such as the runner image under phases left running, is kept for a later run.
321
+ const unremoved = changed.length > 0
322
+ ? await engine.removeImages(changed.map((c) => current[c]).filter((ref) => ref !== ""), signal)
323
+ : [];
324
+ const applied = changed.length > 0 ? `updated:${changed.join(",")}` : "reconciled";
325
+ return record("updated", inPlace ? `${applied}:in_place` : applied, changed.length > 0 ? current : state.previous, unremoved);
279
326
  }
280
327
  if (rollbackText === null || rollbackImages === null)
281
328
  return done("failed", "new_supervisor_not_ready_no_previous", { ...tagged, exitCode: 1 });
@@ -461,8 +508,17 @@ export function dockerRunnerUpdateEngine(deps = {}) {
461
508
  return (await setup.composeUp(dir, signal)).ok;
462
509
  },
463
510
  async removeImages(images, signal) {
464
- for (const ref of images)
465
- await docker(["image", "rm", ref], { signal, timeoutMs: 120_000 });
511
+ const kept = [];
512
+ for (const ref of images) {
513
+ if ((await docker(["image", "rm", ref], { signal, timeoutMs: 120_000 })).code === 0)
514
+ continue;
515
+ // Only Docker saying the image does not exist counts as removed. A refusal, or a daemon that
516
+ // did not answer, keeps it for the next run.
517
+ const inspect = await docker(["image", "inspect", "--format", "{{.Id}}", ref], { signal });
518
+ if (inspect.code === 0 || !/No such image/i.test(inspect.stderr ?? ""))
519
+ kept.push(ref);
520
+ }
521
+ return kept;
466
522
  },
467
523
  async supervisorReadySinceStart(dir, signal) {
468
524
  const compose = runnerComposeArgs(dir);
@@ -0,0 +1,234 @@
1
+ // update-cli.ts — the CLI moves itself to the approved version (CTC-6708). A release check never
2
+ // replaces the code under the process doing the update: it installs the approved CLI into its own
3
+ // directory under ~/.config/catalyst-cloud/cli-runtimes/cli-<version>, checks the package name,
4
+ // the version and that `--version` answers from it, and only then switches one small record
5
+ // (active.json) to it. The person's own `catalyst` command stays where it is: its launcher
6
+ // (bin/launch.js) reads that record and runs the selected version, so npm's bins, scheduled jobs and
7
+ // the saved launcher path never move.
8
+ //
9
+ // A failed install, a wrong package or a version that does not answer leaves the record as it was,
10
+ // so the CLI that was running stays the one that runs. Earlier versions' directories are kept: a
11
+ // replica still running on one keeps its code until it restarts.
12
+ //
13
+ // The launcher only ever moves forward. A record naming a version at or below the launcher's own
14
+ // (an owner who reinstalled a newer CLI by hand, say) is ignored, and so is any record whose
15
+ // directory is not this install's own runtime directory for that version.
16
+ import { spawnSync } from "node:child_process";
17
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
18
+ import { basename, join } from "node:path";
19
+ import { configDirFor, PACKAGE_NAME } from "./config.js";
20
+ // bin/launch.js imports this module before dist/cli.js, on runtimes too old to load the rest of the
21
+ // CLI, so it imports only config.js and node built-ins. These three mirror approved-release.ts's.
22
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
23
+ const exactVersion = (v) => (typeof v === "string" && /^(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})$/.test(v) ? v : null);
24
+ function compareVersions(a, b) {
25
+ const pa = a.split(".").map(Number);
26
+ const pb = b.split(".").map(Number);
27
+ for (let i = 0; i < 3; i++) {
28
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
29
+ if (d !== 0)
30
+ return d;
31
+ }
32
+ return 0;
33
+ }
34
+ /** Set to a bin's own path, that bin's launcher runs itself and never selects (the staged check). */
35
+ export const ACTIVE_RUNTIME_ENV = "CATALYST_CLI_ACTIVE_RUNTIME";
36
+ export function cliRuntimesDirFor(home) {
37
+ return join(configDirFor(home), "cli-runtimes");
38
+ }
39
+ export function activeRuntimePathFor(home) {
40
+ return join(cliRuntimesDirFor(home), "active.json");
41
+ }
42
+ /** The one directory a given version may live in: a record naming any other path is refused. */
43
+ export function runtimeRootFor(home, version) {
44
+ return join(cliRuntimesDirFor(home), `cli-${version}`);
45
+ }
46
+ const packageDir = (root) => join(root, "node_modules", ...PACKAGE_NAME.split("/"));
47
+ export function readActiveRuntime(home) {
48
+ try {
49
+ const raw = JSON.parse(readFileSync(activeRuntimePathFor(home), "utf8"));
50
+ if (!isRecord(raw) || raw.version !== 1 || typeof raw.root !== "string" || typeof raw.activatedAtMs !== "number")
51
+ return null;
52
+ const cliVersion = exactVersion(raw.cliVersion);
53
+ if (cliVersion === null || raw.root !== runtimeRootFor(home, cliVersion))
54
+ return null;
55
+ const prev = raw.previous;
56
+ const previous = isRecord(prev) && typeof prev.root === "string" && exactVersion(prev.cliVersion) !== null
57
+ ? { cliVersion: String(prev.cliVersion), root: prev.root }
58
+ : null;
59
+ return { version: 1, cliVersion, root: raw.root, activatedAtMs: raw.activatedAtMs, previous };
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ /** The installed CLI's version in `root`, when its package is the CLI and its directory is private. */
66
+ function runtimeVersionAt(root) {
67
+ try {
68
+ // A directory others can write to could have its code swapped: never run from one.
69
+ if ((statSync(root).mode & 0o022) !== 0)
70
+ return null;
71
+ const manifest = JSON.parse(readFileSync(join(packageDir(root), "package.json"), "utf8"));
72
+ if (!isRecord(manifest) || manifest.name !== PACKAGE_NAME)
73
+ return null;
74
+ return exactVersion(manifest.version);
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ /**
81
+ * The bin the launcher should run instead of its own code, or null to run its own: no record, a
82
+ * record outside this install's runtime directory, a version not newer than `ownVersion`, or a
83
+ * directory whose package does not match the record.
84
+ */
85
+ export function activeRuntimeBin(home, ownVersion, binFile = "catalyst.js") {
86
+ const active = readActiveRuntime(home);
87
+ const own = exactVersion(ownVersion);
88
+ if (active === null || own === null || compareVersions(active.cliVersion, own) <= 0)
89
+ return null;
90
+ if (runtimeVersionAt(active.root) !== active.cliVersion)
91
+ return null;
92
+ // The same bin file the person ran (bin/launch.js passes its own name), so a deprecated name keeps its notice.
93
+ const bin = join(packageDir(active.root), "bin", basename(binFile));
94
+ return existsSync(bin) ? bin : null;
95
+ }
96
+ export const spawnCliCommand = (argv, cwd, env) => {
97
+ const [cmd, ...args] = argv;
98
+ if (cmd === undefined)
99
+ return { status: 1, stdout: "", stderr: "no command" };
100
+ const run = spawnSync(cmd, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", timeout: 10 * 60_000 });
101
+ return { status: run.error ? 1 : run.status, stdout: run.stdout ?? "", stderr: run.error ? run.error.message : (run.stderr ?? "") };
102
+ };
103
+ /**
104
+ * Install exact `version` of the CLI into its own directory and check it, without selecting it:
105
+ * its package name and version, and that its `--version` answers with that version. An already
106
+ * checked directory is reused. The skills pack update reads its bundled skills from here too.
107
+ */
108
+ export function stageCliRuntime(home, version, deps = {}) {
109
+ if (exactVersion(version) === null)
110
+ return { ok: false, detail: "target_invalid" };
111
+ const dir = cliRuntimesDirFor(home);
112
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
113
+ const root = runtimeRootFor(home, version);
114
+ if (runtimeVersionAt(root) === version)
115
+ return { ok: true, root };
116
+ // One staging at a time: another release check could otherwise remove the directory this one just
117
+ // put in place, while processes run from it.
118
+ const lock = join(dir, ".staging.lock");
119
+ if (!takeLock(lock))
120
+ return { ok: false, detail: "update_running" };
121
+ try {
122
+ return stageLocked(dir, root, version, deps);
123
+ }
124
+ finally {
125
+ try {
126
+ unlinkSync(lock);
127
+ }
128
+ catch {
129
+ // released already
130
+ }
131
+ }
132
+ }
133
+ /** Longer than npm's own timeout, so a live staging's lock is never taken over. */
134
+ const STAGING_LOCK_STALE_MS = 15 * 60_000;
135
+ function takeLock(path) {
136
+ for (let attempt = 0; attempt < 2; attempt++) {
137
+ try {
138
+ closeSync(openSync(path, "wx", 0o600));
139
+ return true;
140
+ }
141
+ catch {
142
+ // A run killed mid-install leaves its lock behind; past the stale age it is taken over.
143
+ try {
144
+ if (Date.now() - statSync(path).mtimeMs <= STAGING_LOCK_STALE_MS)
145
+ return false;
146
+ unlinkSync(path);
147
+ }
148
+ catch {
149
+ // gone already: try again
150
+ }
151
+ }
152
+ }
153
+ return false;
154
+ }
155
+ function stageLocked(dir, root, version, deps) {
156
+ const exec = deps.exec ?? spawnCliCommand;
157
+ const env = deps.env ?? process.env;
158
+ // Another check may have finished it while this one waited for the lock.
159
+ if (runtimeVersionAt(root) === version)
160
+ return { ok: true, root };
161
+ const staging = join(dir, `.staging-cli-${version}-${process.pid}-${Date.now()}`);
162
+ const fail = (detail) => {
163
+ rmSync(staging, { recursive: true, force: true });
164
+ return { ok: false, detail };
165
+ };
166
+ mkdirSync(staging, { mode: 0o700 });
167
+ // An exact version of the one CLI package: never a tag or range, never a global install.
168
+ const install = exec(["npm", "install", "--prefix", staging, "--no-save", "--no-audit", "--no-fund", "--omit=dev", `${PACKAGE_NAME}@${version}`], staging, env);
169
+ if (install.status !== 0)
170
+ return fail("install_failed");
171
+ if (runtimeVersionAt(staging) !== version)
172
+ return fail("package_mismatch");
173
+ const stagedBin = join(packageDir(staging), "bin", "catalyst.js");
174
+ const smoke = exec([deps.nodePath ?? process.execPath, stagedBin, "--version"], staging, { ...env, [ACTIVE_RUNTIME_ENV]: stagedBin });
175
+ if (smoke.status !== 0 || !smoke.stdout.split(/\s+/).includes(version))
176
+ return fail("smoke_failed");
177
+ try {
178
+ rmSync(root, { recursive: true, force: true });
179
+ renameSync(staging, root);
180
+ }
181
+ catch {
182
+ return fail("activate_failed");
183
+ }
184
+ return { ok: true, root };
185
+ }
186
+ /** The skills a staged CLI package bundles. */
187
+ export const stagedSkillsDir = (root) => join(packageDir(root), "skills");
188
+ /** Install `target` of the CLI beside the running one, check it answers, then select it. */
189
+ export function applyCliUpdate(home, ownVersion, target, deps = {}) {
190
+ const version = exactVersion(target);
191
+ const before = readActiveRuntime(home);
192
+ const running = before && activeRuntimeBin(home, ownVersion) !== null ? before.cliVersion : ownVersion;
193
+ if (version === null)
194
+ return { outcome: "failed", detail: "target_invalid", version: running };
195
+ // Never a move down: a CLI already past the target keeps running.
196
+ if (compareVersions(running, version) >= 0)
197
+ return { outcome: "current", detail: compareVersions(running, version) === 0 ? "current" : "installed_newer", version: running };
198
+ const staged = stageCliRuntime(home, version, deps);
199
+ if (!staged.ok)
200
+ return { outcome: "failed", detail: staged.detail, version: running };
201
+ const root = staged.root;
202
+ const record = {
203
+ version: 1,
204
+ cliVersion: version,
205
+ root,
206
+ activatedAtMs: (deps.now ?? Date.now)(),
207
+ previous: before ? { cliVersion: before.cliVersion, root: before.root } : null,
208
+ };
209
+ const path = activeRuntimePathFor(home);
210
+ const priorBytes = existsSync(path) ? readFileSync(path) : null;
211
+ const write = (bytes) => {
212
+ const tmp = `${path}.${process.pid}.tmp`;
213
+ try {
214
+ writeFileSync(tmp, bytes, { mode: 0o600 });
215
+ renameSync(tmp, path);
216
+ return true;
217
+ }
218
+ catch {
219
+ rmSync(tmp, { force: true });
220
+ return false;
221
+ }
222
+ };
223
+ if (!write(`${JSON.stringify(record, null, 2)}\n`))
224
+ return { outcome: "failed", detail: "activate_failed", version: running };
225
+ // Read back what the stable command will now run; put the earlier record back if it is not this one.
226
+ if (compareVersions(version, ownVersion) > 0 && activeRuntimeBin(home, ownVersion) === null) {
227
+ if (priorBytes === null)
228
+ rmSync(path, { force: true });
229
+ else
230
+ write(priorBytes);
231
+ return { outcome: "failed", detail: "activate_failed", version: running };
232
+ }
233
+ return { outcome: "updated", detail: "activated", version };
234
+ }