@openparachute/hub 0.3.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +284 -0
  3. package/package.json +31 -0
  4. package/src/__tests__/auth.test.ts +101 -0
  5. package/src/__tests__/auto-wire.test.ts +283 -0
  6. package/src/__tests__/cli.test.ts +192 -0
  7. package/src/__tests__/cloudflare-config.test.ts +54 -0
  8. package/src/__tests__/cloudflare-detect.test.ts +68 -0
  9. package/src/__tests__/cloudflare-state.test.ts +92 -0
  10. package/src/__tests__/cloudflare-tunnel.test.ts +207 -0
  11. package/src/__tests__/config.test.ts +18 -0
  12. package/src/__tests__/env-file.test.ts +125 -0
  13. package/src/__tests__/expose-auth-preflight.test.ts +201 -0
  14. package/src/__tests__/expose-cloudflare.test.ts +484 -0
  15. package/src/__tests__/expose-interactive.test.ts +703 -0
  16. package/src/__tests__/expose-last-provider.test.ts +113 -0
  17. package/src/__tests__/expose-off-auto.test.ts +269 -0
  18. package/src/__tests__/expose-state.test.ts +101 -0
  19. package/src/__tests__/expose.test.ts +1581 -0
  20. package/src/__tests__/hub-control.test.ts +346 -0
  21. package/src/__tests__/hub-server.test.ts +157 -0
  22. package/src/__tests__/hub.test.ts +116 -0
  23. package/src/__tests__/install.test.ts +1145 -0
  24. package/src/__tests__/lifecycle.test.ts +608 -0
  25. package/src/__tests__/migrate.test.ts +422 -0
  26. package/src/__tests__/notes-serve.test.ts +135 -0
  27. package/src/__tests__/port-assign.test.ts +178 -0
  28. package/src/__tests__/process-state.test.ts +140 -0
  29. package/src/__tests__/scribe-config.test.ts +193 -0
  30. package/src/__tests__/scribe-provider-interactive.test.ts +361 -0
  31. package/src/__tests__/services-manifest.test.ts +177 -0
  32. package/src/__tests__/status.test.ts +347 -0
  33. package/src/__tests__/tailscale-commands.test.ts +111 -0
  34. package/src/__tests__/tailscale-detect.test.ts +64 -0
  35. package/src/__tests__/vault-auth-status.test.ts +164 -0
  36. package/src/__tests__/vault-tokens-create-interactive.test.ts +183 -0
  37. package/src/__tests__/well-known.test.ts +214 -0
  38. package/src/auto-wire.ts +184 -0
  39. package/src/cli.ts +482 -0
  40. package/src/cloudflare/config.ts +58 -0
  41. package/src/cloudflare/detect.ts +58 -0
  42. package/src/cloudflare/state.ts +96 -0
  43. package/src/cloudflare/tunnel.ts +135 -0
  44. package/src/commands/auth.ts +69 -0
  45. package/src/commands/expose-auth-preflight.ts +217 -0
  46. package/src/commands/expose-cloudflare.ts +329 -0
  47. package/src/commands/expose-interactive.ts +428 -0
  48. package/src/commands/expose-off-auto.ts +199 -0
  49. package/src/commands/expose.ts +522 -0
  50. package/src/commands/install.ts +422 -0
  51. package/src/commands/lifecycle.ts +324 -0
  52. package/src/commands/migrate.ts +253 -0
  53. package/src/commands/scribe-provider-interactive.ts +269 -0
  54. package/src/commands/status.ts +238 -0
  55. package/src/commands/vault-tokens-create-interactive.ts +137 -0
  56. package/src/commands/vault.ts +17 -0
  57. package/src/config.ts +16 -0
  58. package/src/env-file.ts +76 -0
  59. package/src/expose-last-provider.ts +71 -0
  60. package/src/expose-state.ts +125 -0
  61. package/src/help.ts +279 -0
  62. package/src/hub-control.ts +254 -0
  63. package/src/hub-origin.ts +44 -0
  64. package/src/hub-server.ts +113 -0
  65. package/src/hub.ts +674 -0
  66. package/src/notes-serve.ts +135 -0
  67. package/src/port-assign.ts +125 -0
  68. package/src/process-state.ts +111 -0
  69. package/src/scribe-config.ts +149 -0
  70. package/src/service-spec.ts +296 -0
  71. package/src/services-manifest.ts +171 -0
  72. package/src/tailscale/commands.ts +41 -0
  73. package/src/tailscale/detect.ts +107 -0
  74. package/src/tailscale/run.ts +28 -0
  75. package/src/vault/auth-status.ts +179 -0
  76. package/src/well-known.ts +127 -0
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * Tiny static-file server for the @openparachute/notes PWA bundle.
5
+ *
6
+ * Notes is a SPA — no backend of its own. `parachute start notes` invokes
7
+ * this shim with the installed `dist/` path so the PWA is served at a
8
+ * known port and can be reverse-proxied by `parachute expose` alongside
9
+ * the other services.
10
+ *
11
+ * Invoked as:
12
+ * bun <this-file> --port <n> [--dist <path>] [--mount <prefix>]
13
+ *
14
+ * `--mount` (default `/notes`) is the path prefix the reverse proxy hands
15
+ * us. We strip it before resolving against `dist/` so a request for
16
+ * `/notes/sw.js` reads `{dist}/sw.js` rather than the nonexistent
17
+ * `{dist}/notes/sw.js`. Without the strip, the SW + .webmanifest both
18
+ * SPA-fall-back to index.html with content-type text/html, and the PWA
19
+ * install prompt never fires. Pass `--mount ""` (or `--mount /`) when the
20
+ * bundle is served at the origin root.
21
+ *
22
+ * If --dist is omitted, we resolve @openparachute/notes's dist directory
23
+ * via Bun.resolveSync. If that fails (package not installed globally, or
24
+ * package doesn't ship dist/), exit 1 with a clear error.
25
+ */
26
+
27
+ import { existsSync } from "node:fs";
28
+ import { dirname, join, resolve } from "node:path";
29
+
30
+ interface Args {
31
+ port: number;
32
+ dist?: string;
33
+ mount: string;
34
+ }
35
+
36
+ function parseArgs(argv: string[]): Args {
37
+ let port = 5173;
38
+ let dist: string | undefined;
39
+ let mount = "/notes";
40
+ for (let i = 0; i < argv.length; i++) {
41
+ const a = argv[i];
42
+ if (a === "--port") {
43
+ const v = argv[++i];
44
+ if (!v) throw new Error("--port requires a value");
45
+ const n = Number.parseInt(v, 10);
46
+ if (!Number.isInteger(n) || n <= 0 || n > 65535) {
47
+ throw new Error(`--port must be 1..65535, got "${v}"`);
48
+ }
49
+ port = n;
50
+ } else if (a === "--dist") {
51
+ const v = argv[++i];
52
+ if (!v) throw new Error("--dist requires a value");
53
+ dist = resolve(v);
54
+ } else if (a === "--mount") {
55
+ const v = argv[++i];
56
+ if (v === undefined) throw new Error("--mount requires a value");
57
+ mount = normalizeMount(v);
58
+ } else {
59
+ throw new Error(`unknown argument: ${a}`);
60
+ }
61
+ }
62
+ return { port, dist, mount };
63
+ }
64
+
65
+ export function normalizeMount(raw: string): string {
66
+ if (raw === "" || raw === "/") return "";
67
+ return raw.replace(/\/+$/, "");
68
+ }
69
+
70
+ function resolveNotesDist(): string {
71
+ const pkgPath = Bun.resolveSync("@openparachute/notes/package.json", process.cwd());
72
+ const root = dirname(pkgPath);
73
+ const dist = join(root, "dist");
74
+ if (!existsSync(dist)) {
75
+ throw new Error(
76
+ `@openparachute/notes is installed but has no dist/ directory at ${dist}. The package may not ship a prebuilt bundle — ask the notes maintainer to add a prepublishOnly build step.`,
77
+ );
78
+ }
79
+ return dist;
80
+ }
81
+
82
+ function mimeFor(path: string): string | undefined {
83
+ // Bun.file infers MIME from extension but doesn't know .webmanifest;
84
+ // without this the PWA install prompt sees text/html and bails.
85
+ if (path.endsWith(".webmanifest")) return "application/manifest+json";
86
+ return undefined;
87
+ }
88
+
89
+ export function notesFetch(dist: string, mount: string): (req: Request) => Response {
90
+ const indexHtml = join(dist, "index.html");
91
+ const spaShell = () =>
92
+ new Response(Bun.file(indexHtml), {
93
+ headers: { "content-type": "text/html; charset=utf-8" },
94
+ });
95
+
96
+ return (req) => {
97
+ const url = new URL(req.url);
98
+ let pathname = url.pathname;
99
+ if (mount && (pathname === mount || pathname.startsWith(`${mount}/`))) {
100
+ pathname = pathname.slice(mount.length) || "/";
101
+ }
102
+ if (pathname === "/" || pathname.endsWith("/")) {
103
+ return spaShell();
104
+ }
105
+ const filePath = join(dist, decodeURIComponent(pathname));
106
+ if (!filePath.startsWith(dist)) {
107
+ return new Response("forbidden", { status: 403 });
108
+ }
109
+ if (existsSync(filePath)) {
110
+ const file = Bun.file(filePath);
111
+ const mime = mimeFor(filePath);
112
+ return new Response(file, mime ? { headers: { "content-type": mime } } : undefined);
113
+ }
114
+ return spaShell();
115
+ };
116
+ }
117
+
118
+ if (import.meta.main) {
119
+ const { port, dist: distArg, mount } = parseArgs(process.argv.slice(2));
120
+
121
+ let dist: string;
122
+ try {
123
+ dist = distArg ?? resolveNotesDist();
124
+ } catch (err) {
125
+ console.error(`parachute-notes-serve: ${err instanceof Error ? err.message : String(err)}`);
126
+ process.exit(1);
127
+ }
128
+
129
+ Bun.serve({
130
+ port,
131
+ fetch: notesFetch(dist, mount),
132
+ });
133
+
134
+ console.log(`notes static-serve listening on :${port} (dist=${dist}, mount=${mount || "/"})`);
135
+ }
@@ -0,0 +1,125 @@
1
+ import { parseEnvFile, upsertEnvLine, writeEnvFile } from "./env-file.ts";
2
+ import { CANONICAL_PORT_MAX, CANONICAL_PORT_MIN, PORT_RESERVATIONS } from "./service-spec.ts";
3
+
4
+ /**
5
+ * The CLI is the port authority for Parachute services. At install time it
6
+ * picks a port for each service, writes `PORT=<port>` into the service's
7
+ * `~/.parachute/<svc>/.env`, and reflects the chosen port in services.json.
8
+ * Services keep a compiled-in fallback (e.g. vault → 1940) so a stand-alone
9
+ * `bun run` still works, but the CLI's PORT env var wins on installs it
10
+ * manages.
11
+ *
12
+ * Why up-front assignment instead of detect-on-collision-at-boot:
13
+ * - Two services racing to bind the same port produces an opaque "address in
14
+ * use" deep inside one of them. Assigning at install lets the CLI keep
15
+ * a single coherent picture of who owns what.
16
+ * - The hub's reverse-proxy targets are computed from services.json. If a
17
+ * service silently falls back to a different port at runtime, the hub
18
+ * proxies to a dead port and the user sees a 502 with no explanation.
19
+ * - Re-installs stay idempotent: the existing `PORT=` in .env wins, so a
20
+ * user who edited their port keeps it across upgrades.
21
+ */
22
+
23
+ export type AssignmentSource = "canonical" | "fallback-in-range" | "fallback-out-of-range";
24
+
25
+ export interface PortAssignment {
26
+ readonly port: number;
27
+ readonly source: AssignmentSource;
28
+ /** Set when the canonical slot wasn't available — caller logs it. */
29
+ readonly warning?: string;
30
+ }
31
+
32
+ /**
33
+ * Pure: pick a port given the canonical default and the set of ports we
34
+ * already know to be taken.
35
+ *
36
+ * 1. Prefer canonical (the slot the service expects, e.g. vault → 1940).
37
+ * 2. On collision, walk the unassigned canonical reservations (1944..1949
38
+ * today) — keeps the install inside the Parachute range so other
39
+ * software doesn't accidentally land on the same port.
40
+ * 3. Range exhausted: walk past CANONICAL_PORT_MAX. The warning lets the
41
+ * caller surface it; the install still proceeds.
42
+ *
43
+ * Third-party services (no canonical slot) skip step 1 and start at step 2.
44
+ */
45
+ export function assignPort(
46
+ canonical: number | undefined,
47
+ occupied: Iterable<number>,
48
+ ): PortAssignment {
49
+ const taken = new Set(occupied);
50
+
51
+ if (canonical !== undefined && !taken.has(canonical)) {
52
+ return { port: canonical, source: "canonical" };
53
+ }
54
+
55
+ for (const reservation of PORT_RESERVATIONS) {
56
+ if (reservation.status !== "reserved") continue;
57
+ if (taken.has(reservation.port)) continue;
58
+ const warning =
59
+ canonical !== undefined
60
+ ? `canonical port ${canonical} is in use; assigned ${reservation.port} from the unassigned Parachute range.`
61
+ : `assigned port ${reservation.port} from the unassigned Parachute range (no canonical slot for this service).`;
62
+ return { port: reservation.port, source: "fallback-in-range", warning };
63
+ }
64
+
65
+ let p = CANONICAL_PORT_MAX + 1;
66
+ while (taken.has(p) && p < 65536) p++;
67
+ return {
68
+ port: p,
69
+ source: "fallback-out-of-range",
70
+ warning: `Parachute canonical range (${CANONICAL_PORT_MIN}–${CANONICAL_PORT_MAX}) is full; assigned ${p} outside the range — may conflict with other software.`,
71
+ };
72
+ }
73
+
74
+ export interface AssignServicePortOpts {
75
+ /** Path to the service's `.env` file. */
76
+ readonly envPath: string;
77
+ /** Canonical default for this service, or undefined for third-party. */
78
+ readonly canonical?: number;
79
+ /** Ports we already know to be taken. */
80
+ readonly occupied: Iterable<number>;
81
+ }
82
+
83
+ export interface AssignServicePortResult {
84
+ readonly port: number;
85
+ /** "preserved" when an existing PORT in .env was kept; otherwise the
86
+ * source from `assignPort`. */
87
+ readonly source: "preserved" | AssignmentSource;
88
+ /** True when we wrote PORT into .env on this call. */
89
+ readonly written: boolean;
90
+ /** Warning to surface to the user, if any. */
91
+ readonly warning?: string;
92
+ }
93
+
94
+ /**
95
+ * Reconcile a service's PORT with its `.env`. Idempotent:
96
+ * - If PORT is already set in .env, preserve it (`source: "preserved"`).
97
+ * Re-installs and user-edited ports survive across upgrades.
98
+ * - Otherwise call `assignPort` and write `PORT=<port>` into .env.
99
+ *
100
+ * Reads only the value of PORT from .env; everything else is round-tripped
101
+ * untouched via `parseEnvFile` / `upsertEnvLine` / `writeEnvFile`.
102
+ */
103
+ export function assignServicePort(opts: AssignServicePortOpts): AssignServicePortResult {
104
+ const env = parseEnvFile(opts.envPath);
105
+ const existing = env.values.PORT;
106
+ if (existing !== undefined && /^[1-9]\d{0,4}$/.test(existing)) {
107
+ const port = Number(existing);
108
+ if (port > 0 && port < 65536) {
109
+ return { port, source: "preserved", written: false };
110
+ }
111
+ }
112
+
113
+ const assignment = assignPort(opts.canonical, opts.occupied);
114
+ const nextLines = upsertEnvLine(env.lines, "PORT", String(assignment.port));
115
+ writeEnvFile(opts.envPath, nextLines);
116
+ const result: AssignServicePortResult = {
117
+ port: assignment.port,
118
+ source: assignment.source,
119
+ written: true,
120
+ };
121
+ if (assignment.warning) {
122
+ return { ...result, warning: assignment.warning };
123
+ }
124
+ return result;
125
+ }
@@ -0,0 +1,111 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { CONFIG_DIR } from "./config.ts";
4
+
5
+ /**
6
+ * Per-service state lives under `<configDir>/<svc>/...`. `svc` is the
7
+ * short name (`vault`, `notes`, `scribe`, `channel`) so paths stay tidy —
8
+ * `~/.parachute/vault/run/vault.pid` rather than `parachute-vault/run/…`.
9
+ *
10
+ * The single source of truth for whether a service is running is
11
+ * `pid file present` + `process.kill(pid, 0)` succeeds. A stale PID file
12
+ * (process died without cleanup) reads as stopped; writers of the PID
13
+ * file own removing it on clean shutdown.
14
+ */
15
+
16
+ export function serviceDir(svc: string, configDir: string = CONFIG_DIR): string {
17
+ return join(configDir, svc);
18
+ }
19
+
20
+ export function runDir(svc: string, configDir: string = CONFIG_DIR): string {
21
+ return join(serviceDir(svc, configDir), "run");
22
+ }
23
+
24
+ export function logsDir(svc: string, configDir: string = CONFIG_DIR): string {
25
+ return join(serviceDir(svc, configDir), "logs");
26
+ }
27
+
28
+ export function pidPath(svc: string, configDir: string = CONFIG_DIR): string {
29
+ return join(runDir(svc, configDir), `${svc}.pid`);
30
+ }
31
+
32
+ export function logPath(svc: string, configDir: string = CONFIG_DIR): string {
33
+ return join(logsDir(svc, configDir), `${svc}.log`);
34
+ }
35
+
36
+ export function readPid(svc: string, configDir: string = CONFIG_DIR): number | undefined {
37
+ const p = pidPath(svc, configDir);
38
+ if (!existsSync(p)) return undefined;
39
+ const raw = readFileSync(p, "utf8").trim();
40
+ const n = Number.parseInt(raw, 10);
41
+ return Number.isInteger(n) && n > 0 ? n : undefined;
42
+ }
43
+
44
+ export function writePid(svc: string, pid: number, configDir: string = CONFIG_DIR): void {
45
+ const p = pidPath(svc, configDir);
46
+ mkdirSync(dirname(p), { recursive: true });
47
+ writeFileSync(p, `${pid}\n`);
48
+ }
49
+
50
+ export function clearPid(svc: string, configDir: string = CONFIG_DIR): void {
51
+ const p = pidPath(svc, configDir);
52
+ if (existsSync(p)) rmSync(p, { force: true });
53
+ }
54
+
55
+ export function ensureLogPath(svc: string, configDir: string = CONFIG_DIR): string {
56
+ const p = logPath(svc, configDir);
57
+ mkdirSync(dirname(p), { recursive: true });
58
+ return p;
59
+ }
60
+
61
+ export type AliveFn = (pid: number) => boolean;
62
+
63
+ export const defaultAlive: AliveFn = (pid: number) => {
64
+ try {
65
+ process.kill(pid, 0);
66
+ return true;
67
+ } catch {
68
+ return false;
69
+ }
70
+ };
71
+
72
+ /**
73
+ * Three-state rather than two so we don't lie about services we can't see:
74
+ *
75
+ * - `running` — PID file present, `kill(pid, 0)` succeeds.
76
+ * - `stopped` — PID file present, process gone (stale pidfile, or cleanly shut down).
77
+ * - `unknown` — no PID file. Service may be externally managed (user ran
78
+ * `parachute-vault serve` directly, or legacy launchd-era). Don't claim stopped.
79
+ */
80
+ export interface ProcessState {
81
+ status: "running" | "stopped" | "unknown";
82
+ pid?: number;
83
+ /** mtime of the PID file — a stand-in for "process start time". */
84
+ startedAt?: Date;
85
+ }
86
+
87
+ export function processState(
88
+ svc: string,
89
+ configDir: string = CONFIG_DIR,
90
+ alive: AliveFn = defaultAlive,
91
+ ): ProcessState {
92
+ const pid = readPid(svc, configDir);
93
+ if (pid === undefined) return { status: "unknown" };
94
+ if (!alive(pid)) return { status: "stopped", pid };
95
+ const p = pidPath(svc, configDir);
96
+ const startedAt = existsSync(p) ? statSync(p).mtime : undefined;
97
+ return { status: "running", pid, startedAt };
98
+ }
99
+
100
+ /** Human-friendly uptime like "2h 13m" / "4d 6h" / "45s". */
101
+ export function formatUptime(startedAt: Date, now: Date = new Date()): string {
102
+ const ms = Math.max(0, now.getTime() - startedAt.getTime());
103
+ const s = Math.floor(ms / 1000);
104
+ if (s < 60) return `${s}s`;
105
+ const m = Math.floor(s / 60);
106
+ if (m < 60) return `${m}m`;
107
+ const h = Math.floor(m / 60);
108
+ if (h < 24) return `${h}h ${m % 60}m`;
109
+ const d = Math.floor(h / 24);
110
+ return `${d}d ${h % 24}h`;
111
+ }
@@ -0,0 +1,149 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { parseEnvFile, upsertEnvLine, writeEnvFile } from "./env-file.ts";
4
+
5
+ /**
6
+ * Reads / merges scribe's transcription provider into
7
+ * `<configDir>/scribe/config.json` and writes the corresponding API key (when
8
+ * the chosen provider needs one) into `<configDir>/scribe/.env`.
9
+ *
10
+ * Both files are merged in place so we never clobber unrelated keys — auto-wire
11
+ * already owns `auth.required_token` in the same config, and operators
12
+ * sometimes hand-edit other top-level blocks.
13
+ */
14
+
15
+ /**
16
+ * Transcription providers scribe ships with today (per `parachute-scribe`
17
+ * 0.x README). Source-of-truth is intentionally hand-maintained on the CLI
18
+ * side: the install prompt needs a curated, ordered list with platform
19
+ * caveats for each option, which scribe's runtime registry doesn't surface.
20
+ *
21
+ * Drift caught by the test that asserts the keys here match scribe's
22
+ * `availableProviders().transcription`.
23
+ */
24
+ export const SCRIBE_PROVIDERS = [
25
+ {
26
+ key: "parakeet-mlx",
27
+ label: "parakeet-mlx",
28
+ blurb: "local, Apple Silicon, fastest — requires `parakeet-mlx` binary on PATH",
29
+ apiKeyEnv: undefined,
30
+ },
31
+ {
32
+ key: "onnx-asr",
33
+ label: "onnx-asr",
34
+ blurb: "local, cross-platform (Sherpa-ONNX)",
35
+ apiKeyEnv: undefined,
36
+ },
37
+ {
38
+ key: "whisper",
39
+ label: "whisper",
40
+ blurb:
41
+ "local, any platform — requires `whisper-ctranslate2` (`pip install whisper-ctranslate2`)",
42
+ apiKeyEnv: undefined,
43
+ },
44
+ {
45
+ key: "groq",
46
+ label: "groq",
47
+ blurb: "cloud, generous free tier, very fast",
48
+ apiKeyEnv: "GROQ_API_KEY",
49
+ },
50
+ {
51
+ key: "openai",
52
+ label: "openai",
53
+ blurb: "cloud, paid, reference Whisper API",
54
+ apiKeyEnv: "OPENAI_API_KEY",
55
+ },
56
+ ] as const;
57
+
58
+ export type ScribeProviderKey = (typeof SCRIBE_PROVIDERS)[number]["key"];
59
+
60
+ /** Default provider scribe falls back to when the config doesn't pick one. */
61
+ export const SCRIBE_DEFAULT_PROVIDER: ScribeProviderKey = "parakeet-mlx";
62
+
63
+ export function isKnownScribeProvider(value: string): value is ScribeProviderKey {
64
+ return SCRIBE_PROVIDERS.some((p) => p.key === value);
65
+ }
66
+
67
+ export function apiKeyEnvFor(provider: ScribeProviderKey): string | undefined {
68
+ return SCRIBE_PROVIDERS.find((p) => p.key === provider)?.apiKeyEnv;
69
+ }
70
+
71
+ export function scribeConfigPath(configDir: string): string {
72
+ return join(configDir, "scribe", "config.json");
73
+ }
74
+
75
+ export function scribeEnvPath(configDir: string): string {
76
+ return join(configDir, "scribe", ".env");
77
+ }
78
+
79
+ export interface ScribeProviderState {
80
+ provider: string | undefined;
81
+ /** True when the file exists; false on a fresh install. */
82
+ configExists: boolean;
83
+ }
84
+
85
+ export function readScribeProviderState(configDir: string): ScribeProviderState {
86
+ const path = scribeConfigPath(configDir);
87
+ if (!existsSync(path)) return { provider: undefined, configExists: false };
88
+ try {
89
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
90
+ const provider =
91
+ parsed && typeof parsed === "object" && !Array.isArray(parsed) && parsed.transcribe
92
+ ? typeof parsed.transcribe.provider === "string"
93
+ ? parsed.transcribe.provider
94
+ : undefined
95
+ : undefined;
96
+ return { provider, configExists: true };
97
+ } catch {
98
+ // Malformed JSON — treat as empty so the writer can repair it. The auth
99
+ // block belongs to auto-wire; if it's broken, downstream auto-wire will
100
+ // overwrite when it next runs anyway.
101
+ return { provider: undefined, configExists: true };
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Merge `transcribe.provider = <provider>` into the scribe config.json,
107
+ * preserving any other top-level keys (notably `auth.required_token` written
108
+ * by auto-wire).
109
+ */
110
+ export function writeScribeProvider(configDir: string, provider: ScribeProviderKey): void {
111
+ const path = scribeConfigPath(configDir);
112
+ mkdirSync(dirname(path), { recursive: true });
113
+ let current: Record<string, unknown> = {};
114
+ if (existsSync(path)) {
115
+ try {
116
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
117
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
118
+ current = parsed as Record<string, unknown>;
119
+ }
120
+ } catch {
121
+ // Malformed → overwrite, same convention as auto-wire's writeScribeConfig.
122
+ }
123
+ }
124
+ const existingTranscribe =
125
+ typeof current.transcribe === "object" &&
126
+ current.transcribe !== null &&
127
+ !Array.isArray(current.transcribe)
128
+ ? (current.transcribe as Record<string, unknown>)
129
+ : {};
130
+ const next = {
131
+ ...current,
132
+ transcribe: { ...existingTranscribe, provider },
133
+ };
134
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
135
+ writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`);
136
+ renameSync(tmp, path);
137
+ }
138
+
139
+ /**
140
+ * Idempotent upsert of a single `KEY=value` into `<configDir>/scribe/.env`.
141
+ * Used for the API-key prompt result. Other lines (auto-wire keys, manual
142
+ * operator edits) are preserved.
143
+ */
144
+ export function writeScribeApiKey(configDir: string, envKey: string, value: string): void {
145
+ const path = scribeEnvPath(configDir);
146
+ const parsed = parseEnvFile(path);
147
+ const lines = upsertEnvLine(parsed.lines, envKey, value);
148
+ writeEnvFile(path, lines);
149
+ }