diff --git a/bun.lock b/bun.lock index 89e0116f..20597705 100644 --- a/bun.lock +++ b/bun.lock @@ -33,7 +33,10 @@ }, "packages/rt-client": { "name": "@mattstack/rt-client", - "version": "0.2.0", + "version": "0.3.0", + "dependencies": { + "jsonc-parser": "^3.3.1", + }, "peerDependencies": { "@mattstack/glance": ">=0.13.0", }, diff --git a/commands/__tests__/home.test.ts b/commands/__tests__/home.test.ts new file mode 100644 index 00000000..d57b9e15 --- /dev/null +++ b/commands/__tests__/home.test.ts @@ -0,0 +1,307 @@ +import { describe, test, expect, spyOn } from "bun:test"; +import { gatherHomeState, homeInit, type HomeProbes, type SopsYamlSeam } from "../home.ts"; +import { buildInitPlan } from "../../lib/home/init-plan.ts"; +import type { ExecResult, ExecSeam } from "../../lib/home/init-exec.ts"; +import { renderSopsYaml, type AgeExecResult, type AgeKeySeam } from "../../lib/home/age-key.ts"; +import { mattstackHome } from "../../lib/rt-paths.ts"; +import { join } from "path"; + +const FAKE_PUBLIC_KEY = "age1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"; +const FAKE_PRIVATE_KEY = "AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ"; +const SOPS_YAML_PATH = join(mattstackHome(), ".sops.yaml"); + +/** In-memory .sops.yaml — never touches the real filesystem. */ +class FakeSopsYamlSeam implements SopsYamlSeam { + files = new Map(); + writes: { path: string; content: string }[] = []; + + constructor(initial?: { path: string; content: string }) { + if (initial) this.files.set(initial.path, initial.content); + } + + read(path: string): string | null { + return this.files.get(path) ?? null; + } + + write(path: string, content: string): void { + this.files.set(path, content); + this.writes.push({ path, content }); + } +} + +/** No key in the keychain yet; ensureAgeKey mints one — never touches the real keychain. */ +class FakeAgeKeySeam implements AgeKeySeam { + calls: string[][] = []; + + async run(cmd: string[]): Promise { + this.calls.push(cmd); + if (cmd[1] === "find-generic-password") { + return { code: 44, stdout: "", stderr: "The specified item could not be found in the keychain." }; + } + if (cmd[0] === "age-keygen") { + return { code: 0, stdout: `# public key: ${FAKE_PUBLIC_KEY}\n${FAKE_PRIVATE_KEY}\n`, stderr: "" }; + } + if (cmd[1] === "add-generic-password") return { code: 0, stdout: "", stderr: "" }; + throw new Error(`FakeAgeKeySeam: unexpected call ${cmd.join(" ")}`); + } +} + +function fakeProbes(overrides: Partial): HomeProbes { + return { + isGitRepo: () => false, + exists: () => false, + listTeamClones: () => [], + readFile: () => null, + ...overrides, + }; +} + +describe("gatherHomeState", () => { + test("hasUserClone is true only when user/ is itself a git clone", () => { + const probes = fakeProbes({ + isGitRepo: (dir) => dir.endsWith("/user"), + }); + const state = gatherHomeState("/home", probes); + expect(state.hasUserClone).toBe(true); + }); + + test("a plain (non-git) user/ directory does not count as a clone, and yields no foldInPrefs step", () => { + const probes = fakeProbes({ + // user/ exists on disk but isn't a git repo — e.g. a half-materialized + // or manually-created directory, not the mattstack-prefs clone. + exists: (path) => path.endsWith("/user"), + isGitRepo: () => false, + }); + const state = gatherHomeState("/home", probes); + expect(state.hasUserClone).toBe(false); + + const plan = buildInitPlan(state); + expect(plan.steps.map((s) => s.kind)).not.toContain("foldInPrefs"); + }); + + test("prefsRemoteUrl is parsed from user/.git/config while the clone still exists", () => { + const probes = fakeProbes({ + isGitRepo: (dir) => dir.endsWith("/user"), + readFile: (path) => + path.endsWith("/user/.git/config") + ? '[remote "origin"]\n\turl = https://github.com/mattgoodwin/mattstack-prefs.git\n' + : null, + }); + const state = gatherHomeState("/home", probes); + expect(state.prefsRemoteUrl).toBe("https://github.com/mattgoodwin/mattstack-prefs.git"); + }); + + test("prefsRemoteUrl is undefined when there is no user clone, even if readFile would return something", () => { + const probes = fakeProbes({ + isGitRepo: () => false, + readFile: () => '[remote "origin"]\n\turl = https://example.com/should-not-be-read.git\n', + }); + const state = gatherHomeState("/home", probes); + expect(state.prefsRemoteUrl).toBeUndefined(); + }); + + test("prefsRemoteUrl is undefined when the config can't be read or parsed", () => { + const probes = fakeProbes({ + isGitRepo: (dir) => dir.endsWith("/user"), + readFile: () => null, + }); + const state = gatherHomeState("/home", probes); + expect(state.prefsRemoteUrl).toBeUndefined(); + + const plan = buildInitPlan(state); + expect(plan.steps).toEqual([]); + expect(plan.reason).toBe("prefs-remote-unreadable"); + }); +}); + +/** Records argv only; used to prove preflight/idempotence run zero real steps. */ +class FakeSeam implements ExecSeam { + calls: string[][] = []; + constructor( + private opts: { + failRun?: (cmd: string[]) => boolean; + throwOn?: (cmd: string[]) => boolean; + stdout?: (cmd: string[]) => string; + } = {}, + ) {} + + async run(cmd: string[]): Promise { + this.calls.push(cmd); + if (this.opts.throwOn?.(cmd)) throw new Error(`spawn ${cmd[0]} ENOENT`); + if (this.opts.failRun?.(cmd)) return { code: 1, stdout: "", stderr: "boom" }; + return { code: 0, stdout: this.opts.stdout?.(cmd) ?? "", stderr: "" }; + } + async writeFile(): Promise {} + async removeDir(): Promise {} + async mkTempDir(): Promise { + return "/tmp/rt-home-fold-test"; + } +} + +/** Runs `homeInit`, catching the `process.exit` call the failure paths make. */ +async function runHomeInit( + probes: HomeProbes, + exec: ExecSeam, + ageKeySeam: AgeKeySeam = new FakeAgeKeySeam(), + args: string[] = [], + sopsYamlSeam: SopsYamlSeam = new FakeSopsYamlSeam(), +): Promise<{ exitCode: number | undefined; logs: string[] }> { + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const logs: string[] = []; + spyOn(console, "log").mockImplementation((...parts: unknown[]) => { + logs.push(parts.map(String).join(" ")); + }); + spyOn(console, "error").mockImplementation(() => {}); + try { + await homeInit(args, {}, probes, exec, ageKeySeam, sopsYamlSeam); + return { exitCode: undefined, logs }; + } catch { + const code = exitSpy.mock.calls.at(-1)?.[0] as number | undefined; + return { exitCode: code, logs }; + } finally { + exitSpy.mockRestore(); + (console.log as unknown as { mockRestore: () => void }).mockRestore(); + (console.error as unknown as { mockRestore: () => void }).mockRestore(); + } +} + +describe("homeInit", () => { + test("already-initialized: exits cleanly, runs no preflight or step, but still ensures the age key (idempotent)", async () => { + const seam = new FakeSeam(); + const ageKeySeam = new FakeAgeKeySeam(); + const { exitCode } = await runHomeInit(fakeProbes({ isGitRepo: () => true }), seam, ageKeySeam); + + expect(exitCode).toBeUndefined(); + expect(seam.calls).toEqual([]); + expect(ageKeySeam.calls.some((c) => c[1] === "find-generic-password")).toBe(true); + }); + + test("already-initialized: backfills .sops.yaml when it's missing (a home repo that predates this step)", async () => { + const seam = new FakeSeam(); + const ageKeySeam = new FakeAgeKeySeam(); + const sopsYamlSeam = new FakeSopsYamlSeam(); + + await runHomeInit(fakeProbes({ isGitRepo: () => true }), seam, ageKeySeam, [], sopsYamlSeam); + + expect(sopsYamlSeam.files.get(SOPS_YAML_PATH)).toBe(renderSopsYaml(FAKE_PUBLIC_KEY)); + }); + + test("already-initialized: an existing .sops.yaml with the current key's recipient is left untouched", async () => { + const seam = new FakeSeam(); + const ageKeySeam = new FakeAgeKeySeam(); + const sopsYamlSeam = new FakeSopsYamlSeam({ path: SOPS_YAML_PATH, content: renderSopsYaml(FAKE_PUBLIC_KEY) }); + + await runHomeInit(fakeProbes({ isGitRepo: () => true }), seam, ageKeySeam, [], sopsYamlSeam); + + expect(sopsYamlSeam.writes).toEqual([]); + }); + + test("already-initialized: an existing .sops.yaml with a stale recipient (key rotation) is rewritten", async () => { + const seam = new FakeSeam(); + const ageKeySeam = new FakeAgeKeySeam(); + const sopsYamlSeam = new FakeSopsYamlSeam({ path: SOPS_YAML_PATH, content: renderSopsYaml("age1stale") }); + + await runHomeInit(fakeProbes({ isGitRepo: () => true }), seam, ageKeySeam, [], sopsYamlSeam); + + expect(sopsYamlSeam.files.get(SOPS_YAML_PATH)).toBe(renderSopsYaml(FAKE_PUBLIC_KEY)); + }); + + test("already-initialized --dry-run: never touches the age key either", async () => { + const seam = new FakeSeam(); + const ageKeySeam = new FakeAgeKeySeam(); + const sopsYamlSeam = new FakeSopsYamlSeam(); + const { exitCode } = await runHomeInit(fakeProbes({ isGitRepo: () => true }), seam, ageKeySeam, ["--dry-run"], sopsYamlSeam); + + expect(exitCode).toBeUndefined(); + expect(ageKeySeam.calls).toEqual([]); + expect(sopsYamlSeam.writes).toEqual([]); + }); + + test("prefs-remote-unreadable: exits 1 and runs no preflight, init step, or age-key call", async () => { + const seam = new FakeSeam(); + const ageKeySeam = new FakeAgeKeySeam(); + const probes = fakeProbes({ + isGitRepo: (dir) => dir.endsWith("/user"), // hasUserClone, home itself is not a repo + readFile: () => null, // config unreadable -> prefsRemoteUrl stays undefined + }); + const { exitCode } = await runHomeInit(probes, seam, ageKeySeam); + + expect(exitCode).toBe(1); + expect(seam.calls).toEqual([]); + expect(ageKeySeam.calls).toEqual([]); + }); + + test("preflight failure (gh not authenticated) prints a hint, runs no init step, and never touches the age key", async () => { + const seam = new FakeSeam({ failRun: (cmd) => cmd[0] === "gh" }); + const ageKeySeam = new FakeAgeKeySeam(); + const { exitCode } = await runHomeInit(fakeProbes({}), seam, ageKeySeam); + + expect(exitCode).toBe(1); + // Only the gh check ran — filter-repo's check and every init step were + // never reached. + expect(seam.calls).toEqual([["gh", "auth", "status"]]); + expect(ageKeySeam.calls).toEqual([]); + }); + + test("preflight: a missing binary (spawn throws) is caught as an install hint, not a raw crash", async () => { + const seam = new FakeSeam({ throwOn: (cmd) => cmd[0] === "git" && cmd[1] === "filter-repo" }); + const ageKeySeam = new FakeAgeKeySeam(); + const { exitCode } = await runHomeInit(fakeProbes({}), seam, ageKeySeam); + + expect(exitCode).toBe(1); + expect(seam.calls).toEqual([ + ["gh", "auth", "status"], + ["git", "filter-repo", "--version"], + ]); + expect(ageKeySeam.calls).toEqual([]); + }); + + test("a fresh, fully successful init mints the age key and writes .sops.yaml as a distinct step after adoption, before returning", async () => { + const seam = new FakeSeam({ + failRun: (cmd) => cmd[0] === "gh" && cmd[1] === "repo" && cmd[2] === "view", // not-found -> falls through to create + stdout: (cmd) => + cmd[0] === "gh" && cmd[1] === "repo" && cmd[2] === "create" ? "https://github.com/testuser/mattstack-home\n" : "", + }); + const ageKeySeam = new FakeAgeKeySeam(); + const sopsYamlSeam = new FakeSopsYamlSeam(); + // Minimal state -> no cruft, no user clone: createRepo, gitInit, + // writeGitignore, writeOwners, adoptCommit, push. + const { exitCode, logs } = await runHomeInit(fakeProbes({}), seam, ageKeySeam, [], sopsYamlSeam); + + expect(exitCode).toBeUndefined(); + // The init steps ran to completion before the age key was touched. + expect(seam.calls.length).toBeGreaterThan(0); + expect(ageKeySeam.calls.some((c) => c[1] === "find-generic-password")).toBe(true); + expect(ageKeySeam.calls.some((c) => c[0] === "age-keygen")).toBe(true); + expect(ageKeySeam.calls.some((c) => c[1] === "add-generic-password")).toBe(true); + expect(sopsYamlSeam.files.get(SOPS_YAML_PATH)).toBe(renderSopsYaml(FAKE_PUBLIC_KEY)); + + // The mint (and the age-key-ready line) happen BEFORE the success line — + // never print success ahead of a mint that could still fail. + const readyIdx = logs.findIndex((l) => l.includes("age key ready")); + const successIdx = logs.findIndex((l) => l.includes("is now the git-backed home repo")); + expect(readyIdx).toBeGreaterThanOrEqual(0); + expect(successIdx).toBeGreaterThan(readyIdx); + }); + + test("--dry-run never touches the age key, even on a fresh (not-yet-initialized) home", async () => { + const seam = new FakeSeam(); + const ageKeySeam = new FakeAgeKeySeam(); + const { exitCode } = await runHomeInit(fakeProbes({}), seam, ageKeySeam, ["--dry-run"]); + + expect(exitCode).toBeUndefined(); + expect(seam.calls).toEqual([]); + expect(ageKeySeam.calls).toEqual([]); + }); + + test("a failing init step aborts before the age key is ever touched", async () => { + const seam = new FakeSeam({ failRun: (cmd) => cmd.join(" ") === "git commit -m home: adopt the declarative layer" }); + const ageKeySeam = new FakeAgeKeySeam(); + const { exitCode } = await runHomeInit(fakeProbes({}), seam, ageKeySeam); + + expect(exitCode).toBe(1); + expect(ageKeySeam.calls).toEqual([]); + }); +}); diff --git a/commands/daemon.ts b/commands/daemon.ts index 736977a9..7f33ca3a 100644 --- a/commands/daemon.ts +++ b/commands/daemon.ts @@ -38,7 +38,12 @@ import { loadRepoTracking, grants, saveRepoTracking, parseCachesArg, CACHE_KINDS import { createProjectMRs } from "../lib/daemon/project-mrs-store.ts"; import { getStateDb } from "../lib/state/index.ts"; import { timeAgo } from "../lib/tui/utils/label.ts"; -import { trayAppPath, TRAY_APP_NAME, TRAY_APP_BUNDLE } from "../lib/rt-paths.ts"; +import { trayAppPath, installedTrayAppPath, TRAY_APP_NAME, TRAY_APP_BUNDLE } from "../lib/rt-paths.ts"; + +/** Where to point an "open it" hint: the bundle's real install location if we can find one, else the conventional ~/Applications destination. */ +function trayAppHintPath(): string { + return installedTrayAppPath(TRAY_APP_BUNDLE) ?? trayAppPath(); +} function formatUptime(ms: number): string { const seconds = Math.floor(ms / 1000); @@ -83,7 +88,7 @@ export async function install(_args: string[] = []): Promise { console.log(` ${green}✓${reset} tray app is registering daemon`); } else { console.log(` ${yellow}⚠${reset} ${TRAY_APP_NAME} not reachable — open it to finish setup`); - console.log(` ${dim} ${bold}open ${trayAppPath()}${reset}`); + console.log(` ${dim} ${bold}open ${trayAppHintPath()}${reset}`); } // Wait for daemon to come online @@ -164,7 +169,7 @@ export async function start(): Promise { const result = await trayQuery("/daemon/start", "POST"); if (!result?.ok) { console.log(`\n ${yellow}${TRAY_APP_NAME} is not running${reset}`); - console.log(` ${dim}open it: ${bold}open ${trayAppPath()}${reset}\n`); + console.log(` ${dim}open it: ${bold}open ${trayAppHintPath()}${reset}\n`); return; } @@ -192,7 +197,7 @@ export async function restart(): Promise { const result = await trayQuery("/daemon/restart", "POST"); if (!result?.ok) { console.log(`\n ${yellow}${TRAY_APP_NAME} is not running${reset}`); - console.log(` ${dim}open it: ${bold}open ${trayAppPath()}${reset}\n`); + console.log(` ${dim}open it: ${bold}open ${trayAppHintPath()}${reset}\n`); return; } console.log(` ${dim}restarting daemon via tray…${reset}`); diff --git a/commands/home.ts b/commands/home.ts new file mode 100644 index 00000000..cddb9103 --- /dev/null +++ b/commands/home.ts @@ -0,0 +1,254 @@ +/** + * rt home — the git-backed ~/.mattstack home repo. + * + * rt home init [--dry-run] print, then run, the adoption plan + * rt home key export print the age private key once, for a password manager + * + * `init` gathers state, prints the plan from lib/home/init-plan.ts, and + * (unless --dry-run) runs it through lib/home/init-exec.ts's injected seam. + * `key export` delegates entirely to lib/home/age-key.ts. + */ + +import { existsSync, readFileSync, readdirSync, writeFileSync } from "fs"; +import { join } from "path"; +import type { CommandContext } from "../lib/command-tree.ts"; +import { mattstackHome, teamsDir } from "../lib/rt-paths.ts"; +import { buildInitPlan, type HomeState, type InitStep } from "../lib/home/init-plan.ts"; +import { createRealExecSeam, executeInitPlan, type ExecResult, type ExecSeam } from "../lib/home/init-exec.ts"; +import { parseOriginUrl } from "../lib/home/git-config.ts"; +import { + AgeKeyAbsentError, + createRealAgeKeySeam, + ensureAgeKey, + keyExport, + renderSopsYaml, + sopsYamlRecipient, + type AgeKeySeam, +} from "../lib/home/age-key.ts"; + +/** Stray root cruft deleted at init time, not adopted into the repo. */ +const CRUFT_CANDIDATES = ["skills.jsonc.pre-pack", "skills.jsonc.retired-backup"]; + +export interface HomeProbes { + isGitRepo(dir: string): boolean; + exists(path: string): boolean; + listTeamClones(): string[]; + /** Pure fs read; null when the file is missing or unreadable. */ + readFile(path: string): string | null; +} + +export interface SopsYamlSeam { + read(path: string): string | null; + write(path: string, content: string): void; +} + +function defaultSopsYamlSeam(): SopsYamlSeam { + return { + read: (path) => { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } + }, + write: (path, content) => writeFileSync(path, content), + }; +} + +function defaultProbes(): HomeProbes { + return { + isGitRepo: (dir) => existsSync(join(dir, ".git")), + exists: (path) => existsSync(path), + listTeamClones: () => { + const dir = teamsDir(); + if (!existsSync(dir)) return []; + try { + return readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + .map((entry) => entry.name); + } catch { + return []; + } + }, + readFile: (path) => { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } + }, + }; +} + +export function gatherHomeState(home: string, probes: HomeProbes): HomeState { + // hasUserClone gates foldInPrefs, which runs `git filter-repo` against + // this directory — a plain (non-git) user/ must not trigger it. + const hasUserClone = probes.isGitRepo(join(home, "user")); + // Read while user/.git still exists — unlinkUserClone (lib/home/init-exec.ts) + // removes it before the fold-in re-clones from this URL. + const prefsRemoteUrl = hasUserClone + ? (parseOriginUrl(probes.readFile(join(home, "user", ".git", "config")) ?? "") ?? undefined) + : undefined; + + return { + isRepo: probes.isGitRepo(home), + hasUserClone, + hasTeamClones: probes.listTeamClones(), + cruft: CRUFT_CANDIDATES.filter((name) => probes.exists(join(home, name))), + prefsRemoteUrl, + }; +} + +function describeStep(step: InitStep): string { + switch (step.kind) { + case "createRepo": + return `create the private GitHub repo ${step.name}`; + case "gitInit": + return `git init -b ${step.branch}`; + case "writeGitignore": + return "write the boundary .gitignore"; + case "writeOwners": + return "write snapshot-owners.jsonc"; + case "deleteCruft": + return `delete stray cruft: ${step.paths.join(", ")}`; + case "unlinkUserClone": + return "unlink user/.git (fold-in re-clones from the origin remote)"; + case "foldInPrefs": + return `fold mattstack-prefs history into user/ (git filter-repo, from ${step.sourceUrl})`; + case "adoptCommit": + return `commit: "${step.message}"`; + case "push": + return `push -u origin ${step.branch}`; + } +} + +/** + * The sole mint site: `key export` (lib/home/age-key.ts:keyExport) refuses + * to mint, precisely so a keychain-access error there can never be mistaken + * for "no key yet". Idempotent (ensureAgeKey mints only on provable + * absence), so it's safe to run on every init — including the + * already-initialized short-circuit, for a home repo that predates this + * step. + * + * Also (re)writes `.sops.yaml` whenever it's missing or its recipient + * doesn't match the current key — the one place `rt secrets set` gets a + * creation rule to encrypt against. A hand-edited file already carrying the + * right recipient is left untouched. `.sops.yaml` is a TRACKED file, so a + * write here needs a human commit — the snapshot daemon doesn't exist yet. + */ +async function ensureHomeAgeKey(seams: AgeKeySeam, sopsYamlSeam: SopsYamlSeam = defaultSopsYamlSeam()): Promise { + const { publicKey } = await ensureAgeKey(seams); + + const sopsYamlPath = join(mattstackHome(), ".sops.yaml"); + const existing = sopsYamlSeam.read(sopsYamlPath); + if (existing === null || sopsYamlRecipient(existing) !== publicKey) { + sopsYamlSeam.write(sopsYamlPath, renderSopsYaml(publicKey)); + console.log( + `rt home init: wrote ${sopsYamlPath} (recipient ${publicKey}) — it's tracked, so commit it:\n` + + ` git -C ${mattstackHome()} add .sops.yaml && git -C ${mattstackHome()} commit -m "home: sops recipient"`, + ); + } + + console.log( + `rt home init: age key ready — recipient ${publicKey}.\n` + + " Run `rt home key export` to save the private key to your password manager.", + ); +} + +const GH_AUTH_HINT = "gh is not authenticated. Run:\n gh auth login"; +const FILTER_REPO_HINT = "git-filter-repo is not installed. Run:\n brew install git-filter-repo"; + +/** + * A missing binary makes the seam's `run()` throw (Bun.spawn rejects on + * ENOENT) rather than return a non-zero code, so each check needs its own + * catch — an uncaught throw here would surface as a raw stack instead of the + * install hint. + */ +async function preflight(exec: ExecSeam): Promise { + let auth: ExecResult; + try { + auth = await exec.run(["gh", "auth", "status"]); + } catch { + return GH_AUTH_HINT; + } + if (auth.code !== 0) return GH_AUTH_HINT; + + let filterRepo: ExecResult; + try { + filterRepo = await exec.run(["git", "filter-repo", "--version"]); + } catch { + return FILTER_REPO_HINT; + } + if (filterRepo.code !== 0) return FILTER_REPO_HINT; + + return null; +} + +export async function homeInit( + args: string[], + _ctx: CommandContext = {}, + probes: HomeProbes = defaultProbes(), + exec: ExecSeam = createRealExecSeam(mattstackHome()), + ageKeySeam: AgeKeySeam = createRealAgeKeySeam(), + sopsYamlSeam: SopsYamlSeam = defaultSopsYamlSeam(), +): Promise { + const dryRun = args.includes("--dry-run"); + const home = mattstackHome(); + const state = gatherHomeState(home, probes); + const plan = buildInitPlan(state); + + if (plan.reason === "already-initialized") { + console.log(`rt home init: ${home} is already a git repo — nothing to do.`); + if (!dryRun) await ensureHomeAgeKey(ageKeySeam, sopsYamlSeam); + return; + } + + if (plan.reason === "prefs-remote-unreadable") { + console.error( + `rt home init: could not read the origin URL from ${join(home, "user", ".git", "config")} — ` + + "refusing to fold in a remote it can't identify.", + ); + process.exit(1); + } + + console.log(`rt home init plan for ${home}:`); + plan.steps.forEach((step, i) => console.log(` ${i + 1}. ${describeStep(step)}`)); + + if (dryRun) return; + + const preflightError = await preflight(exec); + if (preflightError) { + console.error(`\nrt home init: preflight failed — nothing was run.\n${preflightError}`); + process.exit(1); + } + + console.log(""); + const result = await executeInitPlan(plan.steps, exec, (message) => console.log(` ${message}`)); + + if (!result.ok) { + console.error(`\nrt home init: failed at step "${result.failedStep}":\n${result.stderr}`); + process.exit(1); + } + + // Mint (or backfill) BEFORE the success line: printing success ahead of a + // failed mint would tell the operator init worked while `rt secrets set` + // still has no key or creation rule to encrypt against. + await ensureHomeAgeKey(ageKeySeam, sopsYamlSeam); + console.log(`\nrt home init: ${home} is now the git-backed home repo.`); +} + +export async function homeKeyExport( + _args: string[], + _ctx: CommandContext = {}, + seams: AgeKeySeam = createRealAgeKeySeam(), +): Promise { + try { + await keyExport(seams, (text) => console.log(text)); + } catch (err) { + if (err instanceof AgeKeyAbsentError) { + console.error(`rt home key export: ${err.message}`); + process.exit(1); + } + throw err; + } +} diff --git a/commands/secrets.ts b/commands/secrets.ts new file mode 100644 index 00000000..88cb50f3 --- /dev/null +++ b/commands/secrets.ts @@ -0,0 +1,170 @@ +/** + * rt secrets — sops-encrypted secrets under ~/.mattstack/user/secrets/. + * + * rt secrets set [--stdin] write one key + * rt secrets list list a domain's key names (never values) + * rt secrets rotate [--stdin] replace a value, print the rotation commit message + * + * The value is NEVER a CLI arg — that would put it in argv (shell history, + * `ps`, and rt's own CLI command log). It comes from a no-echo TTY prompt, or + * from stdin with --stdin (scripting). All three verbs delegate to + * lib/secrets/store.ts; this module only parses args, collects the value, + * wires the real seams, and reports NoAgeKeyError/InvalidSecretsSegmentError + * with a clear pointer (mirrors commands/home.ts's AgeKeyAbsentError handling). + */ + +import type { CommandContext } from "../lib/command-tree.ts"; +import { createRealAgeKeySeam } from "../lib/home/age-key.ts"; +import { + InvalidSecretsSegmentError, + NoAgeKeyError, + createRealSecretsExecSeam, + listSecretNames, + rotateSecret, + writeSecret, + type SecretsSeams, +} from "../lib/secrets/store.ts"; + +const CTRL_C = ""; +const DEL = ""; + +function createRealSecretsSeams(): SecretsSeams { + return { ageKeySeam: createRealAgeKeySeam(), execSeam: createRealSecretsExecSeam() }; +} + +/** Strips only recognized flags — anything else (even a malformed "--x") stays positional so validation rejects it visibly instead of it silently vanishing. */ +function positional(args: string[]): string[] { + return args.filter((a) => a !== "--stdin"); +} + +function reportSecretsError(err: unknown): never { + if (err instanceof NoAgeKeyError || err instanceof InvalidSecretsSegmentError) { + console.error(`rt secrets: ${err.message}`); + process.exit(1); + } + throw err; +} + +async function readValueFromStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, ""); +} + +/** + * No-echo prompt (like `read -s`): raw mode so keystrokes never reach the + * terminal, and nothing is echoed back (not even asterisks) — the value + * never touches argv, so this is the only place it's typed. + */ +function promptSecretValue(message: string): Promise { + if (!process.stdin.isTTY) { + return Promise.reject(new Error(`${message}: not a TTY — pass --stdin to read the value from stdin instead`)); + } + process.stdout.write(`${message}: `); + return new Promise((resolve, reject) => { + const stdin = process.stdin; + let value = ""; + const cleanup = () => { + stdin.setRawMode(false); + stdin.pause(); + stdin.removeListener("data", onData); + }; + const onData = (chunk: Buffer) => { + for (const ch of chunk.toString("utf8")) { + if (ch === "\n" || ch === "\r") { + cleanup(); + process.stdout.write("\n"); + resolve(value); + return; + } + if (ch === CTRL_C) { + cleanup(); + process.stdout.write("\n"); + reject(new Error("cancelled")); + return; + } + if (ch === DEL || ch === "\b") { + value = value.slice(0, -1); + continue; + } + value += ch; + } + }; + stdin.resume(); + stdin.setRawMode(true); + stdin.on("data", onData); + }); +} + +async function collectValue(message: string, args: string[]): Promise { + return args.includes("--stdin") ? readValueFromStdin() : promptSecretValue(message); +} + +export async function secretsSet( + args: string[], + _ctx: CommandContext = {}, + seams: SecretsSeams = createRealSecretsSeams(), +): Promise { + const [domain, key] = positional(args); + if (!domain || !key) { + console.error("rt secrets set: usage: rt secrets set [--stdin]"); + process.exit(1); + } + + const value = await collectValue(`Value for ${domain}.${key}`, args); + + try { + await writeSecret(domain, key, value, seams); + } catch (err) { + reportSecretsError(err); + } + console.log(`rt secrets set: wrote ${domain}.${key}`); +} + +export async function secretsList( + args: string[], + _ctx: CommandContext = {}, + seams: SecretsSeams = createRealSecretsSeams(), +): Promise { + const [domain] = positional(args); + if (!domain) { + console.error("rt secrets list: usage: rt secrets list "); + process.exit(1); + } + + let names: string[]; + try { + names = await listSecretNames(domain, seams); + } catch (err) { + reportSecretsError(err); + } + + if (names.length === 0) { + console.log(`rt secrets list: no secrets set for domain "${domain}"`); + return; + } + console.log(`Secrets for "${domain}":`); + for (const name of names) console.log(` ${name}`); +} + +export async function secretsRotate( + args: string[], + _ctx: CommandContext = {}, + seams: SecretsSeams = createRealSecretsSeams(), +): Promise { + const [domain, key] = positional(args); + if (!domain || !key) { + console.error("rt secrets rotate: usage: rt secrets rotate [--stdin]"); + process.exit(1); + } + + const value = await collectValue(`New value for ${domain}.${key}`, args); + + let message: string; + try { + message = await rotateSecret(domain, key, () => value, seams); + } catch (err) { + reportSecretsError(err); + } + console.log(`rt secrets rotate: ${message}`); +} diff --git a/commands/settings-keys.ts b/commands/settings-keys.ts index 5cc61c30..661ecf7c 100644 --- a/commands/settings-keys.ts +++ b/commands/settings-keys.ts @@ -39,7 +39,7 @@ import { type Resolved, } from "../lib/settings/resolve.ts"; import { setSetting } from "../lib/settings/write.ts"; -import { getDef, type SettingDef, type SettingScope } from "../lib/settings/registry.ts"; +import { getDef, isMigrated, type SettingDef, type SettingScope } from "../lib/settings/registry.ts"; import { buildInterceptRules, writeInterceptRules } from "../lib/endpoint/shim.ts"; // ─── arg parsing (commands/events.ts conventions) ──────────────────────────── @@ -146,7 +146,7 @@ export function formatProvenance(provenance: Provenance[]): string { * to render). */ export function migratedNote(def: SettingDef): string | null { - if (def.migrated) return null; + if (isMigrated(def)) return null; const legacyPart = def.legacyFile ? `reads legacy: ${def.legacyFile}` : "not writable through the settings resolver yet"; const siblingPart = def.siblingCommand ? ` — use \`${def.siblingCommand}\`` : ""; return `${legacyPart}${siblingPart}`; @@ -179,8 +179,8 @@ export async function settingsGet(args: string[]): Promise { key, value: resolved.value, provenance: resolved.provenance, - migrated: def.migrated, - ...(def.migrated ? {} : { legacyFile: def.legacyFile ?? null, siblingCommand: def.siblingCommand ?? null }), + migrated: isMigrated(def), + ...(isMigrated(def) ? {} : { legacyFile: def.legacyFile ?? null, siblingCommand: def.siblingCommand ?? null }), })); return; } diff --git a/commands/settings.ts b/commands/settings.ts index abe7380b..b5e19f07 100644 --- a/commands/settings.ts +++ b/commands/settings.ts @@ -12,7 +12,8 @@ import { dirname, join } from "path"; import { homedir } from "os"; import { rtDir, - TRAY_APP_NAME, DEV_TRAY_APP_NAME, TRAY_APP_BUNDLE, trayAppPath, devTrayAppPath, + TRAY_APP_NAME, DEV_TRAY_APP_NAME, TRAY_APP_BUNDLE, DEV_TRAY_APP_BUNDLE, + trayAppPath, devTrayAppPath, installedTrayAppPath, } from "../lib/rt-paths.ts"; import { currentMode, installRtBinary } from "../lib/dev-mode.ts"; import { spawnSync } from "child_process"; @@ -34,7 +35,7 @@ import { installShellIntegration } from "../lib/shell-integration.ts"; export async function setLinearToken(): Promise { const { textInput } = await import("../lib/rt-render.tsx"); - const secrets = loadSecrets(); + const secrets = await loadSecrets(); // try scopes the prompt only — a failed *save* must surface as an error, // not masquerade as "keeping existing key". @@ -63,7 +64,7 @@ export async function setLinearToken(): Promise { } try { - saveSecret("linearApiKey", linearKey.trim()); + await saveSecret("linearApiKey", linearKey.trim()); } catch (err) { console.log(`\n ${red}✗ failed to save Linear API key: ${err instanceof Error ? err.message : String(err)}${reset}\n`); process.exit(1); @@ -75,7 +76,7 @@ export async function setLinearToken(): Promise { export async function setGitlabToken(): Promise { const { textInput } = await import("../lib/rt-render.tsx"); - const secrets = loadSecrets(); + const secrets = await loadSecrets(); // try scopes the prompt only — a failed *save* must surface as an error, // not masquerade as "keeping existing token". @@ -104,7 +105,7 @@ export async function setGitlabToken(): Promise { } try { - saveSecret("gitlabToken", gitlabToken.trim()); + await saveSecret("gitlabToken", gitlabToken.trim()); } catch (err) { console.log(`\n ${red}✗ failed to save GitLab token: ${err instanceof Error ? err.message : String(err)}${reset}\n`); process.exit(1); @@ -115,7 +116,7 @@ export async function setGitlabToken(): Promise { // ─── StrongDM email ────────────────────────────────────────────────────────── export async function setSdmEmail(args: string[]): Promise { - const secrets = loadSecrets(); + const secrets = await loadSecrets(); const fromArgs = args.find(a => !a.startsWith("--"))?.trim(); let email: string; @@ -153,7 +154,7 @@ export async function setSdmEmail(args: string[]): Promise { } try { - saveSecret("sdmEmail", email.trim()); + await saveSecret("sdmEmail", email.trim()); } catch (err) { console.log(`\n ${red}✗ failed to save StrongDM email: ${err instanceof Error ? err.message : String(err)}${reset}\n`); process.exit(1); @@ -164,7 +165,7 @@ export async function setSdmEmail(args: string[]): Promise { // ─── Linear team ───────────────────────────────────────────────────────────── export async function setLinearTeam(): Promise { - const secrets = loadSecrets(); + const secrets = await loadSecrets(); if (!secrets.linearApiKey) { console.log(`\n ${yellow}Linear API key not configured${reset}`); console.log(` ${dim}run: rt settings linear token${reset}\n`); @@ -202,7 +203,7 @@ async function pickAndSaveTeam(apiKey: string): Promise<{ teamId: string; teamKe const team = teams.find((t) => t.id === selectedId); if (!team) return null; - saveTeamConfig(team.id, team.key); + await saveTeamConfig(team.id, team.key); return { teamId: team.id, teamKey: team.key }; } @@ -465,8 +466,9 @@ export function renderDevModePreload(): string { * Throws when the prod app is absent: stranding the CLI with no rt on PATH is * worse than refusing the switch. */ -function disableDevMode(): void { - const prodBinary = join(trayAppPath(), "Contents", "MacOS", "rt-daemon"); +function disableDevMode(exists: (path: string) => boolean = existsSync): void { + const prodAppPath = installedTrayAppPath(TRAY_APP_BUNDLE, exists) ?? trayAppPath(); + const prodBinary = join(prodAppPath, "Contents", "MacOS", "rt-daemon"); if (!existsSync(prodBinary)) { throw new Error( `cannot switch to prod: ${TRAY_APP_BUNDLE} is not installed, so there is no compiled rt to install at ${DEV_MODE_WRAPPER}. Install the app first (rt --post-install), then retry.`, @@ -500,10 +502,18 @@ interface FlavorInfo { appPath: string; } -function flavorFor(mode: "dev" | "prod"): FlavorInfo { - return mode === "dev" - ? { mode, name: DEV_TRAY_APP_NAME, appPath: devTrayAppPath() } - : { mode, name: TRAY_APP_NAME, appPath: trayAppPath() }; +function flavorFor(mode: "dev" | "prod", exists: (path: string) => boolean = existsSync): FlavorInfo { + const bundle = mode === "dev" ? DEV_TRAY_APP_BUNDLE : TRAY_APP_BUNDLE; + const fixedFallback = mode === "dev" ? devTrayAppPath() : trayAppPath(); + return { + mode, + name: mode === "dev" ? DEV_TRAY_APP_NAME : TRAY_APP_NAME, + // Wherever it's ACTUALLY installed (/Applications, ~/Applications, or the + // machine setting); falls back to the conventional ~/Applications + // location so a genuinely-missing bundle still fails existsSync with a + // sensible path in the error message, rather than null. + appPath: installedTrayAppPath(bundle, exists) ?? fixedFallback, + }; } function launchdLabelFor(mode: "dev" | "prod"): string { @@ -581,7 +591,7 @@ async function handoffToFlavor(outgoing: FlavorInfo, incoming: FlavorInfo): Prom console.log(` ${green}✓${reset} launched ${incoming.appPath}`); } -export async function toggleDevMode(args: string[]): Promise { +export async function toggleDevMode(args: string[], exists: (path: string) => boolean = existsSync): Promise { const { select } = await import("../lib/rt-render.tsx"); const mode = currentMode(); @@ -616,7 +626,7 @@ export async function toggleDevMode(args: string[]): Promise { return; } - const incoming = flavorFor(target); + const incoming = flavorFor(target, exists); // 0. Precondition — the incoming flavor's bundle must exist on disk BEFORE // we touch the running flavor at all, so the toggle can never leave the @@ -662,15 +672,15 @@ export async function toggleDevMode(args: string[]): Promise { console.log(` ${dim}wrapper → ${DEV_MODE_WRAPPER}${reset}`); console.log(` ${dim}source → ${resolvedPath}${reset}`); - await handoffToFlavor(flavorFor(mode), incoming); + await handoffToFlavor(flavorFor(mode, exists), incoming); console.log(` ${dim}restart your terminal (or: source ${shellResult.rcPath ?? "~/.zshrc"}) to activate${reset}`); } else { - disableDevMode(); + disableDevMode(exists); console.log(` ${green}✓${reset} CLI restored to prod mode ${dim}(mattstack.app binary installed at ~/.local/bin/rt)${reset}`); - await handoffToFlavor(flavorFor(mode), incoming); + await handoffToFlavor(flavorFor(mode, exists), incoming); } console.log(""); diff --git a/commands/verify.ts b/commands/verify.ts index 2f56cbb1..cf0d27f3 100644 --- a/commands/verify.ts +++ b/commands/verify.ts @@ -20,7 +20,7 @@ import { bold, cyan, dim, green, yellow, red, reset } from "../lib/tui.ts"; import { detectShell, shellRcPath } from "../lib/shell-integration.ts"; import { legacyDirsPresent, RT_DIR_LABEL, - trayAppPath, devTrayAppPath, legacyTrayAppPaths, + installedTrayAppPath, legacyTrayAppPaths, TRAY_APP_BUNDLE, DEV_TRAY_APP_BUNDLE, } from "../lib/rt-paths.ts"; import { currentMode } from "../lib/dev-mode.ts"; @@ -188,22 +188,22 @@ async function runChecks(): Promise { // failure. const mode = currentMode(); - const activeTrayPath = mode === "dev" ? devTrayAppPath() : trayAppPath(); const activeTrayBundle = mode === "dev" ? DEV_TRAY_APP_BUNDLE : TRAY_APP_BUNDLE; - const inactiveTrayPath = mode === "dev" ? trayAppPath() : devTrayAppPath(); const inactiveTrayBundle = mode === "dev" ? TRAY_APP_BUNDLE : DEV_TRAY_APP_BUNDLE; + const activeTrayPath = installedTrayAppPath(activeTrayBundle); + const inactiveTrayPath = installedTrayAppPath(inactiveTrayBundle); - if (existsSync(activeTrayPath)) { + if (activeTrayPath) { const plistPath = join(activeTrayPath, "Contents/Info.plist"); const trayVersion = existsSync(plistPath) ? cmd(`/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "${plistPath}" 2>/dev/null`) : null; - results.push(pass(activeTrayBundle, trayVersion ? `v${trayVersion} in ~/Applications` : "installed in ~/Applications")); + results.push(pass(activeTrayBundle, trayVersion ? `v${trayVersion} at ${activeTrayPath}` : `installed at ${activeTrayPath}`)); } else { - results.push(fail(activeTrayBundle, `not found — expected ${activeTrayPath}`)); + results.push(fail(activeTrayBundle, "not found — expected in /Applications or ~/Applications")); } - results.push(existsSync(inactiveTrayPath) + results.push(inactiveTrayPath ? skip(inactiveTrayBundle, `also installed at ${inactiveTrayPath} (inactive flavor)`) : skip(inactiveTrayBundle, "not installed (inactive flavor)")); diff --git a/docs/superpowers/plans/2026-08-20-home-repo-foundation.md b/docs/superpowers/plans/2026-08-20-home-repo-foundation.md new file mode 100644 index 00000000..168dcf15 --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-home-repo-foundation.md @@ -0,0 +1,157 @@ +# Home-Repo Foundation (H1 + S + E) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `~/.mattstack` the git-backed home repo with the layer boundary correct (H1), stand up the sops/age secrets layer with keychain key custody (S), and move the settings resolver + a suite-wide registry into `@mattstack/rt-client` (E). + +**Architecture:** Three sequential lanes inside one worktree/branch. H1 gives git a correct view of the tree before anything new lands. S puts every live secret into encrypted-tracked files under `user/secrets/` and gives consumers one read API. E relocates `lib/settings/` machinery into `packages/rt-client/src/settings/` and grows the registry to the whole suite so `rt settings set` accepts `deck.*`/`board.*`/`gitq.*`/`mattstack.*`/`claude.*` keys. + +**Tech Stack:** Bun/TypeScript (rt), `gh` CLI, `git`, `git-filter-repo`, `sops` + `age` CLIs, macOS `security` CLI (keychain). + +**Spec:** `docs/superpowers/specs/2026-08-20-suite-settings-migration.md` (workstreams H, S, and "The suite standard"). The spec's rulings are binding; MAT-374 is the doctrine behind them. + +## Global Constraints + +- Worktree `/Users/matt/Documents/GitHub/repo-tools-rt50b-wt`, branch `goodwinmattheweric/rt-50-settings-keys`. Never touch the main checkout. +- Strict TDD on pure logic; exec seams (git, gh, sops, age, security, filter-repo) stay thin and injectable — tests never invoke the real keychain, real GitHub, or the real `~/.mattstack` (bunfig preload repoints HOME for bun test; never remove it). +- **Live-machine operations** (anything under the real `~/.mattstack`, real `gh repo create`, real keychain writes) are ORCHESTRATOR-ONLY steps, marked as such — implementer subagents build and test the commands, they never run them against the real tree. +- Where the platform blocks agent secret-handling, emit the exact command for Matt to run instead — never work around it. +- Comments follow clean-code rules. New commands: any new `module:` in `lib/command-tree-def.ts` gets its `lib/module-registry.ts` import + entry in the same commit (compiled-binary footgun). +- No monitor exists. Run tests yourself, never wait. +- Gates every task: `bun x tsc --noEmit` → 0; `bun test lib/ commands/ packages/` green. +- Commit per task, trailer: `Co-Authored-By: Claude Fable 5 ` + +--- + +### Task 1: `rt home` command — plan-of-record + boundary gitignore (pure logic) + +**Files:** +- Create: `commands/home.ts` (verbs `init`, `key` added in Task 4; this task: `init` only) +- Create: `lib/home/boundary.ts`, `lib/home/init-plan.ts` +- Create: `lib/home/__tests__/boundary.test.ts`, `lib/home/__tests__/init-plan.test.ts` +- Modify: `lib/command-tree-def.ts` (new `home` node, `init` subcommand, module `./commands/home.ts`, fn `homeInit`), `lib/module-registry.ts` (import + entry) + +**Interfaces:** +- Produces: `HOME_BOUNDARY: { tracked: string[]; ignored: string[] }` and `renderHomeGitignore(): string` (lib/home/boundary.ts); `buildInitPlan(state: HomeState): InitStep[]` where `HomeState = { isRepo: boolean; hasUserClone: boolean; hasTeamClones: string[]; cruft: string[] }` and `InitStep` is a discriminated union `{kind: "createRepo"|"gitInit"|"writeGitignore"|"writeOwners"|"deleteCruft"|"foldInPrefs"|"adoptCommit"|"push", ...args}` (lib/home/init-plan.ts). Task 2 consumes `InitStep`. + +- [ ] **Step 1: Failing tests for the boundary.** `renderHomeGitignore()` output must ignore exactly: `rt/`, `deck/`, `shepherdr/`, `repos/`, `ci-attendants/`, `work/`, `teams/`, `user/local/`, `settings.local.jsonc`, `*.sock`, `.DS_Store` — and NOT ignore `user/`, `skills.jsonc`, `snapshot-owners.jsonc`, `user/secrets/`. Test with `ignore`-style matching (write pairs of path→expected). Also: `buildInitPlan` on `{isRepo:false, hasUserClone:true, hasTeamClones:["claimview"], cruft:["skills.jsonc.pre-pack","skills.jsonc.retired-backup"]}` yields steps in exactly this order: createRepo → gitInit → writeGitignore → writeOwners → deleteCruft → foldInPrefs → adoptCommit → push; on `{isRepo:true,...}` it returns `[]` plus a `reason: "already-initialized"`. +- [ ] **Step 2: Run tests, verify they fail** (`bun test lib/home/`) with module-not-found. +- [ ] **Step 3: Implement** `boundary.ts` + `init-plan.ts` (pure — no fs, no exec). `commands/home.ts:homeInit` composes: gather `HomeState` from injected probes (`{ isGitRepo(dir), exists(path), listTeamClones() }` defaulted to real fs/git), print the plan, `--dry-run` stops there; execution wiring lands in Task 2. +- [ ] **Step 4: Tests green; tree+registry pair added; tsc 0.** +- [ ] **Step 5: Commit** `RT-30: rt home init — boundary + init plan (pure core)`. + +--- + +### Task 2: `rt home init` execution — git/gh/filter-repo seams + +**Files:** +- Create: `lib/home/init-exec.ts`, `lib/home/__tests__/init-exec.test.ts` +- Modify: `commands/home.ts` + +**Interfaces:** +- Consumes: `InitStep[]` from Task 1. +- Produces: `executeInitPlan(steps, exec: ExecSeam, log): Promise` with `ExecSeam = { run(cmd: string[], opts?: {cwd?: string}): Promise<{code:number; stdout:string; stderr:string}> }`. Every external call goes through `exec.run` — tests use a scripted fake recording argv. + +- [ ] **Step 1: Failing tests** asserting the exact argv sequences per step kind: + - createRepo → `["gh","repo","create","","--private"]` (name from opts; default `mattstack-home`, owner defaulted to the authenticated user) + - gitInit → `["git","init","-b","main"]` in `~` +`.mattstack` cwd, then `["git","remote","add","origin",""]` + - foldInPrefs → temp-dir clone of the `user/` remote, `["git","filter-repo","--to-subdirectory-filter","user"]` in the temp clone, then in the home repo `["git","fetch","","main"]` + `["git","merge","FETCH_HEAD","--allow-unrelated-histories","-m",...]`, then removal of `user/.git` (via seam `removeDir`, not shell rm) + - adoptCommit → `["git","add","-A"]` + `["git","commit","-m","home: adopt the declarative layer"]` + - push → `["git","push","-u","origin","main"]` + - a failing step aborts the remaining steps and returns `{ok:false, failedStep, stderr}` — test with a fake that fails at writeGitignore. +- [ ] **Step 2: Run, verify fail.** +- [ ] **Step 3: Implement** `init-exec.ts`; `homeInit` wires the real seam (`Bun.spawn`-based runCapture, `env: process.env` — PATH-snapshot gotcha) and refuses to run when `HomeState.isRepo` (idempotence). Preflight: `gh auth status` must pass and `git filter-repo --version` must resolve; on failure print the install commands (`brew install git-filter-repo`) and exit 1 — no partial runs. +- [ ] **Step 4: Tests green; tsc 0.** +- [ ] **Step 5: Commit** `RT-30: rt home init — execution seams`. + +--- + +### Task 3 (ORCHESTRATOR-ONLY, live machine): run H1 against the real tree + +Not a subagent task. With Matt's `gh` auth: `rt home init --dry-run` from the worktree (`bun run cli.ts home init --dry-run`), review the printed plan, then run it live. Verify after: `git -C ~/.mattstack status` clean; `git log` shows the adoption commit ON TOP of mattstack-prefs history under `user/`; `user/.git` gone; `rt verify` green; `rt settings get rt.worktrees --repo assured-dev` still resolves (user store reads unaffected). The old mattstack-prefs remote retires: archive note only, no deletion of the GitHub repo (history safety). + +--- + +### Task 4: age key custody + `rt home key export` + `.sops.yaml` + +**Files:** +- Create: `lib/home/age-key.ts`, `lib/home/__tests__/age-key.test.ts` +- Modify: `commands/home.ts` (verb `key` with `export`), `lib/command-tree-def.ts` (subcommand under `home`) + +**Interfaces:** +- Produces (AS BUILT after review hardening): `readAgeKey(seams): Promise<{key: string} | {absent: true}>` — `absent` ONLY on exit 44 corroborated by the "could not be found" stderr marker; ANY other failure THROWS ("keychain unreachable … refusing to mint") and callers must let that propagate as a real error, never treat it as missing. `ensureAgeKey(seams): Promise<{publicKey: string}>` mints only on provable absence and stores WITHOUT `-U` (duplicate item fails loudly). `keyExport` never mints (`AgeKeyAbsentError` → "run rt home init"); minting lives in homeInit. `createRealAgeKeySeam()` returns the argv-redacting wrapper; the raw seam is unexported. `renderSopsYaml(publicKey): string` emits creation rules encrypting `user/secrets/**` to the recipient. Tasks 5–6 consume `readAgeKey`'s union contract; the live step writes `.sops.yaml` at `~/.mattstack/.sops.yaml`. + +- [ ] **Step 1: Failing tests**: keygen path (fake `age-keygen` output → parsed public key, security argv recorded, `-w` value never logged); existing-key path (find succeeds → no keygen); `renderSopsYaml("age1xyz")` contains `path_regex: user/secrets/.*` and the recipient; `keyExport` prints the private key to stdout ONCE with a warning header and never writes it to any file (assert the fake fs saw zero writes). +- [ ] **Step 2: Run, fail.** **Step 3: Implement** (seams injected; real seam uses runCapture with `env: process.env`). +- [ ] **Step 4: Green; tsc 0.** **Step 5: Commit** `RT-32: age key custody in the keychain + sops rules`. + +--- + +### Task 5: secrets read/write API + `rt secrets` verbs + +**Files:** +- Create: `lib/secrets/store.ts`, `lib/secrets/__tests__/store.test.ts`, `commands/secrets.ts` +- Modify: `lib/command-tree-def.ts` (`secrets` node: `set`, `list`, `rotate`; hidden from help until S lands? No — visible, honest), `lib/module-registry.ts` + +**Interfaces:** +- Produces: `readSecret(domain: string, key: string, seams): Promise` and `writeSecret(domain, key, value, seams): Promise` over files `~/.mattstack/user/secrets/.json` — read = `["sops","-d",""]` with `SOPS_AGE_KEY` injected from `readAgeKey` (never via argv, only env), parse JSON, per-process memo; write = decrypt-merge-encrypt via `["sops","--set",...]` or decrypt+edit+`["sops","-e","-i"]` (implementer picks the sops idiom that round-trips cleanly; test pins the chosen argv). `rotateSecret(domain, key, minter)` re-mints + writes + returns the commit message `secrets: rotate .`. `listSecretNames` decrypts and returns keys only, never values. +- Domains ruled by the spec inventory: `rt` (linearApiKey, gitlabToken, linearTeamId, linearTeamKey, sdmEmail, switchboardToken, switchboardAdminToken), `deck` (cfApiToken, cfZoneId, sessionSecret, passwordHash.), `board` (slackToken, slackClientSecret, slackSigningSecret). + +- [ ] **Step 1: Failing tests** (fake sops seam): read path env carries SOPS_AGE_KEY and argv never contains the key; missing file → null, no throw; write argv pinned; memo invalidated by writeSecret; `rt secrets list` output contains names not values (fake decrypted payload with a canary value, assert canary absent from stdout). +- [ ] **Step 2: Run, fail.** **Step 3: Implement.** **Step 4: Green; tsc 0; tree+registry pairs.** +- [ ] **Step 5: Commit** `RT-32: sops-backed secrets store + rt secrets verbs`. + +--- + +### Task 6: port rt's secrets consumers + daemon verb for the extension + +**Files:** +- Modify: `lib/linear.ts` (loadSecrets/saveSecret/saveTeamConfig re-back onto `lib/secrets/store.ts`, same signatures so callers don't churn), `lib/daemon/handlers/secrets.ts` (new verb `secrets:read` returning ONLY the whitelisted keys the extension needs — implementer greps `extensions/vscode/rt-context/src/secrets.ts` for its field usage and whitelists exactly those), `extensions/vscode/rt-context/src/secrets.ts` (daemon call replaces the file read; extension builds standalone — check its own tsconfig/build) +- Tests: extend `lib/daemon/__tests__/settings-handlers.test.ts` pattern for the new verb; `lib/__tests__/` coverage for the re-backed loaders (async now? loadSecrets is sync today — keep a sync facade over a decrypt-once memo primed at first call, or make callers async; implementer reports which; the survey lists every caller: lib/enrich.ts:263,298,414, lib/daemon/freshness.ts:129,269, handlers/secrets.ts:35, handlers/discussions.ts:117,146, lib/sdm/browser-login.ts:318, commands/settings.ts:37,80,118,168) + +**Interfaces:** +- Consumes: Task 5's store API. Produces: `secrets.json` has zero rt readers; the plaintext file's deletion is a live-machine step AFTER the import (orchestrator, with the ext updated). + +- [ ] Steps: failing tests → implement → green → commit `RT-32: consumers read the encrypted store; extension via daemon`. + +--- + +### Task 7 (ORCHESTRATOR-ONLY, live): secrets import + plaintext retirement + +`rt home key export` → Matt stores in password manager. Then per-domain import: read current plaintext (`~/.mattstack/rt/secrets.json`, deck `platform.json` secrets + `settings.json` secret/hashes, board `.env` three slack values), `rt secrets set` each (or hand Matt the commands if the classifier blocks), commit (encrypted blobs land in git — verify `git show` displays sops ciphertext, NEVER plaintext, before pushing). Delete: rt `secrets.json`, board `.env` CF pair. Deck/board plaintext originals retire in their own lanes (their readers still point at them until then). `rt verify` + daemon restart + spot-check: `rt status` still enriches (gitlab token flows), extension still resolves. + +--- + +### Task 8: resolver extraction into `@mattstack/rt-client` + +**Files:** +- Create: `packages/rt-client/src/settings/{resolve,stores,identity,write,registry-machinery}.ts` (moved from `lib/settings/`, import paths adjusted; `registry.ts` splits: machinery types + `getDef/allDefs/validateValue` → `registry-machinery.ts`; the def TABLE stays separate — Task 9) +- Create: `packages/rt-client/src/settings/registry-defs.ts` (the suite table; starts as rt's 16 rows verbatim) +- Modify: `lib/settings/*.ts` → thin re-export barrels (`export * from "../../packages/rt-client/src/settings/resolve.ts"` style — every existing rt importer keeps working unchanged), `packages/rt-client/src/index.ts` (export the settings module), `packages/rt-client/package.json` (version → 0.3.0) +- Move: `lib/settings/__tests__/*` → `packages/rt-client/src/settings/__tests__/` (paths only; assertions unchanged — this is the proof the move is pure) + +**Interfaces:** +- Produces: `@mattstack/rt-client` exports `getSetting/listSettings/explainSetting/setSetting/expandVariables`, the registry API, and the secrets store surface Task 5 built if cleanly separable (else secrets stay rt-internal this lane and deck/board get them via their lanes — implementer reports). + +- [ ] **Step 1:** Move files, fix imports, re-export barrels. **Step 2:** `bun test packages/ lib/ commands/` — the moved suite green UNCHANGED (any assertion edit beyond paths = report as concern). **Step 3:** tsc 0. **Step 4:** rt-client's own `bun test tests/` (its existing suite) green. **Step 5: Commit** `RT-50: settings machinery lives in rt-client (pure move)`. + +--- + +### Task 9: the suite registry + +**Files:** +- Modify: `packages/rt-client/src/settings/registry-defs.ts` — add, per the spec's tables (exact scopes/types from the spec; every new def `migrated` is omitted — that flag is rt-legacy-specific): `mattstack.integrations` (team, object, deep), `mattstack.tracking` (team, object, deep), `mattstack.appPath` (machine, string, replace), `claude.marketplaces` (user+team, array, replace), `claude.plugins` (user+team, array, replace), `deck.apps` (user, object, deep), `deck.access` (user, object, deep), `deck.platform` (machine, object, deep), `board.*` rows (team: gitlabHost, projects, members, title, botUsernames, ticketPrefixes, slack; user: staleAfterDays, workspaces, defaultMember, triage; machine: claudeCommand, cwds, rtRepos, triageMaxConcurrent, switchboardUrl; two doctor keys: `board.doctorSkill`, `board.triage.doctorSkill` — distinct rows, the BOARD-14 semantic difference lives in board's reader, note it in each row's description), `gitq.workSlots` (machine), `gitq.forges` (user), `gitq.board` (machine) +- Modify: registry enumeration tests (counts change once; write the new literal arrays), `commands/settings-keys.ts` if any labeling assumes `rt.` prefix (survey says renderers are prefix-agnostic — verify) + +**Interfaces:** +- Consumes: Task 8's machinery. Produces: `rt settings set deck.access --scope user ''` works end-to-end (e2e settings suite gets one new case proving a non-rt prefix round-trips). + +- [ ] Steps: failing e2e/unit test for a `deck.*` round-trip → add defs → green → tsc 0 → commit `RT-50: one suite registry — deck/board/gitq/mattstack/claude keys land`. + +--- + +### Task 10: foundation gates + merge readiness + +- [ ] `bun x tsc --noEmit` 0; `bun run test:all` green (delete `dist/rt` first); rt-client suite green; e2e settings suite green. +- [ ] Orchestrator: verify against the LIVE tree that `rt settings list` renders the suite keys and `rt verify` stays green (daemon restart to pick up handlers). +- [ ] Commit any stragglers; branch ready for PR (merge decision is the orchestrator's checkpoint with Matt). diff --git a/docs/superpowers/specs/2026-08-20-suite-settings-migration.md b/docs/superpowers/specs/2026-08-20-suite-settings-migration.md new file mode 100644 index 00000000..785e8890 --- /dev/null +++ b/docs/superpowers/specs/2026-08-20-suite-settings-migration.md @@ -0,0 +1,252 @@ +# The home-repo program — suite settings + secrets + snapshot/restore (spec) + +RT-50 step 2, expanded twice by Matt (2026-08-20): first to cover every mattstack +app (rt, deck, board, gitq), then to fold in **RT-32** (secrets sops/age-encrypted +IN the repos) and **RT-30** (~/.mattstack becomes the git-backed home repo with an +auto-snapshot daemon). Governing doctrine: MAT-374. RT-31 (restore + materialize +contract) is recommended IN — it is the acceptance test of the whole architecture +and the age-key bootstrap lives there. Deck's state.db adoption and the +mattstack-TLD rehome remain MAT-384; board/gitq state.db adoption is a follow-on +lane. + +## Workstream H — home repo (RT-30, per MAT-374 rulings 1-3, 5) + +- `rt home init`: provision a private GitHub repo (`gh`), make `~/.mattstack` the + clone. Adoption: today `~/.mattstack` is NOT a repo; `user/` is the + mattstack-prefs clone and `teams/claimview` a team clone. Ruling needed→taken: + mattstack-prefs' history and content fold INTO the home repo as `user/` (its + standalone remote retires); team clones stay independent nested clones, + gitignored by the home repo. +- The gitignore IS the boundary: tracked = declarative (`user/`, `skills.jsonc`, + snapshot-owners.jsonc, per-tool declarative zones as they adopt); encrypted- + tracked = secrets; ignored = runtime (`rt/`, `deck/` runtime files, + `shepherdr/` (jobs/runs/worktrees), `repos/`, `ci-attendants/`, `work/`, + `teams/`, `user/local/` (machine-local; holds attic tarballs today), + `settings.local.jsonc` — machine-local never travels, per MAT-374 stratum 5). + The fold-in preserves mattstack-prefs' own `.gitignore` semantics; `user/local/` + is additionally hoisted into the home repo's ignore so the boundary does not + depend on the inner file surviving. Stray root cruft (`skills.jsonc.pre-pack`, + `skills.jsonc.retired-backup`) is deleted at H1, not adopted. +- Fold-in mechanism hint for the planner: rewrite mattstack-prefs history under + `user/` (`git filter-repo --to-subdirectory-filter user`), merge into the new + home repo with `--allow-unrelated-histories`, remove the nested `user/.git`; + team clones under `teams/` stay independent nested clones, ignored. +- Snapshot daemon (in the rt daemon): watches tracked paths, debounced + auto-commits with per-path messages, background push. `snapshot-owners.jsonc` + committed; claimed zones are never auto-committed; janitor commits a marked + snapshot when a claimed zone stays dirty past threshold. +- Strict TDD; git/exec seams thin. + +## Workstream S — secrets layer (RT-32, per MAT-374 ruling 4) + +- sops/age: secret paths under the home repo encrypt transparently on commit; + decrypt only on machines holding the age key. Age key: generated at init, + macOS keychain, never in the repo; documented key-migration channel. +- Rotation ceremony: one command re-mints/re-encrypts a named secret + commits. + Doctrine: rotate, never rewrite history. +- **Age-key migration channel (ruling):** `rt home key export` prints the age + secret key once for the user's password manager; `rt restore` prompts to paste + it and installs it in the keychain. The password manager is the channel — + pre-trusted, works on a bare machine, teammate-viable. Never written to the + repo, never to a synced file. +- Secret inventory migrating in (from the four surveys): + `~/.mattstack/rt/secrets.json` (linear/gitlab/sdm/switchboard tokens), deck's + `cfApiToken`/`cfZoneId` (today plaintext 0644), its session `secret`, and its + per-app `passwordHash`es (hashes are secrets here — the user store is + git-synced, and the local-apps incident is exactly hashes-in-git), board's + `.env` SLACK_TOKEN plus SLACK_CLIENT_SECRET and SLACK_SIGNING_SECRET (the + reworked setup/integration flow reads them from S). Deleted, not carried: + board's orphaned CF_API_TOKEN/CF_ACCOUNT_ID. Non-secret slack identifiers + (SLACK_APP_ID, SLACK_CLIENT_ID) live in `mattstack.integrations.slack` (team). +- Consumers move to a single read API in the shared settings module (decrypts via + sops/age locally); the rt daemon's grant-gated `secrets:forge-token` verb stays + the out-of-process path (gitq precedent). The rt-context VS Code extension's + direct `secrets.json` read moves to a daemon call — in scope, it blocks + deleting the plaintext file. +- Pods/lanes never get the age key (deploy-key readers see ciphertext only). +- Where the classifier blocks agent secret-handling, the lane hands Matt the + mint/rotate commands instead of running them. + +## Workstream R — restore + materialize (RT-31, recommended in) + +- `rt restore /`: clone to `~/.mattstack`, age-key bootstrap from + keychain/channel, then materialize: every tool's materialize-from-config verb + runs (rt itself, then deck/board/gitq as they adopt). claude.marketplaces/ + claude.plugins replay is the installer handoff's need #4. +- The materialize contract is the regeneration rule: anything unrecoverable from + the declarative layer is a bug in what we declared. + +Evidence: four survey reports (2026-08-20) over repo-tools@137a840, local-apps, +mr-board, gitq. Key facts cited inline. + +## The suite standard + +- **Stores** (existing, RT-47): user `~/.mattstack/user/settings.jsonc` (a git repo + — mattstack-prefs), team `~/.mattstack/teams//mattstack/settings.jsonc`, + machine `~/.mattstack/settings.local.jsonc`. Flat key namespace with app + prefixes: `rt.*`, `deck.*`, `board.*`, `gitq.*`. Repo-scoped sections keyed by + normalized remote identity, exactly as rt does today. +- **One resolver, many processes.** The resolver implementation moves to the + shared package (`@mattstack/rt-client` grows a `settings` module housing what is + today `lib/settings/{resolve,stores,identity,registry,write}.ts`, splitting + machinery from per-app def tables in the move); rt imports it from there, + deck/board/gitq consume it in-process via their rt-client dependency (board: + file:, gitq: npm — today wrongly in devDependencies while imported at runtime, + fixed in the gitq lane; deck: adds it via npm). No daemon + round-trip for reads — apps that boot before the rt daemon (deck) must still + resolve. The daemon's `settings:get/list` verbs stay for out-of-process callers. +- **One suite registry, in the shared package.** The installer lane (handoff + 2026-08-20) requires `rt settings set` to accept deck/board/gitq/mattstack/ + claude keys — so the key table is a single suite registry living beside the + resolver in the shared package. Each app imports the machinery plus its own + prefix's defs; `rt settings list/get/explain/set` operate over the whole suite + table. App key changes ride an rt-client version bump (acceptable: single + maintainer, file:/npm consumers already re-install on change). +- **Scope hygiene (hard rule):** the user store is git-synced — never a secret, + never an absolute path (pathGuardFields already enforce the path half for + user/team). Secrets stay out of ALL stores until RT-32; machine-local paths and + identities go to machine scope. +- **Write path:** `setSetting` (comment-preserving jsonc edit, refusal ladder, + team never auto-created). Migration order per key is atomic: port the reader to + the resolver + flip `migrated: true` + write the store value in one change. + +## rt key dispositions + +| Key / file | Disposition | Scope | Notes | +|---|---|---|---| +| `rt.cron` (cron.jsonc) | migrate | machine | absolute paths; boot-only reader — `rt settings set` prints daemon-restart hint | +| `rt.llm` (llm.json) | **DELETE** (Matt, 2026-08-20) | — | dead chain post-branch-removal: `llmPrompt` has zero production callers; delete lib/llm.ts, the `rt settings llm` verb, llm.json, the registry row, tests | +| `rt.notifications` (notifications.json) | migrate | user | pure prefs; notifier re-reads per call → propagates live | +| `rt.repoTracking` (repo-tracking.json) | migrate | machine | ruled; keys are repo names not identities; per-tick reads → live | +| `rt.runaway` (runaway-config.json) | migrate | machine | thresholds; boot-only-ish, keep restart hint | +| `rt.workspacePrefs` (workspace-prefs.json) | migrate | machine | absolute dirs; rt nav reader | +| `rt.sync` (repos/assured-dev/sync.json) | migrate | team.repo | autoResolve rules are repo conventions | +| `rt.variations` (variations.json) | migrate | team.repo | pgr-qa variation is team knowledge | +| `rt.presets` (presets/*.json) | migrate | user.repo | personal run presets; SHAPE CHANGE: dir-of-files → `{ "": {entries} }` | +| `rt.dopplerTemplate` (doppler-template.yaml) | migrate | team.repo | YAML→JSONC conversion; reconciler reads via resolver afterward | +| `rt.branchNaming` (branch-naming.json) | migrate value, KEEP file | team.repo | rt has zero readers; file stays for the VS Code ext until it follows (ext reads by repo NAME, not identity — its port is a separate change) | +| `rt.hooks` (repos/*/hooks.json) | **DEFERRED** | — | generated bash shims grep hooks.json directly; migrating requires shim regeneration — its own small lane; registry row stays migrated:false | +| panel-columns.json | not settings | — | Swift-tray-only UI state; leave; revisit in the tray UI session | +| logdy-pino-columns.json | not settings | — | generated artifact for logdy; regenerated by commands/daemon.ts | +| endpoints.json | not settings | — | runtime port claims (daemon-owned) | +| agent-tasks/ | delete cruft | — | write-only July artifacts + a stray `repos/origin/agent-tasks` path-bug dir | +| repos/*/config.json + lib/repo-config.ts | **DELETE** | — | zero callers post step 1; the legacy rung is its only reader | +| secrets.json | workstream S: contents move to the encrypted secret paths; plaintext file deleted once the rt-context extension reads via the daemon | encrypted | +| `rt settings linear team` surface | **DELETE (recommend)** | — | write-only, zero readers (step-1 finding); pending Matt's ruling | + +**Legacy rung deletion:** the rung serves only `rt.roles`/`rt.intercepts`/ +`rt.worktrees` out of per-repo config.json, whose values already live in the team +store (RT-47). Delete resolve.ts:156-196 + collectSlots wiring + `legacy` scope + +caller opts (endpoint/config.ts, worktree/config.ts, settings-keys.ts) + the +config.json mtime probe in endpoint/shim.ts + the legacy test suite (mapped in the +survey). After this, `~/.mattstack/rt` holds runtime only. + +## deck (MAT-384 settings half) + +| Item | Disposition | Scope | +|---|---|---| +| settings.json `apps.*.published`, `publicFollowsOverride` | `deck.apps` store key | user | +| settings.json `apps.*.passwordHash` | workstream S encrypted secret path (hashes never enter a plaintext store) | encrypted | +| settings.json `secret`, `apps.*.override`, `passwordVersion` | runtime → stays local (state.db when MAT-384 lands) | — | +| access.json | `deck.access` store key | user | +| platform.json `publicDomain`, `legacyPrefixes` | `deck.platform` store key | machine | +| platform.json `tlds` | derived cache from portless — NOT settings; stays runtime | — | +| platform.json `secrets.cfApiToken/cfZoneId` | workstream S: sops/age-encrypted secret path in the home repo; deck reads via the shared secrets API | encrypted | +| registry.json, api.json | untouched here (MAT-384 state.db) | — | + +**Security fixes riding along (both found in survey):** +1. `local-apps/data/settings.json` is git-tracked with real argon2 hashes + a live + session secret; checkout-mode runs read/write it. Fix: untrack + gitignore, + point checkout runs at the state dir, rotate the session secret and the hashes' + passwords (Matt), note history rewrite as Matt's call. +2. platform.json (and the new interim secrets file) get 0600. + +Deck adds the rt-client dependency and replaces its four eager hand-rolled caches +for the migrated keys with resolver reads (its boot-env import-ordering contract +must be preserved; hot-reload semantics unchanged — deck restart applies edits). + +## board + +| config.json key(s) | Disposition | Scope | +|---|---|---| +| gitlabHost, projects, members (full roster + hidden defaults), title, botUsernames, ticketPrefixes, slack.* | `board.*` keys | team | +| staleAfterDays, *Workspace names, defaultMember, triage user-intent flags | `board.*` keys | user | +| claudeCommand, reviewCwd/respondCwd/doctorCwd, rtRepos, triage.maxConcurrent | `board.*` keys | machine | +| port, host | DELETE (deck's PORT env is authoritative; survey: config port is dead) | +| reviewSkill, respondSkill | DELETE (dead — skills.jsonc manifests shadow them) | +| doctorSkill + triage.doctorSkill | keep BOTH as distinct keys with today's distinct semantics (BOARD-14: triage variant is never manifest-resolved) | +| switchboard.url | machine key; the `POST /peer/join` writer moves to a machine-scope setSetting | +| members[].hidden toggle | user-scope write (board's settings write goes through the shared writer) | +| config.team.json + materialize + setup.ts seeding | RETIRED — the team store IS the team layer; setup reads stores | +| .env orphaned CF_API_TOKEN/CF_ACCOUNT_ID | delete lines (orphaned secret) | +| state/* files | untouched (state.db lane later) | + +## gitq + +| Item | Disposition | Scope | +|---|---|---| +| settings.json workSlotLocation, maxWorkSlots | `gitq.*` keys | machine | +| settings.json forges (host-keyed map, tokenEnv names only) | `gitq.forges` key | user | +| checkout config.json repos, port, herdrWorkspace | `gitq.board` key | machine | +| stacks/, operation-log.json, state/jobs/, leases, pause files | untouched (state.db lane later) | +| repos.json + generated/ accessors, src/core/linear.ts | DELETE (dead, zero callers) | +| README secrets-fallback claim (:147) | fix (stale — direct secrets.json reads removed in MAT-33) | + +gitq's `GITQ_CONFIG_DIR` import-time snapshot and the four frozen legacy constants +must be handled so tests keep isolating (bunfig preload contract preserved). + +## Installer-lane keys (handoff-2026-08-20-installer-needs-from-settings-lane.md) + +The installer consumes whatever this lane rules; these are the rulings: + +| Need | Key(s) | Scope | Shape ruling | +|---|---|---|---| +| Team integrations | `mattstack.integrations` | team | as proposed: `{forge:{host,provider}, slack?:{appId,clientId,channel,callbackPort}, linear?:{teamKey}, switchboard?:{url}}`; client secrets → RT-32, never here | +| Team tracking intent | `mattstack.tracking` (team) layered over `rt.repoTracking` (machine) | team + machine | team key is IDENTITY-keyed (`host/group/repo`) declared intent; machine key stays NAME-keyed grants as today; the daemon merges (machine wins per-repo) resolving identities→names via the repo index | +| Pack requirements | NOT a settings key | — | pack-side `requirements.jsonc` next to the manifest — requirements travel and version with the pack; `rt setup plan` reads it | +| Claude plugin replay | `claude.marketplaces`, `claude.plugins` | user + team | flat arrays; restore (RT-31) replays them | +| Suite-app keys | the deck/board/gitq tables above | as ruled above | registered in the suite registry, so `rt settings set` accepts them | +| App bundle path | `mattstack.appPath` | machine | written by the app at launch; rt reads it instead of hardcoding `~/Applications` | +| Triage cron | `rt.cron` machine key | machine | installer appends a trigger via `rt settings set` once migrated | + +Follow-ups this lane creates but does not do: the rt-context extension's move +off name-keyed `branch-naming.json` (blocker for retiring those files). NOT a +follow-up: the extension's direct `secrets.json` read — that port is IN +workstream S (it blocks deleting the plaintext file). A reply file with final +key names goes next to the installer handoff now that Matt has ratified. + +## Sequencing (deadline-shaped: every lane separately mergeable) + +1. **H1 — home repo init + gitignore boundary** (RT-30 provisioning half). + `~/.mattstack` becomes the clone with the layer boundary correct BEFORE any + new content lands in git. mattstack-prefs folds in as `user/`. +2. **S — secrets layer** (RT-32). Must precede any migration that touches a + token (deck's CF token, board's env writers, rt secrets consumers). +3. **E — resolver + suite registry extraction** into the shared package + (pure refactor; consumers bun-install). +4. **RT keys wave** (table above) + legacy rung deletion + repo-config deletion + + cruft. `~/.mattstack/rt` is then runtime-only. +5. **deck settings** + both security fixes (CF token → S; git-tracked + data/settings.json untracked + rotated). +6. **board settings** (largest consumer surgery: three config writers rehomed; + .env SLACK_TOKEN → S). +7. **gitq settings** + dead-code deletion. +8. **H2 — snapshot daemon** (RT-30 daemon half: watcher, owners, janitor). After + the stores are the source of truth so it snapshots the real layer. +9. **R — restore + materialize** (RT-31): the program's acceptance test; also + closes MAT-375's residue (strata now live in the home repo). + +Sequencing flag (not this program): MAT-30/MAT-376 org creation — if the home +repo should live in the final org, create the org before H1, or provision under +m4ttheweric and `git remote set-url` later (cheap; recommended, don't block). + +## Rulings ratified in conversation (Matt, 2026-08-20 — proceed; veto any in flight) + +1. `rt settings linear team`: delete the surface (recommended — write-only) or park? +2. `rt.hooks` deferral out of this lane (recommended) — OK? +3. Deck git-history rewrite for the committed password hashes/secret: rotate-only + (recommended minimum) or also rewrite history? +4. Board `members` at team scope with per-user `hidden` overlay at user scope — + confirm the roster is team truth (config.team.json's 16) not config.json's 8. +5. Resolver extraction target: grow `@mattstack/rt-client` (recommended — board + and gitq already depend on it) vs a new `@mattstack/settings` package. diff --git a/e2e/tests/settings.test.ts b/e2e/tests/settings.test.ts index 108834a2..840b9bf6 100644 --- a/e2e/tests/settings.test.ts +++ b/e2e/tests/settings.test.ts @@ -455,6 +455,20 @@ describe("rt settings (four stores, one resolver — e2e)", () => { ]); }, 40_000); + test("a non-rt suite key (deck.access) round-trips end to end with provenance", async () => { + const res = await finished( + runRt(["settings", "set", "deck.access", '{"members":["alice"]}', "--scope", "user"]), + ); + expect(res.exitCode).toBe(0); + expect(stripAnsi(res.stdout)).toContain("deck.access set (user)"); + + const out = await rtJson(["settings", "get", "deck.access", "--json"]); + expect(out.ok).toBe(true); + expect(out.migrated).toBe(true); + expect(out.value).toEqual({ members: ["alice"] }); + expect(out.provenance).toEqual([{ scope: "user", file: userStore }]); + }, 30_000); + // ── 3. stores → shim → child ─────────────────────────────────────────────── test("intercept install builds the rules and the shim from the stores alone", async () => { diff --git a/e2e/tests/verify.test.ts b/e2e/tests/verify.test.ts index 4fc7b747..316895c3 100644 --- a/e2e/tests/verify.test.ts +++ b/e2e/tests/verify.test.ts @@ -80,11 +80,18 @@ describe("verify", () => { expect(check!.status).toBe("pass"); }); - test("active flavor's tray app hard-fails when missing (no bundle is installed in this fixture home)", () => { + test("active flavor's tray app: hard-fails when genuinely nowhere on this machine", () => { + // installedTrayAppPath (Item 5) also checks the real, absolute + // /Applications — a location the fixture `home` (passed as the + // subprocess's HOME) can never isolate, unlike ~/Applications. A machine + // that genuinely has the bundle installed there is expected to pass this + // check even against an otherwise-empty fixture home, so assert against + // that reality instead of assuming a fixed "always fails" outcome. const activeBundle = activeFlavor(home) === "dev" ? DEV_TRAY_APP_BUNDLE : TRAY_APP_BUNDLE; const check = findCheck(activeBundle); expect(check).toBeDefined(); - expect(check!.status).toBe("fail"); + const reallyInstalledSystemWide = existsSync(join("/Applications", activeBundle)); + expect(check!.status).toBe(reallyInstalledSystemWide ? "pass" : "fail"); }); test("inactive flavor's tray app is informational, never a failure", () => { diff --git a/extensions/vscode/rt-context/bun.lock b/extensions/vscode/rt-context/bun.lock index 16c7d69f..82cbcdc5 100644 --- a/extensions/vscode/rt-context/bun.lock +++ b/extensions/vscode/rt-context/bun.lock @@ -8,6 +8,7 @@ "@mattstack/glance": "^0.19.0", }, "devDependencies": { + "@types/bun": "latest", "@types/vscode": "^1.85.0", "@vscode/vsce": "^3.0.0", "esbuild": "^0.24.0", @@ -170,6 +171,10 @@ "@textlint/types": ["@textlint/types@15.5.2", "", { "dependencies": { "@textlint/ast-node-types": "15.5.2" } }, "sha512-sJOrlVLLXp4/EZtiWKWq9y2fWyZlI8GP+24rnU5avtPWBIMm/1w97yzKrAqYF8czx2MqR391z5akhnfhj2f/AQ=="], + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], + "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], "@types/sarif": ["@types/sarif@2.1.7", "", {}, "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ=="], @@ -244,6 +249,8 @@ "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -658,6 +665,8 @@ "undici": ["undici@7.24.6", "", {}, "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA=="], + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], diff --git a/extensions/vscode/rt-context/package.json b/extensions/vscode/rt-context/package.json index 3e0df033..cf258c5d 100644 --- a/extensions/vscode/rt-context/package.json +++ b/extensions/vscode/rt-context/package.json @@ -78,6 +78,7 @@ "install-local": "bun run package && cursor --install-extension rt-context-0.1.0.vsix" }, "devDependencies": { + "@types/bun": "latest", "@types/vscode": "^1.85.0", "@vscode/vsce": "^3.0.0", "esbuild": "^0.24.0", diff --git a/extensions/vscode/rt-context/src/__tests__/secrets.test.ts b/extensions/vscode/rt-context/src/__tests__/secrets.test.ts new file mode 100644 index 00000000..a419b6c7 --- /dev/null +++ b/extensions/vscode/rt-context/src/__tests__/secrets.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test'; +import { pickDaemonSecret } from '../secretsMapping'; + +// getSecret/setSecret (secrets.ts) need a vscode.ExtensionContext and are +// exercised by hand in the extension host; this covers the daemon-response +// mapping (secretsMapping.ts), which is the part RT-32 actually changed and +// the one piece of secrets.ts's logic reachable without a vscode host. +describe('pickDaemonSecret', () => { + test('ok + value present -> {status: "ok", value}', () => { + const response = { ok: true, data: { linearApiKey: 'lin_api_x', gitlabToken: 'glpat-x' } }; + expect(pickDaemonSecret(response, 'linearApiKey')).toEqual({ status: 'ok', value: 'lin_api_x' }); + expect(pickDaemonSecret(response, 'gitlabToken')).toEqual({ status: 'ok', value: 'glpat-x' }); + }); + + test('ok but the key is absent from the response (never configured) -> "unset", not a failure', () => { + const response = { ok: true, data: { linearApiKey: 'lin_api_x' } }; + expect(pickDaemonSecret(response, 'gitlabToken')).toEqual({ status: 'unset' }); + }); + + test('an empty string value -> "unset", same as absent', () => { + const response = { ok: true, data: { linearApiKey: '' } }; + expect(pickDaemonSecret(response, 'linearApiKey')).toEqual({ status: 'unset' }); + }); + + test('null response (daemonQuery couldn\'t reach the daemon) -> "daemon-down", distinct from a gate failure', () => { + expect(pickDaemonSecret(null, 'linearApiKey')).toEqual({ status: 'daemon-down' }); + }); + + test('ok:false (the handler\'s token gate refused) -> "gate-failed", carrying the reason, distinct from daemon-down', () => { + const response = { ok: false, error: 'bad-token' }; + expect(pickDaemonSecret(response, 'linearApiKey')).toEqual({ status: 'gate-failed', error: 'bad-token' }); + }); + + test('ok:false with no error field still reports gate-failed rather than falling through silently', () => { + const response = { ok: false }; + expect(pickDaemonSecret(response, 'linearApiKey')).toEqual({ status: 'gate-failed', error: 'unknown' }); + }); +}); diff --git a/extensions/vscode/rt-context/src/daemonClient.ts b/extensions/vscode/rt-context/src/daemonClient.ts index 6792ff3c..7833194c 100644 --- a/extensions/vscode/rt-context/src/daemonClient.ts +++ b/extensions/vscode/rt-context/src/daemonClient.ts @@ -36,7 +36,7 @@ export interface DaemonEvent { */ export async function daemonQuery( path: string, - options?: { method?: string; body?: any }, + options?: { method?: string; body?: any; headers?: Record }, ): Promise { const url = `${API_BASE}${path.startsWith('/') ? path : `/${path}`}`; const method = options?.method ?? 'GET'; @@ -48,7 +48,10 @@ export async function daemonQuery( const fetchOptions: RequestInit = { method, signal: controller.signal, - headers: options?.body ? { 'Content-Type': 'application/json' } : undefined, + headers: { + ...(options?.body ? { 'Content-Type': 'application/json' } : undefined), + ...options?.headers, + }, body: options?.body ? JSON.stringify(options.body) : undefined, }; diff --git a/extensions/vscode/rt-context/src/extension.ts b/extensions/vscode/rt-context/src/extension.ts index 27ea2fd1..b25438d8 100644 --- a/extensions/vscode/rt-context/src/extension.ts +++ b/extensions/vscode/rt-context/src/extension.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import { exec } from 'child_process'; import { branchCache, branchListCache } from './cache'; -import { setSecret } from './secrets'; +import { showCantSaveSecretMessage } from './secrets'; import { initStatusBar, waitForGitAndStart, @@ -77,36 +77,15 @@ export function activate(context: vscode.ExtensionContext) { showBranchSwitcher(context), ), - vscode.commands.registerCommand('rtContext.setLinearApiKey', async () => { - const key = await vscode.window.showInputBox({ - prompt: 'Enter your Linear personal API key', - placeHolder: 'lin_api_...', - password: true, - ignoreFocusOut: true, - }); - if (key) { - await setSecret(context, 'linearApiKey', key); - vscode.window.showInformationMessage('Linear API key saved (shared with rt CLI).'); - branchCache.clear(); - branchListCache.clear(); - scheduleUpdate(context); - } + // RT-32: the extension can't save secrets anymore — show the directed + // `rt secrets set` message immediately rather than prompting for a + // token it would only throw away. + vscode.commands.registerCommand('rtContext.setLinearApiKey', () => { + showCantSaveSecretMessage('linearApiKey'); }), - vscode.commands.registerCommand('rtContext.setGitlabToken', async () => { - const token = await vscode.window.showInputBox({ - prompt: 'Enter your GitLab personal access token (for MR title fallback)', - placeHolder: 'glpat-...', - password: true, - ignoreFocusOut: true, - }); - if (token) { - await setSecret(context, 'gitlabToken', token); - vscode.window.showInformationMessage('GitLab token saved (shared with rt CLI).'); - branchCache.clear(); - branchListCache.clear(); - scheduleUpdate(context); - } + vscode.commands.registerCommand('rtContext.setGitlabToken', () => { + showCantSaveSecretMessage('gitlabToken'); }), vscode.commands.registerCommand('rtContext.refresh', () => { diff --git a/extensions/vscode/rt-context/src/secrets.ts b/extensions/vscode/rt-context/src/secrets.ts index 96118437..58d596fe 100644 --- a/extensions/vscode/rt-context/src/secrets.ts +++ b/extensions/vscode/rt-context/src/secrets.ts @@ -1,53 +1,78 @@ /** * Shared secrets reader for the VS Code extension. * - * Reads from ~/.mattstack/rt/secrets.json (shared with the rt CLI), - * falling back to VS Code's secret store for backward compatibility. + * Reads go through the rt daemon's `secrets:read` verb (RT-32) — the daemon + * owns the encrypted store and, during the migration, its own plaintext + * fallback (lib/linear.ts's loadSecrets), so this module no longer opens + * ~/.mattstack/rt/secrets.json directly. VS Code's secret store is the + * fallback when the daemon is unreachable or the key was never set. * - * Write operations update BOTH stores so the CLI and extension stay in sync. + * Writes: there is no secrets:write daemon verb yet, and this module no + * longer writes ~/.mattstack/rt/secrets.json either (a plaintext write here + * would be a silently-ignored no-op from rt's perspective — the file is + * being retired as a write target). `setSecret` tells the user what to run + * instead rather than pretending to save. */ import * as vscode from 'vscode'; -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { readFileSync } from 'fs'; import { homedir } from 'os'; import { join } from 'path'; +import { daemonQuery } from './daemonClient'; +import { pickDaemonSecret, type DaemonSecretKey } from './secretsMapping'; -const SECRETS_PATH = join(homedir(), '.mattstack', 'rt', 'secrets.json'); +export type { DaemonSecretKey }; -interface RtSecrets { - linearApiKey?: string; - gitlabToken?: string; - linearTeamId?: string; - linearTeamKey?: string; -} +const API_TOKEN_PATH = join(homedir(), '.mattstack', 'rt', 'api-token'); -function readRtSecrets(): RtSecrets { +/** /api/secrets is token-gated (api-auth.ts + the secrets:read handler); '' on a fresh machine just fails the gate, same as a wrong token. */ +function apiToken(): string { try { - return JSON.parse(readFileSync(SECRETS_PATH, 'utf8')); + return readFileSync(API_TOKEN_PATH, 'utf8').trim(); } catch { - return {}; + return ''; } } -function writeRtSecrets(secrets: RtSecrets): void { - const dir = join(homedir(), '.mattstack', 'rt'); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - writeFileSync(SECRETS_PATH, JSON.stringify(secrets, null, 2)); +// Warn once per distinct cause per extension-host lifetime, not once per +// getSecret call (called on every status-bar refresh) — a modal or console +// line per call would be spam, but silently collapsing daemon-down and +// gate-failed into "undefined" hid a real, actionable problem from the user. +const warnedOnce = new Set(); +function warnOnce(dedupeKey: string, message: string): void { + if (warnedOnce.has(dedupeKey)) return; + warnedOnce.add(dedupeKey); + console.warn(`[rt-context] ${message}`); } /** - * Get a secret, preferring ~/.mattstack/rt/secrets.json over VS Code's secret store. + * Get a secret, preferring the daemon's encrypted store over VS Code's + * secret store. */ export async function getSecret( context: vscode.ExtensionContext, - key: 'linearApiKey' | 'gitlabToken', + key: DaemonSecretKey, ): Promise { - // 1. Try shared file first - const rtSecrets = readRtSecrets(); - const fileValue = rtSecrets[key]; - if (fileValue) return fileValue; + const response = await daemonQuery('/api/secrets', { headers: { 'X-RT-Token': apiToken() } }); + const result = pickDaemonSecret(response, key); + + switch (result.status) { + case 'ok': + return result.value; + case 'unset': + break; // normal — key was never set anywhere; no warning + case 'daemon-down': + warnOnce('daemon-down', 'rt daemon unreachable at :9401 — falling back to VS Code\'s local secret store'); + break; + case 'gate-failed': + warnOnce( + `gate-failed:${result.error}`, + `rt daemon refused the secrets request (${result.error}) — check ~/.mattstack/rt/api-token; falling back to VS Code's local secret store`, + ); + break; + } - // 2. Fall back to VS Code secret store (legacy) + // Fall back to VS Code's own secret store (daemon down, gated, or key unset). const vscodeKey = key === 'linearApiKey' ? 'rtContext.linearApiKey' : 'rtContext.gitlabToken'; @@ -55,22 +80,14 @@ export async function getSecret( } /** - * Store a secret in both ~/.mattstack/rt/secrets.json AND VS Code's secret store. - * This keeps both locations in sync during the transition period. + * RT-32: no longer writes ~/.mattstack/rt/secrets.json (that file is being + * retired as a write target — the encrypted store, written only via + * `rt secrets set`, is the source of truth). There is nothing left to + * collect from the user here, so the command shows this directed message + * immediately rather than prompting for a token it can't save. */ -export async function setSecret( - context: vscode.ExtensionContext, - key: 'linearApiKey' | 'gitlabToken', - value: string, -): Promise { - // 1. Write to shared file - const rtSecrets = readRtSecrets(); - rtSecrets[key] = value; - writeRtSecrets(rtSecrets); - - // 2. Also write to VS Code secrets (backward compatibility) - const vscodeKey = key === 'linearApiKey' - ? 'rtContext.linearApiKey' - : 'rtContext.gitlabToken'; - await context.secrets.store(vscodeKey, value); +export function showCantSaveSecretMessage(key: DaemonSecretKey): void { + vscode.window.showErrorMessage( + `RT Context: can't save secrets from the extension anymore. Set tokens with \`rt secrets set rt ${key}\` — this command no longer writes.`, + ); } diff --git a/extensions/vscode/rt-context/src/secretsMapping.ts b/extensions/vscode/rt-context/src/secretsMapping.ts new file mode 100644 index 00000000..2d2b633a --- /dev/null +++ b/extensions/vscode/rt-context/src/secretsMapping.ts @@ -0,0 +1,42 @@ +/** + * Pure mapping from the daemon's /api/secrets response to one field. + * + * Split out from secrets.ts (which imports vscode transitively via + * daemonClient.ts) so this logic is unit-testable with bun:test — importing + * secrets.ts directly pulls in `vscode`, which only resolves inside the + * extension host. + */ + +/** Structural subset of daemonClient.ts's DaemonResponse — avoids importing that module (and vscode with it) here. */ +export interface DaemonSecretsResponse { + ok: boolean; + data?: unknown; + error?: string; +} + +/** The daemon's secrets:read verb whitelists exactly these two fields (lib/daemon/handlers/secrets.ts) — keep in lockstep. */ +export type DaemonSecretKey = 'linearApiKey' | 'gitlabToken'; + +/** + * Distinguishes three failure shapes that collapsing to `undefined` would + * hide from the caller: `daemon-down` (no response at all — the extension's + * own `daemonQuery` returns null on any fetch failure), `gate-failed` (the + * daemon answered but refused — e.g. a missing/stale ~/.mattstack/rt/api-token, + * secrets:read's own token check), and `unset` (the daemon answered fine, + * the key just isn't configured — the ONLY case that's not worth warning + * about). `getSecret` uses this to warn once per distinct cause instead of + * silently falling through every time. + */ +export type DaemonSecretResult = + | { status: 'ok'; value: string } + | { status: 'unset' } + | { status: 'gate-failed'; error: string } + | { status: 'daemon-down' }; + +export function pickDaemonSecret(response: DaemonSecretsResponse | null, key: DaemonSecretKey): DaemonSecretResult { + if (response === null) return { status: 'daemon-down' }; + if (!response.ok) return { status: 'gate-failed', error: response.error ?? 'unknown' }; + + const value = (response.data as Record | undefined)?.[key]; + return typeof value === 'string' && value.length > 0 ? { status: 'ok', value } : { status: 'unset' }; +} diff --git a/lib/__tests__/cli-logger-redact.test.ts b/lib/__tests__/cli-logger-redact.test.ts index 847ede65..ebd5dd22 100644 --- a/lib/__tests__/cli-logger-redact.test.ts +++ b/lib/__tests__/cli-logger-redact.test.ts @@ -41,4 +41,58 @@ describe("redactSensitiveArgs", () => { redactSensitiveArgs(args); expect(args).toEqual(["--reason", "secret"]); }); + + describe("secrets set|rotate: anything past is redacted, defense in depth", () => { + test("raw argv shape (installCliLogging's seed) — the 'secrets set' prefix is still in the array", () => { + expect(redactSensitiveArgs(["secrets", "set", "rt", "gitlabToken", "glpat-canary-value"])).toEqual([ + "secrets", + "set", + "rt", + "gitlabToken", + "[redacted]", + ]); + }); + + test("raw argv shape: rotate, multiple trailing tokens all redacted", () => { + expect(redactSensitiveArgs(["secrets", "rotate", "board", "slackToken", "canary-1", "canary-2"])).toEqual([ + "secrets", + "rotate", + "board", + "slackToken", + "[redacted]", + "[redacted]", + ]); + }); + + test("raw argv shape: exactly domain+key, nothing to redact", () => { + const args = ["secrets", "set", "rt", "gitlabToken"]; + expect(redactSensitiveArgs(args)).toEqual(args); + }); + + test("leaf `rest` shape (logCommand's entry.args) — command carries the 'secrets set' context", () => { + expect(redactSensitiveArgs(["rt", "gitlabToken", "glpat-canary-value"], "rt secrets set")).toEqual([ + "rt", + "gitlabToken", + "[redacted]", + ]); + }); + + test("leaf `rest` shape: rotate", () => { + expect(redactSensitiveArgs(["board", "slackToken", "canary-value"], "rt secrets rotate")).toEqual([ + "board", + "slackToken", + "[redacted]", + ]); + }); + + test("a command that merely mentions 'secrets' elsewhere is not treated as a write verb", () => { + const args = ["rt"]; + expect(redactSensitiveArgs(args, "rt secrets list")).toEqual(args); + }); + + test("unrelated commands are never affected by the secrets-write check", () => { + const args = ["sdm", "connect", "k"]; + expect(redactSensitiveArgs(args, "rt sdm connect")).toEqual(args); + }); + }); }); diff --git a/lib/__tests__/cli-logger.test.ts b/lib/__tests__/cli-logger.test.ts new file mode 100644 index 00000000..0eb12396 --- /dev/null +++ b/lib/__tests__/cli-logger.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync, readdirSync } from "fs"; +import { join } from "path"; +import { logCommand } from "../cli-logger.ts"; +import { logsDir } from "../rt-paths.ts"; + +/** + * Exercises the REAL logCommand write path (fake HOME, from test-setup.ts's + * preload — never the developer's real ~/.mattstack/rt/logs), not a mocked + * seam: this is the gap the store-level canary test couldn't cover, since + * that one only checked a command's stdout, never the on-disk CLI log every + * command writes to regardless of outcome. + */ +function readTodaysCliLogLines(): string[] { + const dir = logsDir(); + const file = readdirSync(dir).find((f) => f.startsWith("cli.") && f.endsWith(".log")); + if (!file) throw new Error("expected today's cli log file to exist after logCommand()"); + return readFileSync(join(dir, file), "utf8").trim().split("\n").filter(Boolean); +} + +describe("logCommand: rt secrets set|rotate never reaches disk with a value attached", () => { + test("a canary trailing 'rt secrets set ' is redacted in the on-disk line", () => { + const CANARY = "sk_super_secret_canary_never_on_disk"; + + logCommand({ + command: "rt secrets set", + args: ["rt", "gitlabToken", CANARY], + cwd: "/tmp", + durationMs: 1, + outcome: "ok", + }); + + const lines = readTodaysCliLogLines(); + const line = lines.at(-1)!; + expect(line).not.toContain(CANARY); + expect(line).toContain("gitlabToken"); + expect(JSON.parse(line).args).toEqual(["rt", "gitlabToken", "[redacted]"]); + }); + + test("the same holds for rotate, and for an error outcome (the crash/exit path)", () => { + const CANARY = "glpat_rotate_canary_never_on_disk"; + + logCommand({ + command: "rt secrets rotate", + args: ["board", "slackToken", CANARY], + cwd: "/tmp", + durationMs: 1, + outcome: "error", + error: "boom", + }); + + const lines = readTodaysCliLogLines(); + const line = lines.at(-1)!; + expect(line).not.toContain(CANARY); + expect(JSON.parse(line).args).toEqual(["board", "slackToken", "[redacted]"]); + }); +}); diff --git a/lib/__tests__/dev-mode-handoff.test.ts b/lib/__tests__/dev-mode-handoff.test.ts index 3e0835dc..7d87d18a 100644 --- a/lib/__tests__/dev-mode-handoff.test.ts +++ b/lib/__tests__/dev-mode-handoff.test.ts @@ -21,6 +21,12 @@ * * The tray socket itself is a real `Bun.serve({ unix })` fake bound at the * real TRAY_SOCK_PATH (inside the isolated test HOME from bunfig's preload). + * + * `toggleDevMode`'s bundle lookup (via `installedTrayAppPath`, Item 5) also + * checks the real, absolute `/Applications` — a path this test's isolated + * HOME can never redirect. `isolatedExists` denies it unconditionally, so + * this suite's pass/fail never depends on whether the real machine running + * it happens to have mattstack.app/mattstack-dev.app installed for real. */ import { afterEach, describe, expect, test } from "bun:test"; import { @@ -33,6 +39,10 @@ import { toggleDevMode } from "../../commands/settings.ts"; import { TRAY_SOCK_PATH } from "../daemon-config.ts"; import { devTrayAppPath, trayAppPath } from "../rt-paths.ts"; +function isolatedExists(path: string): boolean { + return path.startsWith("/Applications/") ? false : existsSync(path); +} + const HOME = process.env.HOME!; const WRAPPER_PATH = join(HOME, ".local", "bin", "rt"); const DEV_MODE_CONFIG = join(HOME, ".mattstack", "rt", "dev-mode.json"); @@ -153,7 +163,7 @@ describe("toggleDevMode — flavor handoff", () => { mkdirSync(devTrayAppPath(), { recursive: true }); // incoming bundle present setUpFakes(); - await toggleDevMode(["dev"]); + await toggleDevMode(["dev"], isolatedExists); const log = readLog(); expect(oneShotSteps(log)).toEqual(["retire", "osascript", "pkill", "open"]); @@ -178,7 +188,7 @@ describe("toggleDevMode — flavor handoff", () => { writeFileSync(join(trayAppPath(), "Contents", "MacOS", "rt-daemon"), Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x00]), { mode: 0o755 }); // Mach-O magic, not a script setUpFakes(); - await toggleDevMode(["prod"]); + await toggleDevMode(["prod"], isolatedExists); const log = readLog(); expect(oneShotSteps(log)).toEqual(["retire", "osascript", "pkill", "open"]); @@ -200,7 +210,7 @@ describe("toggleDevMode — flavor handoff", () => { // Deliberately do NOT create devTrayAppPath() — the incoming bundle. setUpFakes(); - await toggleDevMode(["dev"]); + await toggleDevMode(["dev"], isolatedExists); expect(readLog()).toEqual([]); // no retire, no osascript, no pkill, no open expect(existsSync(WRAPPER_PATH)).toBe(false); // CLI half never toggled @@ -212,7 +222,7 @@ describe("toggleDevMode — flavor handoff", () => { // Deliberately do NOT create trayAppPath() — the incoming (prod) bundle. setUpFakes(); - await toggleDevMode(["prod"]); + await toggleDevMode(["prod"], isolatedExists); expect(readLog()).toEqual([]); expect(existsSync(WRAPPER_PATH)).toBe(true); // disableDevMode() never ran diff --git a/lib/__tests__/linear.test.ts b/lib/__tests__/linear.test.ts index 93a453fe..27b1f500 100644 --- a/lib/__tests__/linear.test.ts +++ b/lib/__tests__/linear.test.ts @@ -1,11 +1,216 @@ -import { describe, test, expect, afterEach } from "bun:test"; -import { pickStartedState, fetchMyTodoTickets, searchTickets } from "../linear.ts"; +import { describe, test, expect, afterEach, beforeEach, spyOn } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { + pickStartedState, fetchMyTodoTickets, searchTickets, + loadSecrets, saveSecret, saveTeamConfig, getTeamConfig, +} from "../linear.ts"; +import { rtDir } from "../rt-paths.ts"; +import { secretsFilePath, resetSecretsMemo, type SecretsExecResult, type SecretsExecSeam, type SecretsSeams } from "../secrets/store.ts"; +import type { AgeExecResult, AgeKeySeam } from "../home/age-key.ts"; const realFetch = global.fetch; afterEach(() => { global.fetch = realFetch; }); +// ─── Fake secrets seams (encrypted-store stand-in for loadSecrets/saveSecret tests) ── + +function fakeAgeKeySeam(key = "AGE-SECRET-KEY-1TEST"): AgeKeySeam { + return { + async run(cmd): Promise { + if (cmd[0] === "security" && cmd[1] === "find-generic-password") { + return { code: 0, stdout: `${key}\n`, stderr: "" }; + } + throw new Error(`fakeAgeKeySeam: unexpected call ${cmd.join(" ")}`); + }, + }; +} + +/** + * domain -> basename ("rt.json" -> "rt"), works for both real paths and + * --filename-override values, and for the pid-qualified `.json..tmp` + * post-encrypt readback path store.ts decrypts before renaming. + */ +function domainFromPath(p: string): string { + return p + .split("/") + .pop()! + .replace(/\.\d+\.tmp$/, "") + .replace(/\.json$/, ""); +} + +/** + * In-memory sops stand-in: `domains` holds each domain's decrypted payload + * directly (this fake never models real ciphertext bytes), `files` models the + * fs surface (fileExists/readFile/staging) that store.ts's encrypt/decrypt + * flow round-trips through. + */ +function fakeSecretsSeams(seedDomains: Record> = {}): SecretsSeams { + const domains = new Map>(Object.entries(seedDomains)); + const files = new Map(); + const stats = new Map(); + let mtimeCounter = 0; + const touch = (p: string) => { + mtimeCounter += 1; + stats.set(p, { mtimeMs: mtimeCounter, size: files.get(p)?.length ?? 0 }); + }; + for (const domain of domains.keys()) { + const p = secretsFilePath(domain); + files.set(p, JSON.stringify({ sops: {}, data: "seed" })); + touch(p); + } + + const execSeam: SecretsExecSeam = { + fileExists: (p) => files.has(p), + statFile: (p) => stats.get(p) ?? null, + readFile: (p) => { + const v = files.get(p); + if (v === undefined) throw new Error(`fakeSecretsSeams: readFile of missing path ${p}`); + return v; + }, + writeFile: (p, content) => { files.set(p, content); touch(p); }, + ensureDir: () => {}, + chmod: () => {}, + fsyncAndRename: (from, to) => { + const content = files.get(from); + if (content !== undefined) { files.set(to, content); files.delete(from); stats.delete(from); touch(to); } + }, + removeFile: (p) => { files.delete(p); stats.delete(p); }, + async run(cmd): Promise { + if (cmd[0] === "sops" && cmd[1] === "-d") { + const domain = domainFromPath(cmd[2]!); + return { code: 0, stdout: JSON.stringify(domains.get(domain) ?? {}), stderr: "" }; + } + if (cmd[0] === "sops" && cmd[1] === "-e") { + const domain = domainFromPath(cmd[cmd.indexOf("--filename-override") + 1]!); + const outputPath = cmd[cmd.indexOf("--output") + 1]!; + const stagingPath = cmd[cmd.length - 1]!; + const staged = files.get(stagingPath); + if (staged === undefined) throw new Error("fakeSecretsSeams: no staged plaintext for encrypt"); + domains.set(domain, JSON.parse(staged)); + files.set(outputPath, JSON.stringify({ sops: {}, data: "opaque" })); + touch(outputPath); + return { code: 0, stdout: "", stderr: "" }; + } + throw new Error(`fakeSecretsSeams: unexpected call ${cmd.join(" ")}`); + }, + }; + + return { ageKeySeam: fakeAgeKeySeam(), execSeam }; +} + +describe("loadSecrets / saveSecret / saveTeamConfig / getTeamConfig — encrypted store + plaintext fallback", () => { + const origHome = process.env.HOME; + let home: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-linear-secrets-"))); + process.env.HOME = home; + resetSecretsMemo(); // module-level memo persists across tests in one bun test process + }); + + afterEach(() => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + }); + + function writePlaintext(secrets: Record): void { + mkdirSync(rtDir(), { recursive: true }); + writeFileSync(join(rtDir(), "secrets.json"), JSON.stringify(secrets, null, 2)); + } + + test("encrypted store value wins over plaintext when both are present", async () => { + writePlaintext({ linearApiKey: "plaintext-key", gitlabToken: "plaintext-gitlab" }); + const seams = fakeSecretsSeams({ rt: { linearApiKey: "encrypted-key" } }); + + const secrets = await loadSecrets(seams); + + expect(secrets.linearApiKey).toBe("encrypted-key"); + expect(secrets.gitlabToken).toBe("plaintext-gitlab"); // not yet in the encrypted store + }); + + test("falls back to plaintext entirely when the encrypted domain doesn't exist yet (transition state)", async () => { + writePlaintext({ linearApiKey: "plaintext-key" }); + const seams = fakeSecretsSeams(); // no "rt" domain seeded -> file doesn't exist + + expect((await loadSecrets(seams)).linearApiKey).toBe("plaintext-key"); + }); + + test("returns {} when neither store has anything", async () => { + expect(await loadSecrets(fakeSecretsSeams())).toEqual({}); + }); + + test("saveSecret writes to the encrypted store and never touches the plaintext file", async () => { + const seams = fakeSecretsSeams(); + + await saveSecret("linearApiKey", "new-key", seams); + + expect((await loadSecrets(seams)).linearApiKey).toBe("new-key"); + expect(existsSync(join(rtDir(), "secrets.json"))).toBe(false); + }); + + test("saveTeamConfig writes both linearTeamId and linearTeamKey to the encrypted store", async () => { + const seams = fakeSecretsSeams(); + + await saveTeamConfig("team-123", "CV", seams); + + const secrets = await loadSecrets(seams); + expect(secrets.linearTeamId).toBe("team-123"); + expect(secrets.linearTeamKey).toBe("CV"); + }); + + test("getTeamConfig is null until both id and key are set, then returns the pair", async () => { + const seams = fakeSecretsSeams(); + expect(await getTeamConfig(seams)).toBeNull(); + + await saveTeamConfig("team-456", "EM", seams); + expect(await getTeamConfig(seams)).toEqual({ teamId: "team-456", teamKey: "EM" }); + }); + + function brokenExecSeam(): SecretsExecSeam { + return { + fileExists: () => true, + statFile: () => null, + readFile: () => { throw new Error("should not be called"); }, + writeFile: () => {}, + ensureDir: () => {}, + chmod: () => {}, + fsyncAndRename: () => {}, + removeFile: () => {}, + async run(cmd): Promise { + if (cmd[0] === "sops" && cmd[1] === "-d") return { code: 1, stdout: "", stderr: "gpg: decryption failed" }; + throw new Error(`unexpected ${cmd.join(" ")}`); + }, + }; + } + + test("an encrypted-store read failure logs what happened and falls back to plaintext WHEN the plaintext file still exists", async () => { + writePlaintext({ gitlabToken: "plaintext-gitlab" }); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeam(), execSeam: brokenExecSeam() }; + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + + try { + const secrets = await loadSecrets(seams); + expect(secrets.gitlabToken).toBe("plaintext-gitlab"); + expect(errorSpy).toHaveBeenCalledTimes(1); + const logged = errorSpy.mock.calls[0]!.join(" "); + expect(logged).toContain("encrypted store unreadable"); + expect(logged).toContain("using plaintext secrets.json"); + } finally { + errorSpy.mockRestore(); + } + }); + + test("an encrypted-store read failure THROWS when the plaintext file is absent — never silently returns {}", async () => { + // No writePlaintext() call: the transition-only fallback file doesn't exist. + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeam(), execSeam: brokenExecSeam() }; + + await expect(loadSecrets(seams)).rejects.toThrow(/decryption failed/); + }); +}); + /** Stub global.fetch to return canned GraphQL data and capture the request body. */ function mockGraphql(data: unknown): { body: () => { query: string; variables: Record } } { let captured: { query: string; variables: Record } | null = null; diff --git a/lib/__tests__/rt-paths.test.ts b/lib/__tests__/rt-paths.test.ts index 2de72fc9..cec22c65 100644 --- a/lib/__tests__/rt-paths.test.ts +++ b/lib/__tests__/rt-paths.test.ts @@ -23,7 +23,7 @@ import { rtDir, reposDir, repoDataDir, logsDir, migrateLegacyRtDir, legacyDirsPresent, TRAY_APP_NAME, DEV_TRAY_APP_NAME, TRAY_APP_BUNDLE, DEV_TRAY_APP_BUNDLE, - trayAppPath, devTrayAppPath, legacyTrayAppPaths, + trayAppPath, devTrayAppPath, legacyTrayAppPaths, installedTrayAppPath, machineSettingsPath, } from "../rt-paths.ts"; describe("rt-paths", () => { @@ -203,6 +203,87 @@ describe("rt-paths", () => { expect(candidates).toContain(join(rtExec, "../../rt-tray.app")); }); + test("legacyTrayAppPaths sweeps both /Applications and ~/Applications", () => { + process.env.HOME = "/tmp/fake-home-legacy-4"; + const candidates = legacyTrayAppPaths(); + expect(candidates).toContain("/Applications/rt-tray.app"); + expect(candidates).toContain("/tmp/fake-home-legacy-4/Applications/rt-tray.app"); + }); + + // ── installedTrayAppPath (Item 5: rt hardcodes ~/Applications in four places) ── + + describe("installedTrayAppPath", () => { + const makeHome = () => mkdtempSync(join(tmpdir(), "rt-paths-installed-tray-")); + const bundle = "mattstack.app"; + + test("the mattstack.appPath machine setting wins over both fixed locations", () => { + const home = makeHome(); + process.env.HOME = home; + mkdirSync(join(home, ".mattstack"), { recursive: true }); + writeFileSync(machineSettingsPath(), JSON.stringify({ "mattstack.appPath": "/custom/place/mattstack.app" })); + + const exists = (p: string) => p === "/custom/place/mattstack.app" || p === "/Applications/mattstack.app"; + expect(installedTrayAppPath(bundle, exists)).toBe("/custom/place/mattstack.app"); + + rmSync(home, { recursive: true, force: true }); + }); + + test("a machine setting naming a different bundle is never trusted for this lookup, even if it exists on disk", () => { + const home = makeHome(); + process.env.HOME = home; + mkdirSync(join(home, ".mattstack"), { recursive: true }); + // The setting names the PROD bundle; this call asks for the dev bundle. + writeFileSync(machineSettingsPath(), JSON.stringify({ "mattstack.appPath": "/Applications/mattstack.app" })); + + const exists = (p: string) => p === "/Applications/mattstack.app" || p === join(home, "Applications", "mattstack-dev.app"); + expect(installedTrayAppPath("mattstack-dev.app", exists)).toBe(join(home, "Applications", "mattstack-dev.app")); + + rmSync(home, { recursive: true, force: true }); + }); + + test("a machine setting pointing at a bundle that no longer exists is not trusted — falls through to the fixed locations", () => { + const home = makeHome(); + process.env.HOME = home; + mkdirSync(join(home, ".mattstack"), { recursive: true }); + writeFileSync(machineSettingsPath(), JSON.stringify({ "mattstack.appPath": "/gone/mattstack.app" })); + + const exists = (p: string) => p === "/Applications/mattstack.app"; + expect(installedTrayAppPath(bundle, exists)).toBe("/Applications/mattstack.app"); + + rmSync(home, { recursive: true, force: true }); + }); + + test("/Applications/ beats ~/Applications/ when no machine setting is present", () => { + const home = makeHome(); + process.env.HOME = home; + + const exists = (p: string) => p === "/Applications/mattstack.app" || p === join(home, "Applications", "mattstack.app"); + expect(installedTrayAppPath(bundle, exists)).toBe("/Applications/mattstack.app"); + + rmSync(home, { recursive: true, force: true }); + }); + + test("falls back to ~/Applications/ when only that one exists", () => { + const home = makeHome(); + process.env.HOME = home; + + const userPath = join(home, "Applications", "mattstack.app"); + const exists = (p: string) => p === userPath; + expect(installedTrayAppPath(bundle, exists)).toBe(userPath); + + rmSync(home, { recursive: true, force: true }); + }); + + test("null when the bundle is nowhere: no machine setting, not in /Applications, not in ~/Applications", () => { + const home = makeHome(); + process.env.HOME = home; + + expect(installedTrayAppPath(bundle, () => false)).toBeNull(); + + rmSync(home, { recursive: true, force: true }); + }); + }); + // ── Source-guards ──────────────────────────────────────────────────────────── /** Walk .ts sources (skipping tests, node_modules, dist) under a root. */ diff --git a/lib/__tests__/settings-paths-parity.test.ts b/lib/__tests__/settings-paths-parity.test.ts new file mode 100644 index 00000000..91cc73ed --- /dev/null +++ b/lib/__tests__/settings-paths-parity.test.ts @@ -0,0 +1,39 @@ +/** + * Parity guard between lib/rt-paths.ts (the authority, RT-46) and + * packages/rt-client/src/settings/paths.ts (RT-50's deliberate duplicate — + * rt-client cannot import rt's lib/). The rt-client module's own docblock + * says "change [rt-paths.ts] first, mirror here"; nothing enforced that until + * this test. A future edit to one side without the other silently splits the + * two callers onto different store paths. + */ + +import { describe, test, expect, afterEach } from "bun:test"; +import { join } from "path"; +import * as rtPaths from "../rt-paths.ts"; +import * as clientPaths from "../../packages/rt-client/src/settings/paths.ts"; + +describe("settings paths parity (lib/rt-paths.ts vs rt-client/settings/paths.ts)", () => { + const origHome = process.env.HOME; + afterEach(() => { + process.env.HOME = origHome; + }); + + test("userSettingsPath/teamSettingsPath/machineSettingsPath/teamsDir/repoDataDir agree under a faked HOME", () => { + process.env.HOME = "/tmp/parity-fake-home"; + + expect(clientPaths.userSettingsPath()).toBe(rtPaths.userSettingsPath()); + expect(clientPaths.teamSettingsPath("someteam")).toBe(rtPaths.teamSettingsPath("someteam")); + expect(clientPaths.machineSettingsPath()).toBe(rtPaths.machineSettingsPath()); + expect(clientPaths.teamsDir()).toBe(rtPaths.teamsDir()); + expect(clientPaths.repoDataDir("some-repo")).toBe(rtPaths.repoDataDir("some-repo")); + }); + + test("both resolve HOME at call time, not module load", () => { + process.env.HOME = "/tmp/parity-home-1"; + expect(clientPaths.userSettingsPath()).toBe(join("/tmp/parity-home-1", ".mattstack", "user", "settings.jsonc")); + + process.env.HOME = "/tmp/parity-home-2"; + expect(clientPaths.userSettingsPath()).toBe(join("/tmp/parity-home-2", ".mattstack", "user", "settings.jsonc")); + expect(clientPaths.userSettingsPath()).toBe(rtPaths.userSettingsPath()); + }); +}); diff --git a/lib/cli-logger.ts b/lib/cli-logger.ts index d658f5bb..c309178e 100644 --- a/lib/cli-logger.ts +++ b/lib/cli-logger.ts @@ -53,14 +53,42 @@ interface CommandLog { exitCode?: number; } +const SECRETS_WRITE_VERBS = new Set(["set", "rotate"]); + +/** + * `rt secrets set|rotate ` never puts the value on argv by + * design (commands/secrets.ts prompts or reads stdin) — but this is defense + * in depth for any invocation that still carries a trailing token there + * (an old habit, a stray positional), so anything past ` ` is + * redacted regardless of how it got there. Two shapes call this: the leaf + * `rest` args (dispatch's tree walk already consumed the "secrets set" + * prefix, so `command` carries that context instead) and the full raw argv + * seeded before dispatch resolves anything (the prefix is still IN the + * array). + */ +function redactSecretsWriteTail(args: string[], command?: string): string[] { + if (command && /(?:^|\s)secrets (?:set|rotate)$/.test(command)) { + return args.map((a, i) => (i < 2 ? a : "[redacted]")); + } + + for (let i = 0; i + 1 < args.length; i++) { + if (args[i] === "secrets" && SECRETS_WRITE_VERBS.has(args[i + 1]!)) { + return args.map((a, idx) => (idx <= i + 3 ? a : "[redacted]")); + } + } + return args; +} + /** * Returns a copy of args with the value following any `--reason` flag - * replaced by "[redacted]" (also handles the `--reason=value` form). Reason - * text is free-form and often sensitive (e.g. `rt sdm connect`), so it must - * never reach the on-disk CLI log. This only affects what gets logged -- - * the real args passed to command handlers are never touched. + * replaced by "[redacted]" (also handles the `--reason=value` form), plus + * anything past `secrets set|rotate ` (see + * redactSecretsWriteTail). Reason text is free-form and often sensitive + * (e.g. `rt sdm connect`), so it must never reach the on-disk CLI log. This + * only affects what gets logged -- the real args passed to command handlers + * are never touched. */ -export function redactSensitiveArgs(args: string[]): string[] { +export function redactSensitiveArgs(args: string[], command?: string): string[] { const result: string[] = []; for (let i = 0; i < args.length; i++) { const arg = args[i]!; @@ -78,7 +106,7 @@ export function redactSensitiveArgs(args: string[]): string[] { } result.push(arg); } - return result; + return redactSecretsWriteTail(result, command); } export function logCommand(entry: CommandLog): void { @@ -90,7 +118,7 @@ export function logCommand(entry: CommandLog): void { const line = JSON.stringify({ time: new Date().toISOString(), ...entry, - args: redactSensitiveArgs(entry.args), + args: redactSensitiveArgs(entry.args, entry.command), }) + "\n"; const fd = openSync(logPath(), "a"); diff --git a/lib/command-tree-def.ts b/lib/command-tree-def.ts index 3ed21c8a..49a9b96b 100644 --- a/lib/command-tree-def.ts +++ b/lib/command-tree-def.ts @@ -698,6 +698,65 @@ export const TREE: Record = { }, }, + home: { + description: "The git-backed ~/.mattstack home repo", + subcommands: { + init: { + description: "Provision the home repo: print, then run, the adoption plan", + module: "./commands/home.ts", + fn: "homeInit", + args: [ + { name: "Dry run", flag: "--dry-run", type: "boolean", default: false, hint: "Print the plan without running it" }, + ], + }, + key: { + description: "The mattstack age key (keychain-custodied)", + subcommands: { + export: { + description: "Print the age private key once, for your password manager", + module: "./commands/home.ts", + fn: "homeKeyExport", + args: [], + }, + }, + }, + }, + }, + + secrets: { + description: "sops-encrypted secrets under ~/.mattstack/user/secrets/", + subcommands: { + set: { + description: "Write a secret (creates the domain file, or one key within it) — value prompted, never a CLI arg", + module: "./commands/secrets.ts", + fn: "secretsSet", + args: [ + { name: "Domain", type: "text", placeholder: "rt", hint: "Secrets domain (rt, deck, board)" }, + { name: "Key", type: "text", placeholder: "linearApiKey", hint: "Key name within the domain" }, + { name: "Stdin", flag: "--stdin", type: "boolean", default: false, hint: "Read the value from stdin instead of a no-echo prompt (scripting)" }, + ], + }, + list: { + description: "List a domain's secret names (never prints values)", + module: "./commands/secrets.ts", + fn: "secretsList", + args: [ + { name: "Domain", type: "text", placeholder: "rt", hint: "Secrets domain (rt, deck, board)" }, + ], + }, + rotate: { + description: "Replace a secret's value; prints the rotation commit message — value prompted, never a CLI arg", + module: "./commands/secrets.ts", + fn: "secretsRotate", + args: [ + { name: "Domain", type: "text", placeholder: "rt", hint: "Secrets domain (rt, deck, board)" }, + { name: "Key", type: "text", placeholder: "gitlabToken", hint: "Key name within the domain" }, + { name: "Stdin", flag: "--stdin", type: "boolean", default: false, hint: "Read the new value from stdin instead of a no-echo prompt (scripting)" }, + ], + }, + }, + }, + plugin: { description: "Manage user plugins", subcommands: { diff --git a/lib/daemon/__tests__/api-auth.test.ts b/lib/daemon/__tests__/api-auth.test.ts index c7e8eb85..d9cda0b1 100644 --- a/lib/daemon/__tests__/api-auth.test.ts +++ b/lib/daemon/__tests__/api-auth.test.ts @@ -35,6 +35,10 @@ describe("needsToken", () => { test("events list does not require a token", () => { expect(needsToken("GET", "/api/events")).toBe(false); }); + + test("secrets requires a token even though it's a GET — the response body is a credential, not metadata", () => { + expect(needsToken("GET", "/api/secrets")).toBe(true); + }); }); describe("tokenOk", () => { diff --git a/lib/daemon/__tests__/secrets-handler.test.ts b/lib/daemon/__tests__/secrets-handler.test.ts index 8755ad4f..f91c1d33 100644 --- a/lib/daemon/__tests__/secrets-handler.test.ts +++ b/lib/daemon/__tests__/secrets-handler.test.ts @@ -7,7 +7,7 @@ import { describe, expect, test } from "bun:test"; import { createSecretsHandlers } from "../handlers/secrets.ts"; -const fakeCtx = { log: { info: () => {} } } as any; +const fakeCtx = { log: { info: () => {}, debug: () => {} } } as any; function handler(opts: { tracking?: Record; @@ -54,3 +54,74 @@ describe("secrets:forge-token", () => { expect((await h({ repoName: "gitq", forge: "bitbucket" as any })).ok).toBe(false); }); }); + +// secrets:read is deliberately token-gated (not grant-gated, unlike +// secrets:forge-token above): the api-token check happens IN THE HANDLER so +// it covers both the HTTP transport (api-server.ts forwards the verified +// X-RT-Token header into the payload) and the unix-socket transport (which +// has no auth of its own — the handler is the only gate there). +function readHandler(opts: { + extensionSecrets?: () => Promise<{ linearApiKey?: string; gitlabToken?: string }>; + apiToken?: string; +}) { + const h = createSecretsHandlers(fakeCtx, { + extensionSecrets: opts.extensionSecrets ?? (async () => ({})), + apiToken: () => opts.apiToken ?? "test-token", + }); + return h["secrets:read"]; +} + +describe("secrets:read", () => { + test("returns exactly the whitelisted keys the extension reads — linearApiKey and gitlabToken", async () => { + const h = readHandler({ extensionSecrets: async () => ({ linearApiKey: "lin_api_x", gitlabToken: "glpat-x" }) }); + + const res = await h({ token: "test-token" }); + + expect(res).toEqual({ ok: true, data: { linearApiKey: "lin_api_x", gitlabToken: "glpat-x" } }); + }); + + test("omits a key entirely (never a blank string) when it isn't set", async () => { + const h = readHandler({ extensionSecrets: async () => ({ linearApiKey: "lin_api_x" }) }); + + const res = await h({ token: "test-token" }); + + expect(res).toEqual({ ok: true, data: { linearApiKey: "lin_api_x" } }); + expect("gitlabToken" in (res as any).data).toBe(false); + }); + + test("carries no other Secrets field even if the loader returns one — e.g. sdmEmail never leaks", async () => { + const h = readHandler({ + extensionSecrets: async () => ({ linearApiKey: "lin_api_x", gitlabToken: "glpat-x", sdmEmail: "me@example.test" } as any), + }); + + const res = await h({ token: "test-token" }); + + expect(Object.keys((res as any).data).sort()).toEqual(["gitlabToken", "linearApiKey"]); + }); + + test("missing token -> ok:false 'missing-token', refused before any secrets read", async () => { + let called = false; + const h = readHandler({ extensionSecrets: async () => { called = true; return {}; } }); + + const res = await h({}); + + expect(res).toEqual({ ok: false, error: "missing-token" }); + expect(called).toBe(false); + }); + + test("wrong token -> ok:false 'bad-token', refused before any secrets read", async () => { + let called = false; + const h = readHandler({ extensionSecrets: async () => { called = true; return {}; } }); + + const res = await h({ token: "wrong" }); + + expect(res).toEqual({ ok: false, error: "bad-token" }); + expect(called).toBe(false); + }); + + test("the handler is transport-agnostic — a socket caller reading api-token itself and an HTTP-forwarded header both just succeed with the right token", async () => { + const h = readHandler({ extensionSecrets: async () => ({ linearApiKey: "lin_api_x" }), apiToken: "shared-secret" }); + + expect((await h({ token: "shared-secret" })).ok).toBe(true); + }); +}); diff --git a/lib/daemon/api-auth.ts b/lib/daemon/api-auth.ts index 14c65e83..e41b923b 100644 --- a/lib/daemon/api-auth.ts +++ b/lib/daemon/api-auth.ts @@ -36,12 +36,16 @@ export function loadOrCreateApiToken(tokenPath: string = API_TOKEN_PATH): string return token; } -/** True when a request mutates state and must present the local token. */ +/** True when a request mutates state, or (secrets) returns raw credential values, and must present the local token. */ export function needsToken(method: string, pathname: string): boolean { if (method === "OPTIONS") return false; if (pathname === "/api/shutdown") return true; if (pathname === "/api/sdm/reconnect") return true; if (pathname === "/api/events/emit") return true; + // Gated despite being a GET: every other read-only route returns metadata + // (branch names, MR titles, ports) safe under the open-CORS "reads are + // free" policy above; this one's response body IS the credential. + if (pathname === "/api/secrets") return true; return false; } diff --git a/lib/daemon/api-server.ts b/lib/daemon/api-server.ts index e4a52be6..8d080997 100644 --- a/lib/daemon/api-server.ts +++ b/lib/daemon/api-server.ts @@ -33,6 +33,7 @@ const API_INDEX = { { method: "POST", path: "/api/sdm/reconnect", description: "Reconnect a StrongDM recent (promptless; fails if an access request is needed)" }, { method: "POST", path: "/api/events/emit", description: "Emit an event onto the pane-communication bus" }, { method: "GET", path: "/api/events", description: "List events matching a topic pattern" }, + { method: "GET", path: "/api/secrets", description: "Whitelisted secret values (linearApiKey, gitlabToken) — token-gated" }, ], websocket_events: [ { type: "status", description: "Full daemon status — after each cache refresh (~5 min)" }, @@ -42,7 +43,7 @@ const API_INDEX = { ], auth: { header: "X-RT-Token", - description: "Required on mutating routes (shutdown, sdm reconnect, events emit). Token at ~/.mattstack/rt/api-token.", + description: "Required on mutating routes (shutdown, sdm reconnect, events emit) and /api/secrets. Token at ~/.mattstack/rt/api-token.", }, }; @@ -59,6 +60,10 @@ const REST_ROUTES: Record = { "/api/sdm/reconnect": { cmd: "sdm:reconnect", method: "POST" }, "/api/events/emit": { cmd: "events:emit", method: "POST" }, "/api/events": { cmd: "events:list", method: "GET" }, + // "/api/secrets" is NOT here — see the dedicated block in fetch() below: + // it needs its header token forwarded into the command payload (the + // secrets:read handler checks payload.token itself, not just this layer), + // which the generic query-params-as-payload path below doesn't do. }; /** Per-connection data on the :9401 WebSocket broadcast channel. */ @@ -145,6 +150,15 @@ export function startApiServer(deps: ApiServerDeps): Server { return Response.json(result, { headers: corsHeaders }); } + // Secrets: forward the X-RT-Token header (already verified above by + // needsToken/tokenOk) into the command payload — secrets:read's own + // handler-level check (the enforcement point that also covers the + // unix socket transport) needs it there, not just on this request. + if (url.pathname === "/api/secrets" && req.method === "GET") { + const result = await handleCommand("secrets:read", { token: req.headers.get("x-rt-token") ?? undefined }, req.signal); + return Response.json(result, { headers: corsHeaders }); + } + // Static routes const route = REST_ROUTES[url.pathname]; if (!route) { diff --git a/lib/daemon/freshness.ts b/lib/daemon/freshness.ts index da99b4fc..910b262b 100644 --- a/lib/daemon/freshness.ts +++ b/lib/daemon/freshness.ts @@ -122,11 +122,11 @@ function makeProvider(host: string, token: string): GitLabProvider { return new GitLabProvider(host, token, providerRequestHook()); } -function ensureProvider(repoName: string, repoPath: string): GitLabProvider | null { +async function ensureProvider(repoName: string, repoPath: string): Promise { const cached = providers.get(repoName); if (cached) return cached; - const secrets = loadSecrets(); + const secrets = await loadSecrets(); if (!secrets.gitlabToken) { log.info(`no gitlabToken; skipping ${repoName}`); return null; @@ -266,7 +266,7 @@ export async function getRepoContext( if (!repoPath) { throw new Error(`repo "${repoName}" not in ~/.mattstack/rt/repos.json (run rt repo add)`); } - const secrets = loadSecrets(); + const secrets = await loadSecrets(); if (!secrets.gitlabToken) { throw new Error("missing gitlabToken in ~/.mattstack/rt/secrets.json (run rt secret set gitlabToken )"); } @@ -656,7 +656,7 @@ async function reconcileFreshnessImpl(env: FreshnessEnv): Promise { if (grants(tracking, repoName).mode !== "live") continue; if (watches.has(repoName)) continue; - const provider = ensureProvider(repoName, repoPath); + const provider = await ensureProvider(repoName, repoPath); if (!provider?.watchEvents) continue; if (!userIdResolved) await ensureUserId(); diff --git a/lib/daemon/handlers/discussions.ts b/lib/daemon/handlers/discussions.ts index f67761ae..156185f0 100644 --- a/lib/daemon/handlers/discussions.ts +++ b/lib/daemon/handlers/discussions.ts @@ -114,7 +114,7 @@ export function createDiscussionHandlers( const repoPath = ctx.repoIndex()[repoName]; try { const repoCtx = await getRepoContext(repoName, repoPath); - const secrets = loadSecrets(); + const secrets = await loadSecrets(); if (!secrets.gitlabToken) return { ok: false, error: "no gitlabToken in secrets" }; const encoded = encodeURIComponent(repoCtx.projectPath); @@ -143,7 +143,7 @@ export function createDiscussionHandlers( const repoPath = ctx.repoIndex()[repoName]; try { const repoCtx = await getRepoContext(repoName, repoPath); - const secrets = loadSecrets(); + const secrets = await loadSecrets(); if (!secrets.gitlabToken) return { ok: false, error: "no gitlabToken in secrets" }; const mutator = new NoteMutator(repoCtx.provider.baseURL, secrets.gitlabToken, providerRequestHook()); await mutator.createNote(repoCtx.projectId, iid, body, discussionId); diff --git a/lib/daemon/handlers/secrets.ts b/lib/daemon/handlers/secrets.ts index b79c761f..49cbd2e6 100644 --- a/lib/daemon/handlers/secrets.ts +++ b/lib/daemon/handlers/secrets.ts @@ -10,10 +10,30 @@ * Deliberately narrow (a token for one forge for one tracked repo) rather * than a general secrets:read: the narrow verb leaks nothing a caller did * not name. + * + * secrets:read (RT-32) is a second, differently-scoped exception to that + * same narrowness rule: the VS Code extension needs a couple of raw values + * (not a repo-scoped token), so it whitelists exactly the fields + * extensions/vscode/rt-context/src/secrets.ts reads — linearApiKey and + * gitlabToken — and nothing else `Secrets` carries. + * + * secrets:read is deliberately TOKEN-gated and GRANT-EXEMPT — the opposite + * split from secrets:forge-token above. It serves the user's own editor + * extension (a single local, already-trusted client), not per-repo + * automation, so a repo-tracking grant check would be the wrong gate; the + * local API token (api-auth.ts) is the right one. That gate is enforced + * HERE, in the handler, not only at the HTTP layer: the unix-socket + * transport (socket-server.ts) has no auth of its own, so a handler-only + * check would leave that transport wide open. `payload.token` carries the + * proof for BOTH transports — api-server.ts forwards its already-verified + * X-RT-Token header into the payload so HTTP callers (the extension) don't + * need to change; a socket caller must read ~/.mattstack/rt/api-token + * itself and pass it the same way. */ import { loadSecrets } from "../../linear.ts"; import { loadRepoTracking, grants, type RepoTracking } from "../../repo-tracking.ts"; +import { loadOrCreateApiToken, tokenOk } from "../api-auth.ts"; import type { Commands, ForgeSlug } from "../../../packages/rt-client/src/commands.ts"; import type { HandlerContext, HandlerMap, TypedHandlers } from "./types.ts"; @@ -24,15 +44,21 @@ const SECRETS_KEY: Record = { export interface SecretsHandlerOverrides { tracking?: () => RepoTracking; - secrets?: () => { gitlabToken?: string; githubToken?: string }; + secrets?: () => { gitlabToken?: string; githubToken?: string } | Promise<{ gitlabToken?: string; githubToken?: string }>; + /** Defaults to `loadSecrets` (the full encrypted-store + plaintext-fallback loader) for secrets:read. */ + extensionSecrets?: () => Promise<{ linearApiKey?: string; gitlabToken?: string }>; + /** Defaults to `loadOrCreateApiToken` (the real ~/.mattstack/rt/api-token, shared with api-auth.ts). */ + apiToken?: () => string; } export function createSecretsHandlers( ctx: HandlerContext, overrides: SecretsHandlerOverrides = {}, -): Pick & HandlerMap { +): Pick & HandlerMap { const tracking = overrides.tracking ?? loadRepoTracking; const secrets = overrides.secrets ?? loadSecrets; + const extensionSecrets = overrides.extensionSecrets ?? loadSecrets; + const apiToken = overrides.apiToken ?? (() => loadOrCreateApiToken()); return { "secrets:forge-token": async (payload: Commands["secrets:forge-token"]["payload"]) => { @@ -53,7 +79,7 @@ export function createSecretsHandlers( }; } - const token = secrets()[SECRETS_KEY[forge]]; + const token = (await secrets())[SECRETS_KEY[forge]]; if (!token) { return { ok: false as const, error: `no ${forge} token in ~/.mattstack/rt/secrets.json (${SECRETS_KEY[forge]})` }; } @@ -61,5 +87,22 @@ export function createSecretsHandlers( ctx.log.info({ repoName, forge }, "secrets:forge-token grant-gated read"); return { ok: true as const, data: { token } }; }, + + "secrets:read": async (payload: Commands["secrets:read"]["payload"]) => { + const provided = payload?.token; + if (!provided) { + return { ok: false as const, error: "missing-token" }; + } + if (!tokenOk(provided, apiToken())) { + return { ok: false as const, error: "bad-token" }; + } + + const all = await extensionSecrets(); + const data: Commands["secrets:read"]["data"] = {}; + if (all.linearApiKey) data.linearApiKey = all.linearApiKey; + if (all.gitlabToken) data.gitlabToken = all.gitlabToken; + ctx.log.debug({ keys: Object.keys(data) }, "secrets:read"); + return { ok: true as const, data }; + }, }; } diff --git a/lib/enrich.ts b/lib/enrich.ts index 606bd33c..1317b6fd 100644 --- a/lib/enrich.ts +++ b/lib/enrich.ts @@ -260,7 +260,7 @@ export async function enrichBranches( } // ── Existing logic (state.db cache + fetch) ── - const secrets = loadSecrets(); + const secrets = await loadSecrets(); const willFetch = !!(secrets.linearApiKey || secrets.gitlabToken); const store = getBranchCacheStore(); @@ -295,7 +295,7 @@ async function fetchAndCache( store: BranchCacheStore, silent: boolean, ): Promise { - const secrets = loadSecrets(); + const secrets = await loadSecrets(); const willFetch = !!(secrets.linearApiKey || secrets.gitlabToken); let showSpinner = false; @@ -411,7 +411,7 @@ export async function refreshAllMRs( onError?: (msg: string) => void, repoName?: string, ): Promise { - const secrets = loadSecrets(); + const secrets = await loadSecrets(); // In the daemon this is the SAME singleton the handler context serves from // (spec "Store-by-store" item 1) — writes below land in the live map and // in state.db together, so the two can never diverge in one process. diff --git a/lib/home/__tests__/age-key.test.ts b/lib/home/__tests__/age-key.test.ts new file mode 100644 index 00000000..000a7249 --- /dev/null +++ b/lib/home/__tests__/age-key.test.ts @@ -0,0 +1,278 @@ +import { describe, test, expect, spyOn } from "bun:test"; +import * as fs from "fs"; +import { + ensureAgeKey, + readAgeKey, + renderSopsYaml, + keyExport, + withArgvRedaction, + AgeKeyAbsentError, + type AgeExecResult, + type AgeKeySeam, +} from "../age-key.ts"; + +/** Matches `fs`, `node:fs`, `fs/promises`, and `node:fs/promises` — every spelling age-key.ts must never import. */ +const FS_IMPORT_RE = /from\s+["'](?:node:)?fs(?:\/promises)?["']|require\(["'](?:node:)?fs(?:\/promises)?["']\)/; + +const FIND_CMD = ["security", "find-generic-password", "-a", "mattstack", "-s", "mattstack-age-key", "-w"]; +const ADD_CMD_PREFIX = ["security", "add-generic-password", "-a", "mattstack", "-s", "mattstack-age-key", "-w"]; +const NOT_FOUND_STDERR = "The specified item could not be found in the keychain."; + +const FAKE_PUBLIC_KEY = "age1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"; +const FAKE_PRIVATE_KEY = "AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ"; +const FAKE_KEYGEN_STDOUT = `# created: 2024-01-01T00:00:00Z\n# public key: ${FAKE_PUBLIC_KEY}\n${FAKE_PRIVATE_KEY}\n`; + +type Call = { cmd: string[]; opts?: { input?: string; sensitive?: boolean } }; + +/** + * Stateful enough to model the real keychain: a successful add-generic-password + * makes the next find-generic-password see the newly stored key. `find.code + * === 44` without an explicit stderr is auto-corroborated with the real + * "could not be found" marker text, so most tests don't need to spell it + * out; pass an explicit non-matching stderr to model the ambiguous cases. + */ +class FakeAgeKeySeam implements AgeKeySeam { + calls: Call[] = []; + private storedKey: string | undefined; + + constructor( + private opts: { + find?: { code: number; stdout: string; stderr?: string }; + keygen?: { code: number; stdout: string }; + deriveY?: { code: number; stdout: string }; + addPassword?: { code: number }; + } = {}, + ) { + if (this.opts.find?.code === 0) this.storedKey = this.opts.find.stdout.trim(); + } + + async run(cmd: string[], runOpts?: { input?: string; sensitive?: boolean }): Promise { + this.calls.push({ cmd, opts: runOpts }); + + if (cmd[0] === "security" && cmd[1] === "find-generic-password") { + if (this.storedKey) return { code: 0, stdout: `${this.storedKey}\n`, stderr: "" }; + const r = this.opts.find ?? { code: 44, stdout: "", stderr: NOT_FOUND_STDERR }; + const stderr = r.stderr ?? (r.code === 44 ? NOT_FOUND_STDERR : ""); + return { code: r.code, stdout: r.stdout, stderr }; + } + if (cmd[0] === "age-keygen" && cmd[1] === "-y") { + const r = this.opts.deriveY ?? { code: 0, stdout: `${FAKE_PUBLIC_KEY}\n` }; + return { code: r.code, stdout: r.stdout, stderr: "" }; + } + if (cmd[0] === "age-keygen") { + const r = this.opts.keygen ?? { code: 0, stdout: FAKE_KEYGEN_STDOUT }; + return { code: r.code, stdout: r.stdout, stderr: "" }; + } + if (cmd[0] === "security" && cmd[1] === "add-generic-password") { + const r = this.opts.addPassword ?? { code: 0 }; + if (r.code === 0) this.storedKey = cmd.at(-1); + return { code: r.code, stdout: "", stderr: r.code === 0 ? "" : "security: write failed" }; + } + + throw new Error(`FakeAgeKeySeam: unexpected call ${cmd.join(" ")}`); + } +} + +describe("readAgeKey", () => { + test("keychain find succeeds -> returns {key}", async () => { + const seam = new FakeAgeKeySeam({ find: { code: 0, stdout: `${FAKE_PRIVATE_KEY}\n` } }); + const result = await readAgeKey(seam); + expect(result).toEqual({ key: FAKE_PRIVATE_KEY }); + expect(seam.calls).toEqual([{ cmd: FIND_CMD, opts: { sensitive: true } }]); + }); + + test("provable absence (exit 44 + the 'could not be found' marker) -> {absent: true}", async () => { + const seam = new FakeAgeKeySeam({ find: { code: 44, stdout: "", stderr: NOT_FOUND_STDERR } }); + const result = await readAgeKey(seam); + expect(result).toEqual({ absent: true }); + }); + + test("exit 44 WITHOUT the corroborating stderr marker -> throws, not treated as absent", async () => { + const seam = new FakeAgeKeySeam({ find: { code: 44, stdout: "", stderr: "some unrelated security error" } }); + await expect(readAgeKey(seam)).rejects.toThrow(/keychain unreachable/i); + }); + + test("exit 36 (locked keychain) -> throws instead of silently reporting absent", async () => { + const seam = new FakeAgeKeySeam({ + find: { code: 36, stdout: "", stderr: "SecKeychainItemCopyContent: the user name or passphrase is not correct" }, + }); + await expect(readAgeKey(seam)).rejects.toThrow(/keychain unreachable/i); + }); + + test("exit 128 (access-control dialog denied) -> throws instead of silently reporting absent", async () => { + const seam = new FakeAgeKeySeam({ + find: { code: 128, stdout: "", stderr: "SecKeychainFindGenericPassword: user interaction is not allowed" }, + }); + await expect(readAgeKey(seam)).rejects.toThrow(/keychain unreachable/i); + }); +}); + +describe("ensureAgeKey", () => { + test("provable absence: generates via age-keygen, stores the private key with NO -U (no overwrite flag), returns the public key", async () => { + const seam = new FakeAgeKeySeam({ find: { code: 44, stdout: "" } }); + + const result = await ensureAgeKey(seam); + + expect(result).toEqual({ publicKey: FAKE_PUBLIC_KEY }); + expect(seam.calls.map((c) => c.cmd)).toEqual([FIND_CMD, ["age-keygen"], [...ADD_CMD_PREFIX, FAKE_PRIVATE_KEY]]); + }); + + test("provable absence: the keychain-write call carries the private key ONLY as its argv value, marked sensitive", async () => { + const seam = new FakeAgeKeySeam({ find: { code: 44, stdout: "" } }); + + await ensureAgeKey(seam); + + const addCall = seam.calls.find((c) => c.cmd[1] === "add-generic-password"); + expect(addCall?.opts?.sensitive).toBe(true); + expect(addCall?.cmd.at(-1)).toBe(FAKE_PRIVATE_KEY); + expect(addCall?.cmd).not.toContain("-U"); + }); + + test("existing key: derives the public key via age-keygen -y, never generates a new key, never writes the keychain again", async () => { + const seam = new FakeAgeKeySeam({ + find: { code: 0, stdout: `${FAKE_PRIVATE_KEY}\n` }, + deriveY: { code: 0, stdout: `${FAKE_PUBLIC_KEY}\n` }, + }); + + const result = await ensureAgeKey(seam); + + expect(result).toEqual({ publicKey: FAKE_PUBLIC_KEY }); + expect(seam.calls.map((c) => c.cmd)).toEqual([FIND_CMD, ["age-keygen", "-y"]]); + // The private key is piped via stdin, never argv. + const deriveCall = seam.calls.find((c) => c.cmd[0] === "age-keygen" && c.cmd[1] === "-y"); + expect(deriveCall?.opts?.input).toBe(FAKE_PRIVATE_KEY); + expect(deriveCall?.cmd).not.toContain(FAKE_PRIVATE_KEY); + }); + + describe("keychain-access errors never trigger a mint (the catastrophe this design exists to prevent)", () => { + test("find exits 36 (locked keychain): throws, and mints/writes nothing", async () => { + const seam = new FakeAgeKeySeam({ + find: { code: 36, stdout: "", stderr: "SecKeychainItemCopyContent: the user name or passphrase is not correct" }, + }); + + await expect(ensureAgeKey(seam)).rejects.toThrow(/keychain unreachable/i); + + expect(seam.calls.map((c) => c.cmd)).toEqual([FIND_CMD]); + expect(seam.calls.some((c) => c.cmd[0] === "age-keygen")).toBe(false); + expect(seam.calls.some((c) => c.cmd.includes("add-generic-password"))).toBe(false); + }); + + test("find exits 128 (access-control dialog denied): throws, and mints/writes nothing", async () => { + const seam = new FakeAgeKeySeam({ + find: { code: 128, stdout: "", stderr: "SecKeychainFindGenericPassword: user interaction is not allowed" }, + }); + + await expect(ensureAgeKey(seam)).rejects.toThrow(/keychain unreachable/i); + + expect(seam.calls.map((c) => c.cmd)).toEqual([FIND_CMD]); + expect(seam.calls.some((c) => c.cmd[0] === "age-keygen")).toBe(false); + expect(seam.calls.some((c) => c.cmd.includes("add-generic-password"))).toBe(false); + }); + }); +}); + +describe("withArgvRedaction", () => { + test("the add-generic-password call's -w value is redacted before it reaches the log, never in the clear", async () => { + const seam = new FakeAgeKeySeam({ find: { code: 44, stdout: "" } }); + const logged: string[][] = []; + const wrapped = withArgvRedaction(seam, (cmd) => logged.push(cmd)); + + await ensureAgeKey(wrapped); + + const loggedAddCall = logged.find((cmd) => cmd.includes("add-generic-password")); + expect(loggedAddCall).toBeDefined(); + expect(loggedAddCall).toContain(""); + expect(logged.flat()).not.toContain(FAKE_PRIVATE_KEY); + + // The underlying seam still receives the real, unredacted key. + const realAddCall = seam.calls.find((c) => c.cmd.includes("add-generic-password")); + expect(realAddCall?.cmd.at(-1)).toBe(FAKE_PRIVATE_KEY); + }); + + test("non-sensitive calls pass through the log unredacted", async () => { + const seam = new FakeAgeKeySeam({ find: { code: 0, stdout: `${FAKE_PRIVATE_KEY}\n` } }); + const logged: string[][] = []; + const wrapped = withArgvRedaction(seam, (cmd) => logged.push(cmd)); + + await readAgeKey(wrapped); + + // find's argv carries no secret value, so redaction is a no-op here — + // only the -w VALUE (not the -w flag itself) is ever a redaction target. + expect(logged).toEqual([FIND_CMD]); + }); +}); + +describe("renderSopsYaml", () => { + test("emits a creation rule encrypting user/secrets/** to the given recipient", () => { + const yaml = renderSopsYaml("age1xyz"); + expect(yaml).toContain("path_regex: user/secrets/.*"); + expect(yaml).toContain("age1xyz"); + }); +}); + +describe("keyExport", () => { + test("existing key: reads it with exactly one find call, prints it exactly once with a warning header, never touches the fs", async () => { + const seam = new FakeAgeKeySeam({ find: { code: 0, stdout: `${FAKE_PRIVATE_KEY}\n` } }); + const printed: string[] = []; + const writeSpy = spyOn(fs, "writeFileSync").mockImplementation(() => { + throw new Error("keyExport must never write a file"); + }); + + try { + await keyExport(seam, (text) => printed.push(text)); + } finally { + writeSpy.mockRestore(); + } + + expect(printed.length).toBe(1); + expect(printed[0]).toContain(FAKE_PRIVATE_KEY); + expect(printed[0]).toMatch(/warning/i); + expect(printed[0]).not.toMatch(/print it again/i); // the header must not claim a false guarantee + expect(writeSpy).not.toHaveBeenCalled(); + expect(seam.calls).toEqual([{ cmd: FIND_CMD, opts: { sensitive: true } }]); + }); + + test("no key yet (provably absent): refuses without minting, points at `rt home init`, prints nothing, never touches the fs", async () => { + const seam = new FakeAgeKeySeam({ find: { code: 44, stdout: "" } }); + const printed: string[] = []; + const writeSpy = spyOn(fs, "writeFileSync").mockImplementation(() => { + throw new Error("keyExport must never write a file"); + }); + + let thrown: unknown; + try { + await keyExport(seam, (text) => printed.push(text)); + } catch (err) { + thrown = err; + } finally { + writeSpy.mockRestore(); + } + + expect(thrown).toBeInstanceOf(AgeKeyAbsentError); + expect((thrown as Error).message).toMatch(/rt home init/); + expect(printed).toEqual([]); + expect(writeSpy).not.toHaveBeenCalled(); + // Export never mints — only the one failed lookup, nothing else. + expect(seam.calls).toEqual([{ cmd: FIND_CMD, opts: { sensitive: true } }]); + expect(seam.calls.some((c) => c.cmd[0] === "age-keygen")).toBe(false); + expect(seam.calls.some((c) => c.cmd.includes("add-generic-password"))).toBe(false); + }); + + test("the module has zero fs imports — the no-file guarantee is structural, not just runtime-observed", () => { + const source = fs.readFileSync(new URL("../age-key.ts", import.meta.url), "utf8"); + expect(source).not.toMatch(FS_IMPORT_RE); + }); + + test("the fs-import guard also catches node:fs, fs/promises, and node:fs/promises spellings", () => { + expect('import { readFileSync } from "fs";').toMatch(FS_IMPORT_RE); + expect('import { readFileSync } from "node:fs";').toMatch(FS_IMPORT_RE); + expect('import { readFile } from "fs/promises";').toMatch(FS_IMPORT_RE); + expect('import { readFile } from "node:fs/promises";').toMatch(FS_IMPORT_RE); + expect('const fs = require("fs");').toMatch(FS_IMPORT_RE); + expect('const fs = require("node:fs");').toMatch(FS_IMPORT_RE); + expect('const fs = require("fs/promises");').toMatch(FS_IMPORT_RE); + expect('const fs = require("node:fs/promises");').toMatch(FS_IMPORT_RE); + // Never a false positive on an unrelated import that merely contains "fs". + expect('import { statfs } from "./statfs-helpers.ts";').not.toMatch(FS_IMPORT_RE); + }); +}); diff --git a/lib/home/__tests__/boundary.test.ts b/lib/home/__tests__/boundary.test.ts new file mode 100644 index 00000000..1428f934 --- /dev/null +++ b/lib/home/__tests__/boundary.test.ts @@ -0,0 +1,122 @@ +import { describe, test, expect } from "bun:test"; +import { HOME_BOUNDARY, renderHomeGitignore } from "../boundary.ts"; + +/** + * Minimal gitignore-semantics matcher for the patterns this module emits. + * Models the one distinction that matters here: a pattern containing a "/" + * anywhere but the very end (a leading slash, or a slash in the middle) is + * anchored to the root and only matches there; a pattern with no such slash + * matches its directory/file name at ANY depth. Not a general gitignore + * engine — just enough to prove renderHomeGitignore() draws the boundary at + * the depth the spec describes. + */ +function isAnchored(pattern: string): boolean { + const withoutTrailingSlash = pattern.endsWith("/") ? pattern.slice(0, -1) : pattern; + return withoutTrailingSlash.includes("/"); +} + +function gitignorePatterns(gitignore: string): string[] { + return gitignore + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")); +} + +/** `*` never crosses a `/` — enough glob support for this module's own patterns (`*.sock`, `user/secrets/*.tmp`). */ +function globToRegExp(body: string): RegExp { + const escaped = body.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*"); + return new RegExp(`^${escaped}$`); +} + +function isIgnored(patterns: string[], path: string): boolean { + const segments = path.split("/"); + const base = segments[segments.length - 1]!; + const dirSegments = segments.slice(0, -1); + + return patterns.some((pattern) => { + const anchored = isAnchored(pattern); + const body = pattern.startsWith("/") ? pattern.slice(1) : pattern; + + if (body.endsWith("/")) { + const dir = body.slice(0, -1); + return anchored ? path === dir || path.startsWith(`${dir}/`) : dirSegments.includes(dir); + } + const regex = globToRegExp(body); + return anchored ? regex.test(path) : regex.test(base); + }); +} + +describe("HOME_BOUNDARY", () => { + test("declares exactly the ruled ignore set, root-anchored for single-segment dirs", () => { + expect(HOME_BOUNDARY.ignored).toEqual([ + "/rt/", + "/deck/", + "/shepherdr/", + "/repos/", + "/ci-attendants/", + "/work/", + "/teams/", + "user/local/", + "settings.local.jsonc", + "user/secrets/*.tmp", + "*.sock", + ".DS_Store", + ]); + }); + + test("declares the tracked declarative surfaces", () => { + expect(HOME_BOUNDARY.tracked).toEqual([ + "user/", + "skills.jsonc", + "snapshot-owners.jsonc", + "user/secrets/", + ]); + }); +}); + +describe("renderHomeGitignore", () => { + const patterns = gitignorePatterns(renderHomeGitignore()); + + const ignoredCases = [ + "rt/state.db", + "rt/rt.sock", + "deck/settings.json", + "shepherdr/jobs/1.json", + "repos/assured-dev/config.json", + "ci-attendants/foo", + "work/scratch", + "teams/claimview/mattstack/settings.jsonc", + "user/local/attic.tar", + "settings.local.jsonc", + ".DS_Store", + "user/.DS_Store", + "deck-api.sock", + "user/secrets/rt.json.tmp", + ]; + + const trackedCases = [ + "user/", + "user/settings.jsonc", + "skills.jsonc", + "snapshot-owners.jsonc", + "user/secrets/", + "user/secrets/rt.json", + ]; + + for (const path of ignoredCases) { + test(`ignores ${path}`, () => { + expect(isIgnored(patterns, path)).toBe(true); + }); + } + + for (const path of trackedCases) { + test(`does not ignore ${path}`, () => { + expect(isIgnored(patterns, path)).toBe(false); + }); + } + + test("root-anchored dir patterns do not match the same name at depth", () => { + expect(isIgnored(patterns, "rt/x")).toBe(true); + expect(isIgnored(patterns, "user/rt/x")).toBe(false); + }); +}); diff --git a/lib/home/__tests__/git-config.test.ts b/lib/home/__tests__/git-config.test.ts new file mode 100644 index 00000000..78aa7a8e --- /dev/null +++ b/lib/home/__tests__/git-config.test.ts @@ -0,0 +1,56 @@ +import { describe, test, expect } from "bun:test"; +import { parseOriginUrl } from "../git-config.ts"; + +describe("parseOriginUrl", () => { + test("extracts the origin remote url from a realistic clone config", () => { + const config = `[core] +\trepositoryformatversion = 0 +\tfilemode = true +\tbare = false +\tlogallrefupdates = true +[remote "origin"] +\turl = https://github.com/mattgoodwin/mattstack-prefs.git +\tfetch = +refs/heads/*:refs/remotes/origin/* +[branch "main"] +\tremote = origin +\tmerge = refs/heads/main +`; + + expect(parseOriginUrl(config)).toBe("https://github.com/mattgoodwin/mattstack-prefs.git"); + }); + + test("extracts an ssh-form url", () => { + const config = `[remote "origin"] +\turl = git@github.com:mattgoodwin/mattstack-prefs.git +\tfetch = +refs/heads/*:refs/remotes/origin/* +`; + expect(parseOriginUrl(config)).toBe("git@github.com:mattgoodwin/mattstack-prefs.git"); + }); + + test("ignores a url line belonging to a different remote", () => { + const config = `[remote "upstream"] +\turl = https://github.com/someone-else/mattstack-prefs.git +[remote "origin"] +\turl = https://github.com/mattgoodwin/mattstack-prefs.git +`; + expect(parseOriginUrl(config)).toBe("https://github.com/mattgoodwin/mattstack-prefs.git"); + }); + + test("returns null when there is no [remote \"origin\"] section", () => { + const config = `[core] +\trepositoryformatversion = 0 +`; + expect(parseOriginUrl(config)).toBeNull(); + }); + + test("returns null for an empty file", () => { + expect(parseOriginUrl("")).toBeNull(); + }); + + test("returns null when the origin section has no url line", () => { + const config = `[remote "origin"] +\tfetch = +refs/heads/*:refs/remotes/origin/* +`; + expect(parseOriginUrl(config)).toBeNull(); + }); +}); diff --git a/lib/home/__tests__/init-exec.test.ts b/lib/home/__tests__/init-exec.test.ts new file mode 100644 index 00000000..2b593630 --- /dev/null +++ b/lib/home/__tests__/init-exec.test.ts @@ -0,0 +1,413 @@ +import { describe, test, expect } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { createRealExecSeam, executeInitPlan, type ExecResult, type ExecSeam } from "../init-exec.ts"; +import { buildInitPlan, type InitStep } from "../init-plan.ts"; + +const PREFS_URL = "https://github.com/mattgoodwin/mattstack-prefs.git"; +const CREATED_URL = "https://github.com/testuser/mattstack-home"; + +type RecordedCall = + | { kind: "run"; cmd: string[]; cwd?: string } + | { kind: "writeFile"; path: string; content: string } + | { kind: "removeDir"; path: string } + | { kind: "mkTempDir" }; + +const noopLog = () => {}; + +function isGhRepoView(cmd: string[]): boolean { + return cmd[0] === "gh" && cmd[1] === "repo" && cmd[2] === "view"; +} +function isGhRepoCreate(cmd: string[]): boolean { + return cmd[0] === "gh" && cmd[1] === "repo" && cmd[2] === "create"; +} + +/** Records every seam call in order; never touches a real fs or subprocess. */ +class FakeExecSeam implements ExecSeam { + calls: RecordedCall[] = []; + + constructor( + private opts: { + stdout?: (cmd: string[]) => string; + failRun?: (cmd: string[]) => string | undefined; + failWriteFile?: string; + } = {}, + ) {} + + async run(cmd: string[], runOpts?: { cwd?: string }): Promise { + this.calls.push({ kind: "run", cmd, cwd: runOpts?.cwd }); + const failure = this.opts.failRun?.(cmd); + if (failure) return { code: 1, stdout: "", stderr: failure }; + return { code: 0, stdout: this.opts.stdout?.(cmd) ?? "", stderr: "" }; + } + + async writeFile(path: string, content: string): Promise { + this.calls.push({ kind: "writeFile", path, content }); + if (this.opts.failWriteFile === path) throw new Error(`write failed: ${path}`); + } + + async removeDir(path: string): Promise { + this.calls.push({ kind: "removeDir", path }); + } + + async mkTempDir(): Promise { + this.calls.push({ kind: "mkTempDir" }); + return "/tmp/rt-home-fold-test"; + } +} + +/** `gh repo view` reports "not found"; the step falls through to `gh repo create`. */ +function repoNotFoundThenCreated(url: string = CREATED_URL): ConstructorParameters[0] { + return { + failRun: (cmd) => (isGhRepoView(cmd) ? "GraphQL: Could not resolve to a Repository" : undefined), + stdout: (cmd) => (isGhRepoCreate(cmd) ? `${url}\n` : ""), + }; +} + +describe("executeInitPlan", () => { + describe("createRepo (resume-safe)", () => { + test("gh repo view reports not-found: falls through to gh repo create", async () => { + const seam = new FakeExecSeam(repoNotFoundThenCreated()); + const steps: InitStep[] = [{ kind: "createRepo", name: "mattstack-home" }]; + + const result = await executeInitPlan(steps, seam, noopLog); + + expect(result).toEqual({ ok: true }); + expect(seam.calls).toEqual([ + { kind: "run", cmd: ["gh", "repo", "view", "mattstack-home", "--json", "isEmpty,url"], cwd: undefined }, + { kind: "run", cmd: ["gh", "repo", "create", "mattstack-home", "--private"], cwd: undefined }, + ]); + }); + + test("gh repo view reports an existing EMPTY repo: reuses its url, never calls gh repo create", async () => { + const seam = new FakeExecSeam({ + stdout: (cmd) => (isGhRepoView(cmd) ? JSON.stringify({ isEmpty: true, url: CREATED_URL }) : ""), + }); + const steps: InitStep[] = [{ kind: "createRepo", name: "mattstack-home" }, { kind: "gitInit", branch: "main" }]; + + const result = await executeInitPlan(steps, seam, noopLog); + + expect(result).toEqual({ ok: true }); + expect(seam.calls).toEqual([ + { kind: "run", cmd: ["gh", "repo", "view", "mattstack-home", "--json", "isEmpty,url"], cwd: undefined }, + { kind: "run", cmd: ["git", "init", "-b", "main"], cwd: undefined }, + { kind: "run", cmd: ["git", "remote", "add", "origin", CREATED_URL], cwd: undefined }, + ]); + }); + + test("gh repo view reports an existing NON-EMPTY repo: fails naming the conflict, never creates or inits", async () => { + const seam = new FakeExecSeam({ + stdout: (cmd) => (isGhRepoView(cmd) ? JSON.stringify({ isEmpty: false, url: CREATED_URL }) : ""), + }); + const steps: InitStep[] = [{ kind: "createRepo", name: "mattstack-home" }, { kind: "gitInit", branch: "main" }]; + + const result = await executeInitPlan(steps, seam, noopLog); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failedStep).toBe("createRepo"); + expect(result.stderr).toContain("mattstack-home"); + expect(result.stderr).toContain("already exists"); + } + expect(seam.calls).toEqual([ + { kind: "run", cmd: ["gh", "repo", "view", "mattstack-home", "--json", "isEmpty,url"], cwd: undefined }, + ]); + }); + + test("empty gh repo create stdout fails the step instead of silently skipping remote add", async () => { + const seam = new FakeExecSeam({ + failRun: (cmd) => (isGhRepoView(cmd) ? "not found" : undefined), + // no stdout scripted for create -> gh prints nothing + }); + const steps: InitStep[] = [{ kind: "createRepo", name: "mattstack-home" }, { kind: "gitInit", branch: "main" }]; + + const result = await executeInitPlan(steps, seam, noopLog); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failedStep).toBe("createRepo"); + expect(result.stderr).toBe("gh repo create printed no repo URL"); + } + // gitInit never ran. + expect(seam.calls).toEqual([ + { kind: "run", cmd: ["gh", "repo", "view", "mattstack-home", "--json", "isEmpty,url"], cwd: undefined }, + { kind: "run", cmd: ["gh", "repo", "create", "mattstack-home", "--private"], cwd: undefined }, + ]); + }); + }); + + test("gitInit: init -b , then wires origin to the URL gh printed", async () => { + const seam = new FakeExecSeam(repoNotFoundThenCreated()); + const steps: InitStep[] = [ + { kind: "createRepo", name: "mattstack-home" }, + { kind: "gitInit", branch: "main" }, + ]; + + const result = await executeInitPlan(steps, seam, noopLog); + + expect(result).toEqual({ ok: true }); + expect(seam.calls).toEqual([ + { kind: "run", cmd: ["gh", "repo", "view", "mattstack-home", "--json", "isEmpty,url"], cwd: undefined }, + { kind: "run", cmd: ["gh", "repo", "create", "mattstack-home", "--private"], cwd: undefined }, + { kind: "run", cmd: ["git", "init", "-b", "main"], cwd: undefined }, + { kind: "run", cmd: ["git", "remote", "add", "origin", CREATED_URL], cwd: undefined }, + ]); + }); + + test("writeGitignore and writeOwners write the step's rendered content verbatim", async () => { + const seam = new FakeExecSeam(); + const steps: InitStep[] = [ + { kind: "writeGitignore", content: "/rt/\n" }, + { kind: "writeOwners", content: "{}\n" }, + ]; + + const result = await executeInitPlan(steps, seam, noopLog); + + expect(result).toEqual({ ok: true }); + expect(seam.calls).toEqual([ + { kind: "writeFile", path: ".gitignore", content: "/rt/\n" }, + { kind: "writeFile", path: "snapshot-owners.jsonc", content: "{}\n" }, + ]); + }); + + test("deleteCruft removes each path via the seam, not shell rm", async () => { + const seam = new FakeExecSeam(); + const steps: InitStep[] = [ + { kind: "deleteCruft", paths: ["skills.jsonc.pre-pack", "skills.jsonc.retired-backup"] }, + ]; + + const result = await executeInitPlan(steps, seam, noopLog); + + expect(result).toEqual({ ok: true }); + expect(seam.calls).toEqual([ + { kind: "removeDir", path: "skills.jsonc.pre-pack" }, + { kind: "removeDir", path: "skills.jsonc.retired-backup" }, + ]); + }); + + test("unlinkUserClone removes user/.git via the seam, not shell rm", async () => { + const seam = new FakeExecSeam(); + const steps: InitStep[] = [{ kind: "unlinkUserClone" }]; + + const result = await executeInitPlan(steps, seam, noopLog); + + expect(result).toEqual({ ok: true }); + expect(seam.calls).toEqual([{ kind: "removeDir", path: "user/.git" }]); + }); + + test("foldInPrefs: clones step.sourceUrl, filter-repo in the clone, fetch HEAD + merge in the home repo, then removes the temp clone", async () => { + const seam = new FakeExecSeam(); + const steps: InitStep[] = [{ kind: "foldInPrefs", sourceUrl: PREFS_URL }]; + + const result = await executeInitPlan(steps, seam, noopLog); + + expect(result).toEqual({ ok: true }); + expect(seam.calls).toEqual([ + { kind: "mkTempDir" }, + { + kind: "run", + cmd: ["git", "clone", "--no-hardlinks", PREFS_URL, "/tmp/rt-home-fold-test"], + cwd: undefined, + }, + { + kind: "run", + cmd: ["git", "filter-repo", "--to-subdirectory-filter", "user"], + cwd: "/tmp/rt-home-fold-test", + }, + // HEAD, not a hardcoded branch name: the tmp clone's default branch + // IS whatever the source remote's default branch is. + { kind: "run", cmd: ["git", "fetch", "/tmp/rt-home-fold-test", "HEAD"], cwd: undefined }, + { + kind: "run", + cmd: [ + "git", + "merge", + "FETCH_HEAD", + "--allow-unrelated-histories", + "-m", + "home: fold in mattstack-prefs history under user/", + ], + cwd: undefined, + }, + { kind: "removeDir", path: "/tmp/rt-home-fold-test" }, + ]); + }); + + test("foldInPrefs: the temp clone is removed even when a step inside it fails", async () => { + const seam = new FakeExecSeam({ + failRun: (cmd) => (cmd[1] === "filter-repo" ? "filter-repo: boom" : undefined), + }); + const steps: InitStep[] = [{ kind: "foldInPrefs", sourceUrl: PREFS_URL }]; + + const result = await executeInitPlan(steps, seam, noopLog); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failedStep).toBe("foldInPrefs"); + expect(result.stderr).toBe("filter-repo: boom"); + } + expect(seam.calls.at(-1)).toEqual({ kind: "removeDir", path: "/tmp/rt-home-fold-test" }); + }); + + test("adoptCommit: add -A then commit with the plan's message", async () => { + const seam = new FakeExecSeam(); + const steps: InitStep[] = [{ kind: "adoptCommit", message: "home: adopt the declarative layer" }]; + + const result = await executeInitPlan(steps, seam, noopLog); + + expect(result).toEqual({ ok: true }); + expect(seam.calls).toEqual([ + { kind: "run", cmd: ["git", "add", "-A"], cwd: undefined }, + { kind: "run", cmd: ["git", "commit", "-m", "home: adopt the declarative layer"], cwd: undefined }, + ]); + }); + + test("push: -u origin ", async () => { + const seam = new FakeExecSeam(); + const steps: InitStep[] = [{ kind: "push", branch: "main" }]; + + const result = await executeInitPlan(steps, seam, noopLog); + + expect(result).toEqual({ ok: true }); + expect(seam.calls).toEqual([{ kind: "run", cmd: ["git", "push", "-u", "origin", "main"], cwd: undefined }]); + }); + + test("runs a full plan's steps in order", async () => { + const seam = new FakeExecSeam(repoNotFoundThenCreated()); + const steps: InitStep[] = [ + { kind: "createRepo", name: "mattstack-home" }, + { kind: "gitInit", branch: "main" }, + { kind: "writeGitignore", content: "/rt/\n" }, + { kind: "writeOwners", content: "{}\n" }, + { kind: "deleteCruft", paths: ["skills.jsonc.pre-pack"] }, + { kind: "unlinkUserClone" }, + { kind: "adoptCommit", message: "home: adopt the declarative layer" }, + { kind: "foldInPrefs", sourceUrl: PREFS_URL }, + { kind: "push", branch: "main" }, + ]; + + const result = await executeInitPlan(steps, seam, noopLog); + + expect(result).toEqual({ ok: true }); + expect(seam.calls.map((c) => c.kind)).toEqual([ + "run", // gh repo view + "run", // gh repo create + "run", // git init + "run", // git remote add origin + "writeFile", // .gitignore + "writeFile", // snapshot-owners.jsonc + "removeDir", // deleteCruft + "removeDir", // unlinkUserClone: user/.git + "run", // git add -A + "run", // git commit + "mkTempDir", + "run", // git clone + "run", // git filter-repo + "run", // git fetch + "run", // git merge + "removeDir", // temp clone cleanup + "run", // git push + ]); + }); + + test("gitlink regression: unlinkUserClone's removeDir(user/.git) runs before adoptCommit's git add -A", async () => { + const seam = new FakeExecSeam(repoNotFoundThenCreated()); + const steps = buildInitPlan({ + isRepo: false, + hasUserClone: true, + hasTeamClones: [], + cruft: [], + prefsRemoteUrl: PREFS_URL, + }).steps; + + const result = await executeInitPlan(steps, seam, noopLog); + + expect(result).toEqual({ ok: true }); + const unlinkIndex = seam.calls.findIndex((c) => c.kind === "removeDir" && c.path === "user/.git"); + const addIndex = seam.calls.findIndex((c) => c.kind === "run" && c.cmd.join(" ") === "git add -A"); + expect(unlinkIndex).toBeGreaterThanOrEqual(0); + expect(addIndex).toBeGreaterThan(unlinkIndex); + }); + + test("a failing step aborts the remaining steps and reports it", async () => { + const seam = new FakeExecSeam({ + failRun: (cmd) => (isGhRepoView(cmd) ? "not found" : undefined), + failWriteFile: ".gitignore", + stdout: (cmd) => (isGhRepoCreate(cmd) ? `${CREATED_URL}\n` : ""), + }); + const steps: InitStep[] = [ + { kind: "createRepo", name: "mattstack-home" }, + { kind: "gitInit", branch: "main" }, + { kind: "writeGitignore", content: "/rt/\n" }, + { kind: "writeOwners", content: "{}\n" }, + { kind: "adoptCommit", message: "home: adopt the declarative layer" }, + { kind: "push", branch: "main" }, + ]; + + const result = await executeInitPlan(steps, seam, noopLog); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failedStep).toBe("writeGitignore"); + expect(result.stderr).toContain(".gitignore"); + } + // writeOwners, adoptCommit and push never ran. + expect(seam.calls.map((c) => c.kind)).toEqual(["run", "run", "run", "run", "writeFile"]); + }); + + test("a failing subprocess (non-zero exit) also aborts the remainder", async () => { + const seam = new FakeExecSeam({ + failRun: (cmd) => (cmd[0] === "gh" ? "gh: not authenticated" : undefined), + }); + const steps: InitStep[] = [ + { kind: "createRepo", name: "mattstack-home" }, + { kind: "gitInit", branch: "main" }, + ]; + + const result = await executeInitPlan(steps, seam, noopLog); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failedStep).toBe("createRepo"); + expect(result.stderr).toBe("gh: not authenticated"); + } + // gh repo view fails (treated as not-found) then gh repo create also fails. + expect(seam.calls).toEqual([ + { kind: "run", cmd: ["gh", "repo", "view", "mattstack-home", "--json", "isEmpty,url"], cwd: undefined }, + { kind: "run", cmd: ["gh", "repo", "create", "mattstack-home", "--private"], cwd: undefined }, + ]); + }); +}); + +describe("createRealExecSeam", () => { + test("run() defaults cwd to home, writeFile/removeDir resolve relative paths against home, absolute paths pass through", async () => { + const home = mkdtempSync(join(tmpdir(), "rt-home-exec-test-")); + try { + const seam = createRealExecSeam(home); + + const pwd = await seam.run(["pwd"]); + expect(pwd.code).toBe(0); + expect(realpathSync(pwd.stdout.trim())).toBe(realpathSync(home)); + + await seam.writeFile("hello.txt", "hi\n"); + expect(readFileSync(join(home, "hello.txt"), "utf8")).toBe("hi\n"); + + await seam.removeDir("hello.txt"); + expect(existsSync(join(home, "hello.txt"))).toBe(false); + + const tmp = await seam.mkTempDir(); + expect(existsSync(tmp)).toBe(true); + expect(tmp.startsWith(home)).toBe(false); + + const outsideFile = join(tmpdir(), `rt-home-exec-outside-${Date.now()}`); + writeFileSync(outsideFile, "x"); + await seam.removeDir(outsideFile); + expect(existsSync(outsideFile)).toBe(false); + + await seam.removeDir(tmp); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/lib/home/__tests__/init-plan.test.ts b/lib/home/__tests__/init-plan.test.ts new file mode 100644 index 00000000..7b980240 --- /dev/null +++ b/lib/home/__tests__/init-plan.test.ts @@ -0,0 +1,118 @@ +import { describe, test, expect } from "bun:test"; +import { buildInitPlan, type HomeState } from "../init-plan.ts"; + +const PREFS_URL = "https://github.com/mattgoodwin/mattstack-prefs.git"; + +describe("buildInitPlan", () => { + test("orders a fresh-adoption plan createRepo through push, unlink before adopt, adopt before fold", () => { + const state: HomeState = { + isRepo: false, + hasUserClone: true, + hasTeamClones: ["claimview"], + cruft: ["skills.jsonc.pre-pack", "skills.jsonc.retired-backup"], + prefsRemoteUrl: PREFS_URL, + }; + + const plan = buildInitPlan(state); + + expect(plan.reason).toBeUndefined(); + expect(plan.steps.map((s) => s.kind)).toEqual([ + "createRepo", + "gitInit", + "writeGitignore", + "writeOwners", + "deleteCruft", + "unlinkUserClone", + "adoptCommit", + "foldInPrefs", + "push", + ]); + + const foldInPrefs = plan.steps.find((s) => s.kind === "foldInPrefs"); + expect(foldInPrefs).toEqual({ kind: "foldInPrefs", sourceUrl: PREFS_URL }); + }); + + test("carries the cruft paths onto the deleteCruft step", () => { + const state: HomeState = { + isRepo: false, + hasUserClone: true, + hasTeamClones: [], + cruft: ["skills.jsonc.pre-pack", "skills.jsonc.retired-backup"], + prefsRemoteUrl: PREFS_URL, + }; + + const plan = buildInitPlan(state); + const deleteCruft = plan.steps.find((s) => s.kind === "deleteCruft"); + expect(deleteCruft).toEqual({ + kind: "deleteCruft", + paths: ["skills.jsonc.pre-pack", "skills.jsonc.retired-backup"], + }); + }); + + test("omits foldInPrefs and unlinkUserClone when there is no user clone to adopt", () => { + const state: HomeState = { isRepo: false, hasUserClone: false, hasTeamClones: [], cruft: [] }; + const plan = buildInitPlan(state); + expect(plan.steps.map((s) => s.kind)).not.toContain("foldInPrefs"); + expect(plan.steps.map((s) => s.kind)).not.toContain("unlinkUserClone"); + }); + + test("omits deleteCruft when there is no stray cruft", () => { + const state: HomeState = { isRepo: false, hasUserClone: false, hasTeamClones: [], cruft: [] }; + const plan = buildInitPlan(state); + expect(plan.steps.map((s) => s.kind)).not.toContain("deleteCruft"); + }); + + test("push carries the same branch gitInit created", () => { + const state: HomeState = { isRepo: false, hasUserClone: false, hasTeamClones: [], cruft: [] }; + const plan = buildInitPlan(state); + const gitInit = plan.steps.find((s) => s.kind === "gitInit"); + const push = plan.steps.find((s) => s.kind === "push"); + expect(gitInit).toBeDefined(); + expect(push).toBeDefined(); + expect((push as { branch: string }).branch).toBe((gitInit as { branch: string }).branch); + }); + + test("already-initialized: returns no steps plus the reason", () => { + const state: HomeState = { + isRepo: true, + hasUserClone: true, + hasTeamClones: ["claimview"], + cruft: ["skills.jsonc.pre-pack"], + prefsRemoteUrl: PREFS_URL, + }; + + const plan = buildInitPlan(state); + + expect(plan.steps).toEqual([]); + expect(plan.reason).toBe("already-initialized"); + }); + + test("prefs-remote-unreadable: a user clone with no parseable origin URL fails loudly instead of emitting an unrunnable fold", () => { + const state: HomeState = { + isRepo: false, + hasUserClone: true, + hasTeamClones: [], + cruft: [], + prefsRemoteUrl: undefined, + }; + + const plan = buildInitPlan(state); + + expect(plan.steps).toEqual([]); + expect(plan.reason).toBe("prefs-remote-unreadable"); + }); + + test("isRepo takes precedence over an unreadable prefs remote", () => { + const state: HomeState = { + isRepo: true, + hasUserClone: true, + hasTeamClones: [], + cruft: [], + prefsRemoteUrl: undefined, + }; + + const plan = buildInitPlan(state); + + expect(plan.reason).toBe("already-initialized"); + }); +}); diff --git a/lib/home/age-key.ts b/lib/home/age-key.ts new file mode 100644 index 00000000..e3fa906b --- /dev/null +++ b/lib/home/age-key.ts @@ -0,0 +1,224 @@ +/** + * The mattstack age key: one identity, custodied in the macOS keychain, + * never written to any file. Every secret path under the home repo's + * `user/secrets/` encrypts to its public recipient (see renderSopsYaml). + * + * All keychain/age-keygen calls route through the injected AgeKeySeam so + * tests never touch the real keychain. The private key crosses process + * boundaries via argv (the keychain write — see addCmd's doc for the + * exposure that's accepted there) or stdin (age-keygen -y); it is never + * logged in the clear — see withArgvRedaction. + */ + +export interface AgeExecResult { + code: number; + stdout: string; + stderr: string; +} + +export interface AgeKeySeam { + /** + * `input`, when set, is piped to the child's stdin (age-keygen -y takes + * the private key this way, keeping it out of argv). + * `sensitive` marks a call whose argv or stdin carries key material, so a + * logging wrapper (withArgvRedaction) knows to redact it. + */ + run(cmd: string[], opts?: { input?: string; sensitive?: boolean }): Promise; +} + +const KEYCHAIN_ACCOUNT = "mattstack"; +const KEYCHAIN_SERVICE = "mattstack-age-key"; + +const FIND_CMD = ["security", "find-generic-password", "-a", KEYCHAIN_ACCOUNT, "-s", KEYCHAIN_SERVICE, "-w"]; + +/** + * `security` has no stdin form for `-w` — the private key is unavoidably a + * literal argv value here, visible for this short-lived process's lifetime + * to anything that can read another process's argv on the machine (`ps + * auxww`, Activity Monitor). Accepted as a brief, single-machine exposure; + * never logged (withArgvRedaction) and never written to a file. No `-U` + * (update-if-exists): a duplicate item makes this call fail outright rather + * than silently overwrite — see ensureAgeKey's doc. + */ +function addCmd(privateKey: string): string[] { + return ["security", "add-generic-password", "-a", KEYCHAIN_ACCOUNT, "-s", KEYCHAIN_SERVICE, "-w", privateKey]; +} + +const ITEM_NOT_FOUND_EXIT_CODE = 44; // macOS security's errSecItemNotFound +const NOT_FOUND_STDERR_MARKER = "could not be found"; + +export type AgeKeyReadResult = { key: string } | { absent: true }; + +/** + * Three-way outcome, not a nullable string: a locked keychain or a denied + * access-control dialog also exits non-zero, and collapsing that into "no + * key" would let a caller mint a replacement over a key that still exists, + * orphaning every file already encrypted to it. Only the corroborated + * "item genuinely absent" case (exit 44 + the stderr marker) is `absent`; + * every other non-zero exit throws instead of guessing. + */ +export async function readAgeKey(seams: AgeKeySeam): Promise { + const result = await seams.run(FIND_CMD, { sensitive: true }); + + if (result.code === 0) { + const key = result.stdout.trim(); + if (key.length === 0) { + throw new Error("security find-generic-password: exited 0 but printed no key — unexpected keychain state"); + } + return { key }; + } + + if (result.code === ITEM_NOT_FOUND_EXIT_CODE && result.stderr.toLowerCase().includes(NOT_FOUND_STDERR_MARKER)) { + return { absent: true }; + } + + throw new Error( + `security find-generic-password: keychain unreachable (exit ${result.code}) — refusing to mint, ` + + `minting now would destroy the existing key\n${result.stderr}`, + ); +} + +function parseAgeKeygenOutput(output: string): { publicKey: string; privateKey: string } { + const publicMatch = output.match(/^# public key: (age1\S+)/m); + const privateMatch = output.match(/^(AGE-SECRET-KEY-1\S+)/m); + if (!publicMatch || !privateMatch) { + throw new Error("age-keygen: could not parse a public/private key pair from its output"); + } + return { publicKey: publicMatch[1]!, privateKey: privateMatch[1]! }; +} + +/** + * Mints and stores a new key ONLY on readAgeKey's provable `absent` — + * anything else (including a keychain-access error) throws instead of + * risking a mint over a key that's still there. `addCmd` carries no `-U` + * on top of that: if an item exists anyway, the keychain write itself + * fails rather than overwriting, the last-resort guard against clobbering + * the custodied key. + */ +export async function ensureAgeKey(seams: AgeKeySeam): Promise<{ publicKey: string }> { + const existing = await readAgeKey(seams); + if ("key" in existing) { + const derived = await seams.run(["age-keygen", "-y"], { input: existing.key, sensitive: true }); + if (derived.code !== 0) { + throw new Error(`age-keygen -y: could not derive the public key from the stored private key\n${derived.stderr}`); + } + return { publicKey: derived.stdout.trim() }; + } + + const generated = await seams.run(["age-keygen"], { sensitive: true }); + if (generated.code !== 0) { + throw new Error(`age-keygen: failed to generate a new age key\n${generated.stderr}`); + } + const { publicKey, privateKey } = parseAgeKeygenOutput(generated.stdout); + + const stored = await seams.run(addCmd(privateKey), { sensitive: true }); + if (stored.code !== 0) { + throw new Error(`security add-generic-password: failed to store the age key in the keychain\n${stored.stderr}`); + } + + return { publicKey }; +} + +export function renderSopsYaml(publicKey: string): string { + return ["creation_rules:", " - path_regex: user/secrets/.*", ` age: ${publicKey}`, ""].join("\n"); +} + +/** The inverse of renderSopsYaml: the `age:` recipient from a rendered .sops.yaml, or null if the shape doesn't match (a hand-edited file with no recognizable recipient line). */ +export function sopsYamlRecipient(content: string): string | null { + return content.match(/^\s*age:\s*(\S+)/m)?.[1] ?? null; +} + +/** Thrown by keyExport when the keychain provably holds no key yet — minting is `rt home init`'s job, never export's. */ +export class AgeKeyAbsentError extends Error {} + +const EXPORT_WARNING = [ + "############################################################", + "# rt home key export — mattstack age private key", + "#", + "# WARNING: this decrypts every secret in the home repo. It", + "# lives only in the keychain and is never written to a file —", + "# save it to your password manager now.", + "############################################################", + "", +].join("\n"); + +/** + * Reads the existing key and prints it once, with a warning header — the + * only place this module hands the key to the outside world, and it is + * stdout only: never a file (see the module doc). Never mints: that keeps + * a keychain-access error here from ever being mistaken for "no key yet" + * and triggering a mint that would orphan the real one. Minting is + * `rt home init`'s job (ensureAgeKey), run once, ahead of time. + */ +export async function keyExport(seams: AgeKeySeam, print: (text: string) => void): Promise { + const result = await readAgeKey(seams); + if (!("key" in result)) { + throw new AgeKeyAbsentError("no age key found in the keychain yet — run `rt home init` first"); + } + print(`${EXPORT_WARNING}${result.key}`); +} + +/** Redacts the value following a `-w` flag; nothing else in argv can carry the key. */ +function redactArgv(cmd: string[]): string[] { + return cmd.map((arg, i) => (i > 0 && cmd[i - 1] === "-w" ? "" : arg)); +} + +/** + * Wraps a seam so callers can log every command that runs without ever + * logging key material: sensitive calls have their `-w` value (the only + * argv slot the key ever occupies) redacted before `log` sees it. `log` + * receives argv only — never stdout/stderr/input — so a call whose key + * travels via stdin or return value is never exposed here either. + */ +export function withArgvRedaction(seam: AgeKeySeam, log: (cmd: string[]) => void): AgeKeySeam { + return { + async run(cmd, opts) { + log(opts?.sensitive ? redactArgv(cmd) : cmd); + return seam.run(cmd, opts); + }, + }; +} + +const CLI_DEBUG = process.env.RT_LOG_LEVEL === "debug"; + +/** + * The CLI process has no per-call debug channel of its own (that's a + * daemon-only seam — see CLAUDE.md's logging architecture); this mirrors + * its RT_LOG_LEVEL=debug gating rather than inventing a separate one. + */ +function debugLog(cmd: string[]): void { + if (CLI_DEBUG) console.error(`[age-key] ${cmd.join(" ")}`); +} + +/** Bun.spawn-based capture, env passed live (PATH-snapshot gotcha). Unexported: only reachable wrapped, via createRealAgeKeySeam. */ +function createRawAgeKeySeam(): AgeKeySeam { + return { + async run(cmd, opts) { + const proc = Bun.spawn(cmd, { + env: process.env, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + // Always close stdin (EOF) even with no input: commands that never + // read it ignore the close, but age-keygen -y blocks on EOF otherwise. + if (opts?.input !== undefined) proc.stdin.write(opts.input); + proc.stdin.end(); + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { code, stdout, stderr }; + }, + }; +} + +/** + * The real seam. Always the redacting wrapper — the raw, unredacted seam + * above is never exported, so there is no reachable path to an unredacted + * default. + */ +export function createRealAgeKeySeam(): AgeKeySeam { + return withArgvRedaction(createRawAgeKeySeam(), debugLog); +} diff --git a/lib/home/boundary.ts b/lib/home/boundary.ts new file mode 100644 index 00000000..57a6c25e --- /dev/null +++ b/lib/home/boundary.ts @@ -0,0 +1,36 @@ +/** + * The home repo's gitignore boundary. + * + * The gitignore IS the boundary: tracked = declarative surfaces (synced + * across machines), ignored = runtime. `user/local/` is hoisted here rather + * than left to mattstack-prefs' own folded-in `.gitignore`, so the boundary + * doesn't depend on that inner file surviving the fold-in. + * + * The single-segment directory patterns are leading-slash anchored + * (`/rt/`, not `rt/`): an unanchored `dir/` pattern matches that name at + * ANY depth in git, so a future tracked `user/rt/` would be silently + * ignored by a bare `rt/`. `*.sock` and `.DS_Store` are deliberately left + * unanchored — those are wanted at any depth. + */ + +export const HOME_BOUNDARY: { tracked: string[]; ignored: string[] } = { + tracked: ["user/", "skills.jsonc", "snapshot-owners.jsonc", "user/secrets/"], + ignored: [ + "/rt/", + "/deck/", + "/shepherdr/", + "/repos/", + "/ci-attendants/", + "/work/", + "/teams/", + "user/local/", + "settings.local.jsonc", + "user/secrets/*.tmp", + "*.sock", + ".DS_Store", + ], +}; + +export function renderHomeGitignore(): string { + return `${HOME_BOUNDARY.ignored.join("\n")}\n`; +} diff --git a/lib/home/git-config.ts b/lib/home/git-config.ts new file mode 100644 index 00000000..d2717ceb --- /dev/null +++ b/lib/home/git-config.ts @@ -0,0 +1,30 @@ +/** + * Pure `.git/config` parsing — no fs, no exec. Used to recover the origin + * remote URL of a clone whose `.git` is about to be (or already was) + * unlinked, so a caller doesn't need `git remote get-url` (a subprocess) to + * answer a question the config file already holds as text. + */ + +/** + * Extracts the `url` under `[remote "origin"]`. Only that one section is + * read — `git config`'s full include/multi-value semantics don't apply here, + * since this reads a single known-shape file rather than resolving a real + * git config graph. + */ +export function parseOriginUrl(gitConfigText: string): string | null { + const lines = gitConfigText.split("\n"); + let inOriginSection = false; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (line.startsWith("[")) { + inOriginSection = /^\[remote\s+"origin"\]$/.test(line); + continue; + } + if (!inOriginSection) continue; + const match = line.match(/^url\s*=\s*(.+)$/); + if (match) return match[1]!.trim(); + } + + return null; +} diff --git a/lib/home/init-exec.ts b/lib/home/init-exec.ts new file mode 100644 index 00000000..ed73a6f4 --- /dev/null +++ b/lib/home/init-exec.ts @@ -0,0 +1,193 @@ +/** + * `rt home init` execution — turns an InitStep[] (lib/home/init-plan.ts) into + * real git/gh/filter-repo calls, all routed through the injected ExecSeam so + * tests never touch a real subprocess or fs. + * + * The seam is bound to the home directory: `run()` defaults its cwd to the + * home repo, and `writeFile`/`removeDir` take paths relative to it. Only the + * foldInPrefs temp clone overrides cwd explicitly. + */ + +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { isAbsolute, join } from "path"; +import type { InitStep } from "./init-plan.ts"; + +export interface ExecResult { + code: number; + stdout: string; + stderr: string; +} + +export interface ExecSeam { + run(cmd: string[], opts?: { cwd?: string }): Promise; + writeFile(path: string, content: string): Promise; + removeDir(path: string): Promise; + mkTempDir(): Promise; +} + +export type InitResult = { ok: true } | { ok: false; failedStep: InitStep["kind"]; stderr: string }; + +const FOLD_MERGE_MESSAGE = "home: fold in mattstack-prefs history under user/"; + +type StepLog = (message: string) => void; + +class StepFailed extends Error { + constructor(public readonly stderr: string) { + super(stderr); + } +} + +async function run(exec: ExecSeam, cmd: string[], opts?: { cwd?: string }): Promise { + const result = await exec.run(cmd, opts); + if (result.code !== 0) throw new StepFailed(result.stderr); + return result.stdout; +} + +/** createRepo's stdout URL is carried forward for gitInit's `remote add`. */ +interface ExecContext { + createdRepoUrl?: string; +} + +async function runStep(step: InitStep, exec: ExecSeam, log: StepLog, ctx: ExecContext): Promise { + switch (step.kind) { + case "createRepo": { + // Resume-safe: a prior run may have already created the repo (e.g. it + // crashed on a later step). `gh repo view` tells us which of three + // states we're in before mutating anything. + log(`checking for an existing GitHub repo ${step.name}`); + const view = await exec.run(["gh", "repo", "view", step.name, "--json", "isEmpty,url"]); + if (view.code === 0) { + let parsed: { isEmpty?: boolean; url?: string }; + try { + parsed = JSON.parse(view.stdout); + } catch { + throw new StepFailed(`gh repo view printed unparseable JSON for ${step.name}`); + } + if (!parsed.isEmpty) { + throw new StepFailed(`GitHub repo "${step.name}" already exists and is not empty`); + } + if (!parsed.url) throw new StepFailed(`gh repo view printed no url for ${step.name}`); + ctx.createdRepoUrl = parsed.url; + return; + } + + log(`creating GitHub repo ${step.name}`); + const stdout = await run(exec, ["gh", "repo", "create", step.name, "--private"]); + const url = stdout.trim().split("\n")[0]; + // Silently skipping `remote add` here would surface as an unrelated + // failure six steps later, at push, with no way back to this step. + if (!url) throw new StepFailed("gh repo create printed no repo URL"); + ctx.createdRepoUrl = url; + return; + } + case "gitInit": { + log(`git init -b ${step.branch}`); + await run(exec, ["git", "init", "-b", step.branch]); + if (ctx.createdRepoUrl) await run(exec, ["git", "remote", "add", "origin", ctx.createdRepoUrl]); + return; + } + case "writeGitignore": { + log("writing the boundary .gitignore"); + await exec.writeFile(".gitignore", step.content); + return; + } + case "writeOwners": { + log("writing snapshot-owners.jsonc"); + await exec.writeFile("snapshot-owners.jsonc", step.content); + return; + } + case "deleteCruft": { + for (const path of step.paths) { + log(`removing stray cruft: ${path}`); + await exec.removeDir(path); + } + return; + } + case "unlinkUserClone": { + // Must run before adoptCommit: see the ordering comment in + // init-plan.ts. foldInPrefs re-clones from step.sourceUrl instead, so + // this .git is never needed again. + log("unlinking user/.git (fold-in re-clones from the origin remote instead)"); + await exec.removeDir("user/.git"); + return; + } + case "foldInPrefs": { + log("folding mattstack-prefs history into user/"); + const tmp = await exec.mkTempDir(); + try { + // --no-hardlinks: if sourceUrl resolves to a local path, a plain + // clone would hardlink objects into the tmp clone; filter-repo + // rewrites history destructively, which would corrupt objects the + // source still shares. + await run(exec, ["git", "clone", "--no-hardlinks", step.sourceUrl, tmp]); + await run(exec, ["git", "filter-repo", "--to-subdirectory-filter", "user"], { cwd: tmp }); + // HEAD, not a hardcoded branch name: the tmp clone's default branch + // IS whatever the source remote's default branch is. + await run(exec, ["git", "fetch", tmp, "HEAD"]); + await run(exec, ["git", "merge", "FETCH_HEAD", "--allow-unrelated-histories", "-m", FOLD_MERGE_MESSAGE]); + } finally { + await exec.removeDir(tmp); + } + return; + } + case "adoptCommit": { + log(`committing: ${step.message}`); + await run(exec, ["git", "add", "-A"]); + await run(exec, ["git", "commit", "-m", step.message]); + return; + } + case "push": { + log(`pushing -u origin ${step.branch}`); + await run(exec, ["git", "push", "-u", "origin", step.branch]); + return; + } + } +} + +export async function executeInitPlan(steps: InitStep[], exec: ExecSeam, log: StepLog): Promise { + const ctx: ExecContext = {}; + for (const step of steps) { + try { + await runStep(step, exec, log, ctx); + } catch (err) { + const stderr = err instanceof StepFailed ? err.stderr : err instanceof Error ? err.message : String(err); + return { ok: false, failedStep: step.kind, stderr }; + } + } + return { ok: true }; +} + +/** The real seam: Bun.spawn-based capture, real fs writes/removal under `home`. */ +export function createRealExecSeam(home: string): ExecSeam { + return { + async run(cmd, opts) { + const proc = Bun.spawn(cmd, { + cwd: opts?.cwd ?? home, + // Bun.spawn resolves the executable against the PATH captured at + // process start; a runtime process.env.PATH mutation is invisible to + // it, so this must be a live reference, not a snapshot taken earlier. + env: process.env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { code, stdout, stderr }; + }, + async writeFile(path, content) { + writeFileSync(join(home, path), content); + }, + async removeDir(path) { + // The foldInPrefs temp clone is already absolute (outside `home`); + // every other caller passes a home-relative path. + rmSync(isAbsolute(path) ? path : join(home, path), { recursive: true, force: true }); + }, + async mkTempDir() { + return mkdtempSync(join(tmpdir(), "rt-home-fold-")); + }, + }; +} diff --git a/lib/home/init-plan.ts b/lib/home/init-plan.ts new file mode 100644 index 00000000..dc8a52ab --- /dev/null +++ b/lib/home/init-plan.ts @@ -0,0 +1,86 @@ +/** + * `rt home init` plan-of-record. + * + * Pure logic: turns a probed HomeState into an ordered InitStep[]. No fs, no + * exec — a separate execution seam runs these against real git and gh. + */ + +import { renderHomeGitignore } from "./boundary.ts"; + +export interface HomeState { + isRepo: boolean; + hasUserClone: boolean; + hasTeamClones: string[]; + cruft: string[]; + /** origin URL of user/.git, parsed before it's ever unlinked. */ + prefsRemoteUrl?: string; +} + +export type InitStep = + | { kind: "createRepo"; name: string } + | { kind: "gitInit"; branch: string } + | { kind: "writeGitignore"; content: string } + | { kind: "writeOwners"; content: string } + | { kind: "deleteCruft"; paths: string[] } + | { kind: "unlinkUserClone" } + | { kind: "foldInPrefs"; sourceUrl: string } + | { kind: "adoptCommit"; message: string } + | { kind: "push"; branch: string }; + +export interface InitPlan { + steps: InitStep[]; + /** + * Set only when the plan is empty: + * - "already-initialized": ~/.mattstack is already a repo. + * - "prefs-remote-unreadable": there's a user/ clone to fold in, but its + * origin URL couldn't be parsed — never emit a fold the executor can't + * actually run. + */ + reason?: "already-initialized" | "prefs-remote-unreadable"; +} + +export const DEFAULT_HOME_REPO_NAME = "mattstack-home"; +export const DEFAULT_HOME_BRANCH = "main"; +export const ADOPT_COMMIT_MESSAGE = "home: adopt the declarative layer"; + +function renderOwnersFile(): string { + return "{\n // snapshot-owners.jsonc — claimed zones the snapshot daemon must never\n // auto-commit. Empty until a zone is claimed.\n}\n"; +} + +/** + * Idempotence lives here, not in the executor: a repo that already exists + * gets an empty plan so the executor never has to re-derive the check. + */ +export function buildInitPlan(state: HomeState): InitPlan { + if (state.isRepo) return { steps: [], reason: "already-initialized" }; + if (state.hasUserClone && !state.prefsRemoteUrl) { + return { steps: [], reason: "prefs-remote-unreadable" }; + } + + const steps: InitStep[] = [ + { kind: "createRepo", name: DEFAULT_HOME_REPO_NAME }, + { kind: "gitInit", branch: DEFAULT_HOME_BRANCH }, + { kind: "writeGitignore", content: renderHomeGitignore() }, + { kind: "writeOwners", content: renderOwnersFile() }, + ]; + + if (state.cruft.length > 0) steps.push({ kind: "deleteCruft", paths: state.cruft }); + + // unlinkUserClone runs BEFORE adoptCommit: a live user/.git left in place + // makes `git add -A` stage `user` as a GITLINK (mode 160000), not the real + // files under it — the fold-in merge below then sees those files as + // untracked and refuses. Removing .git first makes `user/**` ordinary + // tracked files, so the later merge is a clean add/add of identical blobs. + if (state.hasUserClone) steps.push({ kind: "unlinkUserClone" }); + + // adoptCommit runs BEFORE foldInPrefs: folding merges FETCH_HEAD with + // --allow-unrelated-histories, and a merge into a still-unborn HEAD (no + // commits yet) refuses to clobber the untracked user/ files already on + // disk. Committing first turns that merge into a clean 3-way add/add. + steps.push({ kind: "adoptCommit", message: ADOPT_COMMIT_MESSAGE }); + if (state.hasUserClone) steps.push({ kind: "foldInPrefs", sourceUrl: state.prefsRemoteUrl! }); + + steps.push({ kind: "push", branch: DEFAULT_HOME_BRANCH }); + + return { steps }; +} diff --git a/lib/linear.ts b/lib/linear.ts index ba8d7820..30199c36 100644 --- a/lib/linear.ts +++ b/lib/linear.ts @@ -7,17 +7,21 @@ * 3. Fetch ticket title + status from Linear GraphQL API * 4. Cache results in memory (5-minute TTL) * - * API keys stored in ~/.mattstack/rt/secrets.json + * Secrets: the sops-backed store (lib/secrets/store.ts, domain "rt") is the + * source of truth; ~/.mattstack/rt/secrets.json is a transition-only fallback + * (RT-32) read through readPlaintextSecretsFallback — the one function to + * delete once the live import (a later lane) retires the plaintext file. */ -import { readFileSync, writeFileSync, mkdirSync } from "fs"; -import { join, dirname } from "path"; -import { homedir } from "os"; +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; import { rtDir } from "./rt-paths.ts"; +import { readSecret, writeSecret, createRealSecretsExecSeam, type SecretsSeams } from "./secrets/store.ts"; +import { createRealAgeKeySeam } from "./home/age-key.ts"; // ─── Secrets ───────────────────────────────────────────────────────────────── -const SECRETS_PATH = join(rtDir(), "secrets.json"); +const RT_SECRET_DOMAIN = "rt"; interface Secrets { linearApiKey?: string; @@ -29,19 +33,101 @@ interface Secrets { sdmEmail?: string; } -export function loadSecrets(): Secrets { +const RT_SECRET_KEYS: (keyof Secrets)[] = [ + "linearApiKey", "gitlabToken", "githubToken", "linearTeamId", "linearTeamKey", "sdmEmail", +]; + +let realSecretsSeamsSingleton: SecretsSeams | null = null; + +/** Lazily-built real seams, shared across calls in one process (readSecret memoizes per domain on top of this). */ +function defaultSecretsSeams(): SecretsSeams { + return realSecretsSeamsSingleton ??= { + ageKeySeam: createRealAgeKeySeam(), + execSeam: createRealSecretsExecSeam(), + }; +} + +function plaintextSecretsPath(): string { + return join(rtDir(), "secrets.json"); +} + +/** + * RT-32 transition fallback: the plaintext file still holds the real values + * until the live import runs, and stays readable afterward only because + * nothing has deleted it yet. Delete this function (and its two call sites + * in loadSecrets) when that import retires ~/.mattstack/rt/secrets.json — no + * other code path should ever read this file directly. + */ +function readPlaintextSecretsFallback(): Secrets { try { - return JSON.parse(readFileSync(SECRETS_PATH, "utf8")); + return JSON.parse(readFileSync(plaintextSecretsPath(), "utf8")); } catch { return {}; } } -export function saveSecret(key: keyof Secrets, value: string): void { - const secrets = loadSecrets(); - secrets[key] = value; - mkdirSync(dirname(SECRETS_PATH), { recursive: true }); - writeFileSync(SECRETS_PATH, JSON.stringify(secrets, null, 2)); +interface EncryptedRtSecretsResult { + values: Partial; + /** Set when a decrypt attempt threw (keychain unreachable, corrupt ciphertext, …) — loadSecrets decides whether that's fatal. */ + failure?: Error; +} + +/** + * Loops per key rather than one bulk call because `Secrets` has no "read the + * whole domain" shape — but the decrypt itself is all-or-nothing per FILE, + * not per key: `readSecret`'s per-domain memo means every key after the + * first is a cheap object-property lookup, so a decrypt failure always + * surfaces on the very first key attempted. Returning immediately on that + * first failure (instead of looping through the rest) just skips five + * guaranteed-identical failures against the same broken read — it is not + * "returning partial data," since a real decrypt failure never leaves any. + */ +async function readEncryptedRtSecrets(seams: SecretsSeams): Promise { + const values: Partial = {}; + for (const key of RT_SECRET_KEYS) { + try { + const value = await readSecret(RT_SECRET_DOMAIN, key, seams); + if (value !== null) values[key] = value; + } catch (err) { + return { values, failure: err instanceof Error ? err : new Error(String(err)) }; + } + } + return { values }; +} + +/** + * Encrypted store wins per-key when present. On a clean read, the plaintext + * file (transition only — see readPlaintextSecretsFallback) fills whatever + * the encrypted store doesn't have yet. + * + * On a FAILED encrypted read (the store's own fail-closed contract — + * NoAgeKeyError, keychain-unreachable, corrupt ciphertext), this only + * degrades to the plaintext file when that file actually still exists: an + * absent file means there is nothing to fail open TO, so returning `{}` + * here would silently hide a real error behind "no secrets configured." + * Propagate the store's error instead — that's the fail-closed behavior the + * store itself refused to give up. + */ +export async function loadSecrets(seams: SecretsSeams = defaultSecretsSeams()): Promise { + const { values: encrypted, failure } = await readEncryptedRtSecrets(seams); + if (!failure) { + return { ...readPlaintextSecretsFallback(), ...encrypted }; + } + + if (!existsSync(plaintextSecretsPath())) { + throw failure; + } + + console.error(`[secrets] encrypted store unreadable (${failure.message}); using plaintext secrets.json`); + return { ...readPlaintextSecretsFallback(), ...encrypted }; +} + +export async function saveSecret( + key: keyof Secrets, + value: string, + seams: SecretsSeams = defaultSecretsSeams(), +): Promise { + await writeSecret(RT_SECRET_DOMAIN, key, value, seams); } // ─── Branch parser ─────────────────────────────────────────────────────────── @@ -185,19 +271,23 @@ export async function fetchTeams(apiKey: string): Promise { } } -export function getTeamConfig(): { teamId: string; teamKey: string } | null { - const secrets = loadSecrets(); +export async function getTeamConfig( + seams: SecretsSeams = defaultSecretsSeams(), +): Promise<{ teamId: string; teamKey: string } | null> { + const secrets = await loadSecrets(seams); if (secrets.linearTeamId && secrets.linearTeamKey) { return { teamId: secrets.linearTeamId, teamKey: secrets.linearTeamKey }; } return null; } -export function saveTeamConfig(teamId: string, teamKey: string): void { - const secrets = loadSecrets(); - secrets.linearTeamId = teamId; - secrets.linearTeamKey = teamKey; - writeFileSync(SECRETS_PATH, JSON.stringify(secrets, null, 2)); +export async function saveTeamConfig( + teamId: string, + teamKey: string, + seams: SecretsSeams = defaultSecretsSeams(), +): Promise { + await writeSecret(RT_SECRET_DOMAIN, "linearTeamId", teamId, seams); + await writeSecret(RT_SECRET_DOMAIN, "linearTeamKey", teamKey, seams); } // ─── Fetch team tickets ────────────────────────────────────────────────────── diff --git a/lib/module-registry.ts b/lib/module-registry.ts index f9e7df09..f862c755 100644 --- a/lib/module-registry.ts +++ b/lib/module-registry.ts @@ -10,9 +10,11 @@ import * as commit from "../commands/commit.ts"; import * as daemon from "../commands/daemon.ts"; import * as events from "../commands/events.ts"; import * as extension from "../commands/extension.ts"; +import * as home from "../commands/home.ts"; import * as hooks from "../commands/hooks.ts"; import * as port from "../commands/port.ts"; import * as run from "../commands/run.ts"; +import * as secrets from "../commands/secrets.ts"; import * as settings from "../commands/settings.ts"; import * as settingsKeys from "../commands/settings-keys.ts"; import * as sync from "../commands/sync.ts"; @@ -38,9 +40,11 @@ export const MODULE_REGISTRY: Record = { "./commands/daemon.ts": daemon, "./commands/events.ts": events, "./commands/extension.ts": extension, + "./commands/home.ts": home, "./commands/hooks.ts": hooks, "./commands/port.ts": port, "./commands/run.ts": run, + "./commands/secrets.ts": secrets, "./commands/settings.ts": settings, "./commands/settings-keys.ts": settingsKeys, "./commands/sync.ts": sync, diff --git a/lib/rt-paths.ts b/lib/rt-paths.ts index 4336169e..898aba6e 100644 --- a/lib/rt-paths.ts +++ b/lib/rt-paths.ts @@ -22,14 +22,20 @@ * a machine still carrying real legacy dirs. */ -import { lstatSync, mkdirSync, renameSync } from "fs"; +import { existsSync, lstatSync, mkdirSync, renameSync } from "fs"; import { homedir } from "os"; -import { join } from "path"; +import { basename, join } from "path"; +import { getSetting } from "./settings/resolve.ts"; function home(): string { return process.env.HOME ?? homedir(); } +/** ~/.mattstack — the home repo root (RT-30). */ +export function mattstackHome(): string { + return join(home(), ".mattstack"); +} + /** ~/.mattstack/rt — the root of all rt state. App-level files live directly here. */ export function rtDir(): string { return join(home(), ".mattstack", "rt"); @@ -124,6 +130,33 @@ export function devTrayAppPath(): string { return join(home(), "Applications", DEV_TRAY_APP_BUNDLE); } +/** + * Where a bundle is ACTUALLY installed, not just where it's meant to go + * (that's `trayAppPath`/`devTrayAppPath` — install destinations, used by + * post-install.ts). The app's bundles legitimately live in `/Applications` + * now, not only `~/Applications`, so this checks every location rt could + * plausibly have been pointed at or find it in, strongest signal first, and + * verifies each candidate actually exists before trusting it — a stale + * machine setting or a since-removed bundle must never be handed back as + * fact. `exists` is injectable so tests never have to touch the real + * `/Applications`. + */ +export function installedTrayAppPath(bundle: string, exists: (path: string) => boolean = existsSync): string | null { + const { value } = getSetting("mattstack.appPath"); + // `mattstack.appPath` names one specific bundle (whichever flavor wrote + // it); a dev-bundle lookup must never be handed the prod bundle's path + // just because IT happens to exist on disk, or vice versa. + if (typeof value === "string" && value.length > 0 && basename(value) === bundle && exists(value)) return value; + + const systemPath = join("/Applications", bundle); + if (exists(systemPath)) return systemPath; + + const userPath = join(home(), "Applications", bundle); + if (exists(userPath)) return userPath; + + return null; +} + /** * Old rt-tray.app candidate locations, for migration (post-install's * one-shot legacy sweep) and `rt verify` warnings. A FUNCTION, not a const — @@ -131,14 +164,15 @@ export function devTrayAppPath(): string { * HOME at module load, and the source-guard tests below enforce it. * * Mirrors the candidate list scattered across commands/verify.ts and - * commands/post-install.ts today: the installed location under - * ~/Applications, plus the two locations the old Homebrew install put it at - * relative to the running binary (same dir, and one level up for the Cellar - * layout). + * commands/post-install.ts today: both install dirs the app bundles + * themselves can now live in (/Applications and ~/Applications), plus the + * two locations the old Homebrew install put it at relative to the running + * binary (same dir, and one level up for the Cellar layout). */ export function legacyTrayAppPaths(): string[] { const rtExec = process.execPath; return [ + join("/Applications", "rt-tray.app"), join(home(), "Applications", "rt-tray.app"), join(rtExec, "../rt-tray.app"), join(rtExec, "../../rt-tray.app"), diff --git a/lib/sdm/browser-login.ts b/lib/sdm/browser-login.ts index 139b3ce1..7f89afeb 100644 --- a/lib/sdm/browser-login.ts +++ b/lib/sdm/browser-login.ts @@ -79,7 +79,8 @@ export interface BrowserLoginDeps { waitForCdp: (port: number, timeoutMs: number) => Promise; startLogin: (email: string | null, onLine: (l: string) => void) => LoginUrlCapture; showWindow: (cdp: CdpSocket) => Promise; - email: () => string | null; + /** Sync or async: the real seam awaits loadSecrets(); test fakes stay sync. */ + email: () => string | null | Promise; onLine: (line: string) => void; silentBudgetMs?: number; userBudgetMs?: number; @@ -114,7 +115,7 @@ export async function runBrowserLoginWith( // prompts for one on stdin, which is closed in this flow, so it exits with // a cryptic "before printing an auth URL" error. Route to the terminal // flow instead, where sdm can prompt for the email naturally. - const email = deps.email(); + const email = await deps.email(); if (!email || !email.trim()) { return { outcome: "needs-manual", @@ -315,7 +316,7 @@ export function runBrowserLogin(opts: { visible?: boolean; onLine?: (line: strin waitForCdp: realWaitForCdp, startLogin: startLoginCapture, showWindow: realShowWindow, - email: () => loadSecrets().sdmEmail ?? null, + email: async () => (await loadSecrets()).sdmEmail ?? null, onLine, }); } diff --git a/lib/secrets/__tests__/store.test.ts b/lib/secrets/__tests__/store.test.ts new file mode 100644 index 00000000..21b52b3c --- /dev/null +++ b/lib/secrets/__tests__/store.test.ts @@ -0,0 +1,610 @@ +import { describe, test, expect, beforeEach, spyOn } from "bun:test"; +import { + readSecret, + writeSecret, + rotateSecret, + listSecretNames, + secretsFilePath, + resetSecretsMemo, + formatDebugLine, + buildSecretsSpawnOptions, + NoAgeKeyError, + InvalidSecretsSegmentError, + type SecretsExecResult, + type SecretsExecSeam, + type SecretsSeams, +} from "../store.ts"; +import { rtDir, mattstackHome } from "../../rt-paths.ts"; +import type { AgeExecResult, AgeKeySeam } from "../../home/age-key.ts"; +import { dirname, join } from "path"; +import { secretsList } from "../../../commands/secrets.ts"; + +const NOT_FOUND_STDERR = "The specified item could not be found in the keychain."; +const DEFAULT_CIPHERTEXT = JSON.stringify({ data: "opaque", sops: { age: [] } }); + +beforeEach(() => resetSecretsMemo()); + +function fakeAgeKeySeamWithKey(key: string): AgeKeySeam { + return { + async run(cmd): Promise { + if (cmd[0] === "security" && cmd[1] === "find-generic-password") { + return { code: 0, stdout: `${key}\n`, stderr: "" }; + } + throw new Error(`fakeAgeKeySeamWithKey: unexpected call ${cmd.join(" ")}`); + }, + }; +} + +function fakeAgeKeySeamAbsent(): AgeKeySeam { + return { + async run(): Promise { + return { code: 44, stdout: "", stderr: NOT_FOUND_STDERR }; + }, + }; +} + +function fakeAgeKeySeamThrows(): AgeKeySeam { + return { + async run(): Promise { + return { code: 36, stdout: "", stderr: "SecKeychainItemCopyContent: the user name or passphrase is not correct" }; + }, + }; +} + +type Call = { cmd: string[]; opts?: { env?: Record; sensitive?: boolean } }; + +/** Models the write idiom's shape: `-e --output ` only writes simulated ciphertext to `files` on success, matching real sops. */ +class FakeSecretsExecSeam implements SecretsExecSeam { + calls: Call[] = []; + files = new Map(); + stats = new Map(); + ensureDirCalls: { path: string; mode: number }[] = []; + chmodCalls: { path: string; mode: number }[] = []; + removeFileCalls: string[] = []; + fsyncAndRenameCalls: { from: string; to: string }[] = []; + private mtimeCounter = 0; + /** outputPath -> the plaintext JSON that was actually staged for it, so a post-encrypt `-d` on that path round-trips for real (unless a test overrides `encryptOutputContent` to simulate a wrong-recipient/garbled encrypt). */ + private roundTrippablePlaintext = new Map(); + + constructor( + private opts: { + decrypt?: () => SecretsExecResult; + encrypt?: (outputPath: string) => SecretsExecResult; + encryptOutputContent?: string; + } = {}, + ) {} + + fileExists(path: string): boolean { + return this.files.has(path); + } + + /** Fake mtime/size signature — bumped on every write this class knows about, so freshMemoEntry sees a real change. */ + private touch(path: string): void { + this.mtimeCounter += 1; + this.stats.set(path, { mtimeMs: this.mtimeCounter, size: this.files.get(path)?.length ?? 0 }); + } + + statFile(path: string): { mtimeMs: number; size: number } | null { + return this.stats.get(path) ?? null; + } + + readFile(path: string): string { + const content = this.files.get(path); + if (content === undefined) throw new Error(`FakeSecretsExecSeam: readFile of missing path ${path}`); + return content; + } + + writeFile(path: string, content: string): void { + this.files.set(path, content); + this.touch(path); + } + + ensureDir(path: string, mode: number): void { + this.ensureDirCalls.push({ path, mode }); + } + + chmod(path: string, mode: number): void { + this.chmodCalls.push({ path, mode }); + } + + fsyncAndRename(from: string, to: string): void { + this.fsyncAndRenameCalls.push({ from, to }); + const content = this.files.get(from); + if (content !== undefined) { + this.files.set(to, content); + this.files.delete(from); + this.stats.delete(from); + this.touch(to); + } + } + + removeFile(path: string): void { + this.removeFileCalls.push(path); + this.files.delete(path); + this.stats.delete(path); + } + + async run(cmd: string[], runOpts?: { env?: Record; sensitive?: boolean }): Promise { + this.calls.push({ cmd, opts: runOpts }); + + if (cmd[0] === "sops" && cmd[1] === "-d") { + const target = cmd[2]!; + const staged = this.roundTrippablePlaintext.get(target); + if (staged !== undefined) return { code: 0, stdout: staged, stderr: "" }; + return this.opts.decrypt ? this.opts.decrypt() : { code: 0, stdout: "{}", stderr: "" }; + } + if (cmd[0] === "sops" && cmd[1] === "-e") { + const outputIdx = cmd.indexOf("--output"); + const outputPath = cmd[outputIdx + 1]!; + const stagingInputPath = cmd[cmd.length - 1]!; + const result = this.opts.encrypt ? this.opts.encrypt(outputPath) : { code: 0, stdout: "", stderr: "" }; + if (result.code === 0) { + this.files.set(outputPath, this.opts.encryptOutputContent ?? DEFAULT_CIPHERTEXT); + this.touch(outputPath); + if (this.opts.encryptOutputContent === undefined) { + const staged = this.files.get(stagingInputPath); + if (staged !== undefined) this.roundTrippablePlaintext.set(outputPath, staged); + } + } + return result; + } + + throw new Error(`FakeSecretsExecSeam: unexpected call ${cmd.join(" ")}`); + } +} + +/** The exact staging path writeSecret computes — process.pid is stable within this test process. */ +function stagingPath(domain: string): string { + return join(rtDir(), "tmp", `${domain}.${process.pid}.json`); +} + +describe("readSecret", () => { + test("decrypts an existing file; SOPS_AGE_KEY travels via env, argv never carries the key", async () => { + const domain = "rt"; + const path = secretsFilePath(domain); + const execSeam = new FakeSecretsExecSeam({ + decrypt: () => ({ code: 0, stdout: JSON.stringify({ linearApiKey: "lin_api_secret" }), stderr: "" }), + }); + execSeam.writeFile(path, "ciphertext-placeholder"); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamWithKey("AGE-SECRET-KEY-1TEST"), execSeam }; + + const value = await readSecret(domain, "linearApiKey", seams); + + expect(value).toBe("lin_api_secret"); + expect(execSeam.calls).toEqual([ + { cmd: ["sops", "-d", path], opts: { env: { SOPS_AGE_KEY: "AGE-SECRET-KEY-1TEST" }, sensitive: true } }, + ]); + expect(execSeam.calls.flatMap((c) => c.cmd)).not.toContain("AGE-SECRET-KEY-1TEST"); + }); + + test("missing encrypted file -> null, no throw, no sops call, no keychain lookup", async () => { + const execSeam = new FakeSecretsExecSeam(); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamThrows(), execSeam }; + + const value = await readSecret("rt", "anyKey", seams); + + expect(value).toBeNull(); + expect(execSeam.calls).toEqual([]); + }); + + test("a key absent from an existing domain's payload -> null", async () => { + const domain = "rt"; + const path = secretsFilePath(domain); + const execSeam = new FakeSecretsExecSeam({ decrypt: () => ({ code: 0, stdout: "{}", stderr: "" }) }); + execSeam.writeFile(path, "ciphertext"); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamWithKey("AGE-X"), execSeam }; + + expect(await readSecret(domain, "nope", seams)).toBeNull(); + }); + + test("age key provably absent -> NoAgeKeyError pointing at `rt home init`, never an empty secret", async () => { + const domain = "rt"; + const path = secretsFilePath(domain); + const execSeam = new FakeSecretsExecSeam(); + execSeam.writeFile(path, "ciphertext"); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamAbsent(), execSeam }; + + await expect(readSecret(domain, "key", seams)).rejects.toThrow(NoAgeKeyError); + await expect(readSecret(domain, "key", seams)).rejects.toThrow(/rt home init/); + }); + + test("keychain error propagates as a real error — never collapsed into absence or an empty secret", async () => { + const domain = "rt"; + const path = secretsFilePath(domain); + const execSeam = new FakeSecretsExecSeam(); + execSeam.writeFile(path, "ciphertext"); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamThrows(), execSeam }; + + await expect(readSecret(domain, "key", seams)).rejects.toThrow(/keychain unreachable/i); + }); + + test("a sops decrypt failure propagates as a real error", async () => { + const domain = "rt"; + const path = secretsFilePath(domain); + const execSeam = new FakeSecretsExecSeam({ decrypt: () => ({ code: 1, stdout: "", stderr: "sops: no matching creation rule" }) }); + execSeam.writeFile(path, "ciphertext"); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamWithKey("AGE-X"), execSeam }; + + await expect(readSecret(domain, "key", seams)).rejects.toThrow(/sops -d/); + }); + + test("a garbled decrypt payload's error message never echoes the raw (possibly secret-laden) stdout", async () => { + const domain = "rt"; + const path = secretsFilePath(domain); + const CANARY = "GARBLED_CANARY_VALUE"; + const execSeam = new FakeSecretsExecSeam({ decrypt: () => ({ code: 0, stdout: `not-json-but-contains-${CANARY}`, stderr: "" }) }); + execSeam.writeFile(path, "ciphertext"); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamWithKey("AGE-X"), execSeam }; + + let thrown: unknown; + try { + await listSecretNames(domain, seams); + } catch (err) { + thrown = err; + } + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).not.toContain(CANARY); + }); +}); + +describe("domain/key validation — before any filesystem touch", () => { + test("a path-escaping domain is rejected, no exec/fs calls at all", async () => { + const execSeam = new FakeSecretsExecSeam(); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamWithKey("AGE-X"), execSeam }; + + await expect(readSecret("../skills", "key", seams)).rejects.toThrow(InvalidSecretsSegmentError); + expect(execSeam.calls).toEqual([]); + }); + + test("an invalid key is rejected on write, before the keychain or any file is touched", async () => { + const execSeam = new FakeSecretsExecSeam(); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamWithKey("AGE-X"), execSeam }; + + await expect(writeSecret("rt", "bad key with spaces", "v", seams)).rejects.toThrow(InvalidSecretsSegmentError); + expect(execSeam.calls).toEqual([]); + }); + + test("uppercase, underscores, and a leading hyphen are all rejected", async () => { + const execSeam = new FakeSecretsExecSeam(); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamWithKey("AGE-X"), execSeam }; + + for (const bad of ["RT", "rt_domain", "-rt", "rt/../etc"]) { + await expect(readSecret(bad, "key", seams)).rejects.toThrow(InvalidSecretsSegmentError); + } + }); +}); + +describe("writeSecret", () => { + test("a brand-new domain still requires a real age key up front (encryption alone wouldn't need it)", async () => { + const execSeam = new FakeSecretsExecSeam(); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamAbsent(), execSeam }; + + await expect(writeSecret("rt", "gitlabToken", "v", seams)).rejects.toThrow(NoAgeKeyError); + // Never even attempted to encrypt: a machine with no key must never + // silently write a credential it can't read back. + expect(execSeam.calls).toEqual([]); + expect(execSeam.files.size).toBe(0); + }); + + test("stages plaintext under rt/tmp (gitignored), encrypts with --filename-override to a pid-qualified .tmp output, decrypts it for a round-trip readback, then fsync+renames over the target", async () => { + const domain = "rt"; + const path = secretsFilePath(domain); + const execSeam = new FakeSecretsExecSeam({ + decrypt: () => ({ code: 0, stdout: JSON.stringify({ existingKey: "existingVal" }), stderr: "" }), + }); + execSeam.writeFile(path, "ciphertext-before"); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamWithKey("AGE-X"), execSeam }; + + await writeSecret(domain, "newKey", "newVal", seams); + + const staging = stagingPath(domain); + const outputTmp = `${path}.${process.pid}.tmp`; + + expect(execSeam.calls.map((c) => c.cmd)).toEqual([ + ["sops", "-d", path], + ["sops", "-e", "--filename-override", join("user", "secrets", `${domain}.json`), "--output", outputTmp, staging], + ["sops", "-d", outputTmp], + ]); + // The round-trip readback carries the same SOPS_AGE_KEY env as any other sops call — never argv. + expect(execSeam.calls[2]?.opts).toEqual({ env: { SOPS_AGE_KEY: "AGE-X" }, sensitive: true }); + expect(execSeam.fsyncAndRenameCalls).toEqual([{ from: outputTmp, to: path }]); + expect(execSeam.chmodCalls).toEqual([ + { path: outputTmp, mode: 0o600 }, + { path, mode: 0o600 }, + ]); + // Both the staging plaintext and the output-tmp path are cleaned up unconditionally. + expect(execSeam.removeFileCalls.sort()).toEqual([outputTmp, staging].sort()); + // Nothing plaintext survives at either transient path. + expect(execSeam.files.has(staging)).toBe(false); + expect(execSeam.files.has(outputTmp)).toBe(false); + // The real target holds the (simulated) ciphertext, not plaintext. + expect(execSeam.files.get(path)).toBe(DEFAULT_CIPHERTEXT); + }); + + test("no existing file -> starts from an empty payload, still encrypts via the same atomic path", async () => { + const domain = "rt"; + const path = secretsFilePath(domain); + const execSeam = new FakeSecretsExecSeam(); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamWithKey("AGE-X"), execSeam }; + + await writeSecret(domain, "onlyKey", "onlyVal", seams); + + const outputTmp = `${path}.${process.pid}.tmp`; + expect(execSeam.calls.map((c) => c.cmd)).toEqual([ + ["sops", "-e", "--filename-override", join("user", "secrets", `${domain}.json`), "--output", outputTmp, stagingPath(domain)], + ["sops", "-d", outputTmp], + ]); + expect(execSeam.files.get(path)).toBe(DEFAULT_CIPHERTEXT); + }); + + test("the output tmp path is pid-qualified so two concurrent writers can't cross-unlink each other's output", async () => { + const domain = "rt"; + const path = secretsFilePath(domain); + const execSeam = new FakeSecretsExecSeam(); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamWithKey("AGE-X"), execSeam }; + + await writeSecret(domain, "k", "v", seams); + + const encryptCall = execSeam.calls.find((c) => c.cmd[1] === "-e")!; + const outputPath = encryptCall.cmd[encryptCall.cmd.indexOf("--output") + 1]; + expect(outputPath).toBe(`${path}.${process.pid}.tmp`); + }); + + test("the new value never appears in any subprocess argv", async () => { + const domain = "rt"; + const execSeam = new FakeSecretsExecSeam(); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamWithKey("AGE-X"), execSeam }; + + await writeSecret(domain, "k", "super-secret-value-never-in-argv", seams); + + expect(execSeam.calls.flatMap((c) => c.cmd)).not.toContain("super-secret-value-never-in-argv"); + }); + + test("directories are created 0700 before any write", async () => { + const domain = "rt"; + const path = secretsFilePath(domain); + const execSeam = new FakeSecretsExecSeam(); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamWithKey("AGE-X"), execSeam }; + + await writeSecret(domain, "k", "v", seams); + + expect(execSeam.ensureDirCalls).toEqual([ + { path: join(rtDir(), "tmp"), mode: 0o700 }, + { path: dirname(path), mode: 0o700 }, + ]); + }); + + test("an encrypt failure throws, and both the staging file and any partial output are still cleaned up (no file for the user to delete)", async () => { + const domain = "rt"; + const path = secretsFilePath(domain); + const execSeam = new FakeSecretsExecSeam({ encrypt: () => ({ code: 1, stdout: "", stderr: "sops: encrypt failed" }) }); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamWithKey("AGE-X"), execSeam }; + + await expect(writeSecret(domain, "k", "v", seams)).rejects.toThrow(/sops -e/); + + const outputTmp = `${path}.${process.pid}.tmp`; + expect(execSeam.files.has(stagingPath(domain))).toBe(false); + expect(execSeam.files.has(outputTmp)).toBe(false); + expect(execSeam.files.has(path)).toBe(false); // the (nonexistent) target was never created + expect(execSeam.removeFileCalls.sort()).toEqual([outputTmp, stagingPath(domain)].sort()); + }); + + test("a post-encrypt read-back that doesn't round-trip the written value refuses to declare success", async () => { + const domain = "rt"; + const execSeam = new FakeSecretsExecSeam({ encryptOutputContent: "not sops output at all" }); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamWithKey("AGE-X"), execSeam }; + + await expect(writeSecret(domain, "k", "v", seams)).rejects.toThrow(/read-back/i); + }); + + test("a wrong-recipient encrypt (round-trip decrypt doesn't yield the written value) never renames over the target — the original survives untouched", async () => { + const domain = "rt"; + const path = secretsFilePath(domain); + const execSeam = new FakeSecretsExecSeam({ encryptOutputContent: "ciphertext-for-a-different-recipient" }); + execSeam.writeFile(path, "ciphertext-original"); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamWithKey("AGE-X"), execSeam }; + + await expect(writeSecret(domain, "k", "v", seams)).rejects.toThrow(/round-trip/i); + + expect(execSeam.files.get(path)).toBe("ciphertext-original"); + expect(execSeam.fsyncAndRenameCalls).toEqual([]); + }); +}); + +describe("per-process memo", () => { + test("writeSecret invalidates the domain's memo — the next read re-decrypts instead of serving stale data", async () => { + const domain = "rt"; + const path = secretsFilePath(domain); + let decryptCalls = 0; + const execSeam = new FakeSecretsExecSeam({ + decrypt: () => { + decryptCalls++; + return { code: 0, stdout: JSON.stringify({ k: `v${decryptCalls}` }), stderr: "" }; + }, + }); + execSeam.writeFile(path, "ciphertext"); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamWithKey("AGE-X"), execSeam }; + + expect(await readSecret(domain, "k", seams)).toBe("v1"); + expect(decryptCalls).toBe(1); + + // Second read within the same process hits the memo: no new decrypt call. + expect(await readSecret(domain, "k", seams)).toBe("v1"); + expect(decryptCalls).toBe(1); + + await writeSecret(domain, "other", "x", seams); + + // Post-write read must re-decrypt (proves invalidation), not serve v1 again. + expect(await readSecret(domain, "k", seams)).toBe("v2"); + expect(decryptCalls).toBe(2); + }); + + test("a domain deleted out from under the process re-reads as missing, not a stale cached value", async () => { + const domain = "rt"; + const path = secretsFilePath(domain); + const execSeam = new FakeSecretsExecSeam({ + decrypt: () => ({ code: 0, stdout: JSON.stringify({ k: "cached-value" }), stderr: "" }), + }); + execSeam.writeFile(path, "ciphertext"); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamWithKey("AGE-X"), execSeam }; + + expect(await readSecret(domain, "k", seams)).toBe("cached-value"); + + execSeam.files.delete(path); // simulate external deletion + + expect(await readSecret(domain, "k", seams)).toBeNull(); + }); + + test("an mtime/size change from OUTSIDE writeSecret (a rotation landing on disk from another process) invalidates the memo on the next read", async () => { + // Models a long-lived daemon process sitting on a memoized read while a + // sibling `rt secrets set` CLI process rewrites the same file — nothing + // in THIS process ever calls writeSecret, so writeSecret's own + // domainMemo.delete() never fires. Only the mtime/size check catches it. + const domain = "rt"; + const path = secretsFilePath(domain); + let decryptCalls = 0; + const execSeam = new FakeSecretsExecSeam({ + decrypt: () => { + decryptCalls++; + return { code: 0, stdout: JSON.stringify({ k: `v${decryptCalls}` }), stderr: "" }; + }, + }); + execSeam.writeFile(path, "ciphertext-v1"); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamWithKey("AGE-X"), execSeam }; + + expect(await readSecret(domain, "k", seams)).toBe("v1"); + expect(decryptCalls).toBe(1); + + // Directly mutate the fake's fs, bypassing writeSecret entirely. + execSeam.writeFile(path, "ciphertext-v2-different-bytes"); + + expect(await readSecret(domain, "k", seams)).toBe("v2"); + expect(decryptCalls).toBe(2); + }); +}); + +describe("listSecretNames", () => { + test("returns keys only, never values", async () => { + const domain = "rt"; + const path = secretsFilePath(domain); + const execSeam = new FakeSecretsExecSeam({ + decrypt: () => ({ code: 0, stdout: JSON.stringify({ linearApiKey: "SECRET_A", gitlabToken: "SECRET_B" }), stderr: "" }), + }); + execSeam.writeFile(path, "ciphertext"); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamWithKey("AGE-X"), execSeam }; + + const names = await listSecretNames(domain, seams); + + expect(names.sort()).toEqual(["gitlabToken", "linearApiKey"]); + }); + + test("missing file -> empty list, no throw", async () => { + const execSeam = new FakeSecretsExecSeam(); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamThrows(), execSeam }; + + expect(await listSecretNames("rt", seams)).toEqual([]); + }); +}); + +describe("rotateSecret", () => { + test("mints via the injected minter, writes the new value, and returns the rotation commit message", async () => { + const domain = "rt"; + const path = secretsFilePath(domain); + const execSeam = new FakeSecretsExecSeam({ decrypt: () => ({ code: 0, stdout: "{}", stderr: "" }) }); + execSeam.writeFile(path, "ciphertext"); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamWithKey("AGE-X"), execSeam }; + + const message = await rotateSecret(domain, "gitlabToken", () => "glpat-new-value", seams); + + expect(message).toBe(`secrets: rotate ${domain}.gitlabToken`); + }); + + test("supports an async minter", async () => { + const domain = "rt"; + const execSeam = new FakeSecretsExecSeam(); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamWithKey("AGE-X"), execSeam }; + + const message = await rotateSecret(domain, "k", async () => "minted-async", seams); + + expect(message).toBe(`secrets: rotate ${domain}.k`); + }); + + test("validates domain/key before ever calling the minter", async () => { + const execSeam = new FakeSecretsExecSeam(); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamWithKey("AGE-X"), execSeam }; + let minted = false; + + await expect( + rotateSecret( + "../escape", + "k", + () => { + minted = true; + return "v"; + }, + seams, + ), + ).rejects.toThrow(InvalidSecretsSegmentError); + expect(minted).toBe(false); + }); +}); + +describe("real seam spawn options — cwd pin (Task 5 carried review item)", () => { + test("pins cwd to mattstackHome() so sops resolves THIS home's .sops.yaml, never a foreign cwd's", () => { + const opts = buildSecretsSpawnOptions(); + expect(opts.cwd).toBe(mattstackHome()); + }); + + test("still layers opts.env (e.g. SOPS_AGE_KEY) over process.env alongside the cwd pin", () => { + const opts = buildSecretsSpawnOptions({ env: { SOPS_AGE_KEY: "age-secret-key-test" } }); + expect(opts.cwd).toBe(mattstackHome()); + expect(opts.env.SOPS_AGE_KEY).toBe("age-secret-key-test"); + }); +}); + +describe("formatDebugLine (the debugLog path)", () => { + test("a sensitive call's line never includes env values or stdout/stderr, whatever they'd contain", () => { + const line = formatDebugLine(["sops", "-d", "/some/path"], { sensitive: true }); + expect(line).toBe("[secrets] sops -d /some/path (env/output redacted)"); + expect(line).not.toContain("SOPS_AGE_KEY"); + }); + + test("a non-sensitive call's line is unmarked (still argv-only — nothing to redact by construction)", () => { + expect(formatDebugLine(["sops", "-e", "/some/path"])).toBe("[secrets] sops -e /some/path"); + }); +}); + +describe("rt secrets list (command layer)", () => { + test("prints secret names but never the canary value, on stdout OR stderr", async () => { + const domain = "rt"; + const path = secretsFilePath(domain); + const CANARY = "sk_super_secret_canary_value_should_never_print"; + const execSeam = new FakeSecretsExecSeam({ + decrypt: () => ({ code: 0, stdout: JSON.stringify({ apiKey: CANARY, other: "value2" }), stderr: "" }), + }); + execSeam.writeFile(path, "ciphertext"); + const seams: SecretsSeams = { ageKeySeam: fakeAgeKeySeamWithKey("AGE-X"), execSeam }; + + const logs: string[] = []; + const errors: string[] = []; + const logSpy = spyOn(console, "log").mockImplementation((...parts: unknown[]) => { + logs.push(parts.map(String).join(" ")); + }); + const errorSpy = spyOn(console, "error").mockImplementation((...parts: unknown[]) => { + errors.push(parts.map(String).join(" ")); + }); + + try { + await secretsList([domain], {}, seams); + } finally { + logSpy.mockRestore(); + errorSpy.mockRestore(); + } + + const output = [...logs, ...errors].join("\n"); + expect(output).toContain("apiKey"); + expect(output).toContain("other"); + expect(output).not.toContain(CANARY); + expect(output).not.toContain("value2"); + }); +}); diff --git a/lib/secrets/store.ts b/lib/secrets/store.ts new file mode 100644 index 00000000..540c5d7a --- /dev/null +++ b/lib/secrets/store.ts @@ -0,0 +1,421 @@ +/** + * The sops-backed secrets store: `~/.mattstack/user/secrets/.json`, + * decrypted with the mattstack age key (lib/home/age-key.ts) via SOPS_AGE_KEY. + * The key crosses into the sops subprocess ONLY through that env var — never + * argv, never a file — mirroring readAgeKey's own custody rule and its + * `.sops.yaml` creation rule for `user/secrets/**` (lib/home/age-key.ts). + * + * Write idiom: stage plaintext at `~/.mattstack/rt/tmp/..json` + * (rt/ is gitignored — never a tracked path), fsync it, encrypt with + * `--filename-override user/secrets/.json` (keeps the `.sops.yaml` + * path_regex matching even though the real input lives in rt/tmp) into + * `..tmp` (pid-qualified so two concurrent writers can't + * unlink each other's tmp output), decrypt that tmp output and confirm the + * newly written key round-trips — a real check that the encrypt used the + * right recipient, not a content heuristic — and only then fsync + rename + * it over the real target. Every staging/output-tmp path is unlinked in a + * `finally`, so a failure never leaves plaintext at a path the user has to + * find and delete — the original target (untouched until the rename) is + * always the fallback. This keeps the new value out of every subprocess's + * argv too, unlike `sops --set`, which would put it on the sops command + * line. + * + * No file locking: two concurrent `rt secrets set` calls against the same + * domain race on the same rename target (last one to rename wins, silently + * dropping the other's merge). Acceptable for a single-operator CLI; not + * safe for concurrent/multi-process writers. + */ + +import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync } from "fs"; +import { dirname, join } from "path"; +import { mattstackHome, rtDir } from "../rt-paths.ts"; +import { readAgeKey, type AgeKeySeam } from "../home/age-key.ts"; + +export interface SecretsExecResult { + code: number; + stdout: string; + stderr: string; +} + +export interface SecretsExecSeam { + run(cmd: string[], opts?: { env?: Record; sensitive?: boolean }): Promise; + fileExists(path: string): boolean; + /** + * mtime/size signature for the domain memo's staleness check — a rotation + * landing on disk from another process (a CLI `rt secrets set` next to a + * long-lived daemon) changes this even though nothing in THIS process + * called writeSecret to invalidate the memo. Null when the file can't be + * stat'd (treated as "can't prove freshness" — never trust the memo then). + */ + statFile(path: string): { mtimeMs: number; size: number } | null; + /** Raw fs read (no decryption) — used for the post-encrypt ciphertext readback. */ + readFile(path: string): string; + /** Writes the plaintext staging file only: 0600, fsynced. Never used for the real target. */ + writeFile(path: string, content: string): void; + ensureDir(path: string, mode: number): void; + chmod(path: string, mode: number): void; + /** fsyncs `from`, then renames it over `to` — the atomic publish step. */ + fsyncAndRename(from: string, to: string): void; + /** Best-effort unlink; never throws (cleanup must not mask the real error). */ + removeFile(path: string): void; +} + +export interface SecretsSeams { + ageKeySeam: AgeKeySeam; + execSeam: SecretsExecSeam; +} + +/** Thrown when the keychain provably holds no age key yet (readAgeKey's `{absent:true}`). */ +export class NoAgeKeyError extends Error { + constructor() { + super("no age key in the keychain — run `rt home init` first"); + } +} + +// `domain` feeds a filesystem path directly, so it's held to the strict, +// path-escape-proof shape. `key` never touches a path (it's a JSON object +// key), and the brief's own inventory needs room a bare `[a-z0-9-]*` domain +// pattern doesn't give: camelCase names (linearApiKey) and one dotted +// compound (deck's passwordHash.) — so it gets a calibrated pattern +// that still rejects the dangerous shapes (spaces, slashes, control chars) +// without breaking every real key this store is meant to hold. +const DOMAIN_PATTERN = /^[a-z0-9][a-z0-9-]*$/; +const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; + +/** `domain`/`key` become path/object-key components — reject anything else before any fs/exec call. */ +export class InvalidSecretsSegmentError extends Error { + constructor(kind: "domain" | "key", value: string, pattern: RegExp) { + super(`invalid ${kind} "${value}" — must match ${pattern}`); + } +} + +function validateDomain(domain: string): void { + if (!DOMAIN_PATTERN.test(domain)) throw new InvalidSecretsSegmentError("domain", domain, DOMAIN_PATTERN); +} + +function validateKey(key: string): void { + if (!KEY_PATTERN.test(key)) throw new InvalidSecretsSegmentError("key", key, KEY_PATTERN); +} + +/** Validates `domain` — every path construction routes through here, so this is the one choke point. */ +export function secretsFilePath(domain: string): string { + validateDomain(domain); + return join(mattstackHome(), "user", "secrets", `${domain}.json`); +} + +/** + * `{absent:true}` becomes this module's own error, never an empty secret — + * collapsing "no key yet" into "no value" would be indistinguishable from a + * real missing key at every call site downstream. + */ +async function sopsAgeKeyEnv(ageKeySeam: AgeKeySeam): Promise> { + const result = await readAgeKey(ageKeySeam); + if (!("key" in result)) throw new NoAgeKeyError(); + return { SOPS_AGE_KEY: result.key }; +} + +async function sopsDecrypt(filePath: string, env: Record, execSeam: SecretsExecSeam): Promise> { + const result = await execSeam.run(["sops", "-d", filePath], { env, sensitive: true }); + if (result.code !== 0) { + throw new Error(`sops -d ${filePath}: ${result.stderr}`); + } + try { + return JSON.parse(result.stdout); + } catch { + throw new Error(`sops -d ${filePath}: decrypted output was not valid JSON`); + } +} + +interface DomainSig { mtimeMs: number; size: number } +interface DomainMemoEntry { payload: Record; sig: DomainSig } + +/** + * One decrypted domain object per process per domain, valid only while the + * file's mtime/size match the sig it was decrypted under — see + * `freshMemoEntry`. writeSecret still deletes its own entry outright (the + * simplest possible invalidation for the writer's own process); the sig + * check is what catches a rotation from a DIFFERENT process (the long-lived + * daemon sitting next to a CLI `rt secrets set`) that never touches this + * process's memo directly. + */ +const domainMemo = new Map(); + +/** Test-only: bun test shares one process across the whole file — clear cross-test state. */ +export function resetSecretsMemo(): void { + domainMemo.clear(); +} + +function sameSig(a: DomainSig, b: DomainSig): boolean { + return a.mtimeMs === b.mtimeMs && a.size === b.size; +} + +/** Cached payload if the file's current stat still matches the sig it was decrypted under; undefined otherwise (miss OR can't-stat). */ +function freshMemoEntry(domain: string, filePath: string, seams: SecretsSeams): Record | undefined { + const cached = domainMemo.get(domain); + if (!cached) return undefined; + const sig = seams.execSeam.statFile(filePath); + if (!sig || !sameSig(cached.sig, sig)) return undefined; + return cached.payload; +} + +/** + * Null means the file doesn't exist — distinct from a keychain error, which + * throws. fileExists is checked BEFORE consulting the memo (not after) so a + * domain deleted out from under the process re-reads as missing instead of + * serving a stale cached value. + */ +async function decryptDomain(domain: string, seams: SecretsSeams): Promise | null> { + const filePath = secretsFilePath(domain); + if (!seams.execSeam.fileExists(filePath)) { + domainMemo.delete(domain); + return null; + } + + const fresh = freshMemoEntry(domain, filePath, seams); + if (fresh) return fresh; + + const env = await sopsAgeKeyEnv(seams.ageKeySeam); + const parsed = await sopsDecrypt(filePath, env, seams.execSeam); + const sig = seams.execSeam.statFile(filePath) ?? { mtimeMs: -1, size: -1 }; + domainMemo.set(domain, { payload: parsed, sig }); + return parsed; +} + +export async function readSecret(domain: string, key: string, seams: SecretsSeams): Promise { + validateKey(key); + const secrets = await decryptDomain(domain, seams); + if (secrets === null) return null; + return secrets[key] ?? null; +} + +/** Names only — never call this to expose values; commands/secrets.ts prints just the keys. */ +export async function listSecretNames(domain: string, seams: SecretsSeams): Promise { + const secrets = await decryptDomain(domain, seams); + return secrets === null ? [] : Object.keys(secrets); +} + +/** + * Stage → encrypt-to-tmp → decrypt-readback → fsync+rename. The readback + * decrypts the tmp output (before it ever replaces the target) and checks + * that `key` round-trips to `value` — catching a wrong-recipient encrypt + * (a `.sops.yaml` shadowed from $HOME, a stale recipient after rotation) + * that a plaintext/heuristic check on the ciphertext shape could never see. + * Every path this touches outside the real target is removed in `finally`, + * so a thrown error never needs to name a file for the user to clean up — + * there isn't one, and the target is untouched on every failure. + */ +async function encryptDomain( + domain: string, + targetPath: string, + payload: Record, + key: string, + value: string, + env: Record, + execSeam: SecretsExecSeam, +): Promise { + const stagingDir = join(rtDir(), "tmp"); + execSeam.ensureDir(stagingDir, 0o700); + execSeam.ensureDir(dirname(targetPath), 0o700); + + const stagingPath = join(stagingDir, `${domain}.${process.pid}.json`); + const outputTmpPath = `${targetPath}.${process.pid}.tmp`; + // Relative to the home root, matching .sops.yaml's `path_regex: + // user/secrets/.*` — the real input path (under rt/tmp) would never match. + const filenameOverride = join("user", "secrets", `${domain}.json`); + + try { + execSeam.writeFile(stagingPath, JSON.stringify(payload, null, 2)); + + const result = await execSeam.run( + ["sops", "-e", "--filename-override", filenameOverride, "--output", outputTmpPath, stagingPath], + { sensitive: true }, + ); + if (result.code !== 0) { + throw new Error( + `sops -e ${domain}: encryption failed — ${result.stderr}\n` + + "no plaintext was left on disk (staging files are always cleaned up)", + ); + } + + // sops creates outputTmpPath itself, at umask-derived (not 0600) perms. + execSeam.chmod(outputTmpPath, 0o600); + + const decryptResult = await execSeam.run(["sops", "-d", outputTmpPath], { env, sensitive: true }); + let roundTripped: Record | undefined; + if (decryptResult.code === 0) { + try { + roundTripped = JSON.parse(decryptResult.stdout); + } catch { + roundTripped = undefined; + } + } + if (roundTripped?.[key] !== value) { + throw new Error( + `sops -e ${domain}: post-encrypt read-back of ${outputTmpPath} does not round-trip "${key}" — ` + + `refusing to declare success (${targetPath} was left untouched)`, + ); + } + + execSeam.fsyncAndRename(outputTmpPath, targetPath); + execSeam.chmod(targetPath, 0o600); + } finally { + execSeam.removeFile(stagingPath); + execSeam.removeFile(outputTmpPath); + } +} + +export async function writeSecret(domain: string, key: string, value: string, seams: SecretsSeams): Promise { + validateKey(key); + const filePath = secretsFilePath(domain); + + // Encryption alone only needs the recipient's PUBLIC key (from .sops.yaml) + // — a brand-new domain would otherwise encrypt successfully even on a + // machine that holds no private key at all, silently writing a credential + // this machine can never read back. Assert it up front, unconditionally, + // not only when there happens to be an existing file to decrypt. + const env = await sopsAgeKeyEnv(seams.ageKeySeam); + + let existing: Record; + if (!seams.execSeam.fileExists(filePath)) { + existing = {}; + } else { + existing = freshMemoEntry(domain, filePath, seams) ?? await sopsDecrypt(filePath, env, seams.execSeam); + } + + // Invalidate before mutating disk: after a failed encrypt the file may + // hold nothing usable, so a cached ciphertext-derived read would be stale. + domainMemo.delete(domain); + + const updated = { ...existing, [key]: value }; + await encryptDomain(domain, filePath, updated, key, value, env, seams.execSeam); +} + +/** + * Re-mints via the injected minter (a provider-specific token/hash generator + * lives with the caller, not here), writes it, and hands back the commit + * message — committing is the caller's job (a live-machine step). + */ +export async function rotateSecret( + domain: string, + key: string, + minter: () => string | Promise, + seams: SecretsSeams, +): Promise { + validateDomain(domain); + validateKey(key); + const newValue = await minter(); + await writeSecret(domain, key, newValue, seams); + return `secrets: rotate ${domain}.${key}`; +} + +const CLI_DEBUG = process.env.RT_LOG_LEVEL === "debug"; + +/** + * Pure formatter, exported so its redaction can be unit-tested without a + * real subprocess: for a sensitive call this must never echo `opts.env` + * (SOPS_AGE_KEY) or `result.stdout`/`stderr` (the decrypted payload), + * whatever they contain — only argv, which by this module's design never + * carries a secret (SOPS_AGE_KEY travels via env, values via files, never + * argv), so nothing needs positional redaction the way age-key.ts's `-w` + * value does. + */ +export function formatDebugLine(cmd: string[], opts?: { sensitive?: boolean }): string { + return `[secrets] ${cmd.join(" ")}${opts?.sensitive ? " (env/output redacted)" : ""}`; +} + +function debugLog(cmd: string[], sensitive: boolean | undefined): void { + if (!CLI_DEBUG) return; + console.error(formatDebugLine(cmd, { sensitive })); +} + +/** + * Pure builder for the real seam's Bun.spawn opts, split out from `run` so + * the cwd pin is unit-testable without spawning a subprocess: sops discovers + * `.sops.yaml` cwd-relative, so a spawn from a foreign cwd (e.g. a CLI + * command invoked from inside some other repo) can silently match that + * repo's own `.sops.yaml` rules and encrypt to the wrong recipients instead + * of erroring. Pinning `cwd` to `mattstackHome()` makes every sops call + * resolve the home repo's rules regardless of the caller's cwd. + */ +export function buildSecretsSpawnOptions(opts?: { env?: Record }): { + cwd: string; + env: Record; + stdout: "pipe"; + stderr: "pipe"; +} { + return { + cwd: mattstackHome(), + // A fresh object every call (not a live reference/pass-through like + // init-exec.ts's raw `env: process.env`) — but since it's built from + // process.env at call time rather than cached once at module load, a + // runtime PATH mutation is still visible; opts.env only layers + // SOPS_AGE_KEY on top. + env: { ...process.env, ...opts?.env }, + stdout: "pipe", + stderr: "pipe", + }; +} + +/** Real seam: Bun.spawn-based capture, real fs reads/writes. */ +export function createRealSecretsExecSeam(): SecretsExecSeam { + return { + async run(cmd, opts) { + debugLog(cmd, opts?.sensitive); + const proc = Bun.spawn(cmd, buildSecretsSpawnOptions(opts)); + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { code, stdout, stderr }; + }, + fileExists(path) { + return existsSync(path); + }, + statFile(path) { + try { + const st = statSync(path); + return { mtimeMs: st.mtimeMs, size: st.size }; + } catch { + return null; + } + }, + readFile(path) { + return readFileSync(path, "utf8"); + }, + writeFile(path, content) { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const fd = openSync(path, "w", 0o600); + try { + writeSync(fd, content); + fsyncSync(fd); + } finally { + closeSync(fd); + } + chmodSync(path, 0o600); // mode at open() only applies to a freshly-created inode + }, + ensureDir(path, mode) { + mkdirSync(path, { recursive: true, mode }); + chmodSync(path, mode); + }, + chmod(path, mode) { + chmodSync(path, mode); + }, + fsyncAndRename(from, to) { + const fd = openSync(from, "r+"); + try { + fsyncSync(fd); + } finally { + closeSync(fd); + } + renameSync(from, to); + }, + removeFile(path) { + try { + unlinkSync(path); + } catch { + // already gone, or nothing was ever written — cleanup is best-effort + } + }, + }; +} diff --git a/lib/settings/identity.ts b/lib/settings/identity.ts index 4f5b2a2f..98b413a2 100644 --- a/lib/settings/identity.ts +++ b/lib/settings/identity.ts @@ -1,125 +1,5 @@ -/** - * Repo identity: the normalized-remote string that keys `repos.` - * sections in every settings store (RT-47 spec, "Repo identity"). - * - * Identity is `host/path` (lowercase host, path case preserved), derived from - * `remote.origin.url` — never a filesystem path, so it is checkout-location - * independent: every worktree of a repo shares the same remote and therefore - * the same identity. A remote that doesn't match a recognized host form - * (bare local paths are the main case — repos.json has two) normalizes to - * null, meaning repo-scoped sections are unreachable for it and only global - * scopes + legacy apply. That's an honest degrade, not a crash. - * - * Three entry points: - * - `normalizeRemote` is the pure string transform, no I/O. - * - `identityFromRemote` layers the machine store's fork/multi-remote - * overrides (`rt.repoIdentityOverrides`, keyed by observed remote URL) on - * top of `normalizeRemote`. It's synchronous — the one helper every - * non-derivation site uses (run.ts, buildInterceptRules, tests) — so - * fork-pinning works everywhere identity is computed from a remote in - * hand, not just at derivation time. - * - `deriveRepoIdentity` is the async entry point for when only a repo path - * is in hand: it shells out to git for the remote (never a sync spawn — - * this must stay safe to call from daemon contexts) and then routes - * through `identityFromRemote`, memoized per path so repeated callers in - * one process don't re-spawn git. - */ - -import { runCapture } from "../subprocess.ts"; -import { machineSettingsPath } from "../rt-paths.ts"; -import { readStore } from "./stores.ts"; - -// Full-URL forms: scheme://[user[:pass]@]host/path — https, ssh, git, http, ... -const URL_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(?:[^@/]+@)?([^/]+)\/(.+)$/; - -// scp-like scp syntax: [user@]host:path (git@gitlab.com:group/repo.git). -// Deliberately excludes anything starting with "/" (absolute local paths) -// so a Windows-drive-letter-free local remote never falsely matches. -const SCP_RE = /^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/; - -/** - * Pure normalization: `remote` → `host/path` (lowercase host, `.git` and - * embedded credentials stripped) or null when the remote doesn't match a - * recognized host form (local paths, garbage input). - */ -export function normalizeRemote(remote: string): string | null { - const trimmed = remote.trim(); - if (!trimmed) return null; - - let host: string | undefined; - let path: string | undefined; - - const urlMatch = URL_RE.exec(trimmed); - if (urlMatch) { - host = urlMatch[1]; - path = urlMatch[2]; - } else if (!trimmed.startsWith("/") && !trimmed.startsWith("~")) { - const scpMatch = SCP_RE.exec(trimmed); - if (scpMatch) { - host = scpMatch[1]; - path = scpMatch[2]; - } - } - - if (!host || !path) return null; - - const normalizedPath = path.replace(/\.git$/, "").replace(/^\/+/, "").replace(/\/+$/, ""); - if (!normalizedPath) return null; - - return `${host.toLowerCase()}/${normalizedPath}`; -} - -/** - * The sync helper every non-derivation call site uses: machine-store - * fork/multi-remote overrides (exact remote-URL match) then normalizeRemote. - * Reads the machine store fresh each call (files are small; store reads are - * not memoized anywhere in the resolver design). - */ -export function identityFromRemote(remote: string): string | null { - const store = readStore(machineSettingsPath()); - const overrides = store.global["rt.repoIdentityOverrides"]; - if (overrides !== null && typeof overrides === "object" && !Array.isArray(overrides)) { - const hit = (overrides as Record)[remote]; - if (typeof hit === "string") return hit; - } - return normalizeRemote(remote); -} - -// Per-process, per-repo-path memoization. Promise-valued so concurrent -// callers for the same path share one spawn rather than racing. -const memo = new Map>(); - -/** - * Async derivation from a repo path: `git -C config --get - * remote.origin.url`, then identityFromRemote (so overrides apply to - * derivation too). Never a sync spawn — safe to call from daemon contexts. - * - * Only a SUCCESSFUL derivation (non-null identity) is memoized, for the life - * of the process; a remote change after that first success is NOT picked up - * until clearIdentityMemo() — documented behavior, not a bug (see spec: - * derivation is a one-time capture per process, not a live poll). A FAILED - * derivation (no remote yet, git not initialized yet, etc.) is never cached - * and is retried on every subsequent call — a caller racing repo - * provisioning (mid-clone, daemon-startup) must not permanently lose - * identity for a path just because it asked too early. - */ -export async function deriveRepoIdentity(repoPath: string): Promise { - const cached = memo.get(repoPath); - if (cached) return cached; - - const result = await (async (): Promise => { - const spawned = await runCapture(["git", "-C", repoPath, "config", "--get", "remote.origin.url"]); - if (spawned.exitCode !== 0) return null; - const remote = spawned.stdout.trim(); - if (!remote) return null; - return identityFromRemote(remote); - })(); - - if (result !== null) memo.set(repoPath, Promise.resolve(result)); - return result; -} - -/** Test-only: clear the derivation memo so a test can force re-derivation. */ -export function clearIdentityMemo(): void { - memo.clear(); -} +// RT-50: repo identity normalization/derivation moved to @mattstack/rt-client. +// Every existing rt importer of lib/settings/identity.ts keeps working +// unchanged through this re-export barrel; the implementation lives at the +// path below. +export * from "../../packages/rt-client/src/settings/identity.ts"; diff --git a/lib/settings/registry.ts b/lib/settings/registry.ts index 0d9d1ec5..97b10e27 100644 --- a/lib/settings/registry.ts +++ b/lib/settings/registry.ts @@ -1,299 +1,6 @@ -/** - * The settings schema registry (RT-47): a static table describing every - * known `rt.*` settings key, plus lookup and validation helpers. - * - * This module is pure data — no file IO, no daemon dependency, safe to - * import anywhere (including the daemon thread). The resolver - * (lib/settings/resolve.ts) is the only consumer that layers store files on - * top of these defs; this file just says what a key IS and what a legal - * value for it looks like. - * - * `migrated: true` means the reader for this key goes through the resolver - * (wave 1: rt.roles, rt.intercepts, rt.worktrees, rt.repoIdentityOverrides). - * `migrated: false` keys still appear in `rt settings list` (so the full - * settings map is visible even before a key's reader has been ported), but - * `set` on them refuses — see the spec's "Schema registry" section for why - * writing a value nothing reads is the dishonesty class this design bans. - */ - -export type SettingScope = "user" | "team" | "machine"; - -export interface SettingDef { - key: string; - type: "string" | "number" | "boolean" | "object" | "array"; - scopes: SettingScope[]; - default?: unknown; - merge: "replace" | "deep"; - teamLocked?: boolean; - secret?: boolean; - repoScoped?: boolean; - migrated: boolean; - legacyFile?: string; - siblingCommand?: string; - pathGuardFields?: string[]; - description: string; -} - -const ALL_SCOPES: SettingScope[] = ["user", "team", "machine"]; - -const REGISTRY: SettingDef[] = [ - // --- migrated:true (wave 1) --------------------------------------------- - { - key: "rt.roles", - type: "object", - scopes: ALL_SCOPES, - merge: "deep", - repoScoped: true, - migrated: true, - pathGuardFields: ["hook"], - description: "Per-repo dev-role definitions: port pools, env passthrough, and the dev-server hook command.", - }, - { - key: "rt.intercepts", - type: "array", - scopes: ALL_SCOPES, - merge: "replace", - repoScoped: true, - migrated: true, - description: "Per-repo endpoint intercept rules consumed by rt intercept install.", - }, - { - key: "rt.worktrees", - type: "object", - scopes: ALL_SCOPES, - default: { onDeck: 0 }, - merge: "deep", - repoScoped: true, - migrated: true, - description: "Per-repo worktree pool config (onDeck size, ready steps, name pool); root/branchFormat/ready computed-or-empty in the reader.", - }, - { - key: "rt.repoIdentityOverrides", - type: "object", - scopes: ["machine"], - merge: "replace", - migrated: true, - description: "Map of observed remote URL to pinned repo identity, for forks/multi-remote repos on this machine.", - }, - { - key: "rt.repoRoots", - type: "array", - scopes: ["machine"], - default: [], - merge: "replace", - migrated: true, - description: - 'Directories rt scans for git repos (rt cd, run-outside-a-repo pickers). Entries may start with "~/" or use "${home}". One level deep, plus worktree-pool parent folders one level deeper.', - }, - - // --- migrated:false (wave 1 legacy-file keys) --------------------------- - { - key: "rt.llm", - type: "object", - scopes: ALL_SCOPES, - merge: "deep", - migrated: false, - legacyFile: "llm.json", - description: "LLM provider and model selection for rt's AI-assisted commands.", - }, - { - key: "rt.cron", - type: "object", - scopes: ALL_SCOPES, - merge: "deep", - migrated: false, - legacyFile: "cron.jsonc", - description: "Scheduled rt job definitions and their cron expressions.", - }, - { - key: "rt.repoTracking", - type: "object", - scopes: ALL_SCOPES, - merge: "deep", - migrated: false, - legacyFile: "repo-tracking.json", - description: "Which repos rt tracks for background sync and status polling.", - }, - { - key: "rt.notifications", - type: "object", - scopes: ALL_SCOPES, - merge: "deep", - migrated: false, - legacyFile: "notifications.json", - siblingCommand: "rt settings notifications", - description: "Desktop notification preferences (which events notify, sound on/off).", - }, - { - key: "rt.sync", - type: "object", - scopes: ALL_SCOPES, - merge: "deep", - repoScoped: true, - migrated: false, - legacyFile: "repos//sync.json", - description: "Branch sync behavior: fast-forward rules and stale-branch handling.", - }, - { - key: "rt.branchNaming", - type: "object", - scopes: ALL_SCOPES, - merge: "deep", - repoScoped: true, - migrated: false, - legacyFile: "repos//branch-naming.json", - description: "Templates rt uses to derive branch names from ticket identifiers.", - }, - { - key: "rt.variations", - type: "object", - scopes: ALL_SCOPES, - merge: "deep", - repoScoped: true, - migrated: false, - legacyFile: "repos//variations.json", - description: "Named parameter sets rt run can pick between for a command.", - }, - { - key: "rt.presets", - type: "object", - scopes: ALL_SCOPES, - merge: "deep", - repoScoped: true, - migrated: false, - legacyFile: "repos//presets/.json", - description: "Saved argument presets for frequently repeated rt commands.", - }, - { - key: "rt.dopplerTemplate", - type: "object", - scopes: ALL_SCOPES, - merge: "deep", - repoScoped: true, - migrated: false, - legacyFile: "repos//doppler-template.yaml", - description: "Template used to generate a repo's Doppler secrets config.", - }, - { - key: "rt.workspacePrefs", - type: "object", - scopes: ALL_SCOPES, - merge: "deep", - migrated: false, - legacyFile: "workspace-prefs.json", - description: "Per-machine editor/terminal preferences applied when opening a worktree.", - }, - { - key: "rt.runaway", - type: "object", - scopes: ALL_SCOPES, - merge: "deep", - migrated: false, - legacyFile: "runaway-config.json", - siblingCommand: "rt settings runaway", - description: "Thresholds for the runaway-process guard that kills stuck dev servers.", - }, - { - key: "rt.hooks", - type: "object", - scopes: ALL_SCOPES, - merge: "deep", - repoScoped: true, - migrated: false, - legacyFile: "repos//hooks.json", - description: "User-defined lifecycle hooks rt runs around commands (pre/post command scripts).", - }, -]; - -const BY_KEY: Map = new Map(REGISTRY.map((def) => [def.key, def])); - -/** Looks up a def by its flat namespaced key (e.g. "rt.roles"). */ -export function getDef(key: string): SettingDef | undefined { - return BY_KEY.get(key); -} - -/** Every registered def, in registry declaration order. */ -export function allDefs(): SettingDef[] { - return [...REGISTRY]; -} - -const PATH_LIKE = /^[/~]/; - -function typeOf(value: unknown): string { - if (value === null) return "null"; - if (Array.isArray(value)) return "array"; - return typeof value; -} - -/** - * Checks whether `value` is a legal value for `def`: the JSON-ish type - * matches def.type, and (when def.pathGuardFields is set) no guarded field - * anywhere in the value looks like an absolute path or home-relative path - * literal — those are only legal in the machine store's own file contents, - * never as a shared-scope value (spec: "No path type exists ... enforced for - * wave-1 keys on the hook field specifically"). - */ -export function validateValue(def: SettingDef, value: unknown): { ok: true } | { ok: false; reason: string } { - const typeCheck = checkType(def.type, value); - if (!typeCheck.ok) return typeCheck; - - if (def.pathGuardFields && def.pathGuardFields.length > 0) { - const violation = findPathGuardViolation(value, def.pathGuardFields); - if (violation) { - return { - ok: false, - reason: `field "${violation.field}" looks like a path literal ("${violation.value}"); path literals are only legal in the machine store`, - }; - } - } - - return { ok: true }; -} - -function checkType(type: SettingDef["type"], value: unknown): { ok: true } | { ok: false; reason: string } { - switch (type) { - case "string": - return typeof value === "string" ? { ok: true } : { ok: false, reason: `expected string, got ${typeOf(value)}` }; - case "number": - return typeof value === "number" ? { ok: true } : { ok: false, reason: `expected number, got ${typeOf(value)}` }; - case "boolean": - return typeof value === "boolean" ? { ok: true } : { ok: false, reason: `expected boolean, got ${typeOf(value)}` }; - case "array": - return Array.isArray(value) ? { ok: true } : { ok: false, reason: `expected array, got ${typeOf(value)}` }; - case "object": - return value !== null && typeof value === "object" && !Array.isArray(value) - ? { ok: true } - : { ok: false, reason: `expected object, got ${typeOf(value)}` }; - } -} - -/** - * Walks a value looking for any object field named in `guardFields` whose - * string value looks like a path literal (leading `/` or `~`). Recurses - * through plain objects and arrays; best-effort (spec: "enforced for wave-1 - * keys on the hook field specifically, best-effort elsewhere"). - */ -function findPathGuardViolation( - value: unknown, - guardFields: string[], -): { field: string; value: string } | null { - if (Array.isArray(value)) { - for (const item of value) { - const hit = findPathGuardViolation(item, guardFields); - if (hit) return hit; - } - return null; - } - - if (value !== null && typeof value === "object") { - for (const [field, fieldValue] of Object.entries(value as Record)) { - if (guardFields.includes(field) && typeof fieldValue === "string" && PATH_LIKE.test(fieldValue)) { - return { field, value: fieldValue }; - } - const hit = findPathGuardViolation(fieldValue, guardFields); - if (hit) return hit; - } - } - - return null; -} +// RT-50: the settings schema registry moved to @mattstack/rt-client, split +// into machinery (lookup + validation, re-exported here) and the def table +// (packages/rt-client/src/settings/registry-defs.ts). Every existing rt +// importer of lib/settings/registry.ts keeps working unchanged through this +// re-export barrel. +export * from "../../packages/rt-client/src/settings/registry-machinery.ts"; diff --git a/lib/settings/resolve.ts b/lib/settings/resolve.ts index 9a7ddc44..2d11df61 100644 --- a/lib/settings/resolve.ts +++ b/lib/settings/resolve.ts @@ -1,679 +1,4 @@ -/** - * The settings resolver (RT-47): one read path that layers the four stores, - * the legacy per-repo file and the registry default into a single answer plus - * the provenance that explains it. - * - * Scope ladder, weakest → strongest: - * - * default < legacy < team < user < team.repo < user.repo < machine < machine.repo - * - * `legacy` is the pre-migration per-tool file for a key (wave 1: the three - * `repos//config.json` keys). It beats the registry default and loses to - * every authored store, and it carries real provenance so migration progress is - * observable rather than folklore. - * - * Merge is per-key schema, never global (`SettingDef.merge`): - * - `replace` — the strongest valid scope wins atomically; provenance has - * exactly one entry. - * - `deep` — object values overlay field-by-field walking weakest → strongest; - * arrays and scalars inside a deep key still replace atomically. Provenance - * lists every scope that still owns at least one leaf of the resolved value, - * weakest-first — a scope whose every field was overridden is NOT listed - * (same honesty rule that makes `replace` provenance length 1). - * - * Degrade rules (teammates run version-skewed binaries; one unknown key in the - * team store must never brick resolution): - * - explicit `get`/`explain` of an unregistered key → throw. - * - unregistered keys FOUND in files → warn + skip, surfaced by `listSettings` - * with `unregistered: true`. - * - a registered key whose found value fails validation → warn + skip THAT - * scope only, labeled `invalid` in list/explain; weaker and stronger scopes - * still apply. - * - * Three deliberate decisions this file makes that the spec left to the - * implementation: - * 1. **The path-literal guard is scope-aware.** `validateValue`'s guarded - * fields (`rt.roles.hook`) are only illegal in SHARED scopes. The machine - * store is explicitly allowed path literals, and the legacy per-repo file - * is full of them today — guarding those would reject exactly the values - * wave 1 has to keep reading. So team/user rungs get the full check, - * machine/legacy rungs get the type check alone. - * 2. **A value found in a store the def does not allow is skipped**, labeled - * like any other invalid value (`rt.repoIdentityOverrides` is machine-only; - * honouring a team-store copy of it would defeat the schema). - * 3. **`explain` shows values AS AUTHORED** (never expanded) because its job - * is to say what is in which file, and **`list` degrades** an unexpandable - * value to its raw form with an `expandError` label rather than throwing — - * one bad value must not brick a survey of every key. `get` is the loud - * one: an unsatisfiable closed-set variable throws. - * - * The resolver is daemon-FREE and sync: no spawns anywhere, repo identity is a - * pre-derived input (see identity.ts for the async derivation). Store files are - * parsed fresh per call — they are small, and memoization is a later - * optimization that would need invalidation this wave does not have. - * - * Writes (`setSetting`) land in a later task; this module is read-side only. - */ - -import { homedir } from "os"; -import { join } from "path"; -import { readJson } from "../json-store.ts"; -import { - machineSettingsPath, - repoDataDir, - teamSettingsPath, - teamsDir, - userSettingsPath, -} from "../rt-paths.ts"; -import { allDefs, getDef, validateValue, type SettingDef, type SettingScope } from "./registry.ts"; -import { listTeams, readStore, type StoreFile } from "./stores.ts"; - -// ─── Public types ──────────────────────────────────────────────────────────── - -export type Scope = - | "machine.repo" - | "machine" - | "user.repo" - | "team.repo" - | "user" - | "team" - | "legacy" - | "default"; - -/** The scope ladder, weakest first. Also the order every result is built in. */ -export const SCOPE_ORDER: Scope[] = [ - "default", - "legacy", - "team", - "user", - "team.repo", - "user.repo", - "machine", - "machine.repo", -]; - -export interface Provenance { - scope: Scope; - /** The file the value came from; null for the registry default. */ - file: string | null; -} - -export interface ResolveOpts { - /** Normalized repo identity (identity.ts). Null/absent = repo rungs are unreachable. */ - repoIdentity?: string | null; - /** Expand closed-set variables in the resolved value. Default true. */ - expand?: boolean; - expandCtx?: { repoRoot?: string; worktree?: string }; - /** Supplying a repoName enables the legacy rung for keys the reader maps. */ - legacy?: { repoName?: string }; -} - -export interface Resolved { - value: T; - /** ALWAYS an array, weakest-first. Length 1 for replace keys. */ - provenance: Provenance[]; -} - -/** A scope whose authored value was found but refused (type, path guard, or store). */ -export interface InvalidScope { - scope: Scope; - file: string | null; - reason: string; -} - -export interface ListedSetting { - key: string; - value: unknown; - provenance: Provenance[]; - migrated: boolean; - /** Present only for keys found in files but absent from the registry. */ - unregistered?: true; - /** Scopes skipped during resolution, with the reason each was refused. */ - invalid?: InvalidScope[]; - /** Set when the value could not be expanded here; `value` is then raw. */ - expandError?: string; -} - -export interface ExplainRow { - scope: Scope; - file: string | null; - present: boolean; - /** The value AS AUTHORED — never variable-expanded. */ - value?: unknown; - /** Set when the value was ignored because the key is teamLocked. */ - shadowed?: "teamLocked"; - /** Set when the value was refused; the reason it was refused. */ - invalid?: string; -} - -export interface ExpandCtx { - repoRoot?: string; - worktree?: string; - home: string; - teamsDir: string; -} - -// ─── The legacy rung ───────────────────────────────────────────────────────── - -export interface LegacyReader { - (key: string, repoName: string): unknown | undefined; -} - -/** - * The three wave-1 keys and the `repos//config.json` field each one used - * to live in. A key that is not in this map has no legacy rung. - */ -const LEGACY_KEY_MAP: Record = { - "rt.roles": "roles", - "rt.intercepts": "intercepts", - "rt.worktrees": "worktrees", -}; - -function legacyFilePath(repoName: string): string { - return join(repoDataDir(repoName), "config.json"); -} - -/** - * The shipped legacy reader: the named key's field out of the per-repo - * config.json. `readJson` already degrades a missing or malformed file to the - * fallback, which is exactly the "no legacy value" answer we want. - */ -export const defaultLegacyReader: LegacyReader = (key, repoName) => { - const field = LEGACY_KEY_MAP[key]; - if (field === undefined) return undefined; - const raw = readJson>(legacyFilePath(repoName), {}); - return raw[field]; -}; - -let legacyReader: LegacyReader = defaultLegacyReader; - -/** - * TEST SEAM ONLY. Production code uses the shipped reader; pass - * `defaultLegacyReader` back to restore it. - */ -export function setLegacyReader(fn: LegacyReader): void { - legacyReader = fn; -} - -// ─── Variables ─────────────────────────────────────────────────────────────── - -const VAR_RE = /\$\{([^}]*)\}/g; -const TEAM_VAR_RE = /^team:(.+)$/; - -/** - * Replaces ONLY `${repoRoot}`, `${worktree}`, `${home}` and `${team:}`. - * Every other `${...}` passes through verbatim — domain templates like the - * interceptor's `${port}` are not ours to expand, and the same string may hold - * both kinds, so substitution is per-occurrence. `${team:}` is lexical: - * `/` with no existence check (a missing team surfaces at use - * time through the consumer's own fail-open path), but the name must be a - * single directory segment — see `teamPath`. A closed-set variable with no - * context in `ctx` throws — silently emitting a half-expanded path is the - * dishonesty this design bans. - * - * Recurses through arrays and plain objects; non-strings pass through. Never - * mutates its input. - */ -export function expandVariables(value: unknown, ctx: ExpandCtx): unknown { - if (typeof value === "string") return expandString(value, ctx); - if (Array.isArray(value)) return value.map((item) => expandVariables(item, ctx)); - if (isPlainObject(value)) { - const out: Record = {}; - for (const [k, v] of Object.entries(value)) out[k] = expandVariables(v, ctx); - return out; - } - return value; -} - -function expandString(input: string, ctx: ExpandCtx): string { - return input.replace(VAR_RE, (match, name: string) => { - if (name === "home") return ctx.home; - if (name === "repoRoot") return required(ctx.repoRoot, "repoRoot", "a repo path"); - if (name === "worktree") return required(ctx.worktree, "worktree", "a worktree path"); - const team = TEAM_VAR_RE.exec(name); - if (team) return teamPath(ctx.teamsDir, team[1] as string); - return match; // not ours — pass through verbatim - }); -} - -/** - * `${team:}` → `/`, but only for a name that is a single - * directory segment. `` is a team NAME, and `join()` normalizes away - * `..`, so `${team:../../.ssh}` would quietly resolve to a path OUTSIDE the - * teams dir — a store value (a team store's own, even) that reads or executes - * from anywhere on disk while still looking like a team-relative reference. - * Any `/`, `\` or `..` therefore throws, on the same closed-set footing as an - * unsatisfiable `${repoRoot}`: `get` surfaces it, `list` degrades that one - * value to an `expandError`, and no half-expanded path is ever emitted. - */ -function teamPath(teamsDir: string, name: string): string { - if (name.includes("/") || name.includes("\\") || name.includes("..")) { - throw new Error( - `rt: cannot expand \${team:${name}} — a team name must be a single directory segment (no "/", "\\" or "..")`, - ); - } - return join(teamsDir, name); -} - -function required(value: string | undefined, name: string, needs: string): string { - if (value === undefined || value === "") { - throw new Error(`rt: cannot expand \${${name}} — this setting was resolved without ${needs}`); - } - return value; -} - -// ─── Store reading ─────────────────────────────────────────────────────────── - -interface StoreBundle { - user: StoreFile; - machine: StoreFile; - /** One per team that has a local settings file, alphabetical (wave 1: overlay all). */ - teams: StoreFile[]; -} - -function readStores(): StoreBundle { - return { - user: readStore(userSettingsPath()), - machine: readStore(machineSettingsPath()), - teams: [...listTeams()].sort().map((team) => readStore(teamSettingsPath(team))), - }; -} - -// ─── Slots: every rung a key could come from, weakest-first ────────────────── - -interface Slot { - scope: Scope; - file: string | null; - present: boolean; - value?: unknown; -} - -function collectSlots(def: SettingDef, stores: StoreBundle, opts: ResolveOpts): Slot[] { - const slots: Slot[] = []; - const identity = opts.repoIdentity ?? null; - const useRepo = def.repoScoped === true && typeof identity === "string" && identity !== ""; - const repoSection = (store: StoreFile): Record | undefined => - useRepo ? store.repos[identity as string] : undefined; - - const push = (scope: Scope, file: string | null, section: Record | undefined) => { - const value = section?.[def.key]; - if (value === undefined) slots.push({ scope, file, present: false }); - else slots.push({ scope, file, present: true, value }); - }; - - /** - * Wave 1 overlays EVERY cloned team, alphabetically, so the result is - * deterministic; multi-team precedence is explicitly deferred (spec: out of - * scope — one team exists today). With no team cloned at all we still emit - * one absent rung so `explain` shows the ladder in full. - */ - const pushTeams = (scope: Scope, section: (store: StoreFile) => Record | undefined) => { - if (stores.teams.length === 0) { - slots.push({ scope, file: null, present: false }); - return; - } - for (const store of stores.teams) push(scope, store.file, section(store)); - }; - - // default — cloned so a caller mutating the resolved value cannot corrupt - // the registry's shared def object. - slots.push( - def.default === undefined - ? { scope: "default", file: null, present: false } - : { scope: "default", file: null, present: true, value: structuredClone(def.default) }, - ); - - // legacy — only reachable when the caller supplies the repo NAME (repos.json - // is the name→path registry; the name/identity bridge is the caller's job). - const repoName = opts.legacy?.repoName; - if (repoName) { - const value = legacyReader(def.key, repoName); - const file = legacyFilePath(repoName); - slots.push( - value === undefined - ? { scope: "legacy", file, present: false } - : { scope: "legacy", file, present: true, value }, - ); - } else { - slots.push({ scope: "legacy", file: null, present: false }); - } - - // The ladder itself, weakest → strongest. Repo rungs are omitted entirely - // when they are unreachable (key not repoScoped, or no identity in hand) — - // an unreachable rung in `explain` would be noise, not honesty. - pushTeams("team", (store) => store.global); - push("user", stores.user.file, stores.user.global); - if (useRepo) pushTeams("team.repo", repoSection); - if (useRepo) push("user.repo", stores.user.file, repoSection(stores.user)); - push("machine", stores.machine.file, stores.machine.global); - if (useRepo) push("machine.repo", stores.machine.file, repoSection(stores.machine)); - - return slots; -} - -// ─── Resolution ────────────────────────────────────────────────────────────── - -interface Resolution { - value: unknown; - provenance: Provenance[]; - invalid: InvalidScope[]; - rows: ExplainRow[]; -} - -const TEAM_LOCKED_SCOPES: Scope[] = ["default", "team", "team.repo"]; - -/** The store a scope's value is authored in — the rung's write-side scope. */ -function baseScope(scope: Scope): SettingScope | null { - if (scope === "team" || scope === "team.repo") return "team"; - if (scope === "user" || scope === "user.repo") return "user"; - if (scope === "machine" || scope === "machine.repo") return "machine"; - return null; // default and legacy are not authored in a store -} - -/** - * The path-literal guard applies to SHARED scopes only — the machine store is - * the one place path literals are legal, and the legacy per-repo file is full - * of them today. - */ -function validateForScope( - def: SettingDef, - scope: Scope, - value: unknown, -): { ok: true } | { ok: false; reason: string } { - const shared = scope === "team" || scope === "user" || scope === "team.repo" || scope === "user.repo"; - return validateValue(shared ? def : { ...def, pathGuardFields: undefined }, value); -} - -function resolveDef(def: SettingDef, stores: StoreBundle, opts: ResolveOpts): Resolution { - const slots = collectSlots(def, stores, opts); - const rows: ExplainRow[] = []; - const invalid: InvalidScope[] = []; - const applied: Array<{ scope: Scope; file: string | null; value: unknown }> = []; - - for (const slot of slots) { - const row: ExplainRow = { scope: slot.scope, file: slot.file, present: slot.present }; - if (!slot.present) { - rows.push(row); - continue; - } - row.value = slot.value; - - // teamLocked: team.repo > team > default and nothing else. Other scopes' - // values are reported, never applied. - if (def.teamLocked && !TEAM_LOCKED_SCOPES.includes(slot.scope)) { - row.shadowed = "teamLocked"; - rows.push(row); - continue; - } - - // A key authored in a store its def does not list is not this key. - const base = baseScope(slot.scope); - if (base !== null && !def.scopes.includes(base)) { - const reason = `not settable in the ${base} store (allowed: ${def.scopes.join(", ")})`; - row.invalid = reason; - invalid.push({ scope: slot.scope, file: slot.file, reason }); - rows.push(row); - continue; - } - - // The registry default is trusted; everything read off disk is checked. - if (slot.scope !== "default") { - const check = validateForScope(def, slot.scope, slot.value); - if (!check.ok) { - row.invalid = check.reason; - invalid.push({ scope: slot.scope, file: slot.file, reason: check.reason }); - rows.push(row); - continue; - } - } - - rows.push(row); - applied.push({ scope: slot.scope, file: slot.file, value: slot.value }); - } - - const merged = mergeApplied(def, applied); - return { value: merged.value, provenance: merged.provenance, invalid, rows }; -} - -function mergeApplied( - def: SettingDef, - applied: Array<{ scope: Scope; file: string | null; value: unknown }>, -): { value: unknown; provenance: Provenance[] } { - if (applied.length === 0) return { value: undefined, provenance: [] }; - - // Deep merge is only meaningful for objects; a `deep` def with any other - // type — or a non-object layer, only reachable through a malformed registry - // default since every value read off disk is type-checked — falls back to - // replace rather than inventing semantics for it. - if (def.merge === "deep" && def.type === "object") { - const objectLayers = applied.filter((layer) => isPlainObject(layer.value)); - if (objectLayers.length > 0) { - const { value, contributors } = deepMerge(objectLayers.map((layer) => layer.value)); - return { - value, - provenance: contributors.map((i) => { - const layer = objectLayers[i] as (typeof applied)[number]; - return { scope: layer.scope, file: layer.file }; - }), - }; - } - } - - const winner = applied[applied.length - 1] as (typeof applied)[number]; - return { value: winner.value, provenance: [{ scope: winner.scope, file: winner.file }] }; -} - -// ─── Deep merge with per-leaf attribution ──────────────────────────────────── - -// Leaf paths are joined with NUL so a field name containing a dot cannot -// collide with a nested path of the same spelling. -const PATH_SEP = "\u0000"; - -/** - * Overlays object layers weakest → strongest, tracking which layer owns each - * surviving leaf. Arrays and scalars replace atomically (an array IS a leaf); - * objects recurse. `contributors` is the ascending list of layer indexes that - * still own at least one leaf of the result. - */ -function deepMerge(layers: unknown[]): { value: Record; contributors: number[] } { - const owner = new Map(); - let acc: Record = {}; - - layers.forEach((layer, index) => { - acc = overlay(acc, layer as Record, owner, index, ""); - }); - - const contributors = [...new Set(owner.values())].sort((a, b) => a - b); - return { value: acc, contributors }; -} - -function overlay( - base: Record, - over: Record, - owner: Map, - index: number, - prefix: string, -): Record { - const out: Record = { ...base }; - - for (const [key, value] of Object.entries(over)) { - const path = prefix === "" ? key : `${prefix}${PATH_SEP}${key}`; - const current = out[key]; - - if (isPlainObject(value) && isPlainObject(current)) { - out[key] = overlay(current, value, owner, index, path); - continue; - } - - out[key] = value; - clearOwners(owner, path); - registerLeaves(value, path, owner, index); - } - - return out; -} - -function clearOwners(owner: Map, path: string): void { - owner.delete(path); - const under = `${path}${PATH_SEP}`; - for (const existing of [...owner.keys()]) { - if (existing.startsWith(under)) owner.delete(existing); - } -} - -/** - * Records ownership at LEAF granularity: an object is walked into so that a - * stronger layer overriding every one of its fields takes the whole thing over - * (and the weaker layer correctly drops out of provenance). - */ -function registerLeaves(value: unknown, path: string, owner: Map, index: number): void { - if (isPlainObject(value)) { - const entries = Object.entries(value); - if (entries.length > 0) { - for (const [key, child] of entries) { - registerLeaves(child, `${path}${PATH_SEP}${key}`, owner, index); - } - return; - } - } - owner.set(path, index); -} - -function isPlainObject(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -// ─── Public API ────────────────────────────────────────────────────────────── - -function unknownKey(key: string): Error { - return new Error(`rt: unknown setting "${key}" — not in the settings registry (see \`rt settings list\`)`); -} - -function expandCtxFrom(opts: ResolveOpts): ExpandCtx { - return { - repoRoot: opts.expandCtx?.repoRoot, - worktree: opts.expandCtx?.worktree, - home: process.env.HOME ?? homedir(), - teamsDir: teamsDir(), - }; -} - -function warnInvalid(key: string, entry: InvalidScope): void { - console.warn( - `rt: ignoring "${key}" from the ${entry.scope} scope (${entry.file ?? "no file"}): ${entry.reason}`, - ); -} - -/** - * Resolves one key across the whole ladder. Throws for an unregistered key — - * an explicit get of something rt has never heard of is a caller bug, not a - * degrade (contrast: unknown keys FOUND in files, which only warn). - */ -export function getSetting(key: string, opts: ResolveOpts = {}): Resolved { - const def = getDef(key); - if (!def) throw unknownKey(key); - - const resolution = resolveDef(def, readStores(), opts); - for (const entry of resolution.invalid) warnInvalid(key, entry); - - const shouldExpand = opts.expand ?? true; - const value = - shouldExpand && resolution.value !== undefined - ? expandVariables(resolution.value, expandCtxFrom(opts)) - : resolution.value; - - return { value: value as T, provenance: resolution.provenance }; -} - -/** - * Every registered key resolved (registry order), then every unregistered key - * found in the stores (alphabetical). Nothing here throws: a survey of the - * whole settings map must survive one bad value, so an unexpandable value - * degrades to its raw form plus an `expandError` label. - */ -export function listSettings(opts: ResolveOpts = {}): ListedSetting[] { - const stores = readStores(); - const ctx = expandCtxFrom(opts); - const shouldExpand = opts.expand ?? true; - const out: ListedSetting[] = []; - - for (const def of allDefs()) { - const resolution = resolveDef(def, stores, opts); - for (const entry of resolution.invalid) warnInvalid(def.key, entry); - - const listed: ListedSetting = { - key: def.key, - value: resolution.value, - provenance: resolution.provenance, - migrated: def.migrated, - }; - if (resolution.invalid.length > 0) listed.invalid = resolution.invalid; - - if (shouldExpand && resolution.value !== undefined) { - try { - listed.value = expandVariables(resolution.value, ctx); - } catch (err) { - listed.expandError = (err as Error).message; - console.warn(`rt: showing "${def.key}" unexpanded — ${listed.expandError}`); - } - } - - out.push(listed); - } - - out.push(...listUnregistered(stores, opts)); - return out; -} - -/** - * Keys present in a store file that the registry has never heard of. They are - * never merged (there is no def to say how) — the strongest scope holding one - * is reported as-is, so a teammate's newer key is visible rather than silently - * dropped. - */ -function listUnregistered(stores: StoreBundle, opts: ResolveOpts): ListedSetting[] { - const identity = opts.repoIdentity ?? null; - const found = new Map(); - - const scan = (scope: Scope, file: string, section: Record | undefined) => { - for (const [key, value] of Object.entries(section ?? {})) { - if (getDef(key)) continue; - found.set(key, { scope, file, value }); // later (stronger) scans win - } - }; - const repoSection = (store: StoreFile) => - typeof identity === "string" && identity !== "" ? store.repos[identity] : undefined; - - for (const store of stores.teams) scan("team", store.file, store.global); - scan("user", stores.user.file, stores.user.global); - for (const store of stores.teams) scan("team.repo", store.file, repoSection(store)); - scan("user.repo", stores.user.file, repoSection(stores.user)); - scan("machine", stores.machine.file, stores.machine.global); - scan("machine.repo", stores.machine.file, repoSection(stores.machine)); - - return [...found.entries()] - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, hit]) => { - console.warn( - `rt: unregistered setting "${key}" in ${hit.file} — ignoring it (this rt may be older than the store)`, - ); - return { - key, - value: hit.value, - provenance: [{ scope: hit.scope, file: hit.file }], - migrated: false, - unregistered: true as const, - }; - }); -} - -/** - * One row per reachable rung, weakest-first, with values AS AUTHORED. Repo - * rungs are omitted entirely when the key is not repoScoped or no identity was - * supplied — showing rungs that could never apply would be noise, not honesty. - */ -export function explainSetting(key: string, opts: ResolveOpts = {}): ExplainRow[] { - const def = getDef(key); - if (!def) throw unknownKey(key); - return resolveDef(def, readStores(), opts).rows; -} +// RT-50: the settings resolver moved to @mattstack/rt-client. Every existing +// rt importer of lib/settings/resolve.ts keeps working unchanged through this +// re-export barrel; the implementation lives at the path below. +export * from "../../packages/rt-client/src/settings/resolve.ts"; diff --git a/lib/settings/stores.ts b/lib/settings/stores.ts index 99b27c79..3580dafc 100644 --- a/lib/settings/stores.ts +++ b/lib/settings/stores.ts @@ -1,129 +1,4 @@ -/** - * Reading the four settings store files (RT-47). - * - * `readStore` is the shared "raw JSONC → {global, repos}" step every store - * (user/team/machine) goes through before the resolver layers them by scope. - * It uses jsonc-parser (`parse`) rather than lib/jsonc.ts's stripJsonc: this - * is the one place in rt that also needs to WRITE these files back with - * comments/formatting intact (via jsonc-parser's `modify`/`applyEdits`, added - * alongside `setSetting` in resolve.ts), and both directions should go - * through the same library. stripJsonc keeps its existing callers. - * - * A store file is honest-degrade, not throw-on-read: absent, empty, or - * malformed all resolve to an empty store rather than crashing a caller that - * just wants "whatever settings exist" (teammates run version-skewed - * binaries; a store file with content this rt can't parse must not brick - * every settings read). - */ - -import { existsSync, readdirSync, readFileSync, statSync, type Dirent } from "fs"; -import { parse, type ParseError } from "jsonc-parser"; -import { join } from "path"; -import { teamsDir, teamSettingsPath } from "../rt-paths.ts"; - -export interface StoreFile { - /** Top-level keys other than "repos" — the global scope for this store. */ - global: Record; - /** The "repos" object, keyed by repo identity. Empty if absent. */ - repos: Record>; - /** The path this store was read from (echoed back for provenance). */ - file: string; - /** False only when the file does not exist at all. */ - exists: boolean; -} - -const EMPTY_STORE = (file: string, exists: boolean): StoreFile => ({ - global: {}, - repos: {}, - file, - exists, -}); - -/** - * Reads and parses one settings store file. Never throws: - * - missing file → `{ exists: false }`, empty maps. - * - present but malformed (parse errors, or a root that isn't a JSON - * object) → `{ exists: true }`, empty maps, one console.warn. - * - present and well-formed → `{ exists: true }`, split into - * `global`/`repos`. - */ -export function readStore(file: string): StoreFile { - if (!existsSync(file)) return EMPTY_STORE(file, false); - - let raw: string; - try { - raw = readFileSync(file, "utf8"); - } catch (err) { - console.warn(`rt: failed to read settings store ${file}, ignoring: ${(err as Error).message}`); - return EMPTY_STORE(file, true); - } - - if (raw.trim() === "") return EMPTY_STORE(file, true); - - const errors: ParseError[] = []; - const root = parse(raw, errors, { allowTrailingComma: true }); - - if (errors.length > 0 || root === undefined || typeof root !== "object" || Array.isArray(root)) { - console.warn(`rt: malformed settings store ${file}, ignoring (treating as empty)`); - return EMPTY_STORE(file, true); - } - - const { repos, ...global } = root as Record; - const reposIsValid = repos !== undefined && typeof repos === "object" && repos !== null && !Array.isArray(repos); - - if (repos !== undefined && !reposIsValid) { - console.warn(`rt: malformed "repos" section in settings store ${file}, ignoring repo sections (global keys still apply)`); - } - - const reposValid = reposIsValid ? (repos as Record>) : {}; - - return { global, repos: reposValid, file, exists: true }; -} - -/** - * Names of every team that has a local settings store — i.e. subdirectories - * of teamsDir() that contain mattstack/settings.jsonc. A team dir without a - * settings file (a clone mid-setup, or an unrelated directory) is not yet a - * team as far as the resolver is concerned. - * - * Honest-degrade like readStore, and for a sharper reason: this scan is on the - * path of EVERY settings resolution, so one bad directory entry must never - * brick `rt settings` or any reader behind it. A team clone that was symlinked - * in and later moved leaves a dangling symlink here, and the follow-the-link - * stat that keeps symlinked clones working throws ENOENT on exactly that — so - * the scan is guarded twice: around the readdir (an unreadable teams dir means - * no teams), and around EACH entry (a dangling link, an EACCES, or a stat that - * loses a race with a concurrent move skips that entry and leaves the healthy - * teams intact). - */ -export function listTeams(): string[] { - const dir = teamsDir(); - if (!existsSync(dir)) return []; - - let entries: Dirent[]; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch (err) { - console.warn(`rt: failed to list teams in ${dir}, treating as no teams: ${(err as Error).message}`); - return []; - } - - const teams: string[] = []; - for (const entry of entries) { - try { - // isDirectory() is false for a symlink, but a symlinked team clone is a - // real team — those resolve through stat, which is also what throws on a - // dangling link, hence the per-entry try. - const isDir = - entry.isDirectory() || - (entry.isSymbolicLink() && statSync(join(dir, entry.name)).isDirectory()); - if (!isDir) continue; - if (existsSync(teamSettingsPath(entry.name))) teams.push(entry.name); - } catch (err) { - console.warn( - `rt: skipping unreadable teams entry ${join(dir, entry.name)}: ${(err as Error).message}`, - ); - } - } - return teams; -} +// RT-50: the settings store readers moved to @mattstack/rt-client. Every +// existing rt importer of lib/settings/stores.ts keeps working unchanged +// through this re-export barrel; the implementation lives at the path below. +export * from "../../packages/rt-client/src/settings/stores.ts"; diff --git a/lib/settings/write.ts b/lib/settings/write.ts index 98f0ba6b..5a2beb3a 100644 --- a/lib/settings/write.ts +++ b/lib/settings/write.ts @@ -1,292 +1,4 @@ -/** - * The settings write path (RT-47): `setSetting` — a single, comment-preserving - * write into one of the three AUTHORED stores (user/team/machine; `legacy` and - * `default` are read-only rungs and never appear here). - * - * Writes go through jsonc-parser's `modify`/`applyEdits` rather than - * parse-mutate-stringify, so existing comments and formatting in the store - * file survive a write to an unrelated key (verified: `modify` only rewrites - * the minimal edit range needed for the touched path; everything else in the - * document — including comments — is untouched text). - * - * JSONPath segments are literal object keys, not `.`-namespaced walks: a - * global write targets `[key]` (e.g. `["rt.llm"]`) and a repoScoped write - * targets `["repos", identity, key]`. A dotted key like `"rt.roles"` is one - * path segment, not two — jsonc-parser never splits on `.` (verified with a - * throwaway script against the installed 3.3.1). Missing parents (`"repos"`, - * or the identity's section under it) are created by `modify` itself, and - * that creation is comment-safe — no special-casing needed here. - * - * Creating an absent store file (user/machine only — see "Team selection" - * below for why team stores are never auto-created): the file is seeded - * in-memory as `// header comment\n{}\n` BEFORE the first `modify` call. - * This is required, not cosmetic — a verified footgun: running `modify` on a - * comment-only document with NO braces at all (e.g. just `// header\n`) - * places the new object first and re-emits the header AFTER the closing - * brace, which is backwards. Seeding an empty object ahead of time gives - * `modify` real JSON structure to edit into, and the header comment stays - * exactly where it was written, above the object. - * - * ── Refusals ──────────────────────────────────────────────────────────── - * In order: unregistered key, migrated:false (naming `def.legacyFile` and, - * when present, `def.siblingCommand`), a scope the def does not list, a - * repoIdentity supplied for a key that is not `repoScoped`, a value that - * fails `registry.validateValue` (type check + the path-literal guard), and - * finally — only for `scope: "team"` — a team store that cannot be resolved - * (see below). Filesystem-touching checks (team resolution) run last, after - * every pure/in-memory refusal, so a bad call never creates or touches a - * file it was going to refuse anyway. - * - * ── The path-literal guard is scope-aware ────────────────────────────── - * Mirrors `resolve.ts`'s `validateForScope`: `def.pathGuardFields` (wave 1: - * `rt.roles.hook`) is enforced at `user` and `team` scope, where a path - * literal would silently stop applying the moment a teammate's checkout (or - * this developer's own machine) sits at a different path. `machine` scope is - * exempt — it is the one store where a path literal is the CORRECT way to - * express something local-only, so writes there skip the guard entirely - * (implemented by stripping `pathGuardFields` before calling - * `validateValue`, same trick `resolve.ts` uses on the read side). - * - * ── Team selection (a design decision this task made, per the brief) ── - * The base signature (`setSetting(key, value, scope, opts?)`) is extended - * here with `opts.team`, an explicit team NAME to target. Selection rule for - * `scope: "team"`: - * - `opts.team` given → that team's store; refuse if it has no local - * settings file (a team dir can exist mid-clone without one — see - * `stores.ts#listTeams`). - * - `opts.team` omitted, exactly one team has a local store → use it. - * - `opts.team` omitted, zero or multiple teams have a local store → - * refuse with a clear error (asking for `opts.team` in the multiple - * case). Wave 1 ships exactly one team, so this is the common path; the - * alternative of silently picking "the first team alphabetically" was - * considered and rejected — guessing which team's shared file to mutate - * is exactly the silent-oracle behavior this design bans elsewhere. - * A team store is NEVER auto-created by `setSetting` — team stores are - * seeded by the migration/orchestrator step and live in a repo that needs a - * commit+push to reach teammates; conjuring one here would produce an - * uncommitted, unshared file masquerading as team state. - * - * Every successful `scope: "team"` write prints one reminder line to - * stderr: the edit only exists in this local clone until it is committed - * and pushed. No such reminder for `user`/`machine` (nothing to push there - * in wave 1). - * - * ── Malformed stores refuse rather than edit around the damage ───────── - * An existing store's on-disk text is parsed and checked (`assertEditableJsonc`) - * before `modify` ever runs: real parse errors, a non-object root, or a - * duplicate key anywhere in the tree all refuse with one message naming the - * file. The duplicate-key case is the sharp one — it is not a parse error at - * all (JSON's grammar permits it), but `modify` edits the FIRST occurrence by - * offset while every reader takes the LAST, so a naive edit-in-place would - * report success while the effective value never changes, and the file would - * still degrade to empty on the next `readStore`. Refusing is the only - * option that doesn't either lie about success or write a still-broken file. - * - * ── Writes are write-temp-then-rename ─────────────────────────────────── - * Mirrors `lib/json-store.ts`'s `writeJson`: the edited text is written to a - * `...tmp` file in the SAME directory, then renamed onto - * the real path — stores never tear, matching the rest of rt's persistence - * (the machine store especially has no git fallback to recover a partial - * write from). The tmp file carries the edited TEXT exactly as `applyEdits` - * produced it, never round-tripped through `JSON.stringify` — that's what - * keeps comments alive. - */ - -import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "fs"; -import { applyEdits, modify, parseTree, type JSONPath, type Node, type ParseError } from "jsonc-parser"; -import { randomBytes } from "crypto"; -import { dirname } from "path"; -import { machineSettingsPath, teamSettingsPath, userSettingsPath } from "../rt-paths.ts"; -import { getDef, validateValue, type SettingDef, type SettingScope } from "./registry.ts"; -import { listTeams } from "./stores.ts"; - -export interface SetSettingOpts { - /** Normalized repo identity — required to target a repoScoped key's `repos.` section. */ - repoIdentity?: string; - /** - * Which team's local store to write into, for `scope: "team"`. See the - * module doc's "Team selection" section. Ignored for `user`/`machine`. - */ - team?: string; -} - -const FORMAT = { tabSize: 2, insertSpaces: true, eol: "\n" }; - -function refuse(message: string): never { - throw new Error(`rt: ${message}`); -} - -/** - * Writes `value` for `key` into the given scope's store, preserving every - * existing comment and creating the file/section it needs. See the module - * doc for the full refusal list and the team-selection rule. - */ -export function setSetting(key: string, value: unknown, scope: SettingScope, opts: SetSettingOpts = {}): void { - const def = getDef(key); - if (!def) { - refuse(`unknown setting "${key}" — not in the settings registry (see \`rt settings list\`)`); - } - - if (!def.migrated) { - refuse(migratedFalseMessage(key, def)); - } - - if (!def.scopes.includes(scope)) { - refuse(`"${key}" cannot be set in the ${scope} store (allowed: ${def.scopes.join(", ")})`); - } - - if (opts.repoIdentity !== undefined && def.repoScoped !== true) { - refuse(`"${key}" is not repo-scoped — omit the repo identity`); - } - - // machine scope is exempt from the path-literal guard (see module doc). - const guardedDef: SettingDef = scope === "machine" ? { ...def, pathGuardFields: undefined } : def; - const check = validateValue(guardedDef, value); - if (!check.ok) { - refuse(`refusing to set "${key}": ${check.reason} — use \${team:} or \${repoRoot} instead`); - } - - const storePath = resolveStorePath(scope, opts); - const jsonPath: JSONPath = opts.repoIdentity !== undefined ? ["repos", opts.repoIdentity, key] : [key]; - - writeIntoStore(storePath, jsonPath, value, /* createIfMissing */ scope !== "team"); - - if (scope === "team") { - console.error( - `rt: wrote "${key}" to the local team store (${storePath}) — this is local only until you commit and push it.`, - ); - } -} - -function migratedFalseMessage(key: string, def: SettingDef): string { - const legacyPart = def.legacyFile ? ` — it is still read from ${def.legacyFile}` : ""; - const siblingPart = def.siblingCommand ? `; use \`${def.siblingCommand}\` instead` : ""; - return `"${key}" is not writable through the settings resolver yet${legacyPart}${siblingPart}`; -} - -/** Resolves which store file a write targets, applying the team-selection rule for `scope: "team"`. */ -function resolveStorePath(scope: SettingScope, opts: SetSettingOpts): string { - if (scope === "user") return userSettingsPath(); - if (scope === "machine") return machineSettingsPath(); - - if (opts.team !== undefined) { - const path = teamSettingsPath(opts.team); - if (!existsSync(path)) { - refuse(`team store for "${opts.team}" does not exist (${path}) — clone/seed it before writing to it`); - } - return path; - } - - const teams = listTeams(); - if (teams.length === 0) { - refuse(`no local team store found — clone a team under ~/.mattstack/teams/ or pass opts.team`); - } - if (teams.length > 1) { - refuse(`multiple local team stores found (${teams.join(", ")}) — pass opts.team to choose one`); - } - return teamSettingsPath(teams[0] as string); -} - -/** `// header comment\n{}\n` — see module doc for why the object must be seeded before the first `modify`. */ -function seedHeader(): string { - return `// rt settings — created by \`rt settings set\`. JSONC: comments and trailing commas are fine.\n{}\n`; -} - -/** - * Refuses to edit a store whose on-disk text is not a single well-formed - * JSONC object. Two failure classes: - * - genuine parse errors (unbalanced braces, trailing garbage, etc.) — - * caught by jsonc-parser's own error collection, the same check - * `stores.ts#readStore` runs on the read side; - * - a document that PARSES but is unsafe to `modify`: a non-object root, or - * a duplicate key anywhere in the tree. `modify` edits the FIRST - * occurrence of a duplicate key by offset, while every reader (`parse`, - * `JSON.parse`) takes the LAST — so a naive edit-in-place would report - * success while the effective value never changes (verified with a - * throwaway script: `modify` touched offset 14 in `{"rt.llm":1,"rt.llm":2}`, - * but re-parsing the "fixed" text still returned `2`). Both classes refuse - * rather than silently editing around the damage — the alternative is a - * write that reports success but does nothing, or one that writes a still- - * broken file that the NEXT read honest-degrades to an empty store. - */ -function assertEditableJsonc(file: string, content: string): void { - const errors: ParseError[] = []; - const tree = parseTree(content, errors, { allowTrailingComma: true }); - - const malformed = - errors.length > 0 || tree === undefined || tree.type !== "object" || findDuplicateKey(tree) !== undefined; - - if (malformed) { - refuse(`fix the JSONC syntax error in ${file} first — refusing to edit a malformed store`); - } -} - -/** Depth-first search for the first duplicate property name in any object in the tree. */ -function findDuplicateKey(node: Node): string | undefined { - if (node.type === "object" && node.children) { - const seen = new Set(); - for (const property of node.children) { - const keyNode = property.children?.[0]; - if (keyNode !== undefined && typeof keyNode.value === "string") { - if (seen.has(keyNode.value)) return keyNode.value; - seen.add(keyNode.value); - } - const valueNode = property.children?.[1]; - if (valueNode !== undefined) { - const nested = findDuplicateKey(valueNode); - if (nested !== undefined) return nested; - } - } - return undefined; - } - if (node.type === "array" && node.children) { - for (const child of node.children) { - const nested = findDuplicateKey(child); - if (nested !== undefined) return nested; - } - } - return undefined; -} - -function writeIntoStore(storePath: string, jsonPath: JSONPath, value: unknown, createIfMissing: boolean): void { - let content: string; - if (existsSync(storePath)) { - content = readFileSync(storePath, "utf8"); - if (content.trim() === "") { - content = seedHeader(); - } else { - assertEditableJsonc(storePath, content); - } - } else { - if (!createIfMissing) { - // Unreachable via setSetting today: resolveStorePath already refuses - // every "team" path that lacks a file before we get here. Kept as a - // defensive guard against a future caller of writeIntoStore directly. - refuse(`store file ${storePath} does not exist`); - } - mkdirSync(dirname(storePath), { recursive: true }); - content = seedHeader(); - } - - const edits = modify(content, jsonPath, value, { formattingOptions: FORMAT }); - const next = applyEdits(content, edits); - const finalText = next.endsWith("\n") ? next : `${next}\n`; - - // Write-temp-then-rename in the same directory, mirroring - // lib/json-store.ts's writeJson — stores never tear, and the machine store - // especially has no git fallback to recover a partial write from. The - // edited TEXT is written as-is, never round-tripped through - // JSON.stringify, so comments and formatting survive. - const tmp = `${storePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; - try { - writeFileSync(tmp, finalText); - renameSync(tmp, storePath); - } catch (err) { - try { - unlinkSync(tmp); - } catch { - // tmp file never got created, or was already cleaned up — nothing to do - } - throw err; - } -} +// RT-50: the settings write path moved to @mattstack/rt-client. Every +// existing rt importer of lib/settings/write.ts keeps working unchanged +// through this re-export barrel; the implementation lives at the path below. +export * from "../../packages/rt-client/src/settings/write.ts"; diff --git a/package.json b/package.json index 9045e03f..e54dfaec 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,10 @@ "rt": "./cli.ts" }, "scripts": { - "test": "bun test lib", - "test:watch": "bun test --watch lib", + "test": "bun test lib commands packages", + "test:watch": "bun test --watch lib commands packages", "test:e2e": "bun test --preload ./e2e/setup.ts --timeout 60000 e2e/", - "test:all": "bun test lib && bun test --preload ./e2e/setup.ts --timeout 60000 e2e/", + "test:all": "bun test lib commands packages && bun test --preload ./e2e/setup.ts --timeout 60000 e2e/", "docs:gen": "bun scripts/gen-docs.ts", "docs:check": "bun scripts/check-docs.ts", "docs:update": "bun scripts/update-docs.ts", diff --git a/packages/rt-client/README.md b/packages/rt-client/README.md index 1f2fce1f..6a6db22b 100644 --- a/packages/rt-client/README.md +++ b/packages/rt-client/README.md @@ -20,6 +20,9 @@ const mrs = await readProjectMRs('group/repo'); Requires a running rt daemon. Install rt from the latest GitHub Release (`./rt --post-install`), then `rt verify`. +Bun-only: the settings exec path (`src/settings/exec.ts`) shells out via +`Bun.spawn`, so this package does not run under Node. + `@mattstack/glance` is a peer dependency: rt-client returns glance's forge types so merge request shapes stay identical across rt, gitq, and mr-board. diff --git a/packages/rt-client/package.json b/packages/rt-client/package.json index 69be6e1a..d33b6fc0 100644 --- a/packages/rt-client/package.json +++ b/packages/rt-client/package.json @@ -1,6 +1,6 @@ { "name": "@mattstack/rt-client", - "version": "0.2.0", + "version": "0.3.0", "type": "module", "exports": { ".": { @@ -14,6 +14,9 @@ "peerDependencies": { "@mattstack/glance": ">=0.13.0" }, + "dependencies": { + "jsonc-parser": "^3.3.1" + }, "description": "Typed client for the rt daemon: repos, worktrees, ports, tokens, and the event relay", "license": "MIT", "repository": { @@ -28,9 +31,13 @@ "files": [ "dist", "src", + "!src/**/__tests__", "LICENSE", "README.md" ], + "engines": { + "bun": ">=1.0.0" + }, "scripts": { "build": "bun build src/index.ts --outdir dist --target node --format esm --packages external && tsc -p tsconfig.json", "check-types": "tsc --noEmit -p tsconfig.json" diff --git a/packages/rt-client/src/commands.ts b/packages/rt-client/src/commands.ts index 21cd9da3..b463aae1 100644 --- a/packages/rt-client/src/commands.ts +++ b/packages/rt-client/src/commands.ts @@ -70,6 +70,20 @@ export interface Commands { * the caller's env vars keep precedence on the caller's side. */ "secrets:forge-token": { payload: { repoName: string; forge: ForgeSlug }; data: ForgeTokenData }; + /** + * The whitelisted subset of `Secrets` the VS Code extension reads directly + * (RT-32): only linearApiKey and gitlabToken, both optional (present only + * when set). Not a general secrets export — extend the whitelist here, in + * lockstep with lib/daemon/handlers/secrets.ts and + * extensions/vscode/rt-context/src/secrets.ts, if a consumer needs another key. + * + * `token` is required and checked in the HANDLER (not a transport-layer + * gate alone), since this verb is reachable over the unauthenticated unix + * socket too — see lib/daemon/handlers/secrets.ts's doc comment. HTTP + * callers get it forwarded automatically from their X-RT-Token header; + * socket callers must read ~/.mattstack/rt/api-token themselves. + */ + "secrets:read": { payload: { token?: string }; data: { linearApiKey?: string; gitlabToken?: string } }; "events:emit": { payload: { topic: string; payload?: unknown }; data: { id: number } }; "events:wait": { payload: { pattern: string; after?: number; waitMs?: number }; data: { events: EventsBusEvent[]; cursor: number } }; "events:list": { payload: { pattern: string; after?: number; limit?: number }; data: { events: EventsBusEvent[]; cursor: number } }; @@ -82,6 +96,7 @@ export const COMMAND_NAMES: readonly CommandName[] = [ "discussions:read", "mr:by-branch", "secrets:forge-token", + "secrets:read", "events:emit", "events:wait", "events:list", diff --git a/packages/rt-client/src/index.ts b/packages/rt-client/src/index.ts index 9db84a47..709438c9 100644 --- a/packages/rt-client/src/index.ts +++ b/packages/rt-client/src/index.ts @@ -22,3 +22,30 @@ export { subscribe, DEFAULT_WS_URL } from "./relay.ts"; export type { RelayEventType } from "./relay.ts"; export { repoNameForPath } from "./repos.ts"; + +// ─── Settings (RT-50) ──────────────────────────────────────────────────────── + +export { getSetting, listSettings, explainSetting, expandVariables, setLegacyReader, defaultLegacyReader, SCOPE_ORDER } from "./settings/resolve.ts"; +export type { + Scope, + Provenance, + ResolveOpts, + Resolved, + InvalidScope, + ListedSetting, + ExplainRow, + ExpandCtx, + LegacyReader, +} from "./settings/resolve.ts"; + +export { setSetting } from "./settings/write.ts"; +export type { SetSettingOpts } from "./settings/write.ts"; + +export { getDef, allDefs, validateValue, isMigrated } from "./settings/registry-machinery.ts"; +export type { SettingDef, SettingScope } from "./settings/registry-machinery.ts"; +export { REGISTRY } from "./settings/registry-defs.ts"; + +export { readStore, listTeams } from "./settings/stores.ts"; +export type { StoreFile } from "./settings/stores.ts"; + +export { normalizeRemote, identityFromRemote, deriveRepoIdentity, clearIdentityMemo } from "./settings/identity.ts"; diff --git a/lib/settings/__tests__/identity.test.ts b/packages/rt-client/src/settings/__tests__/identity.test.ts similarity index 98% rename from lib/settings/__tests__/identity.test.ts rename to packages/rt-client/src/settings/__tests__/identity.test.ts index c34a7006..94f24d76 100644 --- a/lib/settings/__tests__/identity.test.ts +++ b/packages/rt-client/src/settings/__tests__/identity.test.ts @@ -9,8 +9,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; -import { runCapture } from "../../subprocess.ts"; -import { machineSettingsPath } from "../../rt-paths.ts"; +import { runCapture } from "../exec.ts"; +import { machineSettingsPath } from "../paths.ts"; import { normalizeRemote, identityFromRemote, deriveRepoIdentity, clearIdentityMemo } from "../identity.ts"; describe("settings/identity", () => { diff --git a/lib/settings/__tests__/registry.test.ts b/packages/rt-client/src/settings/__tests__/registry.test.ts similarity index 65% rename from lib/settings/__tests__/registry.test.ts rename to packages/rt-client/src/settings/__tests__/registry.test.ts index 3f8f4e54..d2cd1d7e 100644 --- a/lib/settings/__tests__/registry.test.ts +++ b/packages/rt-client/src/settings/__tests__/registry.test.ts @@ -5,7 +5,7 @@ */ import { describe, expect, test } from "bun:test"; -import { allDefs, getDef, validateValue, type SettingDef } from "../registry.ts"; +import { allDefs, getDef, isMigrated, validateValue, type SettingDef } from "../registry-machinery.ts"; describe("settings/registry", () => { describe("getDef", () => { @@ -39,7 +39,7 @@ describe("settings/registry", () => { test("every migrated:false def carries a legacyFile", () => { for (const def of allDefs()) { - if (def.migrated) continue; + if (isMigrated(def)) continue; expect(def.legacyFile, `${def.key} is migrated:false but has no legacyFile`).toBeTruthy(); } }); @@ -108,7 +108,7 @@ describe("settings/registry", () => { // outside the scope of this check; rt.roles/rt.intercepts/rt.worktrees // are repoScoped for reasons unrelated to any legacyFile prefix.) for (const def of allDefs()) { - if (def.migrated) continue; + if (isMigrated(def)) continue; const legacyFileIsRepoScoped = def.legacyFile?.startsWith("repos//") ?? false; expect( Boolean(def.repoScoped), @@ -142,7 +142,7 @@ describe("settings/registry", () => { } }); - test("has exactly the 12 wave-1 migrated:false keys plus the 5 migrated:true keys", () => { + test("has exactly the 12 wave-1 migrated:false keys, the 5 migrated:true keys, and the 30 suite keys", () => { const migratedFalseKeys = [ "rt.llm", "rt.cron", @@ -158,8 +158,111 @@ describe("settings/registry", () => { "rt.hooks", ]; const migratedTrueKeys = ["rt.roles", "rt.intercepts", "rt.worktrees", "rt.repoIdentityOverrides", "rt.repoRoots"]; + const suiteKeys = [ + "mattstack.integrations", + "mattstack.tracking", + "mattstack.appPath", + "claude.marketplaces", + "claude.plugins", + "deck.apps", + "deck.access", + "deck.platform", + "board.gitlabHost", + "board.projects", + "board.members", + "board.title", + "board.botUsernames", + "board.ticketPrefixes", + "board.slack", + "board.doctorSkill", + "board.triage.doctorSkill", + "board.staleAfterDays", + "board.workspaces", + "board.defaultMember", + "board.hiddenMembers", + "board.triage", + "board.claudeCommand", + "board.cwds", + "board.rtRepos", + "board.triageMaxConcurrent", + "board.switchboardUrl", + "gitq.workSlots", + "gitq.forges", + "gitq.board", + ]; + expect(suiteKeys).toHaveLength(30); + + expect(allDefs().map((d) => d.key).sort()).toEqual( + [...migratedFalseKeys, ...migratedTrueKeys, ...suiteKeys].sort(), + ); + }); + + test("every suite key (no legacyFile, no explicit migrated flag) resolves as migrated via isMigrated", () => { + for (const def of allDefs()) { + if (def.legacyFile !== undefined) continue; // wave-1 keys, covered above + if (def.key.startsWith("rt.")) continue; // wave-1 migrated:true rows carry migrated:true explicitly + expect(def.migrated, `${def.key} should omit migrated`).toBeUndefined(); + expect(isMigrated(def), `${def.key} should resolve as migrated`).toBe(true); + } + }); + + test("board.doctorSkill and board.triage.doctorSkill are distinct rows with distinct resolution semantics documented", () => { + const doctorSkill = getDef("board.doctorSkill"); + const triageDoctorSkill = getDef("board.triage.doctorSkill"); + + expect(doctorSkill).toBeDefined(); + expect(triageDoctorSkill).toBeDefined(); + expect(doctorSkill?.key).not.toBe(triageDoctorSkill?.key); + expect(doctorSkill?.description).toContain("skills.jsonc"); + expect(triageDoctorSkill?.description).toContain("never resolved"); + }); + + test("board.triage and board.triage.doctorSkill document that they are siblings, not container/field", () => { + // Non-obvious invariant: both are independent flat keys at different + // scopes (user vs team) — a board reader assembles triage config from + // them separately, board.triage does not nest doctorSkill inside it. + const triage = getDef("board.triage"); + const triageDoctorSkill = getDef("board.triage.doctorSkill"); - expect(allDefs().map((d) => d.key).sort()).toEqual([...migratedFalseKeys, ...migratedTrueKeys].sort()); + expect(triage?.description).toContain("sibling"); + expect(triageDoctorSkill?.description).toContain("sibling"); + }); + + test("board.hiddenMembers is a user-scope overlay, distinct from the team-scope board.members roster", () => { + const hiddenMembers = getDef("board.hiddenMembers"); + const members = getDef("board.members"); + + expect(hiddenMembers?.scopes).toEqual(["user"]); + expect(hiddenMembers?.type).toBe("array"); + expect(hiddenMembers?.merge).toBe("replace"); + expect(hiddenMembers?.description).toContain("board.members"); + // The ruling this pins: board.members stays team-only array/replace — + // widening it to user scope would let a personal store shadow the + // whole team roster instead of just hiding entries from it. + expect(members?.scopes).toEqual(["team"]); + }); + + test("scope spot-checks: deck.access is user-only, board.gitlabHost is team-only, gitq.forges is user-only, mattstack.appPath is machine-only", () => { + expect(getDef("deck.access")?.scopes).toEqual(["user"]); + expect(getDef("board.gitlabHost")?.scopes).toEqual(["team"]); + expect(getDef("gitq.forges")?.scopes).toEqual(["user"]); + expect(getDef("mattstack.appPath")?.scopes).toEqual(["machine"]); + }); + + test("claude.marketplaces and claude.plugins are user+team arrays with replace merge", () => { + for (const key of ["claude.marketplaces", "claude.plugins"]) { + const def = getDef(key)!; + expect(def.scopes.sort()).toEqual(["team", "user"]); + expect(def.type).toBe("array"); + expect(def.merge).toBe("replace"); + } + }); + + test("none of the new suite keys carry repoScoped", () => { + for (const def of allDefs()) { + if (def.key.startsWith("rt.")) continue; // wave-1 rows, covered above + expect(def.repoScoped, `${def.key} should not be repoScoped`).toBeFalsy(); + } }); }); diff --git a/lib/settings/__tests__/resolve.test.ts b/packages/rt-client/src/settings/__tests__/resolve.test.ts similarity index 99% rename from lib/settings/__tests__/resolve.test.ts rename to packages/rt-client/src/settings/__tests__/resolve.test.ts index 7ef425c1..c2c08fbf 100644 --- a/lib/settings/__tests__/resolve.test.ts +++ b/packages/rt-client/src/settings/__tests__/resolve.test.ts @@ -19,8 +19,8 @@ import { teamSettingsPath, teamsDir, userSettingsPath, -} from "../../rt-paths.ts"; -import { getDef, type SettingDef } from "../registry.ts"; +} from "../paths.ts"; +import { getDef, type SettingDef } from "../registry-machinery.ts"; import { defaultLegacyReader, expandVariables, diff --git a/lib/settings/__tests__/stores.test.ts b/packages/rt-client/src/settings/__tests__/stores.test.ts similarity index 99% rename from lib/settings/__tests__/stores.test.ts rename to packages/rt-client/src/settings/__tests__/stores.test.ts index cff1eb16..1e44dd15 100644 --- a/lib/settings/__tests__/stores.test.ts +++ b/packages/rt-client/src/settings/__tests__/stores.test.ts @@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; -import { userSettingsPath, teamSettingsPath, teamsDir, machineSettingsPath } from "../../rt-paths.ts"; +import { userSettingsPath, teamSettingsPath, teamsDir, machineSettingsPath } from "../paths.ts"; import { readStore, listTeams } from "../stores.ts"; describe("settings/stores", () => { diff --git a/lib/settings/__tests__/write.test.ts b/packages/rt-client/src/settings/__tests__/write.test.ts similarity index 99% rename from lib/settings/__tests__/write.test.ts rename to packages/rt-client/src/settings/__tests__/write.test.ts index e384f61f..a4fd0d13 100644 --- a/lib/settings/__tests__/write.test.ts +++ b/packages/rt-client/src/settings/__tests__/write.test.ts @@ -11,8 +11,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { dirname, join } from "path"; -import { machineSettingsPath, teamSettingsPath, teamsDir, userSettingsPath } from "../../rt-paths.ts"; -import { getDef, type SettingDef } from "../registry.ts"; +import { machineSettingsPath, teamSettingsPath, teamsDir, userSettingsPath } from "../paths.ts"; +import { getDef, type SettingDef } from "../registry-machinery.ts"; import { setSetting } from "../write.ts"; const IDENTITY = "gitlab.com/assured/assured-dev"; diff --git a/packages/rt-client/src/settings/exec.ts b/packages/rt-client/src/settings/exec.ts new file mode 100644 index 00000000..b0b1095b --- /dev/null +++ b/packages/rt-client/src/settings/exec.ts @@ -0,0 +1,55 @@ +/** + * Async subprocess capture, duplicated from repo-tools/lib/subprocess.ts: + * rt-client has no dependency on rt's lib/, so this can't import runCapture + * from there. lib/subprocess.ts is the authority — change there first, + * mirror here. + * + * execSync blocks the event loop for the entire child lifetime; identity + * derivation must stay safe to call from daemon contexts, hence this instead. + */ + +export interface RunResult { + stdout: string; + stderr: string; + exitCode: number; +} + +/** + * Run argv and capture stdout. Never throws: spawn failures and timeouts + * surface as a non-zero exitCode with whatever stdout was collected. + */ +export async function runCapture( + argv: [string, ...string[]], + opts: { cwd?: string; timeoutMs?: number; stderr?: "ignore" | "pipe" } = {}, +): Promise { + const captureStderr = opts.stderr === "pipe"; + let proc: ReturnType; + try { + proc = Bun.spawn(argv, { + cwd: opts.cwd, + stdin: "ignore", + stdout: "pipe", + stderr: captureStderr ? "pipe" : "ignore", + }); + } catch { + return { stdout: "", stderr: "", exitCode: -1 }; + } + + const timer = setTimeout(() => { + try { proc.kill(); } catch { /* already exited */ } + }, opts.timeoutMs ?? 10_000); + + try { + const stdoutPromise = new Response(proc.stdout as ReadableStream).text(); + const stderrPromise = captureStderr + ? new Response(proc.stderr as ReadableStream).text() + : Promise.resolve(""); + const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]); + const exitCode = await proc.exited; + return { stdout, stderr, exitCode }; + } catch { + return { stdout: "", stderr: "", exitCode: -1 }; + } finally { + clearTimeout(timer); + } +} diff --git a/packages/rt-client/src/settings/identity.ts b/packages/rt-client/src/settings/identity.ts new file mode 100644 index 00000000..4b4a7dfb --- /dev/null +++ b/packages/rt-client/src/settings/identity.ts @@ -0,0 +1,125 @@ +/** + * Repo identity: the normalized-remote string that keys `repos.` + * sections in every settings store (RT-47 spec, "Repo identity"). + * + * Identity is `host/path` (lowercase host, path case preserved), derived from + * `remote.origin.url` — never a filesystem path, so it is checkout-location + * independent: every worktree of a repo shares the same remote and therefore + * the same identity. A remote that doesn't match a recognized host form + * (bare local paths are the main case — repos.json has two) normalizes to + * null, meaning repo-scoped sections are unreachable for it and only global + * scopes + legacy apply. That's an honest degrade, not a crash. + * + * Three entry points: + * - `normalizeRemote` is the pure string transform, no I/O. + * - `identityFromRemote` layers the machine store's fork/multi-remote + * overrides (`rt.repoIdentityOverrides`, keyed by observed remote URL) on + * top of `normalizeRemote`. It's synchronous — the one helper every + * non-derivation site uses (run.ts, buildInterceptRules, tests) — so + * fork-pinning works everywhere identity is computed from a remote in + * hand, not just at derivation time. + * - `deriveRepoIdentity` is the async entry point for when only a repo path + * is in hand: it shells out to git for the remote (never a sync spawn — + * this must stay safe to call from daemon contexts) and then routes + * through `identityFromRemote`, memoized per path so repeated callers in + * one process don't re-spawn git. + */ + +import { runCapture } from "./exec.ts"; +import { machineSettingsPath } from "./paths.ts"; +import { readStore } from "./stores.ts"; + +// Full-URL forms: scheme://[user[:pass]@]host/path — https, ssh, git, http, ... +const URL_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(?:[^@/]+@)?([^/]+)\/(.+)$/; + +// scp-like scp syntax: [user@]host:path (git@gitlab.com:group/repo.git). +// Deliberately excludes anything starting with "/" (absolute local paths) +// so a Windows-drive-letter-free local remote never falsely matches. +const SCP_RE = /^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/; + +/** + * Pure normalization: `remote` → `host/path` (lowercase host, `.git` and + * embedded credentials stripped) or null when the remote doesn't match a + * recognized host form (local paths, garbage input). + */ +export function normalizeRemote(remote: string): string | null { + const trimmed = remote.trim(); + if (!trimmed) return null; + + let host: string | undefined; + let path: string | undefined; + + const urlMatch = URL_RE.exec(trimmed); + if (urlMatch) { + host = urlMatch[1]; + path = urlMatch[2]; + } else if (!trimmed.startsWith("/") && !trimmed.startsWith("~")) { + const scpMatch = SCP_RE.exec(trimmed); + if (scpMatch) { + host = scpMatch[1]; + path = scpMatch[2]; + } + } + + if (!host || !path) return null; + + const normalizedPath = path.replace(/\.git$/, "").replace(/^\/+/, "").replace(/\/+$/, ""); + if (!normalizedPath) return null; + + return `${host.toLowerCase()}/${normalizedPath}`; +} + +/** + * The sync helper every non-derivation call site uses: machine-store + * fork/multi-remote overrides (exact remote-URL match) then normalizeRemote. + * Reads the machine store fresh each call (files are small; store reads are + * not memoized anywhere in the resolver design). + */ +export function identityFromRemote(remote: string): string | null { + const store = readStore(machineSettingsPath()); + const overrides = store.global["rt.repoIdentityOverrides"]; + if (overrides !== null && typeof overrides === "object" && !Array.isArray(overrides)) { + const hit = (overrides as Record)[remote]; + if (typeof hit === "string") return hit; + } + return normalizeRemote(remote); +} + +// Per-process, per-repo-path memoization. Promise-valued so concurrent +// callers for the same path share one spawn rather than racing. +const memo = new Map>(); + +/** + * Async derivation from a repo path: `git -C config --get + * remote.origin.url`, then identityFromRemote (so overrides apply to + * derivation too). Never a sync spawn — safe to call from daemon contexts. + * + * Only a SUCCESSFUL derivation (non-null identity) is memoized, for the life + * of the process; a remote change after that first success is NOT picked up + * until clearIdentityMemo() — documented behavior, not a bug (see spec: + * derivation is a one-time capture per process, not a live poll). A FAILED + * derivation (no remote yet, git not initialized yet, etc.) is never cached + * and is retried on every subsequent call — a caller racing repo + * provisioning (mid-clone, daemon-startup) must not permanently lose + * identity for a path just because it asked too early. + */ +export async function deriveRepoIdentity(repoPath: string): Promise { + const cached = memo.get(repoPath); + if (cached) return cached; + + const result = await (async (): Promise => { + const spawned = await runCapture(["git", "-C", repoPath, "config", "--get", "remote.origin.url"]); + if (spawned.exitCode !== 0) return null; + const remote = spawned.stdout.trim(); + if (!remote) return null; + return identityFromRemote(remote); + })(); + + if (result !== null) memo.set(repoPath, Promise.resolve(result)); + return result; +} + +/** Test-only: clear the derivation memo so a test can force re-derivation. */ +export function clearIdentityMemo(): void { + memo.clear(); +} diff --git a/packages/rt-client/src/settings/paths.ts b/packages/rt-client/src/settings/paths.ts new file mode 100644 index 00000000..598875ae --- /dev/null +++ b/packages/rt-client/src/settings/paths.ts @@ -0,0 +1,50 @@ +/** + * Settings-store path layout, duplicated from repo-tools/lib/rt-paths.ts: + * rt-client has no dependency on rt's lib/, so these literals cannot import + * rtDir()/userSettingsPath()/etc. lib/rt-paths.ts is the authority — change + * there first, mirror here (same convention as transport.ts's DEFAULT_SOCK + * and repos.ts's defaultReposJsonPath). + * + * HOME is resolved at CALL time via `process.env.HOME ?? homedir()`, matching + * the original, so tests can repoint the whole tree at a temp dir. + */ + +import { homedir } from "os"; +import { join } from "path"; + +function home(): string { + return process.env.HOME ?? homedir(); +} + +function rtDir(): string { + return join(home(), ".mattstack", "rt"); +} + +function reposDir(): string { + return join(rtDir(), "repos"); +} + +/** ~/.mattstack/rt/repos/ — a single repo's data directory. */ +export function repoDataDir(repoName: string): string { + return join(reposDir(), repoName); +} + +/** ~/.mattstack/user/settings.jsonc — the user store. */ +export function userSettingsPath(): string { + return join(home(), ".mattstack", "user", "settings.jsonc"); +} + +/** ~/.mattstack/teams//mattstack/settings.jsonc — the team store. */ +export function teamSettingsPath(team: string): string { + return join(teamsDir(), team, "mattstack", "settings.jsonc"); +} + +/** ~/.mattstack/settings.local.jsonc — the machine store (path literals legal here only). */ +export function machineSettingsPath(): string { + return join(home(), ".mattstack", "settings.local.jsonc"); +} + +/** ~/.mattstack/teams — the container every team's local clone lives under. */ +export function teamsDir(): string { + return join(home(), ".mattstack", "teams"); +} diff --git a/packages/rt-client/src/settings/registry-defs.ts b/packages/rt-client/src/settings/registry-defs.ts new file mode 100644 index 00000000..71798585 --- /dev/null +++ b/packages/rt-client/src/settings/registry-defs.ts @@ -0,0 +1,407 @@ +/** + * The settings key TABLE (RT-47/RT-50): rt's rows, plus the suite rows + * (deck/board/gitq/mattstack/claude) — the machinery in registry-machinery.ts + * that reads and validates against this table does not change when rows are + * added. + * + * Suite rows omit `migrated` (see registry-machinery.ts's docblock): they + * carry no rt-legacy file to migrate from, so `isMigrated()` treats the + * absent flag as resolver-backed from day one. + */ + +import type { SettingDef, SettingScope } from "./registry-machinery.ts"; + +const ALL_SCOPES: SettingScope[] = ["user", "team", "machine"]; + +export const REGISTRY: readonly SettingDef[] = [ + // --- migrated:true (wave 1) --------------------------------------------- + { + key: "rt.roles", + type: "object", + scopes: ALL_SCOPES, + merge: "deep", + repoScoped: true, + migrated: true, + pathGuardFields: ["hook"], + description: "Per-repo dev-role definitions: port pools, env passthrough, and the dev-server hook command.", + }, + { + key: "rt.intercepts", + type: "array", + scopes: ALL_SCOPES, + merge: "replace", + repoScoped: true, + migrated: true, + description: "Per-repo endpoint intercept rules consumed by rt intercept install.", + }, + { + key: "rt.worktrees", + type: "object", + scopes: ALL_SCOPES, + default: { onDeck: 0 }, + merge: "deep", + repoScoped: true, + migrated: true, + description: "Per-repo worktree pool config (onDeck size, ready steps, name pool); root/branchFormat/ready computed-or-empty in the reader.", + }, + { + key: "rt.repoIdentityOverrides", + type: "object", + scopes: ["machine"], + merge: "replace", + migrated: true, + description: "Map of observed remote URL to pinned repo identity, for forks/multi-remote repos on this machine.", + }, + { + key: "rt.repoRoots", + type: "array", + scopes: ["machine"], + default: [], + merge: "replace", + migrated: true, + description: + 'Directories rt scans for git repos (rt cd, run-outside-a-repo pickers). Entries may start with "~/" or use "${home}". One level deep, plus worktree-pool parent folders one level deeper.', + }, + + // --- migrated:false (wave 1 legacy-file keys) --------------------------- + { + key: "rt.llm", + type: "object", + scopes: ALL_SCOPES, + merge: "deep", + migrated: false, + legacyFile: "llm.json", + description: "LLM provider and model selection for rt's AI-assisted commands.", + }, + { + key: "rt.cron", + type: "object", + scopes: ALL_SCOPES, + merge: "deep", + migrated: false, + legacyFile: "cron.jsonc", + description: "Scheduled rt job definitions and their cron expressions.", + }, + { + key: "rt.repoTracking", + type: "object", + scopes: ALL_SCOPES, + merge: "deep", + migrated: false, + legacyFile: "repo-tracking.json", + description: "Which repos rt tracks for background sync and status polling.", + }, + { + key: "rt.notifications", + type: "object", + scopes: ALL_SCOPES, + merge: "deep", + migrated: false, + legacyFile: "notifications.json", + siblingCommand: "rt settings notifications", + description: "Desktop notification preferences (which events notify, sound on/off).", + }, + { + key: "rt.sync", + type: "object", + scopes: ALL_SCOPES, + merge: "deep", + repoScoped: true, + migrated: false, + legacyFile: "repos//sync.json", + description: "Branch sync behavior: fast-forward rules and stale-branch handling.", + }, + { + key: "rt.branchNaming", + type: "object", + scopes: ALL_SCOPES, + merge: "deep", + repoScoped: true, + migrated: false, + legacyFile: "repos//branch-naming.json", + description: "Templates rt uses to derive branch names from ticket identifiers.", + }, + { + key: "rt.variations", + type: "object", + scopes: ALL_SCOPES, + merge: "deep", + repoScoped: true, + migrated: false, + legacyFile: "repos//variations.json", + description: "Named parameter sets rt run can pick between for a command.", + }, + { + key: "rt.presets", + type: "object", + scopes: ALL_SCOPES, + merge: "deep", + repoScoped: true, + migrated: false, + legacyFile: "repos//presets/.json", + description: "Saved argument presets for frequently repeated rt commands.", + }, + { + key: "rt.dopplerTemplate", + type: "object", + scopes: ALL_SCOPES, + merge: "deep", + repoScoped: true, + migrated: false, + legacyFile: "repos//doppler-template.yaml", + description: "Template used to generate a repo's Doppler secrets config.", + }, + { + key: "rt.workspacePrefs", + type: "object", + scopes: ALL_SCOPES, + merge: "deep", + migrated: false, + legacyFile: "workspace-prefs.json", + description: "Per-machine editor/terminal preferences applied when opening a worktree.", + }, + { + key: "rt.runaway", + type: "object", + scopes: ALL_SCOPES, + merge: "deep", + migrated: false, + legacyFile: "runaway-config.json", + siblingCommand: "rt settings runaway", + description: "Thresholds for the runaway-process guard that kills stuck dev servers.", + }, + { + key: "rt.hooks", + type: "object", + scopes: ALL_SCOPES, + merge: "deep", + repoScoped: true, + migrated: false, + legacyFile: "repos//hooks.json", + description: "User-defined lifecycle hooks rt runs around commands (pre/post command scripts).", + }, + + // --- mattstack (installer-lane) ----------------------------------------- + { + key: "mattstack.integrations", + type: "object", + scopes: ["team"], + merge: "deep", + description: "Team-wide external integration config (forge/slack/linear/switchboard) the installer provisions; client secrets never live here.", + }, + { + key: "mattstack.tracking", + type: "object", + scopes: ["team"], + merge: "deep", + description: "Team-declared repo tracking intent, identity-keyed; the daemon layers it under machine-scoped rt.repoTracking, machine winning per repo.", + }, + { + key: "mattstack.appPath", + type: "string", + scopes: ["machine"], + merge: "replace", + description: "Absolute path to the installed mattstack.app bundle, written by the app at launch so rt stops hardcoding ~/Applications.", + }, + + // --- claude (installer-lane) -------------------------------------------- + { + key: "claude.marketplaces", + type: "array", + scopes: ["user", "team"], + merge: "replace", + description: "Claude Code plugin marketplaces to replay on restore, in add order.", + }, + { + key: "claude.plugins", + type: "array", + scopes: ["user", "team"], + merge: "replace", + description: "Claude Code plugins to replay on restore, in install order.", + }, + + // --- deck (MAT-384 settings half) --------------------------------------- + { + key: "deck.apps", + type: "object", + scopes: ["user"], + merge: "deep", + description: "Per-app deck publish state (published flag, publicFollowsOverride); password hashes and session secrets stay out of this store.", + }, + { + key: "deck.access", + type: "object", + scopes: ["user"], + merge: "deep", + description: "deck's access-control roster, migrated from access.json.", + }, + { + key: "deck.platform", + type: "object", + scopes: ["machine"], + merge: "deep", + description: "deck's platform-level machine config: public domain and legacy URL prefixes; Cloudflare secrets stay out of this store.", + }, + + // --- board (team) -------------------------------------------------------- + { + key: "board.gitlabHost", + type: "string", + scopes: ["team"], + merge: "replace", + description: "GitLab host the board polls for MRs, shared by the whole team.", + }, + { + key: "board.projects", + type: "array", + scopes: ["team"], + merge: "replace", + description: "GitLab projects the board tracks, shared by the whole team.", + }, + { + key: "board.members", + type: "array", + scopes: ["team"], + merge: "replace", + description: "The board's full member roster, including hidden-by-default entries.", + }, + { + key: "board.title", + type: "string", + scopes: ["team"], + merge: "replace", + description: "Display title shown in the board's UI.", + }, + { + key: "board.botUsernames", + type: "array", + scopes: ["team"], + merge: "replace", + description: "GitLab usernames the board treats as bots, excluded from human MR attribution.", + }, + { + key: "board.ticketPrefixes", + type: "array", + scopes: ["team"], + merge: "replace", + description: "Ticket key prefixes (e.g. RT, MAT) the board links out to Linear from an MR title.", + }, + { + key: "board.slack", + type: "object", + scopes: ["team"], + merge: "deep", + description: "The board's Slack posting config (app id, client id, channel, callback port); client secrets stay out of this store.", + }, + { + key: "board.doctorSkill", + type: "string", + scopes: ["team"], + merge: "replace", + description: "Default doctor skill for repairing a stuck MR; a repo's skills.jsonc doctor slot overrides it when present.", + }, + { + key: "board.triage.doctorSkill", + type: "string", + scopes: ["team"], + merge: "replace", + description: "Doctor skill the board's own API-tier triage sweep runs on your MRs; deliberately never resolved through a repo's skills.jsonc manifest. A sibling flat key of board.triage, not a field inside it — the board reader assembles the two independently.", + }, + + // --- board (user) ---------------------------------------------------------- + { + key: "board.staleAfterDays", + type: "number", + scopes: ["user"], + merge: "replace", + description: "Days of MR inactivity before the board flags it stale, for this developer.", + }, + { + key: "board.workspaces", + type: "object", + scopes: ["user"], + merge: "deep", + description: "Herdr workspace names the board's review/respond/doctor panes launch into, per developer.", + }, + { + key: "board.defaultMember", + type: "string", + scopes: ["user"], + merge: "replace", + description: "Which board member identity this developer's local board runs as by default.", + }, + { + key: "board.hiddenMembers", + type: "array", + scopes: ["user"], + merge: "replace", + description: "Usernames this developer hides from the team roster's board.members list; overlays the team truth without editing it.", + }, + { + key: "board.triage", + type: "object", + scopes: ["user"], + merge: "deep", + description: "This developer's triage user-intent flags (which triage sweeps run automatically); a sibling flat key of board.triage.doctorSkill, not its container — the board reader assembles the two independently.", + }, + + // --- board (machine) --------------------------------------------------- + { + key: "board.claudeCommand", + type: "string", + scopes: ["machine"], + merge: "replace", + description: "Local command used to launch Claude Code for the board's review/respond/doctor panes.", + }, + { + key: "board.cwds", + type: "object", + scopes: ["machine"], + merge: "deep", + description: "Local working directories the board's review/respond/doctor panes launch from.", + }, + { + key: "board.rtRepos", + type: "array", + scopes: ["machine"], + merge: "replace", + description: "rt-registered repo names the board resolves MRs against on this machine.", + }, + { + key: "board.triageMaxConcurrent", + type: "number", + scopes: ["machine"], + merge: "replace", + description: "Max concurrent triage panes the board launches on this machine.", + }, + { + key: "board.switchboardUrl", + type: "string", + scopes: ["machine"], + merge: "replace", + description: "Local switchboard URL the board's POST /peer/join writer targets.", + }, + + // --- gitq ------------------------------------------------------------------ + { + key: "gitq.workSlots", + type: "object", + scopes: ["machine"], + merge: "deep", + description: "gitq's local work-slot config: on-disk location and the max slot count.", + }, + { + key: "gitq.forges", + type: "object", + scopes: ["user"], + merge: "deep", + description: "gitq's host-keyed forge config, tokenEnv names only — never a live token.", + }, + { + key: "gitq.board", + type: "object", + scopes: ["machine"], + merge: "deep", + description: "gitq checkout-board config: tracked repos, local port, and the herdr workspace it launches into.", + }, +]; diff --git a/packages/rt-client/src/settings/registry-machinery.ts b/packages/rt-client/src/settings/registry-machinery.ts new file mode 100644 index 00000000..7ee1378a --- /dev/null +++ b/packages/rt-client/src/settings/registry-machinery.ts @@ -0,0 +1,142 @@ +/** + * The settings schema registry machinery (RT-47/RT-50): lookup and + * validation over a static table describing every known settings key. + * + * This module is pure data plumbing — no file IO, no daemon dependency, safe + * to import anywhere (including the daemon thread). The def TABLE itself + * lives in registry-defs.ts (rt's rows today; a later task adds the rest of + * the suite); this file only knows how to look a def up and check a value + * against it. + * + * `migrated: true` means the reader for this key goes through the resolver. + * `migrated: false` keys still appear in `rt settings list` (so the full + * settings map is visible even before a key's reader has been ported), but + * `set` on them refuses — see the spec's "Schema registry" section for why + * writing a value nothing reads is the dishonesty class this design bans. + * + * `migrated` is omitted entirely (not `undefined` written out) for suite keys + * outside rt's wave-1 legacy-file migration — deck/board/gitq/mattstack/claude + * defs have no legacy file to migrate FROM, so the flag is meaningless for + * them. `isMigrated()` is the one place that turns absence into "yes, + * resolver-backed" — every other module must call it rather than testing + * `def.migrated` directly, or a suite key's absent flag reads as `false` and + * `set` refuses it. + */ + +import { REGISTRY } from "./registry-defs.ts"; + +export type SettingScope = "user" | "team" | "machine"; + +export interface SettingDef { + key: string; + type: "string" | "number" | "boolean" | "object" | "array"; + scopes: SettingScope[]; + default?: unknown; + merge: "replace" | "deep"; + teamLocked?: boolean; + secret?: boolean; + repoScoped?: boolean; + migrated?: boolean; + legacyFile?: string; + siblingCommand?: string; + pathGuardFields?: string[]; + description: string; +} + +const BY_KEY: Map = new Map(REGISTRY.map((def) => [def.key, def])); + +/** Looks up a def by its flat namespaced key (e.g. "rt.roles"). */ +export function getDef(key: string): SettingDef | undefined { + return BY_KEY.get(key); +} + +/** Every registered def, in registry declaration order. */ +export function allDefs(): SettingDef[] { + return [...REGISTRY]; +} + +/** True unless `def.migrated` is explicitly `false` — see the module doc. */ +export function isMigrated(def: SettingDef): boolean { + return def.migrated !== false; +} + +const PATH_LIKE = /^[/~]/; + +function typeOf(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + return typeof value; +} + +/** + * Checks whether `value` is a legal value for `def`: the JSON-ish type + * matches def.type, and (when def.pathGuardFields is set) no guarded field + * anywhere in the value looks like an absolute path or home-relative path + * literal — those are only legal in the machine store's own file contents, + * never as a shared-scope value (spec: "No path type exists ... enforced for + * wave-1 keys on the hook field specifically"). + */ +export function validateValue(def: SettingDef, value: unknown): { ok: true } | { ok: false; reason: string } { + const typeCheck = checkType(def.type, value); + if (!typeCheck.ok) return typeCheck; + + if (def.pathGuardFields && def.pathGuardFields.length > 0) { + const violation = findPathGuardViolation(value, def.pathGuardFields); + if (violation) { + return { + ok: false, + reason: `field "${violation.field}" looks like a path literal ("${violation.value}"); path literals are only legal in the machine store`, + }; + } + } + + return { ok: true }; +} + +function checkType(type: SettingDef["type"], value: unknown): { ok: true } | { ok: false; reason: string } { + switch (type) { + case "string": + return typeof value === "string" ? { ok: true } : { ok: false, reason: `expected string, got ${typeOf(value)}` }; + case "number": + return typeof value === "number" ? { ok: true } : { ok: false, reason: `expected number, got ${typeOf(value)}` }; + case "boolean": + return typeof value === "boolean" ? { ok: true } : { ok: false, reason: `expected boolean, got ${typeOf(value)}` }; + case "array": + return Array.isArray(value) ? { ok: true } : { ok: false, reason: `expected array, got ${typeOf(value)}` }; + case "object": + return value !== null && typeof value === "object" && !Array.isArray(value) + ? { ok: true } + : { ok: false, reason: `expected object, got ${typeOf(value)}` }; + } +} + +/** + * Walks a value looking for any object field named in `guardFields` whose + * string value looks like a path literal (leading `/` or `~`). Recurses + * through plain objects and arrays; best-effort (spec: "enforced for wave-1 + * keys on the hook field specifically, best-effort elsewhere"). + */ +function findPathGuardViolation( + value: unknown, + guardFields: string[], +): { field: string; value: string } | null { + if (Array.isArray(value)) { + for (const item of value) { + const hit = findPathGuardViolation(item, guardFields); + if (hit) return hit; + } + return null; + } + + if (value !== null && typeof value === "object") { + for (const [field, fieldValue] of Object.entries(value as Record)) { + if (guardFields.includes(field) && typeof fieldValue === "string" && PATH_LIKE.test(fieldValue)) { + return { field, value: fieldValue }; + } + const hit = findPathGuardViolation(fieldValue, guardFields); + if (hit) return hit; + } + } + + return null; +} diff --git a/packages/rt-client/src/settings/resolve.ts b/packages/rt-client/src/settings/resolve.ts new file mode 100644 index 00000000..69f46917 --- /dev/null +++ b/packages/rt-client/src/settings/resolve.ts @@ -0,0 +1,688 @@ +/** + * The settings resolver (RT-47): one read path that layers the four stores, + * the legacy per-repo file and the registry default into a single answer plus + * the provenance that explains it. + * + * Scope ladder, weakest → strongest: + * + * default < legacy < team < user < team.repo < user.repo < machine < machine.repo + * + * `legacy` is the pre-migration per-tool file for a key (wave 1: the three + * `repos//config.json` keys). It beats the registry default and loses to + * every authored store, and it carries real provenance so migration progress is + * observable rather than folklore. + * + * Merge is per-key schema, never global (`SettingDef.merge`): + * - `replace` — the strongest valid scope wins atomically; provenance has + * exactly one entry. + * - `deep` — object values overlay field-by-field walking weakest → strongest; + * arrays and scalars inside a deep key still replace atomically. Provenance + * lists every scope that still owns at least one leaf of the resolved value, + * weakest-first — a scope whose every field was overridden is NOT listed + * (same honesty rule that makes `replace` provenance length 1). + * + * Degrade rules (teammates run version-skewed binaries; one unknown key in the + * team store must never brick resolution): + * - explicit `get`/`explain` of an unregistered key → throw. + * - unregistered keys FOUND in files → warn + skip, surfaced by `listSettings` + * with `unregistered: true`. + * - a registered key whose found value fails validation → warn + skip THAT + * scope only, labeled `invalid` in list/explain; weaker and stronger scopes + * still apply. + * + * Three deliberate decisions this file makes that the spec left to the + * implementation: + * 1. **The path-literal guard is scope-aware.** `validateValue`'s guarded + * fields (`rt.roles.hook`) are only illegal in SHARED scopes. The machine + * store is explicitly allowed path literals, and the legacy per-repo file + * is full of them today — guarding those would reject exactly the values + * wave 1 has to keep reading. So team/user rungs get the full check, + * machine/legacy rungs get the type check alone. + * 2. **A value found in a store the def does not allow is skipped**, labeled + * like any other invalid value (`rt.repoIdentityOverrides` is machine-only; + * honouring a team-store copy of it would defeat the schema). + * 3. **`explain` shows values AS AUTHORED** (never expanded) because its job + * is to say what is in which file, and **`list` degrades** an unexpandable + * value to its raw form with an `expandError` label rather than throwing — + * one bad value must not brick a survey of every key. `get` is the loud + * one: an unsatisfiable closed-set variable throws. + * + * The resolver is daemon-FREE and sync: no spawns anywhere, repo identity is a + * pre-derived input (see identity.ts for the async derivation). Store files are + * parsed fresh per call — they are small, and memoization is a later + * optimization that would need invalidation this wave does not have. + * + * Writes (`setSetting`) land in a later task; this module is read-side only. + */ + +import { readFileSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; +import { + machineSettingsPath, + repoDataDir, + teamSettingsPath, + teamsDir, + userSettingsPath, +} from "./paths.ts"; +import { allDefs, getDef, isMigrated, validateValue, type SettingDef, type SettingScope } from "./registry-machinery.ts"; +import { listTeams, readStore, type StoreFile } from "./stores.ts"; + +// ─── Public types ──────────────────────────────────────────────────────────── + +export type Scope = + | "machine.repo" + | "machine" + | "user.repo" + | "team.repo" + | "user" + | "team" + | "legacy" + | "default"; + +/** The scope ladder, weakest first. Also the order every result is built in. */ +export const SCOPE_ORDER: Scope[] = [ + "default", + "legacy", + "team", + "user", + "team.repo", + "user.repo", + "machine", + "machine.repo", +]; + +export interface Provenance { + scope: Scope; + /** The file the value came from; null for the registry default. */ + file: string | null; +} + +export interface ResolveOpts { + /** Normalized repo identity (identity.ts). Null/absent = repo rungs are unreachable. */ + repoIdentity?: string | null; + /** Expand closed-set variables in the resolved value. Default true. */ + expand?: boolean; + expandCtx?: { repoRoot?: string; worktree?: string }; + /** Supplying a repoName enables the legacy rung for keys the reader maps. */ + legacy?: { repoName?: string }; +} + +export interface Resolved { + value: T; + /** ALWAYS an array, weakest-first. Length 1 for replace keys. */ + provenance: Provenance[]; +} + +/** A scope whose authored value was found but refused (type, path guard, or store). */ +export interface InvalidScope { + scope: Scope; + file: string | null; + reason: string; +} + +export interface ListedSetting { + key: string; + value: unknown; + provenance: Provenance[]; + migrated: boolean; + /** Present only for keys found in files but absent from the registry. */ + unregistered?: true; + /** Scopes skipped during resolution, with the reason each was refused. */ + invalid?: InvalidScope[]; + /** Set when the value could not be expanded here; `value` is then raw. */ + expandError?: string; +} + +export interface ExplainRow { + scope: Scope; + file: string | null; + present: boolean; + /** The value AS AUTHORED — never variable-expanded. */ + value?: unknown; + /** Set when the value was ignored because the key is teamLocked. */ + shadowed?: "teamLocked"; + /** Set when the value was refused; the reason it was refused. */ + invalid?: string; +} + +export interface ExpandCtx { + repoRoot?: string; + worktree?: string; + home: string; + teamsDir: string; +} + +// ─── The legacy rung ───────────────────────────────────────────────────────── + +export interface LegacyReader { + (key: string, repoName: string): unknown | undefined; +} + +/** + * The three wave-1 keys and the `repos//config.json` field each one used + * to live in. A key that is not in this map has no legacy rung. + */ +const LEGACY_KEY_MAP: Record = { + "rt.roles": "roles", + "rt.intercepts": "intercepts", + "rt.worktrees": "worktrees", +}; + +function legacyFilePath(repoName: string): string { + return join(repoDataDir(repoName), "config.json"); +} + +/** Read and parse a JSON file; `fallback` on any failure (missing, malformed, or read error). */ +function readJson(path: string, fallback: T): T { + try { + return JSON.parse(readFileSync(path, "utf8")) as T; + } catch { + return fallback; + } +} + +/** + * The shipped legacy reader: the named key's field out of the per-repo + * config.json. `readJson` already degrades a missing or malformed file to the + * fallback, which is exactly the "no legacy value" answer we want. + */ +export const defaultLegacyReader: LegacyReader = (key, repoName) => { + const field = LEGACY_KEY_MAP[key]; + if (field === undefined) return undefined; + const raw = readJson>(legacyFilePath(repoName), {}); + return raw[field]; +}; + +let legacyReader: LegacyReader = defaultLegacyReader; + +/** + * TEST SEAM ONLY. Production code uses the shipped reader; pass + * `defaultLegacyReader` back to restore it. + */ +export function setLegacyReader(fn: LegacyReader): void { + legacyReader = fn; +} + +// ─── Variables ─────────────────────────────────────────────────────────────── + +const VAR_RE = /\$\{([^}]*)\}/g; +const TEAM_VAR_RE = /^team:(.+)$/; + +/** + * Replaces ONLY `${repoRoot}`, `${worktree}`, `${home}` and `${team:}`. + * Every other `${...}` passes through verbatim — domain templates like the + * interceptor's `${port}` are not ours to expand, and the same string may hold + * both kinds, so substitution is per-occurrence. `${team:}` is lexical: + * `/` with no existence check (a missing team surfaces at use + * time through the consumer's own fail-open path), but the name must be a + * single directory segment — see `teamPath`. A closed-set variable with no + * context in `ctx` throws — silently emitting a half-expanded path is the + * dishonesty this design bans. + * + * Recurses through arrays and plain objects; non-strings pass through. Never + * mutates its input. + */ +export function expandVariables(value: unknown, ctx: ExpandCtx): unknown { + if (typeof value === "string") return expandString(value, ctx); + if (Array.isArray(value)) return value.map((item) => expandVariables(item, ctx)); + if (isPlainObject(value)) { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) out[k] = expandVariables(v, ctx); + return out; + } + return value; +} + +function expandString(input: string, ctx: ExpandCtx): string { + return input.replace(VAR_RE, (match, name: string) => { + if (name === "home") return ctx.home; + if (name === "repoRoot") return required(ctx.repoRoot, "repoRoot", "a repo path"); + if (name === "worktree") return required(ctx.worktree, "worktree", "a worktree path"); + const team = TEAM_VAR_RE.exec(name); + if (team) return teamPath(ctx.teamsDir, team[1] as string); + return match; // not ours — pass through verbatim + }); +} + +/** + * `${team:}` → `/`, but only for a name that is a single + * directory segment. `` is a team NAME, and `join()` normalizes away + * `..`, so `${team:../../.ssh}` would quietly resolve to a path OUTSIDE the + * teams dir — a store value (a team store's own, even) that reads or executes + * from anywhere on disk while still looking like a team-relative reference. + * Any `/`, `\` or `..` therefore throws, on the same closed-set footing as an + * unsatisfiable `${repoRoot}`: `get` surfaces it, `list` degrades that one + * value to an `expandError`, and no half-expanded path is ever emitted. + */ +function teamPath(teamsDir: string, name: string): string { + if (name.includes("/") || name.includes("\\") || name.includes("..")) { + throw new Error( + `rt: cannot expand \${team:${name}} — a team name must be a single directory segment (no "/", "\\" or "..")`, + ); + } + return join(teamsDir, name); +} + +function required(value: string | undefined, name: string, needs: string): string { + if (value === undefined || value === "") { + throw new Error(`rt: cannot expand \${${name}} — this setting was resolved without ${needs}`); + } + return value; +} + +// ─── Store reading ─────────────────────────────────────────────────────────── + +interface StoreBundle { + user: StoreFile; + machine: StoreFile; + /** One per team that has a local settings file, alphabetical (wave 1: overlay all). */ + teams: StoreFile[]; +} + +function readStores(): StoreBundle { + return { + user: readStore(userSettingsPath()), + machine: readStore(machineSettingsPath()), + teams: [...listTeams()].sort().map((team) => readStore(teamSettingsPath(team))), + }; +} + +// ─── Slots: every rung a key could come from, weakest-first ────────────────── + +interface Slot { + scope: Scope; + file: string | null; + present: boolean; + value?: unknown; +} + +function collectSlots(def: SettingDef, stores: StoreBundle, opts: ResolveOpts): Slot[] { + const slots: Slot[] = []; + const identity = opts.repoIdentity ?? null; + const useRepo = def.repoScoped === true && typeof identity === "string" && identity !== ""; + const repoSection = (store: StoreFile): Record | undefined => + useRepo ? store.repos[identity as string] : undefined; + + const push = (scope: Scope, file: string | null, section: Record | undefined) => { + const value = section?.[def.key]; + if (value === undefined) slots.push({ scope, file, present: false }); + else slots.push({ scope, file, present: true, value }); + }; + + /** + * Wave 1 overlays EVERY cloned team, alphabetically, so the result is + * deterministic; multi-team precedence is explicitly deferred (spec: out of + * scope — one team exists today). With no team cloned at all we still emit + * one absent rung so `explain` shows the ladder in full. + */ + const pushTeams = (scope: Scope, section: (store: StoreFile) => Record | undefined) => { + if (stores.teams.length === 0) { + slots.push({ scope, file: null, present: false }); + return; + } + for (const store of stores.teams) push(scope, store.file, section(store)); + }; + + // default — cloned so a caller mutating the resolved value cannot corrupt + // the registry's shared def object. + slots.push( + def.default === undefined + ? { scope: "default", file: null, present: false } + : { scope: "default", file: null, present: true, value: structuredClone(def.default) }, + ); + + // legacy — only reachable when the caller supplies the repo NAME (repos.json + // is the name→path registry; the name/identity bridge is the caller's job). + const repoName = opts.legacy?.repoName; + if (repoName) { + const value = legacyReader(def.key, repoName); + const file = legacyFilePath(repoName); + slots.push( + value === undefined + ? { scope: "legacy", file, present: false } + : { scope: "legacy", file, present: true, value }, + ); + } else { + slots.push({ scope: "legacy", file: null, present: false }); + } + + // The ladder itself, weakest → strongest. Repo rungs are omitted entirely + // when they are unreachable (key not repoScoped, or no identity in hand) — + // an unreachable rung in `explain` would be noise, not honesty. + pushTeams("team", (store) => store.global); + push("user", stores.user.file, stores.user.global); + if (useRepo) pushTeams("team.repo", repoSection); + if (useRepo) push("user.repo", stores.user.file, repoSection(stores.user)); + push("machine", stores.machine.file, stores.machine.global); + if (useRepo) push("machine.repo", stores.machine.file, repoSection(stores.machine)); + + return slots; +} + +// ─── Resolution ────────────────────────────────────────────────────────────── + +interface Resolution { + value: unknown; + provenance: Provenance[]; + invalid: InvalidScope[]; + rows: ExplainRow[]; +} + +const TEAM_LOCKED_SCOPES: Scope[] = ["default", "team", "team.repo"]; + +/** The store a scope's value is authored in — the rung's write-side scope. */ +function baseScope(scope: Scope): SettingScope | null { + if (scope === "team" || scope === "team.repo") return "team"; + if (scope === "user" || scope === "user.repo") return "user"; + if (scope === "machine" || scope === "machine.repo") return "machine"; + return null; // default and legacy are not authored in a store +} + +/** + * The path-literal guard applies to SHARED scopes only — the machine store is + * the one place path literals are legal, and the legacy per-repo file is full + * of them today. + */ +function validateForScope( + def: SettingDef, + scope: Scope, + value: unknown, +): { ok: true } | { ok: false; reason: string } { + const shared = scope === "team" || scope === "user" || scope === "team.repo" || scope === "user.repo"; + return validateValue(shared ? def : { ...def, pathGuardFields: undefined }, value); +} + +function resolveDef(def: SettingDef, stores: StoreBundle, opts: ResolveOpts): Resolution { + const slots = collectSlots(def, stores, opts); + const rows: ExplainRow[] = []; + const invalid: InvalidScope[] = []; + const applied: Array<{ scope: Scope; file: string | null; value: unknown }> = []; + + for (const slot of slots) { + const row: ExplainRow = { scope: slot.scope, file: slot.file, present: slot.present }; + if (!slot.present) { + rows.push(row); + continue; + } + row.value = slot.value; + + // teamLocked: team.repo > team > default and nothing else. Other scopes' + // values are reported, never applied. + if (def.teamLocked && !TEAM_LOCKED_SCOPES.includes(slot.scope)) { + row.shadowed = "teamLocked"; + rows.push(row); + continue; + } + + // A key authored in a store its def does not list is not this key. + const base = baseScope(slot.scope); + if (base !== null && !def.scopes.includes(base)) { + const reason = `not settable in the ${base} store (allowed: ${def.scopes.join(", ")})`; + row.invalid = reason; + invalid.push({ scope: slot.scope, file: slot.file, reason }); + rows.push(row); + continue; + } + + // The registry default is trusted; everything read off disk is checked. + if (slot.scope !== "default") { + const check = validateForScope(def, slot.scope, slot.value); + if (!check.ok) { + row.invalid = check.reason; + invalid.push({ scope: slot.scope, file: slot.file, reason: check.reason }); + rows.push(row); + continue; + } + } + + rows.push(row); + applied.push({ scope: slot.scope, file: slot.file, value: slot.value }); + } + + const merged = mergeApplied(def, applied); + return { value: merged.value, provenance: merged.provenance, invalid, rows }; +} + +function mergeApplied( + def: SettingDef, + applied: Array<{ scope: Scope; file: string | null; value: unknown }>, +): { value: unknown; provenance: Provenance[] } { + if (applied.length === 0) return { value: undefined, provenance: [] }; + + // Deep merge is only meaningful for objects; a `deep` def with any other + // type — or a non-object layer, only reachable through a malformed registry + // default since every value read off disk is type-checked — falls back to + // replace rather than inventing semantics for it. + if (def.merge === "deep" && def.type === "object") { + const objectLayers = applied.filter((layer) => isPlainObject(layer.value)); + if (objectLayers.length > 0) { + const { value, contributors } = deepMerge(objectLayers.map((layer) => layer.value)); + return { + value, + provenance: contributors.map((i) => { + const layer = objectLayers[i] as (typeof applied)[number]; + return { scope: layer.scope, file: layer.file }; + }), + }; + } + } + + const winner = applied[applied.length - 1] as (typeof applied)[number]; + return { value: winner.value, provenance: [{ scope: winner.scope, file: winner.file }] }; +} + +// ─── Deep merge with per-leaf attribution ──────────────────────────────────── + +// Leaf paths are joined with NUL so a field name containing a dot cannot +// collide with a nested path of the same spelling. +const PATH_SEP = "\u0000"; + +/** + * Overlays object layers weakest → strongest, tracking which layer owns each + * surviving leaf. Arrays and scalars replace atomically (an array IS a leaf); + * objects recurse. `contributors` is the ascending list of layer indexes that + * still own at least one leaf of the result. + */ +function deepMerge(layers: unknown[]): { value: Record; contributors: number[] } { + const owner = new Map(); + let acc: Record = {}; + + layers.forEach((layer, index) => { + acc = overlay(acc, layer as Record, owner, index, ""); + }); + + const contributors = [...new Set(owner.values())].sort((a, b) => a - b); + return { value: acc, contributors }; +} + +function overlay( + base: Record, + over: Record, + owner: Map, + index: number, + prefix: string, +): Record { + const out: Record = { ...base }; + + for (const [key, value] of Object.entries(over)) { + const path = prefix === "" ? key : `${prefix}${PATH_SEP}${key}`; + const current = out[key]; + + if (isPlainObject(value) && isPlainObject(current)) { + out[key] = overlay(current, value, owner, index, path); + continue; + } + + out[key] = value; + clearOwners(owner, path); + registerLeaves(value, path, owner, index); + } + + return out; +} + +function clearOwners(owner: Map, path: string): void { + owner.delete(path); + const under = `${path}${PATH_SEP}`; + for (const existing of [...owner.keys()]) { + if (existing.startsWith(under)) owner.delete(existing); + } +} + +/** + * Records ownership at LEAF granularity: an object is walked into so that a + * stronger layer overriding every one of its fields takes the whole thing over + * (and the weaker layer correctly drops out of provenance). + */ +function registerLeaves(value: unknown, path: string, owner: Map, index: number): void { + if (isPlainObject(value)) { + const entries = Object.entries(value); + if (entries.length > 0) { + for (const [key, child] of entries) { + registerLeaves(child, `${path}${PATH_SEP}${key}`, owner, index); + } + return; + } + } + owner.set(path, index); +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +function unknownKey(key: string): Error { + return new Error(`rt: unknown setting "${key}" — not in the settings registry (see \`rt settings list\`)`); +} + +function expandCtxFrom(opts: ResolveOpts): ExpandCtx { + return { + repoRoot: opts.expandCtx?.repoRoot, + worktree: opts.expandCtx?.worktree, + home: process.env.HOME ?? homedir(), + teamsDir: teamsDir(), + }; +} + +function warnInvalid(key: string, entry: InvalidScope): void { + console.warn( + `rt: ignoring "${key}" from the ${entry.scope} scope (${entry.file ?? "no file"}): ${entry.reason}`, + ); +} + +/** + * Resolves one key across the whole ladder. Throws for an unregistered key — + * an explicit get of something rt has never heard of is a caller bug, not a + * degrade (contrast: unknown keys FOUND in files, which only warn). + */ +export function getSetting(key: string, opts: ResolveOpts = {}): Resolved { + const def = getDef(key); + if (!def) throw unknownKey(key); + + const resolution = resolveDef(def, readStores(), opts); + for (const entry of resolution.invalid) warnInvalid(key, entry); + + const shouldExpand = opts.expand ?? true; + const value = + shouldExpand && resolution.value !== undefined + ? expandVariables(resolution.value, expandCtxFrom(opts)) + : resolution.value; + + return { value: value as T, provenance: resolution.provenance }; +} + +/** + * Every registered key resolved (registry order), then every unregistered key + * found in the stores (alphabetical). Nothing here throws: a survey of the + * whole settings map must survive one bad value, so an unexpandable value + * degrades to its raw form plus an `expandError` label. + */ +export function listSettings(opts: ResolveOpts = {}): ListedSetting[] { + const stores = readStores(); + const ctx = expandCtxFrom(opts); + const shouldExpand = opts.expand ?? true; + const out: ListedSetting[] = []; + + for (const def of allDefs()) { + const resolution = resolveDef(def, stores, opts); + for (const entry of resolution.invalid) warnInvalid(def.key, entry); + + const listed: ListedSetting = { + key: def.key, + value: resolution.value, + provenance: resolution.provenance, + migrated: isMigrated(def), + }; + if (resolution.invalid.length > 0) listed.invalid = resolution.invalid; + + if (shouldExpand && resolution.value !== undefined) { + try { + listed.value = expandVariables(resolution.value, ctx); + } catch (err) { + listed.expandError = (err as Error).message; + console.warn(`rt: showing "${def.key}" unexpanded — ${listed.expandError}`); + } + } + + out.push(listed); + } + + out.push(...listUnregistered(stores, opts)); + return out; +} + +/** + * Keys present in a store file that the registry has never heard of. They are + * never merged (there is no def to say how) — the strongest scope holding one + * is reported as-is, so a teammate's newer key is visible rather than silently + * dropped. + */ +function listUnregistered(stores: StoreBundle, opts: ResolveOpts): ListedSetting[] { + const identity = opts.repoIdentity ?? null; + const found = new Map(); + + const scan = (scope: Scope, file: string, section: Record | undefined) => { + for (const [key, value] of Object.entries(section ?? {})) { + if (getDef(key)) continue; + found.set(key, { scope, file, value }); // later (stronger) scans win + } + }; + const repoSection = (store: StoreFile) => + typeof identity === "string" && identity !== "" ? store.repos[identity] : undefined; + + for (const store of stores.teams) scan("team", store.file, store.global); + scan("user", stores.user.file, stores.user.global); + for (const store of stores.teams) scan("team.repo", store.file, repoSection(store)); + scan("user.repo", stores.user.file, repoSection(stores.user)); + scan("machine", stores.machine.file, stores.machine.global); + scan("machine.repo", stores.machine.file, repoSection(stores.machine)); + + return [...found.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, hit]) => { + console.warn( + `rt: unregistered setting "${key}" in ${hit.file} — ignoring it (this rt may be older than the store)`, + ); + return { + key, + value: hit.value, + provenance: [{ scope: hit.scope, file: hit.file }], + migrated: false, + unregistered: true as const, + }; + }); +} + +/** + * One row per reachable rung, weakest-first, with values AS AUTHORED. Repo + * rungs are omitted entirely when the key is not repoScoped or no identity was + * supplied — showing rungs that could never apply would be noise, not honesty. + */ +export function explainSetting(key: string, opts: ResolveOpts = {}): ExplainRow[] { + const def = getDef(key); + if (!def) throw unknownKey(key); + return resolveDef(def, readStores(), opts).rows; +} diff --git a/packages/rt-client/src/settings/stores.ts b/packages/rt-client/src/settings/stores.ts new file mode 100644 index 00000000..4a590d9b --- /dev/null +++ b/packages/rt-client/src/settings/stores.ts @@ -0,0 +1,129 @@ +/** + * Reading the four settings store files (RT-47). + * + * `readStore` is the shared "raw JSONC → {global, repos}" step every store + * (user/team/machine) goes through before the resolver layers them by scope. + * It uses jsonc-parser (`parse`) rather than lib/jsonc.ts's stripJsonc: this + * is the one place in rt that also needs to WRITE these files back with + * comments/formatting intact (via jsonc-parser's `modify`/`applyEdits`, added + * alongside `setSetting` in resolve.ts), and both directions should go + * through the same library. stripJsonc keeps its existing callers. + * + * A store file is honest-degrade, not throw-on-read: absent, empty, or + * malformed all resolve to an empty store rather than crashing a caller that + * just wants "whatever settings exist" (teammates run version-skewed + * binaries; a store file with content this rt can't parse must not brick + * every settings read). + */ + +import { existsSync, readdirSync, readFileSync, statSync, type Dirent } from "fs"; +import { parse, type ParseError } from "jsonc-parser"; +import { join } from "path"; +import { teamsDir, teamSettingsPath } from "./paths.ts"; + +export interface StoreFile { + /** Top-level keys other than "repos" — the global scope for this store. */ + global: Record; + /** The "repos" object, keyed by repo identity. Empty if absent. */ + repos: Record>; + /** The path this store was read from (echoed back for provenance). */ + file: string; + /** False only when the file does not exist at all. */ + exists: boolean; +} + +const EMPTY_STORE = (file: string, exists: boolean): StoreFile => ({ + global: {}, + repos: {}, + file, + exists, +}); + +/** + * Reads and parses one settings store file. Never throws: + * - missing file → `{ exists: false }`, empty maps. + * - present but malformed (parse errors, or a root that isn't a JSON + * object) → `{ exists: true }`, empty maps, one console.warn. + * - present and well-formed → `{ exists: true }`, split into + * `global`/`repos`. + */ +export function readStore(file: string): StoreFile { + if (!existsSync(file)) return EMPTY_STORE(file, false); + + let raw: string; + try { + raw = readFileSync(file, "utf8"); + } catch (err) { + console.warn(`rt: failed to read settings store ${file}, ignoring: ${(err as Error).message}`); + return EMPTY_STORE(file, true); + } + + if (raw.trim() === "") return EMPTY_STORE(file, true); + + const errors: ParseError[] = []; + const root = parse(raw, errors, { allowTrailingComma: true }); + + if (errors.length > 0 || root === undefined || typeof root !== "object" || Array.isArray(root)) { + console.warn(`rt: malformed settings store ${file}, ignoring (treating as empty)`); + return EMPTY_STORE(file, true); + } + + const { repos, ...global } = root as Record; + const reposIsValid = repos !== undefined && typeof repos === "object" && repos !== null && !Array.isArray(repos); + + if (repos !== undefined && !reposIsValid) { + console.warn(`rt: malformed "repos" section in settings store ${file}, ignoring repo sections (global keys still apply)`); + } + + const reposValid = reposIsValid ? (repos as Record>) : {}; + + return { global, repos: reposValid, file, exists: true }; +} + +/** + * Names of every team that has a local settings store — i.e. subdirectories + * of teamsDir() that contain mattstack/settings.jsonc. A team dir without a + * settings file (a clone mid-setup, or an unrelated directory) is not yet a + * team as far as the resolver is concerned. + * + * Honest-degrade like readStore, and for a sharper reason: this scan is on the + * path of EVERY settings resolution, so one bad directory entry must never + * brick `rt settings` or any reader behind it. A team clone that was symlinked + * in and later moved leaves a dangling symlink here, and the follow-the-link + * stat that keeps symlinked clones working throws ENOENT on exactly that — so + * the scan is guarded twice: around the readdir (an unreadable teams dir means + * no teams), and around EACH entry (a dangling link, an EACCES, or a stat that + * loses a race with a concurrent move skips that entry and leaves the healthy + * teams intact). + */ +export function listTeams(): string[] { + const dir = teamsDir(); + if (!existsSync(dir)) return []; + + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch (err) { + console.warn(`rt: failed to list teams in ${dir}, treating as no teams: ${(err as Error).message}`); + return []; + } + + const teams: string[] = []; + for (const entry of entries) { + try { + // isDirectory() is false for a symlink, but a symlinked team clone is a + // real team — those resolve through stat, which is also what throws on a + // dangling link, hence the per-entry try. + const isDir = + entry.isDirectory() || + (entry.isSymbolicLink() && statSync(join(dir, entry.name)).isDirectory()); + if (!isDir) continue; + if (existsSync(teamSettingsPath(entry.name))) teams.push(entry.name); + } catch (err) { + console.warn( + `rt: skipping unreadable teams entry ${join(dir, entry.name)}: ${(err as Error).message}`, + ); + } + } + return teams; +} diff --git a/packages/rt-client/src/settings/write.ts b/packages/rt-client/src/settings/write.ts new file mode 100644 index 00000000..e1b28f33 --- /dev/null +++ b/packages/rt-client/src/settings/write.ts @@ -0,0 +1,292 @@ +/** + * The settings write path (RT-47): `setSetting` — a single, comment-preserving + * write into one of the three AUTHORED stores (user/team/machine; `legacy` and + * `default` are read-only rungs and never appear here). + * + * Writes go through jsonc-parser's `modify`/`applyEdits` rather than + * parse-mutate-stringify, so existing comments and formatting in the store + * file survive a write to an unrelated key (verified: `modify` only rewrites + * the minimal edit range needed for the touched path; everything else in the + * document — including comments — is untouched text). + * + * JSONPath segments are literal object keys, not `.`-namespaced walks: a + * global write targets `[key]` (e.g. `["rt.llm"]`) and a repoScoped write + * targets `["repos", identity, key]`. A dotted key like `"rt.roles"` is one + * path segment, not two — jsonc-parser never splits on `.` (verified with a + * throwaway script against the installed 3.3.1). Missing parents (`"repos"`, + * or the identity's section under it) are created by `modify` itself, and + * that creation is comment-safe — no special-casing needed here. + * + * Creating an absent store file (user/machine only — see "Team selection" + * below for why team stores are never auto-created): the file is seeded + * in-memory as `// header comment\n{}\n` BEFORE the first `modify` call. + * This is required, not cosmetic — a verified footgun: running `modify` on a + * comment-only document with NO braces at all (e.g. just `// header\n`) + * places the new object first and re-emits the header AFTER the closing + * brace, which is backwards. Seeding an empty object ahead of time gives + * `modify` real JSON structure to edit into, and the header comment stays + * exactly where it was written, above the object. + * + * ── Refusals ──────────────────────────────────────────────────────────── + * In order: unregistered key, migrated:false (naming `def.legacyFile` and, + * when present, `def.siblingCommand`), a scope the def does not list, a + * repoIdentity supplied for a key that is not `repoScoped`, a value that + * fails `registry.validateValue` (type check + the path-literal guard), and + * finally — only for `scope: "team"` — a team store that cannot be resolved + * (see below). Filesystem-touching checks (team resolution) run last, after + * every pure/in-memory refusal, so a bad call never creates or touches a + * file it was going to refuse anyway. + * + * ── The path-literal guard is scope-aware ────────────────────────────── + * Mirrors `resolve.ts`'s `validateForScope`: `def.pathGuardFields` (wave 1: + * `rt.roles.hook`) is enforced at `user` and `team` scope, where a path + * literal would silently stop applying the moment a teammate's checkout (or + * this developer's own machine) sits at a different path. `machine` scope is + * exempt — it is the one store where a path literal is the CORRECT way to + * express something local-only, so writes there skip the guard entirely + * (implemented by stripping `pathGuardFields` before calling + * `validateValue`, same trick `resolve.ts` uses on the read side). + * + * ── Team selection (a design decision this task made, per the brief) ── + * The base signature (`setSetting(key, value, scope, opts?)`) is extended + * here with `opts.team`, an explicit team NAME to target. Selection rule for + * `scope: "team"`: + * - `opts.team` given → that team's store; refuse if it has no local + * settings file (a team dir can exist mid-clone without one — see + * `stores.ts#listTeams`). + * - `opts.team` omitted, exactly one team has a local store → use it. + * - `opts.team` omitted, zero or multiple teams have a local store → + * refuse with a clear error (asking for `opts.team` in the multiple + * case). Wave 1 ships exactly one team, so this is the common path; the + * alternative of silently picking "the first team alphabetically" was + * considered and rejected — guessing which team's shared file to mutate + * is exactly the silent-oracle behavior this design bans elsewhere. + * A team store is NEVER auto-created by `setSetting` — team stores are + * seeded by the migration/orchestrator step and live in a repo that needs a + * commit+push to reach teammates; conjuring one here would produce an + * uncommitted, unshared file masquerading as team state. + * + * Every successful `scope: "team"` write prints one reminder line to + * stderr: the edit only exists in this local clone until it is committed + * and pushed. No such reminder for `user`/`machine` (nothing to push there + * in wave 1). + * + * ── Malformed stores refuse rather than edit around the damage ───────── + * An existing store's on-disk text is parsed and checked (`assertEditableJsonc`) + * before `modify` ever runs: real parse errors, a non-object root, or a + * duplicate key anywhere in the tree all refuse with one message naming the + * file. The duplicate-key case is the sharp one — it is not a parse error at + * all (JSON's grammar permits it), but `modify` edits the FIRST occurrence by + * offset while every reader takes the LAST, so a naive edit-in-place would + * report success while the effective value never changes, and the file would + * still degrade to empty on the next `readStore`. Refusing is the only + * option that doesn't either lie about success or write a still-broken file. + * + * ── Writes are write-temp-then-rename ─────────────────────────────────── + * Mirrors `lib/json-store.ts`'s `writeJson`: the edited text is written to a + * `...tmp` file in the SAME directory, then renamed onto + * the real path — stores never tear, matching the rest of rt's persistence + * (the machine store especially has no git fallback to recover a partial + * write from). The tmp file carries the edited TEXT exactly as `applyEdits` + * produced it, never round-tripped through `JSON.stringify` — that's what + * keeps comments alive. + */ + +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "fs"; +import { applyEdits, modify, parseTree, type JSONPath, type Node, type ParseError } from "jsonc-parser"; +import { randomBytes } from "crypto"; +import { dirname } from "path"; +import { machineSettingsPath, teamSettingsPath, userSettingsPath } from "./paths.ts"; +import { getDef, isMigrated, validateValue, type SettingDef, type SettingScope } from "./registry-machinery.ts"; +import { listTeams } from "./stores.ts"; + +export interface SetSettingOpts { + /** Normalized repo identity — required to target a repoScoped key's `repos.` section. */ + repoIdentity?: string; + /** + * Which team's local store to write into, for `scope: "team"`. See the + * module doc's "Team selection" section. Ignored for `user`/`machine`. + */ + team?: string; +} + +const FORMAT = { tabSize: 2, insertSpaces: true, eol: "\n" }; + +function refuse(message: string): never { + throw new Error(`rt: ${message}`); +} + +/** + * Writes `value` for `key` into the given scope's store, preserving every + * existing comment and creating the file/section it needs. See the module + * doc for the full refusal list and the team-selection rule. + */ +export function setSetting(key: string, value: unknown, scope: SettingScope, opts: SetSettingOpts = {}): void { + const def = getDef(key); + if (!def) { + refuse(`unknown setting "${key}" — not in the settings registry (see \`rt settings list\`)`); + } + + if (!isMigrated(def)) { + refuse(migratedFalseMessage(key, def)); + } + + if (!def.scopes.includes(scope)) { + refuse(`"${key}" cannot be set in the ${scope} store (allowed: ${def.scopes.join(", ")})`); + } + + if (opts.repoIdentity !== undefined && def.repoScoped !== true) { + refuse(`"${key}" is not repo-scoped — omit the repo identity`); + } + + // machine scope is exempt from the path-literal guard (see module doc). + const guardedDef: SettingDef = scope === "machine" ? { ...def, pathGuardFields: undefined } : def; + const check = validateValue(guardedDef, value); + if (!check.ok) { + refuse(`refusing to set "${key}": ${check.reason} — use \${team:} or \${repoRoot} instead`); + } + + const storePath = resolveStorePath(scope, opts); + const jsonPath: JSONPath = opts.repoIdentity !== undefined ? ["repos", opts.repoIdentity, key] : [key]; + + writeIntoStore(storePath, jsonPath, value, /* createIfMissing */ scope !== "team"); + + if (scope === "team") { + console.error( + `rt: wrote "${key}" to the local team store (${storePath}) — this is local only until you commit and push it.`, + ); + } +} + +function migratedFalseMessage(key: string, def: SettingDef): string { + const legacyPart = def.legacyFile ? ` — it is still read from ${def.legacyFile}` : ""; + const siblingPart = def.siblingCommand ? `; use \`${def.siblingCommand}\` instead` : ""; + return `"${key}" is not writable through the settings resolver yet${legacyPart}${siblingPart}`; +} + +/** Resolves which store file a write targets, applying the team-selection rule for `scope: "team"`. */ +function resolveStorePath(scope: SettingScope, opts: SetSettingOpts): string { + if (scope === "user") return userSettingsPath(); + if (scope === "machine") return machineSettingsPath(); + + if (opts.team !== undefined) { + const path = teamSettingsPath(opts.team); + if (!existsSync(path)) { + refuse(`team store for "${opts.team}" does not exist (${path}) — clone/seed it before writing to it`); + } + return path; + } + + const teams = listTeams(); + if (teams.length === 0) { + refuse(`no local team store found — clone a team under ~/.mattstack/teams/ or pass opts.team`); + } + if (teams.length > 1) { + refuse(`multiple local team stores found (${teams.join(", ")}) — pass opts.team to choose one`); + } + return teamSettingsPath(teams[0] as string); +} + +/** `// header comment\n{}\n` — see module doc for why the object must be seeded before the first `modify`. */ +function seedHeader(): string { + return `// rt settings — created by \`rt settings set\`. JSONC: comments and trailing commas are fine.\n{}\n`; +} + +/** + * Refuses to edit a store whose on-disk text is not a single well-formed + * JSONC object. Two failure classes: + * - genuine parse errors (unbalanced braces, trailing garbage, etc.) — + * caught by jsonc-parser's own error collection, the same check + * `stores.ts#readStore` runs on the read side; + * - a document that PARSES but is unsafe to `modify`: a non-object root, or + * a duplicate key anywhere in the tree. `modify` edits the FIRST + * occurrence of a duplicate key by offset, while every reader (`parse`, + * `JSON.parse`) takes the LAST — so a naive edit-in-place would report + * success while the effective value never changes (verified with a + * throwaway script: `modify` touched offset 14 in `{"rt.llm":1,"rt.llm":2}`, + * but re-parsing the "fixed" text still returned `2`). Both classes refuse + * rather than silently editing around the damage — the alternative is a + * write that reports success but does nothing, or one that writes a still- + * broken file that the NEXT read honest-degrades to an empty store. + */ +function assertEditableJsonc(file: string, content: string): void { + const errors: ParseError[] = []; + const tree = parseTree(content, errors, { allowTrailingComma: true }); + + const malformed = + errors.length > 0 || tree === undefined || tree.type !== "object" || findDuplicateKey(tree) !== undefined; + + if (malformed) { + refuse(`fix the JSONC syntax error in ${file} first — refusing to edit a malformed store`); + } +} + +/** Depth-first search for the first duplicate property name in any object in the tree. */ +function findDuplicateKey(node: Node): string | undefined { + if (node.type === "object" && node.children) { + const seen = new Set(); + for (const property of node.children) { + const keyNode = property.children?.[0]; + if (keyNode !== undefined && typeof keyNode.value === "string") { + if (seen.has(keyNode.value)) return keyNode.value; + seen.add(keyNode.value); + } + const valueNode = property.children?.[1]; + if (valueNode !== undefined) { + const nested = findDuplicateKey(valueNode); + if (nested !== undefined) return nested; + } + } + return undefined; + } + if (node.type === "array" && node.children) { + for (const child of node.children) { + const nested = findDuplicateKey(child); + if (nested !== undefined) return nested; + } + } + return undefined; +} + +function writeIntoStore(storePath: string, jsonPath: JSONPath, value: unknown, createIfMissing: boolean): void { + let content: string; + if (existsSync(storePath)) { + content = readFileSync(storePath, "utf8"); + if (content.trim() === "") { + content = seedHeader(); + } else { + assertEditableJsonc(storePath, content); + } + } else { + if (!createIfMissing) { + // Unreachable via setSetting today: resolveStorePath already refuses + // every "team" path that lacks a file before we get here. Kept as a + // defensive guard against a future caller of writeIntoStore directly. + refuse(`store file ${storePath} does not exist`); + } + mkdirSync(dirname(storePath), { recursive: true }); + content = seedHeader(); + } + + const edits = modify(content, jsonPath, value, { formattingOptions: FORMAT }); + const next = applyEdits(content, edits); + const finalText = next.endsWith("\n") ? next : `${next}\n`; + + // Write-temp-then-rename in the same directory, mirroring + // lib/json-store.ts's writeJson — stores never tear, and the machine store + // especially has no git fallback to recover a partial write from. The + // edited TEXT is written as-is, never round-tripped through + // JSON.stringify, so comments and formatting survive. + const tmp = `${storePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; + try { + writeFileSync(tmp, finalText); + renameSync(tmp, storePath); + } catch (err) { + try { + unlinkSync(tmp); + } catch { + // tmp file never got created, or was already cleaned up — nothing to do + } + throw err; + } +} diff --git a/packages/rt-client/test/index-surface.test.ts b/packages/rt-client/test/index-surface.test.ts new file mode 100644 index 00000000..7ce7bece --- /dev/null +++ b/packages/rt-client/test/index-surface.test.ts @@ -0,0 +1,29 @@ +/** + * Public-surface guard for the settings registry API (RT-50): every consumer + * in-process (deck/board/gitq) reaches these through the npm package entry + * point, not `src/settings/registry-machinery.ts` directly — an export + * missing from index.ts strands them even though the source module has it. + * registry-machinery.ts's own docblock mandates calling `isMigrated()` + * instead of testing `def.migrated`; this test caught that mandate not + * reaching the public surface once already (RT-50 task 9 review). + */ + +import { describe, expect, test } from "bun:test"; +import * as rtClient from "../src/index.ts"; + +describe("index.ts settings registry surface", () => { + test("exports the full registry API", () => { + expect(typeof rtClient.getDef).toBe("function"); + expect(typeof rtClient.allDefs).toBe("function"); + expect(typeof rtClient.validateValue).toBe("function"); + expect(typeof rtClient.isMigrated).toBe("function"); + expect(Array.isArray(rtClient.REGISTRY)).toBe(true); + }); + + test("isMigrated is usable end to end against a real registry def", () => { + const def = rtClient.getDef("deck.access"); + + expect(def).toBeDefined(); + expect(rtClient.isMigrated(def!)).toBe(true); + }); +}); diff --git a/packages/rt-client/tsconfig.json b/packages/rt-client/tsconfig.json index c9eec0ff..477f606c 100644 --- a/packages/rt-client/tsconfig.json +++ b/packages/rt-client/tsconfig.json @@ -19,5 +19,8 @@ }, "include": [ "src" + ], + "exclude": [ + "src/**/__tests__" ] }