Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ const GLOBAL_FLAGS: FlagTable = {
/** Per-verb flag tables. A verb absent here accepts only the global flags. */
export const FLAG_TABLES: Record<string, FlagTable> = {
hosts: { yes: { value: false, help: "confirm removal without a terminal" } },
runner: {
check: { value: false, help: "read the approved release and say what would change, without changing it" },
auto: { value: false, help: "one-line output for the scheduled run" },
dir: { value: true, help: "the runner directory (default: the one setup created)" },
},
login: {
"start-replica": {
value: false,
Expand Down Expand Up @@ -532,6 +537,7 @@ export const FLAG_TABLES: Record<string, FlagTable> = {
export const VERB_USAGE: Record<string, string> = {
hosts:
"hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name> [--json]",
runner: "runner update [--check] [--dir <runner directory>] | runner auto-update [status|on|off]",
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)",
login:
Expand Down
1 change: 1 addition & 0 deletions src/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const agent = (method: "GET" | "POST", name: string) => ({ method, path: `/api/v
export const CAPABILITIES: readonly Capability[] = [
{verb:"hosts",does:"list this account's machines, current use and live capacity",needs:"member",routes:[],since:"0.16.0"},
{verb:"hosts remove|rename",does:"remove or rename a self-hosted machine in this account",needs:"admin",routes:[],since:"0.16.0"},
{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"},
// CTC-4680: plain `catalyst setup` runs the onboard flow, so install.sh may hand off to it. install.sh
// reads this flag rather than `setup --help`, which already exits 0 on 0.15.x.
{ 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 },
Expand Down
5 changes: 5 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cmdHosts, type HostsDeps } from "./hosts.js";
import { cmdRunner, type RunnerCommandDeps } from "./runner-command.js";
import { cmdSeat } from "./seat.js";
// cli.ts — the `catalyst` dispatcher (npm: @catalyst-cloud/cli). Verbs live in
// their own modules; this file keeps every export the 0.1 tests and the bin import.
Expand Down Expand Up @@ -207,6 +208,7 @@ export function usageText(): string {
" catalyst status | notice | me | ready | accounts",
" catalyst seat checkout --size big|med --harness claude,codex,opencode [--session-id ID] [--stage STAGE]",
" catalyst hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name>",
" catalyst runner update [--check] | runner auto-update [status|on|off] (move this machine's runner to the approved release)",
" catalyst mcp add|list|remove (vault references only)",
" catalyst contract [--refresh] [--path <a.b.c>]",
" catalyst query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <cursor|head>>",
Expand Down Expand Up @@ -267,6 +269,7 @@ export interface MainDeps {
var?: VarDeps;
connections?: ConnectionsDeps;
hosts?: HostsDeps;
runner?: RunnerCommandDeps;
identity?: IdentityDeps;
team?: TeamDeps;
legacy?: LegacyDeps;
Expand Down Expand Up @@ -749,6 +752,8 @@ export async function main(
return await cmdIdentity(args, ctx, deps.identity ?? {});
case "hosts":
return await cmdHosts(args, ctx, deps.hosts);
case "runner":
return await cmdRunner(args, ctx, deps.runner);
case "connections":
return await cmdConnections(args, ctx, deps.connections ?? {});
default:
Expand Down
54 changes: 37 additions & 17 deletions src/onboard-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ const CUSTODY_KEYS = [
] as const;

/** A persisted authority selects bindings, never substitutes for a fresh native proof. */
function persistedCustody(dir: string, values: Record<string, string> | null): "absent" | "invalid" | "installed" {
export function persistedCustody(dir: string, values: Record<string, string> | null): "absent" | "invalid" | "installed" {
const present = CUSTODY_KEYS.map(key => values?.[key] ?? "");
if (present.every(value => !value)) return "absent";
if (present.some(value => !value)) return "invalid";
Expand Down Expand Up @@ -186,7 +186,8 @@ export type RunnerExec = (
options: RunnerExecOptions,
) => Promise<{ code: number; stdout: string; stderr?: string }>;

const spawnDocker: RunnerExec = (args, options) =>
/** The docker CLI as a child process. Exported for `catalyst runner update`. */
export const spawnDocker: RunnerExec = (args, options) =>
new Promise((resolve, reject) => {
const child = spawn("docker", args, {
env: options.env,
Expand Down Expand Up @@ -221,6 +222,23 @@ function composeErrorText(raw: string | undefined): string | undefined {
return text !== "" ? text : undefined;
}

/** The Compose arguments for the installation in `dir`, with the Darwin custody file when its
* persisted authority is installed. Null when that authority is only partly present. */
export function runnerComposeArgs(dir: string): string[] | null {
const custody = persistedCustody(dir, readEnvFile(join(dir, ".env")));
if (custody === "invalid") return null;
return [
"compose",
"--project-name",
RUNNER_PROJECT,
"--file",
join(dir, "compose.yaml"),
...(custody === "installed" ? ["--file", join(dir, "compose.darwin-thoughts.yaml")] : []),
"--env-file",
join(dir, ".env"),
];
}

/** The real engine, through the docker CLI. Compose reads `.env` only: the caller's CATALYST_ and
* DOCKER_SOCKET_ variables are removed, because Compose prefers an exported value over the file. */
export function dockerRunnerEngine(
Expand All @@ -242,20 +260,7 @@ export function dockerRunnerEngine(
return { code: 1, stdout: "" };
}
};
const compose = (dir: string): string[] | null => {
const custody = persistedCustody(dir, readEnvFile(join(dir, ".env")));
if (custody === "invalid") return null;
return [
"compose",
"--project-name",
RUNNER_PROJECT,
"--file",
join(dir, "compose.yaml"),
...(custody === "installed" ? ["--file", join(dir, "compose.darwin-thoughts.yaml")] : []),
"--env-file",
join(dir, ".env"),
];
};
const compose = runnerComposeArgs;
const supervisorShell = (dir: string, script: string): string[] | null => {
const files = compose(dir);
if (!files) return null;
Expand Down Expand Up @@ -682,13 +687,18 @@ function parseHosts(body: unknown): ListedHost[] | null {
}

/** `KEY=value` lines; a single-quoted value is unquoted. Anything else is ignored. */
function readEnvFile(path: string): Record<string, string> | null {
export function readEnvFile(path: string): Record<string, string> | null {
let text: string;
try {
text = readFileSync(path, "utf8");
} catch {
return null;
}
return parseEnvText(text);
}

/** `KEY=value` lines of `.env` text, as readEnvFile reads them. */
export function parseEnvText(text: string): Record<string, string> {
const values: Record<string, string> = {};
for (const line of text.split("\n")) {
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
Expand Down Expand Up @@ -734,6 +744,9 @@ export interface OnboardRunnerInput {
chooseName?: (defaultName: string) => Promise<string | null>;
message?: (text: string) => void;
sleep?: (ms: number) => Promise<void>;
/** CTC-4646: turns on the runner's update schedule after it starts; returns the line to show.
* Never fails setup: an update schedule is a convenience, and `catalyst runner update` works without it. */
autoUpdate?: (dir: string) => string;
/** How long a check waits for the host to enroll and advertise. */
waitMs?: number;
pollMs?: number;
Expand Down Expand Up @@ -1310,6 +1323,13 @@ export function onboardRunnerAdapter(input: OnboardRunnerInput = {},
return { state: "failed", reason: "runner_compose_failed", evidence: { ...selected,
project: RUNNER_PROJECT, ...(up.error ? { composeError: up.error } : {}) } };
acted = true;
if (input.autoUpdate) {
try {
input.message?.(input.autoUpdate(p.dir));
} catch {
input.message?.("Automatic runner updates could not be turned on. Run catalyst runner auto-update on to try again.");
}
}
return { state: "done" };
},
};
Expand Down
4 changes: 4 additions & 0 deletions src/onboard-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import type {
import type { OnboardUi } from "./onboard-ui.js";
import { TIMED_OUT_REASONS } from "./setup-onboard-copy.js";
import { fetchMe } from "./transport.js";
import { enableRunnerAutoUpdate } from "./runner-command.js";

export interface OnboardRuntimeHooks {
beforeStep?: (id: OnboardStepId) => Promise<void>;
Expand All @@ -71,6 +72,8 @@ export interface OnboardRuntimeHooks {
realHome?: () => string;
skillNames?: readonly string[];
runnerEngine?: RunnerEngine;
/** Turns on the runner's update schedule once it runs. Without an injected engine, the real one. */
runnerAutoUpdate?: (dir: string) => string;
/** How the daily update step reads this computer's scheduler; tests replace it. */
scheduler?: HousekeepingOptions;
}
Expand Down Expand Up @@ -466,6 +469,7 @@ export function createOnboardRuntime(
: undefined,
sleep: hooks.sleep,
message: (text) => (hooks.ui ? hooks.ui.message(text) : ctx.stderr(text)),
autoUpdate: hooks.runnerAutoUpdate ?? (hooks.runnerEngine ? undefined : (dir) => enableRunnerAutoUpdate(ctx, dir)),
}),
"linear.adopt": onboardWorkflowVerificationAdapter({
message: (text) => (hooks.ui ? hooks.ui.message(text) : ctx.stderr(text)),
Expand Down
199 changes: 199 additions & 0 deletions src/runner-auto-update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";

// The schedule that runs `catalyst runner update --auto` every 15 minutes (CTC-4646): a LaunchAgent
// on macOS, a systemd user timer on Linux. It runs as the person who set the runner up, with the
// docker command they use, and only ever calls this CLI. Turning it off removes exactly these files.

export const AUTO_UPDATE_LABEL = "dev.catalystcloud.runner-update";
export const AUTO_UPDATE_UNIT = "catalyst-runner-update";
export const AUTO_UPDATE_INTERVAL_SECONDS = 900;
/** The minutes past each hour the macOS schedule runs at: every 15 minutes. */
const CALENDAR_MINUTES = [0, 15, 30, 45] as const;

export type SchedulerRun = (cmd: string, args: string[]) => { status: number; stdout: string; stderr: string };

export interface AutoUpdateDeps {
platform?: NodeJS.Platform;
home: string;
uid?: number;
/** The `catalyst` executable the schedule runs. */
cli: string;
/** PATH for the scheduled run: it must find docker and node. */
path: string;
run?: SchedulerRun;
}

export type AutoUpdateStatus =
| { state: "on"; detail: string; path: string }
| { state: "off"; detail: string }
| { state: "unsupported"; detail: string };

/** `su` and many ssh sessions leave XDG_RUNTIME_DIR unset, so `systemctl --user` cannot find a
* running user manager. Its socket is at /run/user/<uid>; use that when it exists. */
function schedulerEnv(): NodeJS.ProcessEnv {
const uid = process.getuid?.();
const runtime = uid === undefined ? undefined : `/run/user/${uid}`;
return !process.env.XDG_RUNTIME_DIR && runtime && existsSync(runtime)
? { ...process.env, XDG_RUNTIME_DIR: runtime }
: process.env;
}

const defaultRun: SchedulerRun = (cmd, args) => {
const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 15_000, env: schedulerEnv() });
return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.error ? r.error.message : (r.stderr ?? "") };
};

const xml = (value: string) =>
value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
/** systemd's own quoting for one ExecStart/Environment word. */
const unitWord = (value: string) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;

export function launchAgentPath(home: string): string {
return join(home, "Library", "LaunchAgents", `${AUTO_UPDATE_LABEL}.plist`);
}
export function systemdUnitPaths(home: string): { service: string; timer: string } {
const base = join(home, ".config", "systemd", "user");
return { service: join(base, `${AUTO_UPDATE_UNIT}.service`), timer: join(base, `${AUTO_UPDATE_UNIT}.timer`) };
}

export function launchAgentPlist(deps: Pick<AutoUpdateDeps, "cli" | "path" | "home">, dir: string): string {
const args = [deps.cli, "runner", "update", "--auto", "--dir", dir];
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
'<plist version="1.0"><dict>',
`<key>Label</key><string>${AUTO_UPDATE_LABEL}</string>`,
`<key>ProgramArguments</key><array>${args.map((a) => `<string>${xml(a)}</string>`).join("")}</array>`,
`<key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(deps.path)}</string><key>HOME</key><string>${xml(deps.home)}</string></dict>`,
// A calendar schedule, not StartInterval: on a busy Mac launchd held a StartInterval job as
// "pended nondemand spawn" for hours (mini-2, 2026-10-17), and RunAtLoad was pended the same
// way. The calendar entries fired on time on that same machine.
`<key>StartCalendarInterval</key><array>${CALENDAR_MINUTES.map((m) => `<dict><key>Minute</key><integer>${m}</integer></dict>`).join("")}</array>`,
"<key>ProcessType</key><string>Standard</string>",
`<key>StandardOutPath</key><string>${xml(join(dir, "update.log"))}</string>`,
`<key>StandardErrorPath</key><string>${xml(join(dir, "update.log"))}</string>`,
"</dict></plist>",
"",
].join("\n");
}

export function systemdUnits(deps: Pick<AutoUpdateDeps, "cli" | "path">, dir: string): { service: string; timer: string } {
return {
service: [
"[Unit]",
"Description=Catalyst runner update",
"",
"[Service]",
"Type=oneshot",
`Environment=${unitWord(`PATH=${deps.path}`)}`,
`ExecStart=${[deps.cli, "runner", "update", "--auto", "--dir", dir].map(unitWord).join(" ")}`,
"",
].join("\n"),
timer: [
"[Unit]",
"Description=Check for an approved Catalyst runner release every 15 minutes",
"",
"[Timer]",
"OnBootSec=5min",
`OnUnitActiveSec=${AUTO_UPDATE_INTERVAL_SECONDS / 60}min`,
"RandomizedDelaySec=60",
"Persistent=true",
"",
"[Install]",
"WantedBy=timers.target",
"",
].join("\n"),
};
}

function writeFileAtomic(path: string, text: string): void {
mkdirSync(dirname(path), { recursive: true });
const temp = `${path}.${process.pid}.tmp`;
writeFileSync(temp, text, { mode: 0o644 });
renameSync(temp, path);
}

export function autoUpdateStatus(deps: AutoUpdateDeps): AutoUpdateStatus {
const platform = deps.platform ?? process.platform;
const run = deps.run ?? defaultRun;
if (platform === "darwin") {
const path = launchAgentPath(deps.home);
if (!existsSync(path)) return { state: "off", detail: "no_launch_agent" };
const loaded = run("launchctl", ["print", `gui/${deps.uid ?? process.getuid?.() ?? 0}/${AUTO_UPDATE_LABEL}`]).status === 0;
return loaded ? { state: "on", detail: "launch_agent_loaded", path } : { state: "off", detail: "launch_agent_not_loaded" };
}
if (platform === "linux") {
const { timer } = systemdUnitPaths(deps.home);
if (!existsSync(timer)) return { state: "off", detail: "no_timer" };
const enabled = run("systemctl", ["--user", "is-enabled", `${AUTO_UPDATE_UNIT}.timer`]);
return enabled.status === 0 ? { state: "on", detail: "timer_enabled", path: timer } : { state: "off", detail: "timer_not_enabled" };
}
return { state: "unsupported", detail: `platform_${platform}` };
}

/** Install and start the schedule. Rewrites the files every time, so a moved CLI or PATH is picked up. */
export function enableAutoUpdate(deps: AutoUpdateDeps, dir: string): AutoUpdateStatus {
const platform = deps.platform ?? process.platform;
const run = deps.run ?? defaultRun;
if (platform === "darwin") {
const uid = deps.uid ?? process.getuid?.() ?? 0;
const path = launchAgentPath(deps.home);
const text = launchAgentPlist(deps, dir);
const unchanged = existsSync(path) && readFileSync(path, "utf8") === text;
const domain = `gui/${uid}`;
const loaded = run("launchctl", ["print", `${domain}/${AUTO_UPDATE_LABEL}`]).status === 0;
if (unchanged && loaded) return { state: "on", detail: "launch_agent_loaded", path };
if (loaded) run("launchctl", ["bootout", `${domain}/${AUTO_UPDATE_LABEL}`]);
writeFileAtomic(path, text);
const boot = run("launchctl", ["bootstrap", domain, path]);
return boot.status === 0
? { state: "on", detail: "launch_agent_loaded", path }
: { state: "off", detail: `launchctl_bootstrap_failed:${boot.stderr.trim().slice(0, 160)}` };
}
if (platform === "linux") {
if (run("systemctl", ["--user", "show-environment"]).status !== 0)
return { state: "unsupported", detail: "no_systemd_user_manager" };
const paths = systemdUnitPaths(deps.home);
const units = systemdUnits(deps, dir);
writeFileAtomic(paths.service, units.service);
writeFileAtomic(paths.timer, units.timer);
run("systemctl", ["--user", "daemon-reload"]);
const enabled = run("systemctl", ["--user", "enable", "--now", `${AUTO_UPDATE_UNIT}.timer`]);
if (enabled.status !== 0)
return { state: "off", detail: `systemctl_enable_failed:${enabled.stderr.trim().slice(0, 160)}` };
// Without lingering, a user timer stops when this person logs out of a headless machine.
const linger = run("loginctl", ["show-user", String(deps.uid ?? process.getuid?.() ?? 0), "--property=Linger"]);
return { state: "on", detail: /Linger=yes/.test(linger.stdout) ? "timer_enabled" : "timer_enabled_without_linger", path: paths.timer };
}
return { state: "unsupported", detail: `platform_${platform}` };
}

/** Stop and remove the schedule's own files. Nothing else. */
export function disableAutoUpdate(deps: AutoUpdateDeps): AutoUpdateStatus {
const platform = deps.platform ?? process.platform;
const run = deps.run ?? defaultRun;
if (platform === "darwin") {
const uid = deps.uid ?? process.getuid?.() ?? 0;
run("launchctl", ["bootout", `gui/${uid}/${AUTO_UPDATE_LABEL}`]);
const path = launchAgentPath(deps.home);
if (existsSync(path)) unlinkSync(path);
return { state: "off", detail: "launch_agent_removed" };
}
if (platform === "linux") {
run("systemctl", ["--user", "disable", "--now", `${AUTO_UPDATE_UNIT}.timer`]);
const paths = systemdUnitPaths(deps.home);
for (const path of [paths.timer, paths.service]) if (existsSync(path)) unlinkSync(path);
run("systemctl", ["--user", "daemon-reload"]);
return { state: "off", detail: "timer_removed" };
}
return { state: "unsupported", detail: `platform_${platform}` };
}

/** PATH for the scheduled run: the CLI's and node's directories first, then the usual docker homes. */
export function scheduledPath(cli: string, nodeExecutable: string, current: string | undefined): string {
const dirs = [dirname(cli), dirname(nodeExecutable), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin",
...(current ?? "").split(":")];
return [...new Set(dirs.filter((d) => d.startsWith("/")))].join(":");
}
Loading
Loading