diff --git a/packages/cli/bin/tps.ts b/packages/cli/bin/tps.ts index c2e33bf..cc2e0bc 100755 --- a/packages/cli/bin/tps.ts +++ b/packages/cli/bin/tps.ts @@ -1055,7 +1055,12 @@ async function main() { case "flair": { const action = rest[0]; - if (!action || !["install", "uninstall", "start", "stop", "restart", "status", "logs", "health", "sync"].includes(action)) { + const validActions = [ + "install", "uninstall", "start", "stop", "restart", "status", "logs", "health", "sync", + // Config actions (ops-wn6g) + "set-hub", "clear-hub", "show", "probe", + ]; + if (!action || !validActions.includes(action)) { console.error( "Usage:\n" + " tps flair install [--flair-dir ~/ops/flair] [--dev]\n" + @@ -1064,7 +1069,11 @@ async function main() { " tps flair status\n" + " tps flair logs\n" + " tps flair health [--agent ] [--flair-url ] [--verbose]\n" + - " tps flair sync [--once] [--interval ] [--dry-run]" + " tps flair sync [--once] [--interval ] [--dry-run]\n" + + " tps flair set-hub [--auth-mode admin-pass-file --auth-path ] [--port ]\n" + + " tps flair clear-hub\n" + + " tps flair show [--json]\n" + + " tps flair probe [--json]" ); process.exit(1); } @@ -1100,6 +1109,16 @@ async function main() { ? process.argv[process.argv.indexOf("--flair-dir") + 1] : undefined, dev: process.argv.includes("--dev"), + // set-hub: first positional after action is the URL. + hub: action === "set-hub" ? rest[1] : undefined, + authMode: process.argv.includes("--auth-mode") + ? process.argv[process.argv.indexOf("--auth-mode") + 1] + : undefined, + authPath: process.argv.includes("--auth-path") + ? process.argv[process.argv.indexOf("--auth-path") + 1] + : undefined, + port: typeof cli.flags.port === "number" ? Number(cli.flags.port) : undefined, + json: Boolean(cli.flags.json), }); break; } diff --git a/packages/cli/src/commands/flair.ts b/packages/cli/src/commands/flair.ts index 43265d7..d0657f3 100644 --- a/packages/cli/src/commands/flair.ts +++ b/packages/cli/src/commands/flair.ts @@ -1,8 +1,14 @@ /** - * tps harper install|uninstall|start|stop|restart|status|logs + * tps flair install|uninstall|start|stop|restart|status|logs (local lifecycle) + * tps flair set-hub|clear-hub|show|probe (team config — ops-wn6g) * - * Manages Harper (Flair backend) as a macOS launchd agent. - * Auto-restarts on crash, starts on login. + * Local lifecycle actions manage Harper (Flair backend) as a macOS launchd + * agent. Auto-restarts on crash, starts on login. + * + * Config actions manage ~/.tps/flair.json so other TPS subcommands (and + * future branch-init) know the team's Flair hub URL without scraping env + * vars. Hub-less mode is valid: set-hub is optional; branches without a + * configured hub get local Flair only (no fed-sync). See ops-wn6g. */ import { execSync } from "node:child_process"; @@ -12,8 +18,9 @@ import { readFileSync, mkdirSync, chmodSync, + renameSync, } from "node:fs"; -import { join, resolve } from "node:path"; +import { join, resolve, dirname } from "node:path"; import { homedir } from "node:os"; import { randomBytes } from "node:crypto"; @@ -31,6 +38,74 @@ const STDERR_LOG = join(LOG_DIR, "flair.error.log"); interface HarperOpts { flairDir?: string; dev?: boolean; + // Config action args (set-hub / clear-hub / show / probe) + hub?: string; + authMode?: string; + authPath?: string; + port?: number; + json?: boolean; +} + +export interface FlairConfigFile { + hub: string | null; + auth: { mode: "admin-pass-file"; path: string } | null; + localPort: number; +} + +const DEFAULT_LOCAL_PORT = 9926; + +function tpsRoot(): string { + return process.env.TPS_ROOT || join(process.env.HOME || homedir(), ".tps"); +} + +function flairConfigPath(): string { + return join(tpsRoot(), "flair.json"); +} + +export function readFlairConfigFile(): FlairConfigFile { + const p = flairConfigPath(); + if (!existsSync(p)) { + return { hub: null, auth: null, localPort: DEFAULT_LOCAL_PORT }; + } + const raw = JSON.parse(readFileSync(p, "utf-8")); + return { + hub: raw.hub ? String(raw.hub) : null, + auth: + raw.auth && raw.auth.mode === "admin-pass-file" && raw.auth.path + ? { mode: "admin-pass-file", path: String(raw.auth.path) } + : null, + localPort: typeof raw.localPort === "number" ? raw.localPort : DEFAULT_LOCAL_PORT, + }; +} + +/** + * Strip user:pass@ userinfo from a URL for display. Returns the original + * string unchanged if it isn't a parseable URL with userinfo. Used by + * `tps flair show` so a previously-stored credential-bearing URL doesn't + * leak via stdout. set-hub rejects credential URLs at write time; + * this is defense-in-depth. + */ +export function redactUrlCredentials(input: string): string { + try { + const u = new URL(input); + if (!u.username && !u.password) return input; + u.username = ""; + u.password = ""; + // Re-encode without trailing colon-only userinfo block. + return u.toString(); + } catch { + return input; + } +} + +export function writeFlairConfigFile(config: FlairConfigFile): void { + const p = flairConfigPath(); + mkdirSync(dirname(p), { recursive: true }); + // Atomic write: tmp + rename so a concurrent reader never sees a partial + // file. Same pattern as cli#281 outbox fix. + const tmp = `${p}.${process.pid}.tmp`; + writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 }); + renameSync(tmp, p); } function getFlairDir(opts: HarperOpts): string { @@ -307,9 +382,163 @@ export async function flairCommand( execSync(`tail -50 "${STDOUT_LOG}"`, { stdio: "inherit" }); break; } + + // -- Config actions (ops-wn6g) --------------------------------------- + + case "set-hub": { + if (!opts.hub) { + console.error( + "Usage: tps flair set-hub [--auth-mode admin-pass-file --auth-path ] [--port ]", + ); + process.exit(1); + } + const trimmed = opts.hub.trim(); + if (trimmed === "") { + console.error("Empty URL not allowed. Use `tps flair clear-hub` to remove the hub."); + process.exit(1); + } + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + console.error(`Invalid URL: ${opts.hub}`); + process.exit(1); + } + // Allowlist protocols. set-hub points the team's Flair config at an HTTP + // endpoint — accepting non-HTTP schemes (file, ftp, websocket, etc.) + // would let a typo write something the probe action then tries to fetch + // with unexpected semantics. Kern nit (PR #284 review). + if (!["https:", "http:"].includes(parsed.protocol)) { + console.error( + `Unsupported protocol: ${parsed.protocol}. Hub must be https:// or http://.`, + ); + process.exit(1); + } + // Reject embedded credentials. set-hub should not store `https://user:pass@host` + // — credentials belong in --auth-mode/--auth-path. Sherlock nit (PR #284 + // review): URLs with userinfo leak via `tps flair show` output and shell + // history. Refuse at write time; downstream `show` also redacts as + // defense-in-depth (forward-compat for already-stored configs). + if (parsed.username || parsed.password) { + console.error( + "URL contains embedded credentials. Use --auth-mode + --auth-path instead; the hub URL should not carry user:pass@host.", + ); + process.exit(1); + } + // Bounds-check the local port if provided. + if (typeof opts.port === "number") { + if (!Number.isInteger(opts.port) || opts.port <= 0 || opts.port > 65535) { + console.error(`Invalid --port: ${opts.port}. Must be an integer in 1..65535.`); + process.exit(1); + } + } + const existing = readFlairConfigFile(); + const config: FlairConfigFile = { + hub: trimmed, + auth: + opts.authMode === "admin-pass-file" && opts.authPath + ? { mode: "admin-pass-file", path: opts.authPath } + : existing.auth, + localPort: typeof opts.port === "number" ? opts.port : existing.localPort, + }; + writeFlairConfigFile(config); + console.log(`Flair hub set: ${config.hub}`); + if (config.auth) { + console.log(`Auth: ${config.auth.mode} at ${config.auth.path}`); + } else { + console.log("Auth: none configured (set with --auth-mode + --auth-path)"); + } + break; + } + + case "clear-hub": { + if (!existsSync(flairConfigPath())) { + console.log("No Flair config to clear."); + return; + } + const existing = readFlairConfigFile(); + writeFlairConfigFile({ hub: null, auth: null, localPort: existing.localPort }); + console.log("Flair hub cleared. Branches will provision in hub-less mode (no fed-sync)."); + break; + } + + case "show": { + const config = readFlairConfigFile(); + // Defense-in-depth: even though set-hub rejects URLs with embedded + // credentials, an already-stored config (from a pre-fix version or + // hand-edited flair.json) could contain user:pass@host. Redact before + // any output reaches stdout / shell history / screen scrollback. + // Sherlock nit (PR #284 review). + const hubDisplay = config.hub ? redactUrlCredentials(config.hub) : null; + const safe = { + hub: hubDisplay, + auth: config.auth ? { mode: config.auth.mode, path: config.auth.path } : null, + localPort: config.localPort, + }; + if (opts.json) { + console.log(JSON.stringify(safe, null, 2)); + } else { + console.log(`Hub: ${hubDisplay ?? "(none — hub-less mode)"}`); + console.log( + `Auth: ${config.auth ? `${config.auth.mode} at ${config.auth.path}` : "(none)"}`, + ); + console.log(`Local port: ${config.localPort}`); + } + break; + } + + case "probe": { + const config = readFlairConfigFile(); + const results: Record = { + config: { hub: config.hub, localPort: config.localPort }, + localFlair: null, + hubReachable: null, + }; + try { + const res = await fetch(`http://127.0.0.1:${config.localPort}/Health/0`, { + signal: AbortSignal.timeout(2_000), + }); + results.localFlair = { ok: res.ok, status: res.status }; + } catch (err) { + results.localFlair = { ok: false, error: (err as Error).message }; + } + if (config.hub) { + try { + const u = new URL(config.hub); + const probeUrl = `${u.protocol}//${u.host}/Health/0`; + const res = await fetch(probeUrl, { signal: AbortSignal.timeout(5_000) }); + results.hubReachable = { ok: res.ok, status: res.status }; + } catch (err) { + results.hubReachable = { ok: false, error: (err as Error).message }; + } + } + if (opts.json) { + console.log(JSON.stringify(results, null, 2)); + } else { + console.log(`Hub: ${config.hub ?? "(none)"}`); + const local = results.localFlair as { ok: boolean; status?: number; error?: string }; + console.log( + `Local Flair: ${local.ok ? `OK (${local.status})` : `UNREACHABLE (${local.error ?? "?"})`}`, + ); + if (config.hub) { + const hub = results.hubReachable as { ok: boolean; status?: number; error?: string }; + console.log( + `Hub probe: ${hub.ok ? `OK (${hub.status})` : `UNREACHABLE (${hub.error ?? "?"})`}`, + ); + } + } + break; + } + default: console.error( - `Unknown action: ${action}\nUsage: tps harper install|uninstall|start|stop|restart|status|logs`, + `Unknown action: ${action}\n` + + `Usage:\n` + + ` tps flair install|uninstall|start|stop|restart|status|logs (local lifecycle)\n` + + ` tps flair set-hub [--auth-mode admin-pass-file --auth-path ]\n` + + ` tps flair clear-hub\n` + + ` tps flair show [--json]\n` + + ` tps flair probe [--json]`, ); process.exit(1); } diff --git a/packages/cli/test/flair-config.test.ts b/packages/cli/test/flair-config.test.ts new file mode 100644 index 0000000..881d6c2 --- /dev/null +++ b/packages/cli/test/flair-config.test.ts @@ -0,0 +1,148 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + readFlairConfigFile, + writeFlairConfigFile, + redactUrlCredentials, + type FlairConfigFile, +} from "../src/commands/flair.js"; + +describe("flair config (ops-wn6g)", () => { + let root: string; + let prevHome: string | undefined; + let prevRoot: string | undefined; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "tps-flair-config-")); + prevHome = process.env.HOME; + prevRoot = process.env.TPS_ROOT; + process.env.HOME = root; + process.env.TPS_ROOT = join(root, ".tps"); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevRoot === undefined) delete process.env.TPS_ROOT; + else process.env.TPS_ROOT = prevRoot; + }); + + test("readFlairConfigFile returns hub-less defaults when no file exists", () => { + const cfg = readFlairConfigFile(); + expect(cfg.hub).toBeNull(); + expect(cfg.auth).toBeNull(); + expect(cfg.localPort).toBe(9926); + }); + + test("writeFlairConfigFile + readFlairConfigFile round-trips", () => { + const written: FlairConfigFile = { + hub: "https://flair.example.com", + auth: { mode: "admin-pass-file", path: "/home/me/.flair/admin-pass" }, + localPort: 19926, + }; + writeFlairConfigFile(written); + const read = readFlairConfigFile(); + expect(read).toEqual(written); + }); + + test("writeFlairConfigFile uses atomic .tmp + rename (no partial file visible)", () => { + writeFlairConfigFile({ hub: "https://x.test", auth: null, localPort: 9926 }); + const tpsDir = process.env.TPS_ROOT!; + // After successful write, the .tmp file should not exist + const files = require("node:fs").readdirSync(tpsDir); + expect(files.some((f: string) => f.startsWith("flair.json.") && f.endsWith(".tmp"))).toBe(false); + expect(files).toContain("flair.json"); + }); + + test("config file is mode 0600 (private)", () => { + writeFlairConfigFile({ hub: "https://x.test", auth: null, localPort: 9926 }); + const p = join(process.env.TPS_ROOT!, "flair.json"); + const st = statSync(p); + // Mask off the file-type bits (S_IFREG etc), keep just the perms. + const mode = st.mode & 0o777; + expect(mode).toBe(0o600); + }); + + test("readFlairConfigFile tolerates auth field with unknown mode (defaults to null)", () => { + // Future-proofing: if a newer version writes a mode we don't know, + // current readers shouldn't blow up — they should treat it as no-auth. + const tpsDir = process.env.TPS_ROOT!; + mkdirSync(tpsDir, { recursive: true }); + writeFileSync( + join(tpsDir, "flair.json"), + JSON.stringify({ + hub: "https://x.test", + auth: { mode: "some-future-mode", path: "/x" }, + localPort: 9926, + }), + ); + const cfg = readFlairConfigFile(); + expect(cfg.hub).toBe("https://x.test"); + expect(cfg.auth).toBeNull(); // unknown mode → null + }); + + test("readFlairConfigFile handles missing localPort by defaulting to 9926", () => { + const tpsDir = process.env.TPS_ROOT!; + mkdirSync(tpsDir, { recursive: true }); + writeFileSync( + join(tpsDir, "flair.json"), + JSON.stringify({ hub: "https://x.test" }), + ); + const cfg = readFlairConfigFile(); + expect(cfg.localPort).toBe(9926); + }); + + test("writeFlairConfigFile creates ~/.tps/ if missing", () => { + // Fresh tmpdir — no .tps subdir yet + expect(existsSync(process.env.TPS_ROOT!)).toBe(false); + writeFlairConfigFile({ hub: null, auth: null, localPort: 9926 }); + expect(existsSync(process.env.TPS_ROOT!)).toBe(true); + expect(existsSync(join(process.env.TPS_ROOT!, "flair.json"))).toBe(true); + }); + + test("hub-less mode persists across read (Nathan 2026-05-17: hub-less is valid)", () => { + writeFlairConfigFile({ hub: null, auth: null, localPort: 9926 }); + const cfg = readFlairConfigFile(); + expect(cfg.hub).toBeNull(); + // Verify it serializes as JSON null, not missing key + const raw = JSON.parse(readFileSync(join(process.env.TPS_ROOT!, "flair.json"), "utf-8")); + expect(raw).toHaveProperty("hub", null); + }); + + // Round-trip persistence of trimmed URL (Kern nit, PR #284 review) + test("writeFlairConfigFile preserves the trimmed URL exactly as set", () => { + // Direct write of a normalized URL — set-hub trims before passing this in. + writeFlairConfigFile({ hub: "https://flair.example.com", auth: null, localPort: 9926 }); + const cfg = readFlairConfigFile(); + expect(cfg.hub).toBe("https://flair.example.com"); + // No leading/trailing whitespace at rest + expect(cfg.hub).toBe((cfg.hub as string).trim()); + }); +}); + +describe("redactUrlCredentials (Sherlock nit, PR #284)", () => { + test("strips user:pass@ from a URL with embedded credentials", () => { + expect(redactUrlCredentials("https://admin:secret@flair.example.com/path")).toBe( + "https://flair.example.com/path", + ); + }); + + test("strips username-only (no password)", () => { + expect(redactUrlCredentials("https://admin@flair.example.com")).toBe( + "https://flair.example.com/", + ); + }); + + test("leaves a credential-free URL unchanged", () => { + expect(redactUrlCredentials("https://flair.example.com/path?q=1")).toBe( + "https://flair.example.com/path?q=1", + ); + }); + + test("returns the input unchanged for non-URL strings", () => { + expect(redactUrlCredentials("not-a-url")).toBe("not-a-url"); + }); +});