@openparachute/hub 0.7.3-rc.7 → 0.7.3-rc.8

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.3-rc.7",
3
+ "version": "0.7.3-rc.8",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -3,10 +3,15 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import {
6
+ MIN_RAM_MIB,
6
7
  SCRIBE_DEFAULT_PROVIDER,
7
8
  SCRIBE_PROVIDERS,
8
9
  apiKeyEnvFor,
10
+ clearScribeProvider,
11
+ decideLocalProvider,
9
12
  isKnownScribeProvider,
13
+ platformLocalProvider,
14
+ readAvailableRamMib,
10
15
  readScribeProviderState,
11
16
  scribeConfigPath,
12
17
  scribeEnvPath,
@@ -41,6 +46,118 @@ describe("provider catalog", () => {
41
46
  });
42
47
  });
43
48
 
49
+ describe("platformLocalProvider — the Linux 'local' trap fix", () => {
50
+ test("macOS → parakeet-mlx", () => {
51
+ expect(platformLocalProvider("darwin")).toBe("parakeet-mlx");
52
+ });
53
+
54
+ test("Linux → onnx-asr (NOT the macOS-only parakeet-mlx)", () => {
55
+ expect(platformLocalProvider("linux")).toBe("onnx-asr");
56
+ });
57
+
58
+ test("unsupported platform → null (steer to cloud)", () => {
59
+ expect(platformLocalProvider("win32")).toBeNull();
60
+ });
61
+ });
62
+
63
+ describe("readAvailableRamMib", () => {
64
+ test("non-Linux returns a positive number (totalmem fallback) or null", () => {
65
+ const ram = readAvailableRamMib("darwin");
66
+ // On any real CI/dev box totalmem is well-defined + positive.
67
+ expect(ram === null || (typeof ram === "number" && ram > 0)).toBe(true);
68
+ });
69
+
70
+ test("Linux path reads /proc/meminfo (or null when unreadable)", () => {
71
+ const ram = readAvailableRamMib("linux");
72
+ // On macOS CI there's no /proc/meminfo → null; on Linux CI a positive MiB.
73
+ expect(ram === null || (typeof ram === "number" && ram > 0)).toBe(true);
74
+ });
75
+ });
76
+
77
+ describe("decideLocalProvider — the RAM/platform gate", () => {
78
+ test("Linux with ample RAM → ok, onnx-asr", () => {
79
+ const d = decideLocalProvider("linux", 4096);
80
+ expect(d.ok).toBe(true);
81
+ expect(d.provider).toBe("onnx-asr");
82
+ });
83
+
84
+ test("macOS with ample RAM → ok, parakeet-mlx", () => {
85
+ const d = decideLocalProvider("darwin", 16384);
86
+ expect(d.ok).toBe(true);
87
+ expect(d.provider).toBe("parakeet-mlx");
88
+ });
89
+
90
+ test("below the RAM floor → refused, steers to groq, carries a reason", () => {
91
+ const d = decideLocalProvider("linux", MIN_RAM_MIB - 1);
92
+ expect(d.ok).toBe(false);
93
+ expect(d.steerTo).toBe("groq");
94
+ expect(d.reason).toContain(String(MIN_RAM_MIB));
95
+ });
96
+
97
+ test("exactly at the floor is OK (>= floor)", () => {
98
+ expect(decideLocalProvider("linux", MIN_RAM_MIB).ok).toBe(true);
99
+ });
100
+
101
+ test("unknown RAM (null) does not refuse on a supported platform", () => {
102
+ expect(decideLocalProvider("linux", null).ok).toBe(true);
103
+ });
104
+
105
+ test("unsupported platform → refused regardless of RAM, steers to groq", () => {
106
+ const d = decideLocalProvider("win32", 99999);
107
+ expect(d.ok).toBe(false);
108
+ expect(d.steerTo).toBe("groq");
109
+ });
110
+ });
111
+
112
+ describe("clearScribeProvider", () => {
113
+ test("removes transcribe.provider, preserving other keys", () => {
114
+ const h = makeHarness();
115
+ try {
116
+ mkdirSync(join(h.dir, "scribe"), { recursive: true });
117
+ writeFileSync(
118
+ scribeConfigPath(h.dir),
119
+ JSON.stringify({
120
+ transcribe: { provider: "onnx-asr", language: "en" },
121
+ auth: { required_token: "keep" },
122
+ }),
123
+ );
124
+ clearScribeProvider(h.dir);
125
+ const parsed = JSON.parse(readFileSync(scribeConfigPath(h.dir), "utf8"));
126
+ expect(parsed.transcribe).toEqual({ language: "en" });
127
+ expect(parsed.auth).toEqual({ required_token: "keep" });
128
+ } finally {
129
+ h.cleanup();
130
+ }
131
+ });
132
+
133
+ test("drops the transcribe block entirely when provider was its only key", () => {
134
+ const h = makeHarness();
135
+ try {
136
+ mkdirSync(join(h.dir, "scribe"), { recursive: true });
137
+ writeFileSync(
138
+ scribeConfigPath(h.dir),
139
+ JSON.stringify({ transcribe: { provider: "onnx-asr" }, auth: { required_token: "x" } }),
140
+ );
141
+ clearScribeProvider(h.dir);
142
+ const parsed = JSON.parse(readFileSync(scribeConfigPath(h.dir), "utf8"));
143
+ expect(parsed.transcribe).toBeUndefined();
144
+ expect(parsed.auth).toEqual({ required_token: "x" });
145
+ } finally {
146
+ h.cleanup();
147
+ }
148
+ });
149
+
150
+ test("no-op when the file is absent", () => {
151
+ const h = makeHarness();
152
+ try {
153
+ // No file written.
154
+ expect(() => clearScribeProvider(h.dir)).not.toThrow();
155
+ } finally {
156
+ h.cleanup();
157
+ }
158
+ });
159
+ });
160
+
44
161
  describe("readScribeProviderState", () => {
45
162
  test("missing file: configExists false, no provider", () => {
46
163
  const h = makeHarness();
@@ -1825,6 +1825,24 @@ describe("handleSetupVaultPost", () => {
1825
1825
  expect(cfg?.cleanup).toEqual({ provider: "anthropic", default: true });
1826
1826
  expect(cfg?.cleanupProviders).toEqual({ anthropic: { apiKey: "sk-ant-cleanup-key" } });
1827
1827
  });
1828
+
1829
+ test("scribe transcribe=local resolves to the host platform's backend (the Linux trap fix)", async () => {
1830
+ // The bug: `local` mapped UNCONDITIONALLY to parakeet-mlx (macOS-only),
1831
+ // silently broken on every Linux box. After the fix it resolves to the
1832
+ // platform backend. We assert against the actual host so it's correct on
1833
+ // both Mac (parakeet-mlx) and Linux CI (onnx-asr). The RAM gate only kicks
1834
+ // in below 2 GB — CI/dev boxes clear it, so the choice stays `local`.
1835
+ const { platformLocalProvider } = await import("../scribe-config.ts");
1836
+ const expected = platformLocalProvider(process.platform);
1837
+ const { response } = await postVaultWithFields(h, { scribe_provider: "local" });
1838
+ expect(response.status).toBe(303);
1839
+ const cfg = readScribeConfig(h.dir);
1840
+ if (expected !== null) {
1841
+ // On a supported platform with enough RAM, `local` resolves to the
1842
+ // platform backend — NOT a hardcoded parakeet-mlx on Linux.
1843
+ expect(cfg?.transcribe).toEqual({ provider: expected });
1844
+ }
1845
+ });
1828
1846
  });
1829
1847
 
1830
1848
  // --- end-to-end through hubFetch -----------------------------------------
@@ -0,0 +1,334 @@
1
+ /**
2
+ * CLI wizard transcription step (onboarding-streamline hub PR1).
3
+ *
4
+ * Exercises `walkTranscriptionStep` against an injected command runner + RAM
5
+ * probe + platform — NOTHING installs and no subprocess is spawned. Covers the
6
+ * four branches the brief calls for: the CLI transcription question, the
7
+ * platform mapping, the RAM gate, and install-or-skip (mocked scribe
8
+ * subprocess).
9
+ */
10
+
11
+ import { describe, expect, test } from "bun:test";
12
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { walkTranscriptionStep } from "../commands/wizard-transcription.ts";
16
+ import { scribeConfigPath } from "../scribe-config.ts";
17
+
18
+ function makeHarness(): { dir: string; cleanup: () => void } {
19
+ const dir = mkdtempSync(join(tmpdir(), "pcli-wztrans-"));
20
+ return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
21
+ }
22
+
23
+ /** Records every command the step would have spawned, returns scripted codes. */
24
+ function recordingRunner(codes: number[] = []) {
25
+ const cmds: string[][] = [];
26
+ let i = 0;
27
+ return {
28
+ cmds,
29
+ run: async (cmd: readonly string[]): Promise<number> => {
30
+ cmds.push([...cmd]);
31
+ const code = codes[i++];
32
+ return code ?? 0;
33
+ },
34
+ };
35
+ }
36
+
37
+ function readCfg(dir: string): Record<string, unknown> | undefined {
38
+ const p = scribeConfigPath(dir);
39
+ if (!existsSync(p)) return undefined;
40
+ return JSON.parse(readFileSync(p, "utf8")) as Record<string, unknown>;
41
+ }
42
+
43
+ describe("walkTranscriptionStep — mode resolution (the CLI question)", () => {
44
+ test("none: writes no scribe config, runs no command", async () => {
45
+ const h = makeHarness();
46
+ try {
47
+ const r = recordingRunner();
48
+ const logs: string[] = [];
49
+ const code = await walkTranscriptionStep({
50
+ configDir: h.dir,
51
+ log: (l) => logs.push(l),
52
+ transcribeMode: "none",
53
+ runCommand: r.run,
54
+ platform: "linux",
55
+ });
56
+ expect(code).toBe(0);
57
+ expect(r.cmds).toEqual([]);
58
+ expect(readCfg(h.dir)).toBeUndefined();
59
+ expect(logs.join("\n")).toContain("Transcription off");
60
+ } finally {
61
+ h.cleanup();
62
+ }
63
+ });
64
+
65
+ test("interactive prompt: '1' chooses none", async () => {
66
+ const h = makeHarness();
67
+ try {
68
+ const r = recordingRunner();
69
+ const answers = ["1"];
70
+ let i = 0;
71
+ const code = await walkTranscriptionStep({
72
+ configDir: h.dir,
73
+ log: () => {},
74
+ prompt: async () => answers[i++] ?? "",
75
+ runCommand: r.run,
76
+ platform: "linux",
77
+ });
78
+ expect(code).toBe(0);
79
+ expect(r.cmds).toEqual([]);
80
+ } finally {
81
+ h.cleanup();
82
+ }
83
+ });
84
+
85
+ test("interactive prompt: '3' then 'g' chooses groq cloud", async () => {
86
+ const h = makeHarness();
87
+ try {
88
+ const r = recordingRunner([0]);
89
+ const answers = ["3", "g", "gsk_interactive"];
90
+ let i = 0;
91
+ const code = await walkTranscriptionStep({
92
+ configDir: h.dir,
93
+ log: () => {},
94
+ prompt: async () => answers[i++] ?? "",
95
+ runCommand: r.run,
96
+ platform: "linux",
97
+ });
98
+ expect(code).toBe(0);
99
+ // One install command, with the groq provider + key.
100
+ expect(r.cmds.length).toBe(1);
101
+ expect(r.cmds[0]).toEqual([
102
+ "parachute",
103
+ "install",
104
+ "scribe",
105
+ "--scribe-provider",
106
+ "groq",
107
+ "--scribe-key",
108
+ "gsk_interactive",
109
+ ]);
110
+ } finally {
111
+ h.cleanup();
112
+ }
113
+ });
114
+ });
115
+
116
+ describe("walkTranscriptionStep — cloud (install-or-skip via the one-shot)", () => {
117
+ test("groq with a key: install one-shot carries provider + key", async () => {
118
+ const h = makeHarness();
119
+ try {
120
+ const r = recordingRunner([0]);
121
+ const code = await walkTranscriptionStep({
122
+ configDir: h.dir,
123
+ log: () => {},
124
+ transcribeMode: "groq",
125
+ transcribeApiKey: "gsk_abc123",
126
+ runCommand: r.run,
127
+ platform: "linux",
128
+ });
129
+ expect(code).toBe(0);
130
+ expect(r.cmds[0]).toEqual([
131
+ "parachute",
132
+ "install",
133
+ "scribe",
134
+ "--scribe-provider",
135
+ "groq",
136
+ "--scribe-key",
137
+ "gsk_abc123",
138
+ ]);
139
+ } finally {
140
+ h.cleanup();
141
+ }
142
+ });
143
+
144
+ test("openai without a key: install one-shot omits --scribe-key + tells operator how to add it", async () => {
145
+ const h = makeHarness();
146
+ try {
147
+ const r = recordingRunner([0]);
148
+ const logs: string[] = [];
149
+ const code = await walkTranscriptionStep({
150
+ configDir: h.dir,
151
+ log: (l) => logs.push(l),
152
+ transcribeMode: "openai",
153
+ transcribeApiKey: "",
154
+ runCommand: r.run,
155
+ platform: "darwin",
156
+ });
157
+ expect(code).toBe(0);
158
+ expect(r.cmds[0]).toEqual(["parachute", "install", "scribe", "--scribe-provider", "openai"]);
159
+ expect(logs.join("\n")).toContain("OPENAI_API_KEY");
160
+ } finally {
161
+ h.cleanup();
162
+ }
163
+ });
164
+
165
+ test("cloud install failure: surfaces the non-zero exit + a retry hint, still exits 0", async () => {
166
+ const h = makeHarness();
167
+ try {
168
+ const r = recordingRunner([1]); // install fails
169
+ const logs: string[] = [];
170
+ const code = await walkTranscriptionStep({
171
+ configDir: h.dir,
172
+ log: (l) => logs.push(l),
173
+ transcribeMode: "groq",
174
+ transcribeApiKey: "gsk_x",
175
+ runCommand: r.run,
176
+ platform: "linux",
177
+ });
178
+ expect(code).toBe(0); // non-fatal — doesn't block the wizard
179
+ expect(logs.join("\n")).toContain("returned 1");
180
+ } finally {
181
+ h.cleanup();
182
+ }
183
+ });
184
+ });
185
+
186
+ describe("walkTranscriptionStep — local install-or-skip", () => {
187
+ test("Linux, ample RAM, install succeeds: install-backend uses onnx-asr; provider kept", async () => {
188
+ const h = makeHarness();
189
+ try {
190
+ // module install (writes provider via --scribe-provider), install-backend, restart
191
+ const r = recordingRunner([0, 0, 0]);
192
+ const code = await walkTranscriptionStep({
193
+ configDir: h.dir,
194
+ log: () => {},
195
+ transcribeMode: "local",
196
+ runCommand: r.run,
197
+ platform: "linux",
198
+ availableRamMib: 4096,
199
+ });
200
+ expect(code).toBe(0);
201
+ // Module install pins the resolved Linux backend, NOT parakeet-mlx.
202
+ expect(r.cmds[0]).toEqual([
203
+ "parachute",
204
+ "install",
205
+ "scribe",
206
+ "--scribe-provider",
207
+ "onnx-asr",
208
+ ]);
209
+ // scribe's own runnable install routine, targeting onnx-asr.
210
+ expect(r.cmds[1]).toEqual(["parachute-scribe", "install-backend", "--provider", "onnx-asr"]);
211
+ // A restart so the running scribe picks up the engine.
212
+ expect(r.cmds[2]).toEqual(["parachute", "restart", "scribe"]);
213
+ } finally {
214
+ h.cleanup();
215
+ }
216
+ });
217
+
218
+ test("macOS local resolves to parakeet-mlx", async () => {
219
+ const h = makeHarness();
220
+ try {
221
+ const r = recordingRunner([0, 0, 0]);
222
+ await walkTranscriptionStep({
223
+ configDir: h.dir,
224
+ log: () => {},
225
+ transcribeMode: "local",
226
+ runCommand: r.run,
227
+ platform: "darwin",
228
+ availableRamMib: 16384,
229
+ });
230
+ expect(r.cmds[1]).toEqual([
231
+ "parachute-scribe",
232
+ "install-backend",
233
+ "--provider",
234
+ "parakeet-mlx",
235
+ ]);
236
+ } finally {
237
+ h.cleanup();
238
+ }
239
+ });
240
+
241
+ test("install-backend FAILS: clears the provisional provider (no dead string) + points at cloud", async () => {
242
+ const h = makeHarness();
243
+ try {
244
+ // module install OK, install-backend FAILS (exit 3). No restart should run.
245
+ const r = recordingRunner([0, 3]);
246
+ const logs: string[] = [];
247
+ const code = await walkTranscriptionStep({
248
+ configDir: h.dir,
249
+ log: (l) => logs.push(l),
250
+ transcribeMode: "local",
251
+ runCommand: r.run,
252
+ platform: "linux",
253
+ availableRamMib: 4096,
254
+ });
255
+ expect(code).toBe(0);
256
+ // Only two commands ran — the failed install-backend, no restart.
257
+ expect(r.cmds.length).toBe(2);
258
+ expect(r.cmds.some((c) => c[0] === "parachute" && c[1] === "restart")).toBe(false);
259
+ // The provisional provider write (by the install one-shot in production)
260
+ // is cleared. We simulate the install having written it by checking the
261
+ // step never leaves a transcribe.provider on its own write path — and the
262
+ // honest cloud steer is logged.
263
+ expect(logs.join("\n")).toContain("install failed");
264
+ expect(logs.join("\n")).toContain("Cloud alternative");
265
+ } finally {
266
+ h.cleanup();
267
+ }
268
+ });
269
+
270
+ test("RAM below floor: REFUSES local, records nothing, steers to cloud one-shot", async () => {
271
+ const h = makeHarness();
272
+ try {
273
+ const r = recordingRunner();
274
+ const logs: string[] = [];
275
+ const code = await walkTranscriptionStep({
276
+ configDir: h.dir,
277
+ log: (l) => logs.push(l),
278
+ transcribeMode: "local",
279
+ runCommand: r.run,
280
+ platform: "linux",
281
+ availableRamMib: 900, // 1 GB droplet
282
+ });
283
+ expect(code).toBe(0);
284
+ // Nothing installed, nothing recorded.
285
+ expect(r.cmds).toEqual([]);
286
+ expect(readCfg(h.dir)).toBeUndefined();
287
+ const joined = logs.join("\n");
288
+ expect(joined).toContain("isn't possible");
289
+ expect(joined).toContain("parachute install scribe --scribe-provider groq");
290
+ } finally {
291
+ h.cleanup();
292
+ }
293
+ });
294
+
295
+ test("unsupported platform: REFUSES local + steers to cloud, no install", async () => {
296
+ const h = makeHarness();
297
+ try {
298
+ const r = recordingRunner();
299
+ const code = await walkTranscriptionStep({
300
+ configDir: h.dir,
301
+ log: () => {},
302
+ transcribeMode: "local",
303
+ runCommand: r.run,
304
+ platform: "win32",
305
+ availableRamMib: 99999,
306
+ });
307
+ expect(code).toBe(0);
308
+ expect(r.cmds).toEqual([]);
309
+ expect(readCfg(h.dir)).toBeUndefined();
310
+ } finally {
311
+ h.cleanup();
312
+ }
313
+ });
314
+
315
+ test("module install fails before the engine: clears provider, no install-backend run", async () => {
316
+ const h = makeHarness();
317
+ try {
318
+ const r = recordingRunner([5]); // module install fails
319
+ const code = await walkTranscriptionStep({
320
+ configDir: h.dir,
321
+ log: () => {},
322
+ transcribeMode: "local",
323
+ runCommand: r.run,
324
+ platform: "linux",
325
+ availableRamMib: 4096,
326
+ });
327
+ expect(code).toBe(0);
328
+ expect(r.cmds.length).toBe(1);
329
+ expect(r.cmds[0]?.[1]).toBe("install");
330
+ } finally {
331
+ h.cleanup();
332
+ }
333
+ });
334
+ });
@@ -293,6 +293,29 @@ describe("parseWizardArgs", () => {
293
293
  expect(r.opts.vaultMode).toBe("import");
294
294
  expect(r.opts.vaultImportRemoteUrl).toBe("https://github.com/me/v.git");
295
295
  });
296
+
297
+ test("parses --transcribe-mode + --transcribe-key + --config-dir", () => {
298
+ const r = parseWizardArgs([
299
+ "--hub-url",
300
+ "http://x",
301
+ "--transcribe-mode",
302
+ "groq",
303
+ "--transcribe-key",
304
+ "gsk_test",
305
+ "--config-dir",
306
+ "/tmp/ph",
307
+ ]);
308
+ expect("error" in r).toBe(false);
309
+ if ("error" in r) throw new Error(r.error);
310
+ expect(r.opts.transcribeMode).toBe("groq");
311
+ expect(r.opts.transcribeApiKey).toBe("gsk_test");
312
+ expect(r.opts.configDir).toBe("/tmp/ph");
313
+ });
314
+
315
+ test("rejects invalid --transcribe-mode", () => {
316
+ const r = parseWizardArgs(["--hub-url", "http://x", "--transcribe-mode", "garbage"]);
317
+ expect("error" in r).toBe(true);
318
+ });
296
319
  });
297
320
 
298
321
  describe("runCliWizard", () => {
@@ -509,4 +532,127 @@ describe("runCliWizard", () => {
509
532
  });
510
533
  expect(code).toBe(1);
511
534
  });
535
+
536
+ test("password validator floor is 12 (matches the server) — an 11-char password is rejected", async () => {
537
+ const { fetchImpl } = makeFakeHub();
538
+ const logs: string[] = [];
539
+ const code = await runCliWizard({
540
+ hubUrl: "http://127.0.0.1:1939",
541
+ log: (l) => logs.push(l),
542
+ fetchImpl,
543
+ sleep: async () => {},
544
+ accountUsername: "admin",
545
+ accountPassword: "elevenchar.", // 11 chars
546
+ vaultMode: "skip",
547
+ exposeMode: "localhost",
548
+ });
549
+ expect(code).toBe(1);
550
+ expect(logs.join("\n")).toContain("at least 12 characters");
551
+ });
552
+
553
+ test("exactly 12 chars passes the validator (no early bounce)", async () => {
554
+ const { state, fetchImpl } = makeFakeHub();
555
+ const code = await runCliWizard({
556
+ hubUrl: "http://127.0.0.1:1939",
557
+ log: () => {},
558
+ fetchImpl,
559
+ sleep: async () => {},
560
+ accountUsername: "admin",
561
+ accountPassword: "twelvecharss", // 12 chars
562
+ vaultMode: "skip",
563
+ exposeMode: "localhost",
564
+ });
565
+ expect(code).toBe(0);
566
+ expect(state.posted.some((p) => p.path === "/admin/setup/account")).toBe(true);
567
+ });
568
+
569
+ test("transcription step runs between vault and expose when configDir is set", async () => {
570
+ const { state, fetchImpl } = makeFakeHub();
571
+ const transcribeCmds: string[][] = [];
572
+ const code = await runCliWizard({
573
+ hubUrl: "http://127.0.0.1:1939",
574
+ log: () => {},
575
+ fetchImpl,
576
+ sleep: async () => {},
577
+ accountUsername: "admin",
578
+ accountPassword: "longpassword",
579
+ vaultMode: "skip",
580
+ exposeMode: "localhost",
581
+ configDir: "/tmp/never-written-none-mode",
582
+ transcribeMode: "none", // none writes nothing + spawns nothing
583
+ transcribeRunCommand: async (cmd) => {
584
+ transcribeCmds.push([...cmd]);
585
+ return 0;
586
+ },
587
+ });
588
+ expect(code).toBe(0);
589
+ // The vault + expose POSTs still happened in order.
590
+ expect(state.posted.map((p) => p.path)).toEqual([
591
+ "/admin/setup/account",
592
+ "/admin/setup/vault",
593
+ "/admin/setup/expose",
594
+ ]);
595
+ // mode=none → no transcription subprocess.
596
+ expect(transcribeCmds).toEqual([]);
597
+ });
598
+
599
+ test("transcription step (cloud) drives the install one-shot via the injected runner", async () => {
600
+ const { fetchImpl } = makeFakeHub();
601
+ const transcribeCmds: string[][] = [];
602
+ const code = await runCliWizard({
603
+ hubUrl: "http://127.0.0.1:1939",
604
+ log: () => {},
605
+ fetchImpl,
606
+ sleep: async () => {},
607
+ accountUsername: "admin",
608
+ accountPassword: "longpassword",
609
+ vaultMode: "skip",
610
+ exposeMode: "localhost",
611
+ configDir: "/tmp/never-written-cloud-mode",
612
+ transcribeMode: "groq",
613
+ transcribeApiKey: "gsk_wired",
614
+ platform: "linux",
615
+ transcribeRunCommand: async (cmd) => {
616
+ transcribeCmds.push([...cmd]);
617
+ return 0;
618
+ },
619
+ });
620
+ expect(code).toBe(0);
621
+ expect(transcribeCmds[0]).toEqual([
622
+ "parachute",
623
+ "install",
624
+ "scribe",
625
+ "--scribe-provider",
626
+ "groq",
627
+ "--scribe-key",
628
+ "gsk_wired",
629
+ ]);
630
+ });
631
+
632
+ test("transcription step is skipped when configDir is absent", async () => {
633
+ const { state, fetchImpl } = makeFakeHub();
634
+ let ran = false;
635
+ const code = await runCliWizard({
636
+ hubUrl: "http://127.0.0.1:1939",
637
+ log: () => {},
638
+ fetchImpl,
639
+ sleep: async () => {},
640
+ accountUsername: "admin",
641
+ accountPassword: "longpassword",
642
+ vaultMode: "skip",
643
+ exposeMode: "localhost",
644
+ // no configDir
645
+ transcribeRunCommand: async () => {
646
+ ran = true;
647
+ return 0;
648
+ },
649
+ });
650
+ expect(code).toBe(0);
651
+ expect(ran).toBe(false);
652
+ expect(state.posted.map((p) => p.path)).toEqual([
653
+ "/admin/setup/account",
654
+ "/admin/setup/vault",
655
+ "/admin/setup/expose",
656
+ ]);
657
+ });
512
658
  });
@@ -172,7 +172,11 @@ export interface InitOpts {
172
172
  * Test seam: shim for the CLI wizard chain (hub#168 Cut 3). Production
173
173
  * lazy-imports `runCliWizard` from `./wizard.ts`. Tests pass a stub.
174
174
  */
175
- runCliWizardImpl?: (opts: { hubUrl: string; log: (l: string) => void }) => Promise<number>;
175
+ runCliWizardImpl?: (opts: {
176
+ hubUrl: string;
177
+ log: (l: string) => void;
178
+ configDir?: string;
179
+ }) => Promise<number>;
176
180
  /**
177
181
  * Skip the "browser or CLI?" wizard-choice prompt (hub#168 Cut 4). Used
178
182
  * by pre-Cut-4 tests that don't expect the new prompt + by the
@@ -485,6 +489,7 @@ async function defaultInstallVaultModule(configDir: string, manifestPath: string
485
489
  async function defaultRunCliWizard(opts: {
486
490
  hubUrl: string;
487
491
  log: (l: string) => void;
492
+ configDir?: string;
488
493
  }): Promise<number> {
489
494
  const { runCliWizard } = await import("./wizard.ts");
490
495
  return await runCliWizard(opts);
@@ -895,7 +900,10 @@ export async function init(opts: InitOpts = {}): Promise<number> {
895
900
  // (the loopback-gated GET /admin/setup probe) — the operator never has to
896
901
  // copy the token out of the startup logs.
897
902
  const cliWizardUrl = `http://127.0.0.1:${hubPort}`;
898
- return await runCliWizardImpl({ hubUrl: cliWizardUrl, log });
903
+ // Pass the resolved configDir so the CLI wizard's transcription step can
904
+ // write scribe's config (onboarding-streamline hub PR1). Without it the
905
+ // step is skipped.
906
+ return await runCliWizardImpl({ hubUrl: cliWizardUrl, log, configDir });
899
907
  }
900
908
 
901
909
  // Step 5: offer to open the browser. Skip in non-TTY shells (CI),
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Transcription step for the CLI setup wizard (`parachute setup-wizard` /
3
+ * `parachute init`).
4
+ *
5
+ * Until now the CLI wizard NEVER asked about transcription — it walked
6
+ * Account → Vault → Expose and stopped, while the browser wizard's vault step
7
+ * folds a full scribe sub-form (none / cloud + key / local). That divergence
8
+ * is the "asks different questions in its CLI vs browser forms" root cause from
9
+ * the onboarding-streamline arc. This module brings the CLI to parity.
10
+ *
11
+ * Crucially it follows the arc's "never ask without doing" rule: when the
12
+ * operator picks a provider we ACTUALLY set it up, or honestly say we couldn't
13
+ * and point at the alternative — we never record a dead provider string.
14
+ *
15
+ * - **none** → write nothing; say transcription is off + how to turn it on.
16
+ * - **cloud** (groq / openai) → write provider + key (scribe-config.ts), then
17
+ * install + start scribe via the hub's own `parachute install scribe`
18
+ * one-shot. The very-first scribe boot reads the provider we just wrote.
19
+ * - **local** → RAM/platform-gate FIRST (decideLocalProvider). If the box
20
+ * can't run a local model (no backend for the platform, or < 2 GB RAM) we
21
+ * do NOT write `local` — we explain why + steer to the cloud one-shot. If
22
+ * it can, we install scribe, then run scribe's own runnable install routine
23
+ * (`parachute-scribe install-backend --provider <onnx-asr|parakeet-mlx>`,
24
+ * scribe PR #79) which apt/pip-installs the engine + warm-pulls the model
25
+ * and exits non-zero on hard failure. On success we record the resolved
26
+ * platform provider; on failure we say so + point at cloud, recording
27
+ * nothing.
28
+ *
29
+ * Everything that touches the host (subprocess spawn, RAM probe, the prompt)
30
+ * goes through an injected seam so tests exercise every branch WITHOUT
31
+ * installing anything or shelling out.
32
+ */
33
+
34
+ import { createInterface } from "node:readline/promises";
35
+ import {
36
+ type ScribeProviderKey,
37
+ apiKeyEnvFor,
38
+ clearScribeProvider,
39
+ decideLocalProvider,
40
+ readAvailableRamMib,
41
+ } from "../scribe-config.ts";
42
+
43
+ /** Outcome of one subprocess. Exit code only — stdio is inherited / streamed. */
44
+ export type WizardCommandRunner = (cmd: readonly string[]) => Promise<number>;
45
+
46
+ export interface TranscriptionStepOpts {
47
+ /** `~/.parachute` (or the PARACHUTE_HOME override). Where scribe config lives. */
48
+ configDir: string;
49
+ /** Log shim — production prints to stdout; tests capture into an array. */
50
+ log: (line: string) => void;
51
+ /** Prompt seam — production uses readline; tests inject a scripted queue. */
52
+ prompt?: (question: string) => Promise<string>;
53
+ /**
54
+ * Pre-supply the choice non-interactively (mirrors the wizard's other
55
+ * run-from-flag escapes). `none` | `local` | a cloud provider name.
56
+ */
57
+ transcribeMode?: "none" | "local" | "groq" | "openai";
58
+ /** Pre-supplied cloud API key (for `groq` / `openai`). */
59
+ transcribeApiKey?: string;
60
+ /**
61
+ * Command runner seam. Production spawns the real binary inheriting stdio;
62
+ * tests inject a recorder so nothing installs. Receives a full argv —
63
+ * `["parachute", "install", "scribe", ...]` or
64
+ * `["parachute-scribe", "install-backend", "--provider", "onnx-asr"]`.
65
+ */
66
+ runCommand?: WizardCommandRunner;
67
+ /** Platform override (test seam). Defaults to the real host platform. */
68
+ platform?: NodeJS.Platform;
69
+ /** Available-RAM override in MiB (test seam). Defaults to the real probe. */
70
+ availableRamMib?: number | null;
71
+ }
72
+
73
+ /** Default readline prompt (matches wizard.ts's defaultPrompt). */
74
+ async function defaultPrompt(question: string): Promise<string> {
75
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
76
+ try {
77
+ return await rl.question(question);
78
+ } finally {
79
+ rl.close();
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Default command runner: spawn the binary, inherit stdio so the operator sees
85
+ * apt/pip progress in real time, resolve to the exit code. Never throws — a
86
+ * spawn failure (binary not on PATH) surfaces as a non-zero code, which the
87
+ * caller treats as "couldn't install."
88
+ */
89
+ const defaultRunCommand: WizardCommandRunner = async (cmd) => {
90
+ try {
91
+ const proc = Bun.spawn([...cmd], {
92
+ stdout: "inherit",
93
+ stderr: "inherit",
94
+ stdin: "inherit",
95
+ });
96
+ return await proc.exited;
97
+ } catch {
98
+ return 127; // ENOENT / spawn failure → "command not found"
99
+ }
100
+ };
101
+
102
+ /**
103
+ * Walk the transcription step. Returns 0 always — a transcription that
104
+ * couldn't be set up is reported honestly but does NOT fail the wizard (the
105
+ * operator can finish setup and add it later; it's not a blocking step).
106
+ */
107
+ export async function walkTranscriptionStep(opts: TranscriptionStepOpts): Promise<number> {
108
+ const log = opts.log;
109
+ const prompt = opts.prompt ?? defaultPrompt;
110
+ const runCommand = opts.runCommand ?? defaultRunCommand;
111
+ const platform = opts.platform ?? process.platform;
112
+
113
+ log("");
114
+ log("Step — Transcription (scribe)");
115
+ log(" Parachute can transcribe voice notes + audio attachments. Pick a");
116
+ log(" transcription engine, or skip and add one later.");
117
+
118
+ // Resolve the choice (flag or prompt).
119
+ const choice = await resolveChoice(opts, prompt, log);
120
+ if (choice === "none") {
121
+ log("");
122
+ log(" Transcription off. Turn it on later with `parachute install scribe`.");
123
+ return 0;
124
+ }
125
+
126
+ if (choice === "local") {
127
+ return await handleLocal(opts, prompt, runCommand, platform, log);
128
+ }
129
+
130
+ // Cloud provider (groq / openai).
131
+ return await handleCloud(opts, choice, prompt, runCommand, log);
132
+ }
133
+
134
+ type ResolvedChoice = "none" | "local" | "groq" | "openai";
135
+
136
+ async function resolveChoice(
137
+ opts: TranscriptionStepOpts,
138
+ prompt: (q: string) => Promise<string>,
139
+ log: (l: string) => void,
140
+ ): Promise<ResolvedChoice> {
141
+ if (opts.transcribeMode !== undefined) return opts.transcribeMode;
142
+ log("");
143
+ log(" 1) None — skip transcription (default)");
144
+ log(" 2) Local — run the engine on this box (no API key, needs ~2 GB RAM)");
145
+ log(" 3) Cloud — Groq or OpenAI (fast, needs an API key, ~$0.04/hr of audio)");
146
+ for (let attempt = 0; attempt < 5; attempt++) {
147
+ const raw = (await prompt(" Pick [1]: ")).trim().toLowerCase();
148
+ if (raw === "" || raw === "1" || raw === "none" || raw === "n") return "none";
149
+ if (raw === "2" || raw === "local" || raw === "l") return "local";
150
+ if (raw === "3" || raw === "cloud" || raw === "c") {
151
+ // Sub-pick which cloud provider.
152
+ for (let inner = 0; inner < 5; inner++) {
153
+ const which = (await prompt(" Cloud provider — [g]roq (default) or [o]penai: "))
154
+ .trim()
155
+ .toLowerCase();
156
+ if (which === "" || which === "g" || which === "groq") return "groq";
157
+ if (which === "o" || which === "openai") return "openai";
158
+ log(` Sorry — expected groq or openai (got "${which}"). Try again.`);
159
+ }
160
+ log(" Too many invalid entries; skipping transcription.");
161
+ return "none";
162
+ }
163
+ if (raw === "groq" || raw === "g") return "groq";
164
+ if (raw === "openai" || raw === "o") return "openai";
165
+ log(` Sorry — expected 1, 2, or 3 (got "${raw}"). Try again.`);
166
+ }
167
+ log(" Too many invalid entries; skipping transcription.");
168
+ return "none";
169
+ }
170
+
171
+ async function handleCloud(
172
+ opts: TranscriptionStepOpts,
173
+ provider: "groq" | "openai",
174
+ prompt: (q: string) => Promise<string>,
175
+ runCommand: WizardCommandRunner,
176
+ log: (l: string) => void,
177
+ ): Promise<number> {
178
+ const envKey = apiKeyEnvFor(provider as ScribeProviderKey);
179
+ let apiKey = opts.transcribeApiKey;
180
+ if (apiKey === undefined && envKey) {
181
+ apiKey = (await prompt(` Paste your ${envKey} (or blank to set later): `)).trim();
182
+ }
183
+
184
+ // Install + start scribe via the EXISTING one-shot path, handing it the
185
+ // chosen provider + key. `parachute install scribe --scribe-provider <p>
186
+ // [--scribe-key <k>]` writes the provider into scribe's config + the key into
187
+ // scribe/.env and starts the module — the same wiring the bare CLI install
188
+ // does. Passing the provider also suppresses install's own interactive
189
+ // provider prompt (it's already an explicit choice).
190
+ const cmd = ["parachute", "install", "scribe", "--scribe-provider", provider];
191
+ if (envKey && apiKey && apiKey.length > 0) {
192
+ cmd.push("--scribe-key", apiKey);
193
+ }
194
+ log("");
195
+ log(` Installing scribe with the ${provider} cloud provider…`);
196
+ const code = await runCommand(cmd);
197
+ if (code !== 0) {
198
+ log(` ✗ scribe install returned ${code}. Retry: \`${cmd.join(" ")}\`.`);
199
+ return 0;
200
+ }
201
+ if (envKey && !(apiKey && apiKey.length > 0)) {
202
+ log(
203
+ ` ✓ Recorded ${provider}. Add ${envKey} later: \`echo '${envKey}=<value>' >> ${opts.configDir}/scribe/.env\` then \`parachute restart scribe\`.`,
204
+ );
205
+ } else {
206
+ log(` ✓ Scribe installed and running with the ${provider} cloud provider.`);
207
+ }
208
+ return 0;
209
+ }
210
+
211
+ async function handleLocal(
212
+ opts: TranscriptionStepOpts,
213
+ prompt: (q: string) => Promise<string>,
214
+ runCommand: WizardCommandRunner,
215
+ platform: NodeJS.Platform,
216
+ log: (l: string) => void,
217
+ ): Promise<number> {
218
+ const ramMib =
219
+ opts.availableRamMib !== undefined ? opts.availableRamMib : readAvailableRamMib(platform);
220
+ const decision = decideLocalProvider(platform, ramMib);
221
+
222
+ if (!decision.ok) {
223
+ // Can't install local here — say EXACTLY why + point at the cloud one-shot.
224
+ // Do NOT record a dead `local` provider string.
225
+ log("");
226
+ log(` ✗ Local transcription isn't possible on this box: ${decision.reason}`);
227
+ log("");
228
+ log(" One-shot cloud alternative — get a free Groq key at https://console.groq.com,");
229
+ log(" then run:");
230
+ log(" parachute install scribe --scribe-provider groq --scribe-key gsk_…");
231
+ log(" (or re-run this wizard and choose Cloud).");
232
+ return 0;
233
+ }
234
+
235
+ const provider = decision.provider as "parakeet-mlx" | "onnx-asr";
236
+ log("");
237
+ log(` This box can run ${provider} locally.`);
238
+
239
+ // Confirm before the (slow, apt/pip) install unless pre-supplied.
240
+ if (opts.transcribeMode === undefined) {
241
+ const ok = (await prompt(` Install ${provider} now? [Y/n]: `)).trim().toLowerCase();
242
+ if (ok === "n" || ok === "no") {
243
+ log(
244
+ " Skipped. Install later with `parachute-scribe install-backend` or re-run this wizard.",
245
+ );
246
+ return 0;
247
+ }
248
+ }
249
+
250
+ // Install the scribe module first, recording the resolved provider so install
251
+ // doesn't prompt for one. (We UNDO this record below if the engine install
252
+ // fails — so a failure never leaves a dead provider string.)
253
+ log("");
254
+ log(" Installing the scribe module…");
255
+ const moduleCode = await runCommand([
256
+ "parachute",
257
+ "install",
258
+ "scribe",
259
+ "--scribe-provider",
260
+ provider,
261
+ ]);
262
+ if (moduleCode !== 0) {
263
+ clearScribeProvider(opts.configDir);
264
+ log(` ✗ scribe module install returned ${moduleCode} — not recording a local provider.`);
265
+ return 0;
266
+ }
267
+
268
+ // Run scribe's OWN runnable install routine (scribe PR #79). It apt/pip-
269
+ // installs the engine, warm-pulls the model, and exits non-zero on hard
270
+ // failure (no engine on PATH, too little RAM on its own re-check, etc.).
271
+ log("");
272
+ log(` Installing the ${provider} engine via scribe (this can take a few minutes)…`);
273
+ // The `--provider <p>` FLAG form is intentional (not the positional
274
+ // `install-backend <p>`): scribe's cmdInstallBackend reads it via getFlag
275
+ // when args[1] starts with "-". If scribe ever moves off its module-level
276
+ // `args` global, re-confirm this flag is still honored.
277
+ const code = await runCommand(["parachute-scribe", "install-backend", "--provider", provider]);
278
+ if (code !== 0) {
279
+ // HONEST skip — undo the provisional provider record; do NOT leave a dead
280
+ // provider string scribe can't honor.
281
+ clearScribeProvider(opts.configDir);
282
+ log("");
283
+ log(` ✗ ${provider} install failed (exit ${code}); not recording it as the provider.`);
284
+ log(
285
+ " Cloud alternative: `parachute install scribe --scribe-provider groq --scribe-key gsk_…`",
286
+ );
287
+ return 0;
288
+ }
289
+
290
+ // Engine installed + verified by scribe — keep the provider recorded (the
291
+ // install step already wrote it) and restart so the running scribe picks it up.
292
+ log("");
293
+ log(` ✓ ${provider} installed and recorded as the transcription provider.`);
294
+ await runCommand(["parachute", "restart", "scribe"]);
295
+ return 0;
296
+ }
@@ -39,8 +39,10 @@
39
39
  */
40
40
 
41
41
  import { createInterface } from "node:readline/promises";
42
+ import { configDir } from "../config.ts";
42
43
  import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
43
44
  import { SESSION_COOKIE_NAME } from "../sessions.ts";
45
+ import { type WizardCommandRunner, walkTranscriptionStep } from "./wizard-transcription.ts";
44
46
 
45
47
  const POLL_INTERVAL_MS = 2000;
46
48
  const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes — generous enough for a slow `bun add` over a flaky connection.
@@ -111,6 +113,29 @@ export interface RunCliWizardOpts {
111
113
  * the wizard's.
112
114
  */
113
115
  exposeMode?: "localhost" | "tailnet" | "public";
116
+ /**
117
+ * `~/.parachute` (or the PARACHUTE_HOME override) — where scribe's config
118
+ * lives. Required for the transcription step to write the chosen provider +
119
+ * key. Init passes its resolved configDir; when absent the transcription
120
+ * step is skipped (the wizard can still walk account / vault / expose).
121
+ */
122
+ configDir?: string;
123
+ /**
124
+ * Pre-supply the transcription answer (non-interactive escape, mirrors the
125
+ * browser wizard's `scribe_provider`). `none` | `local` | `groq` | `openai`.
126
+ */
127
+ transcribeMode?: "none" | "local" | "groq" | "openai";
128
+ /** Pre-supplied cloud transcription API key (groq / openai). */
129
+ transcribeApiKey?: string;
130
+ /**
131
+ * Test seam: replace the transcription step's subprocess runner so tests
132
+ * never install scribe / shell out. Threaded into `walkTranscriptionStep`.
133
+ */
134
+ transcribeRunCommand?: WizardCommandRunner;
135
+ /** Test seam: platform override for the transcription step. */
136
+ platform?: NodeJS.Platform;
137
+ /** Test seam: available-RAM override (MiB) for the transcription step's gate. */
138
+ availableRamMib?: number | null;
114
139
  }
115
140
 
116
141
  /** Cookie jar — tiny, in-memory, no persistence across runs. */
@@ -395,9 +420,14 @@ async function pollOperation(
395
420
  }
396
421
  }
397
422
 
398
- /** Validate password length up front so we don't bounce off a 400 the operator can't see. */
423
+ /**
424
+ * Validate password length up front so we don't bounce off a 400 the operator
425
+ * can't see. Floor is 12 to match the server (`PASSWORD_MIN_LEN`) AND this
426
+ * wizard's own "min 12 chars" copy at the password prompt — the validator was
427
+ * stuck at 8, contradicting both (onboarding-streamline hub PR1).
428
+ */
399
429
  function validatePassword(pw: string): string | undefined {
400
- if (pw.length < 8) return "Password must be at least 8 characters.";
430
+ if (pw.length < 12) return "Password must be at least 12 characters.";
401
431
  return undefined;
402
432
  }
403
433
 
@@ -743,6 +773,24 @@ export async function runCliWizard(opts: RunCliWizardOpts): Promise<number> {
743
773
  if (code !== 0) return code;
744
774
  state = await fetchWizardState(hubUrl, jar, fetchImpl);
745
775
  }
776
+ // Transcription step (onboarding-streamline hub PR1) — the CLI's parity with
777
+ // the browser wizard's folded scribe sub-form. The hub's setup-state machine
778
+ // has no "transcription" step (scribe is a module install, not a wizard gate),
779
+ // so this runs unconditionally between vault and expose rather than off
780
+ // `state.step`. Skipped without a configDir (nowhere to write scribe config).
781
+ // Non-fatal: a transcription that couldn't be set up never blocks setup.
782
+ if (opts.configDir !== undefined) {
783
+ await walkTranscriptionStep({
784
+ configDir: opts.configDir,
785
+ log,
786
+ prompt,
787
+ ...(opts.transcribeMode !== undefined ? { transcribeMode: opts.transcribeMode } : {}),
788
+ ...(opts.transcribeApiKey !== undefined ? { transcribeApiKey: opts.transcribeApiKey } : {}),
789
+ ...(opts.transcribeRunCommand !== undefined ? { runCommand: opts.transcribeRunCommand } : {}),
790
+ ...(opts.platform !== undefined ? { platform: opts.platform } : {}),
791
+ ...(opts.availableRamMib !== undefined ? { availableRamMib: opts.availableRamMib } : {}),
792
+ });
793
+ }
746
794
  if (state.step === "expose") {
747
795
  const code = await walkExposeStep(hubUrl, jar, state, ctx);
748
796
  if (code !== 0) return code;
@@ -852,6 +900,21 @@ export function parseWizardArgs(args: readonly string[]): ParsedWizardArgs | { e
852
900
  }
853
901
  out.opts.exposeMode = value;
854
902
  break;
903
+ case "--transcribe-mode":
904
+ if (!consumeValue()) return { error: `${key} requires a value` };
905
+ if (value !== "none" && value !== "local" && value !== "groq" && value !== "openai") {
906
+ return { error: `${key} must be one of none, local, groq, openai` };
907
+ }
908
+ out.opts.transcribeMode = value;
909
+ break;
910
+ case "--transcribe-key":
911
+ if (!consumeValue()) return { error: `${key} requires a value` };
912
+ out.opts.transcribeApiKey = value;
913
+ break;
914
+ case "--config-dir":
915
+ if (!consumeValue()) return { error: `${key} requires a path` };
916
+ out.opts.configDir = value;
917
+ break;
855
918
  default:
856
919
  if (a.startsWith("--")) return { error: `unknown argument "${a}"` };
857
920
  return { error: `unexpected positional argument "${a}"` };
@@ -877,12 +940,19 @@ export async function runSetupWizardCommand(args: readonly string[]): Promise<nu
877
940
  " [--bootstrap-token <token>]\n" +
878
941
  " [--vault-mode create|import|skip] [--vault-name <name>]\n" +
879
942
  " [--vault-import-url <url>] [--vault-import-pat <pat>] [--vault-import-replace]\n" +
943
+ " [--transcribe-mode none|local|groq|openai] [--transcribe-key <key>]\n" +
944
+ " [--config-dir <path>]\n" +
880
945
  " [--expose-mode localhost|tailnet|public]",
881
946
  );
882
947
  return 1;
883
948
  }
949
+ // Default configDir from PARACHUTE_HOME (matching the rest of the CLI) so the
950
+ // standalone `parachute setup-wizard` invocation can run the transcription
951
+ // step. An explicit `--config-dir` wins.
952
+ const wizardOpts = { ...parsed.opts };
953
+ if (wizardOpts.configDir === undefined) wizardOpts.configDir = configDir();
884
954
  return await runCliWizard({
885
- ...parsed.opts,
955
+ ...wizardOpts,
886
956
  log: (line) => console.log(line),
887
957
  });
888
958
  }
@@ -1,4 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { totalmem } from "node:os";
2
3
  import { dirname, join } from "node:path";
3
4
  import { parseEnvFile, upsertEnvLine, writeEnvFile } from "./env-file.ts";
4
5
 
@@ -60,6 +61,118 @@ export type ScribeProviderKey = (typeof SCRIBE_PROVIDERS)[number]["key"];
60
61
  /** Default provider scribe falls back to when the config doesn't pick one. */
61
62
  export const SCRIBE_DEFAULT_PROVIDER: ScribeProviderKey = "parakeet-mlx";
62
63
 
64
+ /**
65
+ * Resolve the "local" choice to the CORRECT platform backend. The setup
66
+ * wizard (browser + CLI) lets the operator pick "local" without knowing the
67
+ * engine name; this picks the one that actually runs here.
68
+ *
69
+ * - macOS → `parakeet-mlx` (Apple Silicon MLX)
70
+ * - Linux → `onnx-asr` (cross-platform Sherpa-ONNX)
71
+ * - other → `null` (no local backend — steer to cloud)
72
+ *
73
+ * Mirrors scribe's own `platformLocalProvider` (parachute-scribe
74
+ * src/install-backend.ts) so hub and scribe can't drift on the mapping.
75
+ * Fixes the long-standing bug where the wizard mapped `local` UNCONDITIONALLY
76
+ * to `parakeet-mlx`, which silently fails on every Linux box (the common
77
+ * DigitalOcean / VPS deploy).
78
+ */
79
+ export function platformLocalProvider(
80
+ platform: NodeJS.Platform,
81
+ ): "parakeet-mlx" | "onnx-asr" | null {
82
+ if (platform === "darwin") return "parakeet-mlx";
83
+ if (platform === "linux") return "onnx-asr";
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Minimum available RAM (MiB) below which a local ASR model would be
89
+ * OOM-killed. Mirrors scribe's `MIN_RAM_MIB` (parachute-scribe
90
+ * src/install-backend.ts) — the 1 GB DigitalOcean droplet is the box this
91
+ * guards against; a local Parakeet/ONNX model needs ~2 GB to load.
92
+ */
93
+ export const MIN_RAM_MIB = 2048;
94
+
95
+ /**
96
+ * Available RAM in MiB, or `null` when it can't be determined.
97
+ *
98
+ * Linux: reads `MemAvailable` from `/proc/meminfo` (the honest figure — free
99
+ * plus reclaimable cache), matching scribe's probe so the two layers agree on
100
+ * the same droplet. Falls back to `MemFree` on very old kernels that predate
101
+ * MemAvailable.
102
+ *
103
+ * Non-Linux: falls back to `os.totalmem()` (there's no MemAvailable analogue;
104
+ * total is a coarse upper bound but enough to keep a tiny VM from offering
105
+ * local). macOS dev boxes comfortably clear the floor, so the coarseness is
106
+ * harmless there.
107
+ *
108
+ * Sync by design: the wizard's provider-decision path is synchronous, and the
109
+ * `/proc/meminfo` read is a tiny file. Tests inject `availableRamMib` directly
110
+ * to exercise the gate without touching the real host.
111
+ */
112
+ export function readAvailableRamMib(platform: NodeJS.Platform = process.platform): number | null {
113
+ if (platform === "linux") {
114
+ try {
115
+ const text = readFileSync("/proc/meminfo", "utf8");
116
+ const avail = /^MemAvailable:\s+(\d+)\s*kB/m.exec(text);
117
+ const free = /^MemFree:\s+(\d+)\s*kB/m.exec(text);
118
+ const kb = avail ? Number(avail[1]) : free ? Number(free[1]) : null;
119
+ if (kb === null || !Number.isFinite(kb)) return null;
120
+ return Math.floor(kb / 1024);
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+ // Non-Linux: total physical memory as a coarse fallback.
126
+ try {
127
+ const bytes = totalmem();
128
+ if (!Number.isFinite(bytes) || bytes <= 0) return null;
129
+ return Math.floor(bytes / (1024 * 1024));
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Decide whether a LOCAL transcription provider is offerable / acceptable on
137
+ * this host, and if not, why + what to steer to. Both wizard surfaces (browser
138
+ * POST handler + CLI) consult this BEFORE recording a `local` choice so we
139
+ * never write a dead provider string that scribe can't run.
140
+ *
141
+ * `ok: false` carries a `reason` (shown inline) and a `steerTo` cloud provider
142
+ * the caller should redirect to.
143
+ */
144
+ export interface LocalProviderDecision {
145
+ ok: boolean;
146
+ /** The resolved platform backend when `ok` (parakeet-mlx / onnx-asr). */
147
+ provider?: "parakeet-mlx" | "onnx-asr";
148
+ /** Human-readable reason when `ok` is false (no local backend / too little RAM). */
149
+ reason?: string;
150
+ /** Cloud provider to redirect to when local is unavailable. */
151
+ steerTo?: "groq";
152
+ }
153
+
154
+ export function decideLocalProvider(
155
+ platform: NodeJS.Platform,
156
+ availableRamMib: number | null,
157
+ ): LocalProviderDecision {
158
+ const provider = platformLocalProvider(platform);
159
+ if (provider === null) {
160
+ return {
161
+ ok: false,
162
+ reason: `No local transcription backend runs on "${platform}". Use a cloud provider instead.`,
163
+ steerTo: "groq",
164
+ };
165
+ }
166
+ if (availableRamMib !== null && availableRamMib < MIN_RAM_MIB) {
167
+ return {
168
+ ok: false,
169
+ reason: `This box has ${availableRamMib} MiB available RAM, below the ${MIN_RAM_MIB} MiB a local ASR model needs (it would be OOM-killed). Use a cloud provider instead — groq is fast (~$0.04/hr of audio).`,
170
+ steerTo: "groq",
171
+ };
172
+ }
173
+ return { ok: true, provider };
174
+ }
175
+
63
176
  export function isKnownScribeProvider(value: string): value is ScribeProviderKey {
64
177
  return SCRIBE_PROVIDERS.some((p) => p.key === value);
65
178
  }
@@ -136,6 +249,38 @@ export function writeScribeProvider(configDir: string, provider: ScribeProviderK
136
249
  renameSync(tmp, path);
137
250
  }
138
251
 
252
+ /**
253
+ * Remove `transcribe.provider` from scribe's config.json (preserving every
254
+ * other key). Used by the wizard's local-install path to UNDO a provisional
255
+ * provider record when the engine install fails — so we never leave a dead
256
+ * provider string scribe can't honor. A no-op when the file or the
257
+ * `transcribe` block is absent.
258
+ */
259
+ export function clearScribeProvider(configDir: string): void {
260
+ const path = scribeConfigPath(configDir);
261
+ if (!existsSync(path)) return;
262
+ let current: Record<string, unknown>;
263
+ try {
264
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
265
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
266
+ current = parsed as Record<string, unknown>;
267
+ } catch {
268
+ return; // malformed → leave it; auto-wire repairs on next run
269
+ }
270
+ const transcribe = current.transcribe;
271
+ if (typeof transcribe !== "object" || transcribe === null || Array.isArray(transcribe)) {
272
+ return;
273
+ }
274
+ // Drop `provider` from the transcribe block (destructure-omit, no `delete`).
275
+ const { provider: _dropped, ...block } = transcribe as Record<string, unknown>;
276
+ const { transcribe: _omit, ...rest } = current;
277
+ const next: Record<string, unknown> =
278
+ Object.keys(block).length === 0 ? rest : { ...rest, transcribe: block };
279
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
280
+ writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`);
281
+ renameSync(tmp, path);
282
+ }
283
+
139
284
  /**
140
285
  * Idempotent upsert of a single `KEY=value` into `<configDir>/scribe/.env`.
141
286
  * Used for the API-key prompt result. Other lines (auto-wire keys, manual
@@ -74,6 +74,11 @@ import {
74
74
  readOperatorTokenFile,
75
75
  } from "./operator-token.ts";
76
76
  import { isHttpsRequest } from "./request-protocol.ts";
77
+ import {
78
+ decideLocalProvider,
79
+ platformLocalProvider,
80
+ readAvailableRamMib,
81
+ } from "./scribe-config.ts";
77
82
  import { SEED_VERSION } from "./service-spec.ts";
78
83
  import { findService, readManifestLenient } from "./services-manifest.ts";
79
84
  import {
@@ -2410,8 +2415,23 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
2410
2415
  // Cleanup-without-transcribe is a valid combo: the operator can
2411
2416
  // hit scribe's REST cleanup endpoint directly with their own raw
2412
2417
  // text. We install scribe + write the cleanup block in that case.
2413
- const scribeProvider = String(form.get("scribe_provider") ?? "").trim();
2418
+ let scribeProvider = String(form.get("scribe_provider") ?? "").trim();
2414
2419
  const scribeCleanupProvider = String(form.get("scribe_cleanup_provider") ?? "").trim();
2420
+ // RAM/platform gate: if the operator asked for `local` on a box that can't
2421
+ // run a local ASR model (no local backend for the platform, or too little
2422
+ // RAM — the 1 GB droplet would OOM), redirect the choice to a cloud provider
2423
+ // (groq) rather than recording a dead `local` string scribe can never honor.
2424
+ // The reason is logged; the inline UI surfaces it via the scribe op poll.
2425
+ if (scribeProvider === "local") {
2426
+ const decision = decideLocalProvider(process.platform, readAvailableRamMib());
2427
+ if (!decision.ok) {
2428
+ console.warn(
2429
+ `[setup-wizard] local transcription unavailable on this host: ${decision.reason} ` +
2430
+ `Steering to "${decision.steerTo}".`,
2431
+ );
2432
+ scribeProvider = decision.steerTo ?? "groq";
2433
+ }
2434
+ }
2415
2435
  const wantsTranscribe = scribeProvider !== "" && scribeProvider !== "none";
2416
2436
  const wantsCleanup = scribeCleanupProvider !== "" && scribeCleanupProvider !== "none";
2417
2437
  let scribeOpId: string | undefined;
@@ -2590,16 +2610,29 @@ interface WizardScribeConfig {
2590
2610
  transcribe?: { provider: string; apiKey: string };
2591
2611
  /** Set when the operator chose a cleanup provider (anything other than "none"). */
2592
2612
  cleanup?: { provider: string; apiKey: string };
2613
+ /**
2614
+ * Platform override for resolving the `local` choice (test seam). Defaults to
2615
+ * the real host platform. Mac → parakeet-mlx, Linux → onnx-asr.
2616
+ */
2617
+ platform?: NodeJS.Platform;
2593
2618
  }
2594
2619
  function writeScribeConfigForWizard(configDir: string, config: WizardScribeConfig): void {
2595
2620
  const update: Record<string, unknown> = {};
2621
+ const platform = config.platform ?? process.platform;
2596
2622
 
2597
2623
  if (config.transcribe) {
2598
2624
  const { provider, apiKey } = config.transcribe;
2599
- // For `local` (Mac MLX / cross-platform ONNX), just set the
2600
- // provider name no key needed.
2625
+ // For `local`, resolve to the CORRECT platform backend parakeet-mlx on
2626
+ // macOS, onnx-asr on Linux. (Was hardcoded to parakeet-mlx, which silently
2627
+ // fails on every Linux box.) No key needed for local. The caller's
2628
+ // RAM/platform gate is the single place that decides "local isn't possible
2629
+ // here" and should have steered to cloud before reaching this writer — but
2630
+ // if that gate is ever bypassed and the platform has no local backend, we
2631
+ // write "none" (transcription off) rather than a dead provider string, so
2632
+ // this writer can never record something that silently fails.
2601
2633
  if (provider === "local") {
2602
- update.transcribe = { provider: "parakeet-mlx" };
2634
+ const resolved = platformLocalProvider(platform);
2635
+ update.transcribe = { provider: resolved ?? "none" };
2603
2636
  } else {
2604
2637
  // Cloud providers need a key. Empty key → just set provider;
2605
2638
  // the operator can paste the key later via /scribe/admin