@catalyst-cloud/cli 0.16.24 → 0.16.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/runner.js ADDED
@@ -0,0 +1,628 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { lstatSync, readdirSync, readFileSync, realpathSync, rmdirSync, rmSync, writeFileSync, chmodSync, renameSync } from "node:fs";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { basename, dirname, isAbsolute, join, relative } from "node:path";
6
+ import { positionals } from "./args.js";
7
+ import { loadConfig } from "./config.js";
8
+ import { CliError, UsageError } from "./errors.js";
9
+ import { onboardStateRoot } from "./onboard.js";
10
+ import { dockerRunnerEngine, RUNNER_PROJECT, RUNNER_SESSION_NETWORK } from "./onboard-runner.js";
11
+ import { EGRESS_ARTIFACTS, NATIVE_EGRESS_DIR } from "./onboard-runner-egress.js";
12
+ import { stdinIsTty } from "./prompt.js";
13
+ import { apiClient } from "./transport.js";
14
+ // `catalyst runner stop|start|status|uninstall`: the lifecycle of the self-hosted runner that
15
+ // `catalyst setup --runner` installed on this machine (CTC-5930). Stop and start keep the
16
+ // registration; uninstall revokes it in the cloud and removes only what setup created, checking
17
+ // ownership before each removal, and prints whatever it left behind. A rerun picks up where an
18
+ // interrupted uninstall stopped: the cloud identifiers are journaled before anything is removed.
19
+ const ATTEMPT_HOST_LABEL = "dev.catalystcloud.attempt.host-id";
20
+ const ATTEMPT_ID_LABEL = "dev.catalystcloud.attempt.id";
21
+ const NETWORK_LABEL = "dev.catalystcloud.session-network";
22
+ const COMPOSE_VOLUMES = ["supervisor-state", "host-credential"].map(v => `${RUNNER_PROJECT}_${v}`);
23
+ const COMPOSE_FILES = ["compose.yaml", "compose.darwin-thoughts.yaml"];
24
+ /** Files setup writes into the runner directory. The work folders are listed separately. */
25
+ const SETUP_FILES = [...COMPOSE_FILES, ".env", "key-request.json"];
26
+ const SETUP_DIRS = ["session-egress", "host-disk"];
27
+ /** Repository checkouts, the thoughts checkout and their locks: kept unless the owner says so. */
28
+ const WORK_DIRS = ["slots", "thoughts", "locks"];
29
+ const DARWIN_SIBLINGS = ["darwin-thoughts-custody", "darwin-thoughts-requests", "darwin-thoughts-responses"];
30
+ /** The custody service setup installs, and the disk sampler a Mac runner may carry (CTC-5929). */
31
+ const LAUNCH_AGENTS = ["dev.catalystcloud.runner.darwin-thoughts-custody", "dev.catalystcloud.host-disk"];
32
+ const EGRESS_TABLE = "catalyst_session";
33
+ const JOURNAL = "uninstall.json";
34
+ const RUNNER_UID = "10001:10001";
35
+ const HOST_ID = /^[A-Za-z0-9._:-]{1,128}$/;
36
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
37
+ /** Compose reads `.env` only: an exported CATALYST_ or DOCKER_SOCKET_ value would win over the file. */
38
+ const dockerEnv = () => Object.fromEntries(Object.entries(process.env)
39
+ .filter(([key]) => !key.startsWith("CATALYST_") && !key.startsWith("DOCKER_SOCKET_")));
40
+ const spawnCommand = (file, args, options = {}) => new Promise((resolve) => {
41
+ const env = file === "docker" ? dockerEnv() : process.env;
42
+ if (options.interactive) {
43
+ // sudo may ask for a password on this terminal.
44
+ const child = spawn(file, args, { env, stdio: "inherit", timeout: options.timeoutMs ?? 300_000 });
45
+ child.on("error", () => resolve({ code: 127, stdout: "", stderr: "" }));
46
+ child.on("close", code => resolve({ code: code ?? 1, stdout: "", stderr: "" }));
47
+ return;
48
+ }
49
+ const child = execFile(file, args, { env, timeout: options.timeoutMs ?? 120_000, maxBuffer: 4_000_000 }, (error, stdout, stderr) => resolve({
50
+ code: error ? (typeof error.code === "number" ? error.code : 127) : 0,
51
+ stdout: String(stdout), stderr: String(stderr),
52
+ }));
53
+ child.stdin?.end(options.input ?? "");
54
+ });
55
+ function owned(path, uid, kind) {
56
+ try {
57
+ const stat = lstatSync(path);
58
+ return (kind === "file" ? stat.isFile() : stat.isDirectory()) && stat.uid === uid && (stat.mode & 0o022) === 0;
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
64
+ const exists = (path) => { try {
65
+ lstatSync(path);
66
+ return true;
67
+ }
68
+ catch {
69
+ return false;
70
+ } };
71
+ /** The onboarding engine's reads, through this command's own runner. */
72
+ function dockerEnvRun(run) {
73
+ return (args, options) => run("docker", args, { input: options.input, timeoutMs: options.timeoutMs });
74
+ }
75
+ export function runnerComposeArgs(inst) {
76
+ return ["compose", "--project-name", RUNNER_PROJECT, ...inst.files.flatMap(file => ["--file", file]),
77
+ "--env-file", join(inst.dir, ".env")];
78
+ }
79
+ /** The installation this machine's Compose project names, verified before anything acts on it.
80
+ * Null when there is none: no project containers and no runner directory setup wrote. */
81
+ async function locate(ctx, run, uid) {
82
+ const listed = await run("docker", ["ps", "--all", "--no-trunc",
83
+ "--filter", `label=com.docker.compose.project=${RUNNER_PROJECT}`, "--format",
84
+ '{{.ID}}\t{{.Label "com.docker.compose.service"}}\t{{.State}}\t{{.Label "com.docker.compose.project.working_dir"}}\t{{.Label "com.docker.compose.project.config_files"}}']);
85
+ if (listed.code !== 0)
86
+ throw new CliError("Docker is not reachable, so the runner could not be read. Start Docker and run this again.", "runner_docker_unavailable", 1);
87
+ const rows = listed.stdout.split("\n").map(line => line.trim()).filter(Boolean).map(line => line.split("\t"));
88
+ let dir;
89
+ let files;
90
+ const containers = [];
91
+ for (const [id, service, state, workingDir, configFiles] of rows) {
92
+ if (!id || !service || !state || !workingDir || !configFiles || !/^[0-9a-f]{12,64}$/.test(id))
93
+ throw new CliError(`The ${RUNNER_PROJECT} Compose project has a container this command cannot read. Nothing was changed.`, "runner_installation_unverified", 1);
94
+ if ((dir !== undefined && dir !== workingDir) || (files !== undefined && files.join(",") !== configFiles))
95
+ throw new CliError(`The ${RUNNER_PROJECT} Compose project was started from more than one folder. Nothing was changed.`, "runner_installation_unverified", 1);
96
+ dir = workingDir;
97
+ files = configFiles.split(",");
98
+ containers.push({ id, service, state });
99
+ }
100
+ if (dir === undefined) {
101
+ let state;
102
+ try {
103
+ state = onboardStateRoot(ctx.home, ctx.env);
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ const fallback = join(state, "runner");
109
+ if (!exists(join(fallback, ".env")) && !exists(join(fallback, "compose.yaml")) && !exists(join(fallback, JOURNAL)))
110
+ return null;
111
+ dir = fallback;
112
+ files = COMPOSE_FILES.map(name => join(fallback, name)).filter(path => exists(path));
113
+ }
114
+ // Only a folder under this person's home, holding files they own, is setup's.
115
+ const unverified = () => new CliError(`The runner folder ${dir} is not one setup created for you (it must be yours and inside your home). Nothing was changed.`, "runner_installation_unverified", 1);
116
+ if (!isAbsolute(dir))
117
+ throw unverified();
118
+ try {
119
+ const inHome = relative(realpathSync(ctx.home), realpathSync(dir));
120
+ if (inHome === "" || inHome === ".." || inHome.startsWith("../") || isAbsolute(inHome) || !owned(dir, uid, "directory"))
121
+ throw unverified();
122
+ }
123
+ catch (error) {
124
+ if (error instanceof CliError)
125
+ throw error;
126
+ throw unverified();
127
+ }
128
+ for (const file of files)
129
+ if (dirname(file) !== dir || !COMPOSE_FILES.includes(basename(file)) || !owned(file, uid, "file"))
130
+ throw unverified();
131
+ if (exists(join(dir, ".env")) && !owned(join(dir, ".env"), uid, "file"))
132
+ throw unverified();
133
+ return { dir, files: files, containers };
134
+ }
135
+ function readJournal(dir) {
136
+ try {
137
+ const value = JSON.parse(readFileSync(join(dir, JOURNAL), "utf8"));
138
+ const field = (key, shape) => typeof value[key] === "string" && shape.test(value[key]) ? value[key] : null;
139
+ return value.version === 1 ? { version: 1, hostId: field("hostId", HOST_ID), team: field("team", /^[A-Za-z0-9_-]{1,32}$/),
140
+ tenant: field("tenant", /^[A-Za-z0-9._-]{1,128}$/), requestId: field("requestId", UUID), teamId: field("teamId", /^[A-Za-z0-9-]{1,64}$/),
141
+ cloudRevoked: value.cloudRevoked === true } : null;
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ }
147
+ function writeJournal(dir, journal) {
148
+ const temporary = join(dir, `${JOURNAL}.${process.pid}.tmp`);
149
+ writeFileSync(temporary, JSON.stringify(journal) + "\n", { mode: 0o600 });
150
+ chmodSync(temporary, 0o600);
151
+ renameSync(temporary, join(dir, JOURNAL));
152
+ }
153
+ function readKeyRequest(dir) {
154
+ try {
155
+ const value = JSON.parse(readFileSync(join(dir, "key-request.json"), "utf8"));
156
+ return typeof value.requestId === "string" && UUID.test(value.requestId) && typeof value.teamId === "string" && typeof value.account === "string"
157
+ ? { requestId: value.requestId, teamId: value.teamId, account: value.account } : null;
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ }
163
+ async function supervisorRunning(run, inst) {
164
+ const read = await run("docker", [...runnerComposeArgs(inst), "ps", "--status", "running", "--services"]);
165
+ if (read.code !== 0)
166
+ return null;
167
+ return read.stdout.split("\n").some(line => line.trim() === "supervisor");
168
+ }
169
+ async function confirmUninstall(summary) {
170
+ const prompt = createInterface({ input: process.stdin, output: process.stderr });
171
+ try {
172
+ for (const line of summary)
173
+ process.stderr.write(`${line}\n`);
174
+ return /^(y|yes)$/i.test((await prompt.question("Uninstall the runner from this machine? [y/N] ")).trim());
175
+ }
176
+ finally {
177
+ prompt.close();
178
+ }
179
+ }
180
+ /** The actions `cmdRunnerLifecycle` owns; `runner update` and `auto-update` live in runner-command.ts. */
181
+ export const RUNNER_LIFECYCLE_ACTIONS = ["status", "stop", "start", "uninstall"];
182
+ export async function cmdRunnerLifecycle(args, ctx, deps = {}) {
183
+ const parts = positionals(args);
184
+ const action = parts[0] ?? "status";
185
+ if (parts.length > 1 || !["status", "stop", "start", "uninstall"].includes(action))
186
+ throw new UsageError("runner takes: status | stop | start | uninstall [--yes] [--delete-checkouts]");
187
+ if ((args.flags.yes === true || args.flags["delete-checkouts"] === true) && action !== "uninstall")
188
+ throw new UsageError("--yes and --delete-checkouts are only valid with runner uninstall");
189
+ const run = deps.run ?? spawnCommand;
190
+ const uid = deps.uid ?? process.getuid?.() ?? -1;
191
+ const platform = deps.platform ?? process.platform;
192
+ const say = (line) => { if (!args.json)
193
+ ctx.stdout(line); };
194
+ const inst = await locate(ctx, run, uid);
195
+ if (action === "status") {
196
+ if (!inst) {
197
+ if (args.json)
198
+ ctx.stdout(JSON.stringify({ installed: false }));
199
+ else
200
+ ctx.stdout("No Catalyst runner is installed on this machine.");
201
+ return 0;
202
+ }
203
+ const supervisor = inst.containers.find(c => c.service === "supervisor")?.state ?? "absent";
204
+ const state = supervisor === "running" ? "running" : inst.containers.length === 0 ? "not started" : "stopped";
205
+ if (args.json)
206
+ ctx.stdout(JSON.stringify({ installed: true, state, dir: inst.dir, containers: inst.containers.map(({ service, state }) => ({ service, state })) }));
207
+ else {
208
+ ctx.stdout(`Runner: ${state} (folder ${inst.dir})`);
209
+ for (const c of inst.containers)
210
+ ctx.stdout(` ${c.service}: ${c.state}`);
211
+ }
212
+ return 0;
213
+ }
214
+ if (action === "stop" || action === "start") {
215
+ if (!inst || inst.containers.length === 0 && action === "stop")
216
+ throw new CliError("No Catalyst runner is running on this machine. Set one up with: catalyst setup --runner", "runner_not_installed", 1);
217
+ if (!exists(join(inst.dir, ".env")) || inst.files.length === 0)
218
+ throw new CliError(`The runner folder ${inst.dir} is missing its settings. Repair it with: catalyst setup --runner`, "runner_installation_unverified", 1);
219
+ const before = await supervisorRunning(run, inst);
220
+ if (before === null)
221
+ throw new CliError("Compose could not read the runner's state. Nothing was changed.", "runner_compose_failed", 1);
222
+ if (action === "stop") {
223
+ if (before) {
224
+ // Only the supervisor: it stops taking work and advertises draining. The deadline watchdog
225
+ // keeps running so an attempt already started still ends at its deadline.
226
+ const stopped = await run("docker", [...runnerComposeArgs(inst), "stop", "supervisor"], { timeoutMs: 180_000 });
227
+ if (stopped.code !== 0 || (await supervisorRunning(run, inst)) !== false)
228
+ throw new CliError(`The runner did not stop: ${(stopped.stderr || stopped.stdout).trim().split("\n").slice(-1)[0] ?? "compose failed"}`, "runner_compose_failed", 1);
229
+ }
230
+ const attempts = await run("docker", ["ps", "--filter", `label=${ATTEMPT_ID_LABEL}`, "--format", "{{.ID}}"]);
231
+ const running = attempts.code === 0 ? attempts.stdout.split("\n").filter(Boolean).length : null;
232
+ if (args.json)
233
+ ctx.stdout(JSON.stringify({ stopped: true, alreadyStopped: !before, runningAttempts: running }));
234
+ else {
235
+ say(before ? "Stopped the runner. This machine takes no new work, and keeps its registration." : "The runner was already stopped. This machine takes no new work.");
236
+ if (running)
237
+ say(`${running} attempt container(s) already started keep running until they finish or reach their deadline.`);
238
+ say("Start it again with: catalyst runner start");
239
+ }
240
+ return 0;
241
+ }
242
+ if (exists(join(inst.dir, JOURNAL))) {
243
+ // An uninstall that stopped the runner but changed nothing in the cloud (no login, another
244
+ // account, a member's 403, an outage) leaves a machine that can still work: start undoes it.
245
+ const pending = readJournal(inst.dir);
246
+ if (pending === null || pending.cloudRevoked)
247
+ throw new CliError("An uninstall of this runner has started, so it cannot be started again. Finish it with: catalyst runner uninstall", "runner_uninstall_pending", 1);
248
+ rmSync(join(inst.dir, JOURNAL), { force: true });
249
+ say("An earlier uninstall stopped this runner before the cloud changed anything, so it is cancelled.");
250
+ }
251
+ if (!before) {
252
+ const up = await run("docker", [...runnerComposeArgs(inst), "up", "--detach"], { timeoutMs: 600_000 });
253
+ if (up.code !== 0 || (await supervisorRunning(run, inst)) !== true)
254
+ throw new CliError(`The runner did not start: ${(up.stderr || up.stdout).trim().split("\n").slice(-1)[0] ?? "compose failed"}`, "runner_compose_failed", 1);
255
+ }
256
+ const enrollment = await dockerRunnerEngine({ exec: dockerEnvRun(run), platform }).enrollment(inst.dir);
257
+ const hostId = enrollment && !("unenrolled" in enrollment) ? enrollment.hostId : null;
258
+ if (args.json)
259
+ ctx.stdout(JSON.stringify({ started: true, alreadyRunning: before, hostId }));
260
+ else {
261
+ say(before ? "The runner is already running." : "Started the runner. It takes work again with the same registration.");
262
+ if (hostId)
263
+ say(`Host: ${hostId}`);
264
+ }
265
+ return 0;
266
+ }
267
+ return uninstall(args, ctx, { ...deps, run, uid, platform }, inst);
268
+ }
269
+ async function uninstall(args, ctx, deps, inst) {
270
+ const { run, uid, platform } = deps;
271
+ const steps = [];
272
+ const record = (item, outcome, detail) => {
273
+ steps.push({ item, outcome, ...(detail ? { detail } : {}) });
274
+ };
275
+ const deleteCheckouts = args.flags["delete-checkouts"] === true;
276
+ const darwinAgents = platform === "darwin" ? LAUNCH_AGENTS.map(label => ({ label, plist: join(ctx.home, "Library", "LaunchAgents", `${label}.plist`) })).filter(a => exists(a.plist)) : [];
277
+ const egressPresent = platform === "linux" && (EGRESS_ARTIFACTS.some(a => exists(a.path)) || exists(NATIVE_EGRESS_DIR));
278
+ let runnerDir = inst?.dir;
279
+ if (!runnerDir)
280
+ try {
281
+ runnerDir = join(onboardStateRoot(ctx.home, ctx.env), "runner");
282
+ }
283
+ catch { /* no state root */ }
284
+ const darwinDirs = runnerDir && platform === "darwin" ? DARWIN_SIBLINGS.map(name => join(dirname(runnerDir), name)).filter(exists) : [];
285
+ if (!inst && darwinAgents.length === 0 && darwinDirs.length === 0 && !egressPresent) {
286
+ if (args.json)
287
+ ctx.stdout(JSON.stringify({ installed: false, steps: [] }));
288
+ else
289
+ ctx.stdout("No Catalyst runner is installed on this machine. Nothing to remove.");
290
+ return 0;
291
+ }
292
+ // The cloud identity first, while the credential volume still exists. A journal from an earlier,
293
+ // interrupted run supplies it after the volume is gone.
294
+ let journal = inst ? readJournal(inst.dir) ?? { version: 1, hostId: null, team: null, tenant: null, requestId: null, teamId: null, cloudRevoked: false }
295
+ : { version: 1, hostId: null, team: null, tenant: null, requestId: null, teamId: null, cloudRevoked: false };
296
+ if (inst && !journal.hostId && exists(join(inst.dir, ".env")) && inst.files.length > 0) {
297
+ const enrollment = await dockerRunnerEngine({ exec: dockerEnvRun(run), platform }).enrollment(inst.dir);
298
+ if (enrollment && !("unenrolled" in enrollment)) {
299
+ const e = enrollment;
300
+ journal = { ...journal, hostId: e.hostId, team: e.team, tenant: e.tenant };
301
+ }
302
+ }
303
+ const keyRequest = inst ? readKeyRequest(inst.dir) : null;
304
+ if (keyRequest && !journal.requestId)
305
+ journal = { ...journal, requestId: keyRequest.requestId, teamId: keyRequest.teamId, tenant: journal.tenant ?? keyRequest.account };
306
+ const summary = [
307
+ inst ? `Runner folder: ${inst.dir}` : "Runner folder: none",
308
+ `Host registration: ${journal.hostId ?? "none found"}${journal.team ? ` (team ${journal.team})` : ""}`,
309
+ `Runner key: ${journal.requestId ? "will be revoked" : "none issued by setup"}`,
310
+ `Checkouts and thoughts: ${deleteCheckouts ? "deleted" : "kept"}`,
311
+ ];
312
+ if (args.flags.yes !== true) {
313
+ const tty = deps.isTty ?? stdinIsTty;
314
+ const confirmed = tty() && await (deps.confirm ?? confirmUninstall)(summary);
315
+ if (!confirmed) {
316
+ ctx.stdout(args.json ? JSON.stringify({ removed: false, reason: "confirmation_required" })
317
+ : "Runner kept. Use --yes to confirm the uninstall without a terminal.");
318
+ return 2;
319
+ }
320
+ }
321
+ // The cloud steps need an owner or admin login on this runner's account. Check that before
322
+ // anything changes, so a wrong login leaves the machine running and taking work.
323
+ const cfg = journal.hostId || journal.requestId ? loadConfig(ctx.home) : null;
324
+ if (journal.hostId || journal.requestId) {
325
+ if (!cfg)
326
+ record("host registration", "failed", "not logged in: run catalyst login as an owner or admin, then run this again");
327
+ else if (journal.tenant && cfg.account !== journal.tenant)
328
+ record("host registration", "failed", `this runner belongs to account ${journal.tenant}, but you are logged in to ${cfg.account}`);
329
+ if (steps.length > 0) {
330
+ if (args.json)
331
+ ctx.stdout(JSON.stringify({ removed: false, steps, leftBehind: [] }));
332
+ else {
333
+ for (const s of steps)
334
+ ctx.stdout(`${s.outcome.padEnd(7)} ${s.item}${s.detail ? ` (${s.detail})` : ""}`);
335
+ ctx.stdout("Nothing was changed: the runner keeps running. Fix the line above and run: catalyst runner uninstall");
336
+ }
337
+ return 1;
338
+ }
339
+ }
340
+ if (inst)
341
+ writeJournal(inst.dir, journal);
342
+ // 1. No new work: stop the supervisor before the cloud forgets it.
343
+ if (inst && inst.containers.some(c => c.service === "supervisor" && c.state === "running")) {
344
+ const stopped = await run("docker", [...runnerComposeArgs(inst), "stop", "supervisor"], { timeoutMs: 180_000 });
345
+ record("supervisor", stopped.code === 0 ? "stopped" : "failed", stopped.code === 0 ? undefined : "compose stop failed");
346
+ }
347
+ // 2. The cloud: the host registration, the runner key, and admission when no other machine needs it.
348
+ let cloudFailed = false;
349
+ if (cfg) {
350
+ const client = apiClient(cfg, ctx);
351
+ const account = encodeURIComponent(cfg.account);
352
+ const call = async (label, request, done, statuses) => {
353
+ try {
354
+ const { status } = await request();
355
+ const [outcome, detail] = status >= 200 && status < 300 ? [done] : statuses[status] ?? ["failed", `the cloud answered ${status}`];
356
+ record(label, outcome, detail);
357
+ if (outcome === "failed")
358
+ cloudFailed = true;
359
+ else if (inst && !journal.cloudRevoked) {
360
+ journal = { ...journal, cloudRevoked: true };
361
+ writeJournal(inst.dir, journal);
362
+ }
363
+ }
364
+ catch (error) {
365
+ record(label, "failed", error instanceof Error ? error.message : "the cloud could not be reached");
366
+ cloudFailed = true;
367
+ }
368
+ };
369
+ if (journal.hostId)
370
+ await call("host registration", () => client.deleteJson(`/api/v1/hosts/${encodeURIComponent(journal.hostId)}?account=${account}`, { accept: [403, 404] }), "revoked", { 404: ["absent", "already revoked"], 403: ["failed", "admin access is required"] });
371
+ else
372
+ record("host registration", "absent", "this machine never enrolled");
373
+ if (journal.requestId)
374
+ await call("runner key", () => client.deleteJson(`/api/v1/agent/runner-keys/${journal.requestId}?account=${account}`, { accept: [403, 404, 409, 503] }), "revoked", { 404: ["absent", "no key was issued for this machine"], 403: ["failed", "admin access is required"],
375
+ 409: ["failed", "the key's issue is still settling; run this again shortly"], 503: ["failed", "the key service is unavailable; run this again shortly"] });
376
+ if (journal.teamId && journal.team && !cloudFailed) {
377
+ // Admission is the team's, not the machine's: switch it off only when no machine remains.
378
+ try {
379
+ const listed = await client.getJson(`/api/v1/hosts/enrollments?account=${account}`);
380
+ // A machine serves its enrolled team and every team added to it later (CTC-4455).
381
+ const serves = (h) => h.team === journal.team || (Array.isArray(h.teams) && h.teams.includes(journal.team));
382
+ const others = (listed.body.hosts ?? []).filter(h => serves(h) && h.revokedAtMs === null && h.hostId !== journal.hostId);
383
+ if (others.length > 0)
384
+ record("team runner admission", "kept", `${others.length} other machine(s) still run team ${journal.team}`);
385
+ else {
386
+ const put = await client.putJson(`/api/v1/agent/runner-admission?account=${account}&team=${encodeURIComponent(journal.teamId)}`, { admissionEnabled: false }, { accept: [403, 404, 409] });
387
+ record("team runner admission", put.status === 200 ? "removed" : "kept", put.status === 200 ? "switched off: no machine runs this team" : `left as it was (the cloud answered ${put.status})`);
388
+ }
389
+ }
390
+ catch {
391
+ record("team runner admission", "kept", "left as it was: the machine list could not be read");
392
+ }
393
+ }
394
+ }
395
+ if (cloudFailed)
396
+ return finish(args, ctx, steps, inst, true, "The runner is stopped, but its registration is not fully revoked, so its local files and credential are kept. Fix the line above and run: catalyst runner uninstall");
397
+ // 3. Attempt containers and workspace volumes this host's supervisor created, by label.
398
+ if (journal.hostId) {
399
+ const containers = await run("docker", ["ps", "--all", "--no-trunc", "--filter", `label=${ATTEMPT_HOST_LABEL}=${journal.hostId}`, "--format", `{{.ID}}\t{{.Label "${ATTEMPT_HOST_LABEL}"}}`]);
400
+ const ids = containers.code === 0 ? containers.stdout.split("\n").map(l => l.split("\t")).filter(([id, host]) => id && host === journal.hostId).map(([id]) => id) : null;
401
+ if (ids === null)
402
+ record("attempt containers", "failed", "could not be listed");
403
+ else if (ids.length === 0)
404
+ record("attempt containers", "absent");
405
+ else {
406
+ const removed = await run("docker", ["rm", "--force", ...ids], { timeoutMs: 300_000 });
407
+ record("attempt containers", removed.code === 0 ? "removed" : "failed", `${ids.length}`);
408
+ }
409
+ const volumes = await run("docker", ["volume", "ls", "--filter", `label=${ATTEMPT_HOST_LABEL}=${journal.hostId}`, "--format", `{{.Name}}\t{{.Label "${ATTEMPT_HOST_LABEL}"}}`]);
410
+ const names = volumes.code === 0 ? volumes.stdout.split("\n").map(l => l.split("\t")).filter(([name, host]) => name && /^catalyst-[A-Za-z0-9_.-]+$/.test(name) && host === journal.hostId).map(([name]) => name) : null;
411
+ if (names === null)
412
+ record("attempt volumes", "failed", "could not be listed");
413
+ else if (names.length === 0)
414
+ record("attempt volumes", "absent");
415
+ else {
416
+ const removed = await run("docker", ["volume", "rm", ...names], { timeoutMs: 300_000 });
417
+ record("attempt volumes", removed.code === 0 ? "removed" : "failed", names.join(", "));
418
+ }
419
+ }
420
+ // 4. Work folders, before Compose goes: on native Linux the runner uid owns them, and the
421
+ // supervisor image is how setup handed them over, so it is how they are emptied.
422
+ if (inst && deleteCheckouts) {
423
+ const present = WORK_DIRS.map(name => join(inst.dir, name)).filter(exists);
424
+ const foreign = present.filter(path => !owned(path, uid, "directory"));
425
+ if (foreign.length > 0 && inst.files.length > 0) {
426
+ const emptied = await run("docker", [...runnerComposeArgs(inst), "run", "--rm", "-T", "--no-deps", "--user", RUNNER_UID,
427
+ "--entrypoint", "find", "supervisor", ...foreign, "-mindepth", "1", "-delete"], { timeoutMs: 600_000 });
428
+ if (emptied.code !== 0)
429
+ record("checkouts", "failed", "the runner's files could not be emptied");
430
+ }
431
+ for (const path of present) {
432
+ try {
433
+ if (owned(path, uid, "directory"))
434
+ rmSync(path, { recursive: true, force: true });
435
+ else
436
+ rmdirSync(path);
437
+ record(basename(path), "removed");
438
+ }
439
+ catch {
440
+ record(basename(path), "failed", `${path} is not empty`);
441
+ }
442
+ }
443
+ }
444
+ // 5. The Compose project: both services and their two volumes (the host secret and journal).
445
+ if (inst && inst.files.length > 0 && exists(join(inst.dir, ".env"))) {
446
+ const down = await run("docker", [...runnerComposeArgs(inst), "down", "--volumes", "--remove-orphans", "--timeout", "30"], { timeoutMs: 300_000 });
447
+ record("compose project", down.code === 0 ? "removed" : "failed", down.code === 0 ? undefined : (down.stderr || down.stdout).trim().split("\n").slice(-1)[0]);
448
+ }
449
+ for (const volume of COMPOSE_VOLUMES) {
450
+ const inspect = await run("docker", ["volume", "inspect", "--format", '{{index .Labels "com.docker.compose.project"}}', volume]);
451
+ if (inspect.code !== 0)
452
+ continue;
453
+ if (inspect.stdout.trim() !== RUNNER_PROJECT) {
454
+ record(volume, "kept", "not labelled as this project's");
455
+ continue;
456
+ }
457
+ const removed = await run("docker", ["volume", "rm", volume]);
458
+ record(volume, removed.code === 0 ? "removed" : "failed");
459
+ }
460
+ // 6. Linux session egress: the root units and the nftables table setup's commands installed.
461
+ if (egressPresent)
462
+ linuxEgress: {
463
+ const hash = (path) => { try {
464
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
465
+ }
466
+ catch {
467
+ return null;
468
+ } };
469
+ const changed = EGRESS_ARTIFACTS.filter(a => exists(a.path) && hash(a.path) !== a.digest);
470
+ if (changed.length > 0) {
471
+ for (const a of changed)
472
+ record(a.path, "kept", "changed since setup installed it");
473
+ break linuxEgress;
474
+ }
475
+ const sudo = deps.isTty?.() ?? stdinIsTty() ? [] : ["-n"];
476
+ const commands = [
477
+ ["systemctl", "disable", "--now", "catalyst-session-egress-attest.timer", "catalyst-session-egress.service"],
478
+ ["systemctl", "stop", "catalyst-session-egress-attest.service"],
479
+ ["rm", "-f", ...EGRESS_ARTIFACTS.map(a => a.path)],
480
+ ["systemctl", "daemon-reload"],
481
+ ["nft", "delete", "table", "inet", EGRESS_TABLE],
482
+ ["rm", "-rf", NATIVE_EGRESS_DIR],
483
+ ["rmdir", "--ignore-fail-on-non-empty", "/usr/lib/catalyst", "/var/lib/catalyst"],
484
+ ];
485
+ const failed = [];
486
+ for (const command of commands) {
487
+ const result = await run("sudo", [...sudo, ...command], { interactive: sudo.length === 0, timeoutMs: 120_000 });
488
+ // The table is already gone when an earlier run removed it.
489
+ if (result.code !== 0 && !(command[0] === "nft" && /no such file|does not exist/i.test(result.stderr)) && !(command[0] === "systemctl" && /not (be )?(loaded|found)|does not exist/i.test(result.stderr)))
490
+ failed.push(`sudo ${command.join(" ")}`);
491
+ }
492
+ const left = EGRESS_ARTIFACTS.filter(a => exists(a.path)).map(a => a.path);
493
+ if (exists(NATIVE_EGRESS_DIR))
494
+ left.push(NATIVE_EGRESS_DIR);
495
+ if (failed.length === 0 && left.length === 0)
496
+ record("session-egress units and nftables table", "removed");
497
+ else
498
+ record("session-egress units and nftables table", "failed", `run these yourself: ${failed.join("; ") || left.map(p => `sudo rm -rf ${p}`).join("; ")}`);
499
+ }
500
+ // 7. The session network, only when it carries setup's label; after the egress units, whose
501
+ // boot service would create it again.
502
+ const network = await run("docker", ["network", "inspect", "--format", `{{index .Labels "${NETWORK_LABEL}"}}`, RUNNER_SESSION_NETWORK]);
503
+ if (network.code === 0) {
504
+ if (network.stdout.trim() !== "v1")
505
+ record(RUNNER_SESSION_NETWORK, "kept", "not created by setup");
506
+ else {
507
+ const removed = await run("docker", ["network", "rm", RUNNER_SESSION_NETWORK]);
508
+ record(RUNNER_SESSION_NETWORK, removed.code === 0 ? "removed" : "failed", removed.code === 0 ? undefined : "still in use by a container");
509
+ }
510
+ }
511
+ // 8. macOS: the storage custody service (and a disk sampler, when one was installed) and its folders.
512
+ for (const agent of darwinAgents) {
513
+ let ours = false;
514
+ try {
515
+ ours = owned(agent.plist, uid, "file") && readFileSync(agent.plist, "utf8").includes(`<string>${agent.label}</string>`);
516
+ }
517
+ catch { /* unreadable */ }
518
+ if (!ours) {
519
+ record(agent.plist, "kept", "not the file setup installed");
520
+ continue;
521
+ }
522
+ for (const domain of [`gui/${uid}`, `user/${uid}`])
523
+ await run("launchctl", ["bootout", `${domain}/${agent.label}`]);
524
+ const loaded = (await run("launchctl", ["print", `user/${uid}/${agent.label}`])).code === 0 ||
525
+ (await run("launchctl", ["print", `gui/${uid}/${agent.label}`])).code === 0;
526
+ if (loaded) {
527
+ record(agent.label, "failed", `still loaded: run launchctl bootout user/${uid}/${agent.label}`);
528
+ continue;
529
+ }
530
+ try {
531
+ rmSync(agent.plist);
532
+ record(agent.label, "removed");
533
+ }
534
+ catch {
535
+ record(agent.plist, "failed", "could not be deleted");
536
+ }
537
+ }
538
+ for (const path of darwinDirs) {
539
+ if (!owned(path, uid, "directory")) {
540
+ record(path, "kept", "not yours");
541
+ continue;
542
+ }
543
+ try {
544
+ rmSync(path, { recursive: true, force: true });
545
+ record(basename(path), "removed");
546
+ }
547
+ catch {
548
+ record(path, "failed", "could not be deleted");
549
+ }
550
+ }
551
+ // 9. The files setup wrote. The key-request marker and the .env (which holds the spent join token)
552
+ // go only now that the cloud has revoked what they name.
553
+ if (inst) {
554
+ const before = steps.length;
555
+ for (const name of SETUP_DIRS) {
556
+ const path = join(inst.dir, name);
557
+ if (!exists(path))
558
+ continue;
559
+ if (!owned(path, uid, "directory")) {
560
+ record(path, "kept", "not yours");
561
+ continue;
562
+ }
563
+ try {
564
+ rmSync(path, { recursive: true, force: true });
565
+ }
566
+ catch {
567
+ record(path, "failed", "could not be deleted");
568
+ }
569
+ }
570
+ for (const name of SETUP_FILES) {
571
+ const path = join(inst.dir, name);
572
+ if (!exists(path))
573
+ continue;
574
+ try {
575
+ rmSync(path);
576
+ }
577
+ catch {
578
+ record(path, "failed", "could not be deleted");
579
+ }
580
+ }
581
+ if (steps.length === before)
582
+ record("runner settings", "removed", inst.dir);
583
+ }
584
+ const failed = steps.some(s => s.outcome === "failed");
585
+ if (inst && !failed) {
586
+ try {
587
+ rmSync(join(inst.dir, JOURNAL), { force: true });
588
+ rmdirSync(inst.dir);
589
+ }
590
+ catch { /* kept checkouts remain */ }
591
+ }
592
+ return finish(args, ctx, steps, inst, failed, failed
593
+ ? "Some of the runner is left on this machine (above). Fix those lines and run catalyst runner uninstall again; it picks up where this run stopped."
594
+ : undefined);
595
+ }
596
+ function finish(args, ctx, steps, inst, failed, note) {
597
+ const left = steps.filter(s => s.outcome === "failed" || s.outcome === "kept").map(s => `${s.item}${s.detail ? `: ${s.detail}` : ""}`);
598
+ if (inst) {
599
+ for (const name of WORK_DIRS) {
600
+ const path = join(inst.dir, name);
601
+ if (!exists(path))
602
+ continue;
603
+ let entries = 0;
604
+ try {
605
+ entries = readdirSync(path).length;
606
+ }
607
+ catch { /* unreadable: the runner uid owns it */
608
+ entries = -1;
609
+ }
610
+ left.push(`${path}${name === "slots" ? " (repository checkouts)" : name === "thoughts" ? " (thoughts checkout)" : ""}${entries === 0 ? ", empty" : ""}: delete with catalyst runner uninstall --delete-checkouts`);
611
+ }
612
+ if (exists(join(inst.dir, JOURNAL)))
613
+ left.push(`${join(inst.dir, JOURNAL)} (lets the next run finish the uninstall)`);
614
+ }
615
+ if (args.json) {
616
+ ctx.stdout(JSON.stringify({ removed: !failed, steps, leftBehind: left }));
617
+ return failed ? 1 : 0;
618
+ }
619
+ for (const s of steps)
620
+ ctx.stdout(`${s.outcome.padEnd(7)} ${s.item}${s.detail ? ` (${s.detail})` : ""}`);
621
+ if (left.length > 0) {
622
+ ctx.stdout("Left behind on this machine:");
623
+ for (const line of left)
624
+ ctx.stdout(` ${line}`);
625
+ }
626
+ ctx.stdout(note ?? "Uninstalled the runner from this machine.");
627
+ return failed ? 1 : 0;
628
+ }
package/dist/transport.js CHANGED
@@ -128,7 +128,8 @@ export class ApiClient {
128
128
  await this.refuseIfNotOk(res, `DELETE ${path}`);
129
129
  return {
130
130
  status: res.status,
131
- body: await this.parseBody(res, `DELETE ${path}`),
131
+ // A revoke may answer 204 with no body.
132
+ body: res.status === 204 ? null : await this.parseBody(res, `DELETE ${path}`),
132
133
  headers: res.headers,
133
134
  };
134
135
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/cli",
3
- "version": "0.16.24",
3
+ "version": "0.16.27",
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.24 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.27 — written in this repository for customer accounts -->
8
8
 
9
9
  # Catalyst's GitHub
10
10