From cf94bc0b2fa9654104a52bcfa36c01a6f5b6a68b Mon Sep 17 00:00:00 2001 From: Anvil Date: Sun, 17 May 2026 15:06:55 +0000 Subject: [PATCH 1/3] ops-7x9y: tps office join --tunnel-via auto-provisioning Adds launchd supervision (SSH tunnel + persistent office connect) to tps office join: - --tunnel-via : auto-provision two launchd plists after join handshake (ai.tpsdev.tunnel- for the SSH tunnel, ai.tpsdev.office- for persistent office connect) - --port : override auto-pick from 33700-33999 range - --force: regenerate supervision when it already exists - --keep-units: skip supervision teardown on revoke tps office status now reports supervision state via launchctl. tps office revoke tears down supervision units (plist delete, launchctl unload, manifest removal) by default. New module: src/commands/office-supervision.ts - plist generation (atomic tmp+rename write, mirroring flair pattern) - port scanning across existing LaunchAgents - launchctl load/unload wrappers - supervision manifest read/write (0644, no secrets) - SSH reachability validation before install 15 tests in test/office-supervision.test.ts covering manifest I/O, plist rendering, port scanning, and teardown. --- packages/cli/bin/tps.ts | 23 +- .../cli/src/commands/office-supervision.ts | 434 ++++++++++++++++++ packages/cli/src/commands/office.ts | 84 +++- packages/cli/test/office-supervision.test.ts | 368 +++++++++++++++ 4 files changed, 904 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/commands/office-supervision.ts create mode 100644 packages/cli/test/office-supervision.test.ts diff --git a/packages/cli/bin/tps.ts b/packages/cli/bin/tps.ts index c2e33bf..bbe9824 100755 --- a/packages/cli/bin/tps.ts +++ b/packages/cli/bin/tps.ts @@ -125,6 +125,10 @@ const cli = meow( priority: { type: "string" }, version: { type: "string" }, verbose: { type: "boolean", default: false }, + // ops-7x9y: office join supervision flags + tunnelVia: { type: "string" }, + force: { type: "boolean", default: false }, + keepUnits: { type: "boolean", default: false }, // Task envelope flags (mail send --task) task: { type: "string" }, taskId: { type: "string" }, @@ -562,16 +566,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..82dbddc --- /dev/null +++ b/packages/cli/src/commands/office-supervision.ts @@ -0,0 +1,434 @@ +/** + * 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; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + 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"; + } +} + +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 + + ${ssh} + -N + -L + ${params.localPort}:127.0.0.1:${params.localPort} + ${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 = join(home, "ops/tps/packages/cli/dist/bin/tps.js"); + const logDir = join(home, ".tps", "logs"); + + return ` + + + + Label + ${label} + + ProgramArguments + + ${bun} + run + ${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 + ".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 { + execSync(`launchctl list "${label}" 2>/dev/null || true`, { + encoding: "utf-8", + }).trim(); + return true; + } 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