diff --git a/packages/cli/bin/tps.ts b/packages/cli/bin/tps.ts index c2e33bf..19a97cf 100755 --- a/packages/cli/bin/tps.ts +++ b/packages/cli/bin/tps.ts @@ -125,6 +125,9 @@ const cli = meow( priority: { type: "string" }, version: { type: "string" }, verbose: { type: "boolean", default: false }, + // ops-7x9y: office join supervision flags (--force is already declared above) + tunnelVia: { type: "string" }, + keepUnits: { type: "boolean", default: false }, // Task envelope flags (mail send --task) task: { type: "string" }, taskId: { type: "string" }, @@ -562,16 +565,27 @@ async function main() { } else if (action === "join") { const joinToken = rest[2]; if (!rest[1] || !joinToken) { - console.error("Usage: tps office join "); + console.error("Usage: tps office join [--tunnel-via ] [--port ] [--force]"); process.exit(1); } - await runOffice({ action: "join", agent: rest[1], joinToken }); + await runOffice({ + action: "join", + agent: rest[1], + joinToken, + tunnelVia: cli.flags.tunnelVia as string | undefined, + port: cli.flags.port as number | undefined, + force: cli.flags.force as boolean, + }); } else if (action === "revoke") { if (!rest[1]) { - console.error("Usage: tps office revoke "); + console.error("Usage: tps office revoke [--keep-units]"); process.exit(1); } - await runOffice({ action: "revoke", agent: rest[1] }); + await runOffice({ + action: "revoke", + agent: rest[1], + keepUnits: cli.flags.keepUnits as boolean, + }); } else if (action === "setup") { const dryRun = process.argv.includes("--dry-run") || process.argv.includes("--dry"); await runOffice({ action: "setup", agent: rest[1], dryRun }); diff --git a/packages/cli/src/commands/office-supervision.ts b/packages/cli/src/commands/office-supervision.ts new file mode 100644 index 0000000..2202f51 --- /dev/null +++ b/packages/cli/src/commands/office-supervision.ts @@ -0,0 +1,463 @@ +/** + * ops-7x9y: launchd supervision for `tps office join --tunnel-via`. + * + * Generates and manages macOS launchd plists that keep SSH tunnels and + * office-connect processes alive across reboots. Each branch-office gets + * two units: + * - ai.tpsdev.tunnel-: autossh-style port forward + * - ai.tpsdev.office-: persistent office connect + * + * All state is tracked in ~/.tps/branch-office//supervision.json. + */ + +import { execSync, spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +// ---- Port scanning ---- + +const PORT_RANGE_START = 33700; +const PORT_RANGE_END = 33999; + +/** + * Find the first unused local TCP port in the deterministic range 33700–33999. + * A port is considered "taken" if a launchd plist in ~/Library/LaunchAgents + * references it in a -L argument. + */ +export function findFreeLaunchdPort(home: string = homedir()): number { + const launchAgentsDir = join(home, "Library", "LaunchAgents"); + const taken = new Set(); + + if (existsSync(launchAgentsDir)) { + const { readdirSync } = require("node:fs"); + for (const f of readdirSync(launchAgentsDir)) { + if (!f.endsWith(".plist")) continue; + try { + const content = readFileSync(join(launchAgentsDir, f), "utf-8"); + // Match -L :127.0.0.1: or -L :localhost: + const re = /-L<\/string>\s*(\d+):/g; + for (const m of content.matchAll(re)) { + taken.add(parseInt(m[1], 10)); + } + } catch { + // unreadable plist → skip + } + } + } + + for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port++) { + if (!taken.has(port)) return port; + } + + throw new Error( + `No free port found in range ${PORT_RANGE_START}–${PORT_RANGE_END}. ` + + `Free up a port or use --port to choose manually.` + ); +} + +// ---- Plist generation ---- + +function resolveBun(): string { + try { + return execSync("which bun", { encoding: "utf-8" }).trim(); + } catch { + return "/opt/homebrew/bin/bun"; + } +} + +function resolveSsh(): string { + try { + return execSync("which ssh", { encoding: "utf-8" }).trim(); + } catch { + return "/usr/bin/ssh"; + } +} + +/** + * Resolve the path to the bundled tps.js CLI entrypoint. + * Precedence: 1) TPS_CLI_PATH env var, 2) /ops/tps/packages/cli/dist/bin/tps.js + */ +function resolveTpsJs(home: string): string { + if (process.env.TPS_CLI_PATH) return process.env.TPS_CLI_PATH; + return join(home, "ops/tps/packages/cli/dist/bin/tps.js"); +} + +/** + * Escape XML special characters for safe string interpolation into plist XML. + * Covers: & < > " + */ +function xmlEscape(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export interface TunnelPlistParams { + name: string; + localPort: number; + tunnelVia: string; + home?: string; +} + +export function generateTunnelPlist(params: TunnelPlistParams): string { + const home = params.home ?? homedir(); + const label = `ai.tpsdev.tunnel-${params.name}`; + const ssh = resolveSsh(); + const logDir = join(home, ".tps", "logs"); + + return ` + + + + Label + ${label} + + ProgramArguments + + ${xmlEscape(ssh)} + -N + -L + ${params.localPort}:127.0.0.1:${params.localPort} + -- + ${xmlEscape(params.tunnelVia)} + + + RunAtLoad + + + KeepAlive + + + StandardOutPath + ${join(logDir, `tunnel-${params.name}.log`)} + + StandardErrorPath + ${join(logDir, `tunnel-${params.name}.error.log`)} + + +`; +} + +export interface OfficePlistParams { + name: string; + home?: string; +} + +export function generateOfficePlist(params: OfficePlistParams): string { + const home = params.home ?? homedir(); + const label = `ai.tpsdev.office-${params.name}`; + const bun = resolveBun(); + const tpsJs = resolveTpsJs(home); + const logDir = join(home, ".tps", "logs"); + + return ` + + + + Label + ${label} + + ProgramArguments + + ${xmlEscape(bun)} + run + ${xmlEscape(tpsJs)} + office + connect + ${params.name} + + + WorkingDirectory + ${join(home, "ops/tps")} + + RunAtLoad + + + KeepAlive + + + StandardOutPath + ${join(logDir, `office-${params.name}.log`)} + + StandardErrorPath + ${join(logDir, `office-${params.name}.error.log`)} + + +`; +} + +// ---- Atomic plist writes ---- + +function plistPath(label: string, home: string = homedir()): string { + return join(home, "Library", "LaunchAgents", `${label}.plist`); +} + +export function writePlist(label: string, content: string, home?: string): string { + const h = home ?? homedir(); + const dest = plistPath(label, h); + const tmp = `${dest}.${process.pid}.tmp`; + + mkdirSync(join(h, "Library", "LaunchAgents"), { recursive: true }); + writeFileSync(tmp, content, { encoding: "utf-8", mode: 0o644 }); + renameSync(tmp, dest); + return dest; +} + +export function deletePlist(label: string, home?: string): void { + const dest = plistPath(label, home); + if (existsSync(dest)) { + unlinkSync(dest); + } +} + +// ---- launchctl helpers ---- + +function isUnitLoaded(label: string): boolean { + try { + const out = execSync(`launchctl list "${label}" 2>/dev/null || true`, { + encoding: "utf-8", + }).trim(); + return out.length > 0 && out !== "-"; + } catch { + return false; + } +} + +export function loadUnit(plistPath: string): void { + execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" }); +} + +export function unloadUnit(plistPath: string, label: string): void { + // Try to unload; if it's already gone that's fine + try { + if (isUnitLoaded(label)) { + execSync(`launchctl unload "${plistPath}" 2>/dev/null || true`, { + stdio: "pipe", + }); + } + } catch { + // Best-effort + } +} + +export interface UnitState { + label: string; + loaded: boolean; + pid: number | null; + lastExitStatus: number | null; +} + +export function getUnitState(label: string): UnitState { + const state: UnitState = { label, loaded: false, pid: null, lastExitStatus: null }; + try { + // `launchctl list