Skip to content
Merged
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
23 changes: 21 additions & 2 deletions packages/cli/bin/tps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" +
Expand All @@ -1064,7 +1069,11 @@ async function main() {
" tps flair status\n" +
" tps flair logs\n" +
" tps flair health [--agent <id>] [--flair-url <url>] [--verbose]\n" +
" tps flair sync [--once] [--interval <seconds>] [--dry-run]"
" tps flair sync [--once] [--interval <seconds>] [--dry-run]\n" +
" tps flair set-hub <url> [--auth-mode admin-pass-file --auth-path <path>] [--port <n>]\n" +
" tps flair clear-hub\n" +
" tps flair show [--json]\n" +
" tps flair probe [--json]"
);
process.exit(1);
}
Expand Down Expand Up @@ -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;
}
Expand Down
239 changes: 234 additions & 5 deletions packages/cli/src/commands/flair.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand All @@ -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 {
Expand Down Expand Up @@ -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 <url> [--auth-mode admin-pass-file --auth-path <path>] [--port <n>]",
);
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<string, unknown> = {
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 <url> [--auth-mode admin-pass-file --auth-path <path>]\n` +
` tps flair clear-hub\n` +
` tps flair show [--json]\n` +
` tps flair probe [--json]`,
);
process.exit(1);
}
Expand Down
Loading
Loading