@openparachute/hub 0.7.3-rc.6 → 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.
@@ -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
  });
@@ -15,11 +15,21 @@
15
15
  * / `crashed` / `starting` / `restarting`) + pid. Absent when the
16
16
  * hub is in CLI mode (no supervisor injected through HubFetchDeps).
17
17
  *
18
- * `focus` ("core" | "experimental") comes from each module's `module.json`
19
- * when declared, else `focusForShort`'s default map. The SPA groups core first
20
- * + de-emphasizes experimental it NEVER hides a module. This is what makes a
21
- * running, self-registered module (channel) visible + installable; the old
22
- * `CURATED_MODULES = ["vault","scribe"]` whitelist made it invisible.
18
+ * `focus` ("core" | "experimental" | "deprecated") comes from each module's
19
+ * `module.json` when declared, else `focusForShort`'s default map. The SPA
20
+ * groups core first, de-emphasizes experimental, and de-emphasizes
21
+ * `deprecated` (notes-daemon / runner) further it NEVER hides an installed
22
+ * module (so an existing operator can still manage / uninstall a deprecated
23
+ * one). This is what makes a running, self-registered module (channel) visible
24
+ * + installable; the old `CURATED_MODULES = ["vault","scribe"]` whitelist made
25
+ * it invisible.
26
+ *
27
+ * `available_to_install` is the fresh-install OFFER (2026-06-25): a module the
28
+ * hub will push as a new install. It's `available && focus !== "deprecated"`
29
+ * — so a `deprecated` module that is ALREADY installed still surfaces (via the
30
+ * `modules` union + `installed: true`) and is manageable, but a deprecated
31
+ * module is never offered as a fresh install. `agent` (`experimental`) stays
32
+ * offered.
23
33
  *
24
34
  * Bearer-gated on `parachute:host:auth` to match the rest of `/api/auth/*`
25
35
  * and `/api/grants` — the admin SPA mints this scope via
@@ -199,14 +209,33 @@ interface ModuleWireShape {
199
209
  display_name: string;
200
210
  tagline: string;
201
211
  /**
202
- * Discovery tier (2026-06-09 modular-UI architecture). `core` modules render
203
- * in the headline group; `experimental` modules render in a de-emphasized
204
- * "Experimental" group below never hidden. Resolved from the module's
205
- * `module.json` `focus` when declared, else `focusForShort`'s default map
206
- * (vault/scribe/hub/surface core, channel/runner/others experimental).
212
+ * Discovery tier (2026-06-09 modular-UI architecture; `deprecated` added
213
+ * 2026-06-25). `core` modules render in the headline group; `experimental`
214
+ * modules render in a de-emphasized "Experimental" group; `deprecated`
215
+ * modules (notes-daemon / runner) render in a further-de-emphasized
216
+ * "Deprecated" group never hidden when installed. Resolved from the
217
+ * module's `module.json` `focus` when declared, else `focusForShort`'s
218
+ * default map (vault/scribe/hub/surface → core, notes/runner → deprecated,
219
+ * others → experimental).
207
220
  */
208
221
  focus: ModuleFocus;
222
+ /**
223
+ * True iff the hub knows how to install this module (`package`/`manifest`
224
+ * resolvable). Historically "in the curated install catalog". Still set for
225
+ * every known module; a purely third-party services.json row is false.
226
+ * NOTE: this is NOT the fresh-install OFFER — a `deprecated` module is still
227
+ * `available: true` (it's installable for back-compat / re-install) but is
228
+ * excluded from `available_to_install`.
229
+ */
209
230
  available: boolean;
231
+ /**
232
+ * The fresh-install OFFER (2026-06-25): whether the hub presents this module
233
+ * in the "available to install fresh" set. `available && focus !==
234
+ * "deprecated"`. The SPA's "Install a module" catalog filters on this so
235
+ * notes-daemon / runner aren't pushed on a fresh box; an already-installed
236
+ * deprecated module still surfaces (in the Installed section) for management.
237
+ */
238
+ available_to_install: boolean;
210
239
  installed: boolean;
211
240
  installed_version: string | null;
212
241
  latest_version: string | null;
@@ -562,8 +591,8 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
562
591
  // module's manifest-declared `focus` (when installed + declared) wins; else
563
592
  // the `focusForShort` default map. Sort: `core` group first (with the
564
593
  // CURATED_MODULES recommended-install order floated to the top of that
565
- // group), then `experimental` the SPA renders the two groups; `focus`
566
- // never hides a module.
594
+ // group), then `experimental`, then `deprecated` (notes / runner) last the
595
+ // SPA renders the groups; `focus` never hides an installed module.
567
596
  const recommendedOrder = new Map<string, number>(
568
597
  (CURATED_MODULES as readonly string[]).map((s, i) => [s, i]),
569
598
  );
@@ -585,6 +614,11 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
585
614
  // known modules; a purely third-party services.json row (no install
586
615
  // package) is not hub-installable → false.
587
616
  available: m !== undefined,
617
+ // Fresh-install OFFER (2026-06-25): installable AND not deprecated. A
618
+ // deprecated short (notes / runner) stays `available` (re-installable
619
+ // for back-compat) but is dropped from the offer set so the SPA's
620
+ // "Install a module" catalog doesn't push it on a fresh box.
621
+ available_to_install: m !== undefined && focus !== "deprecated",
588
622
  installed: installed !== undefined,
589
623
  installed_version: installed?.version ?? null,
590
624
  latest_version: latestByShort.get(short) ?? null,
@@ -598,7 +632,7 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
598
632
  };
599
633
  return row;
600
634
  });
601
- const focusRank = (f: ModuleFocus): number => (f === "core" ? 0 : 1);
635
+ const focusRank = (f: ModuleFocus): number => (f === "core" ? 0 : f === "experimental" ? 1 : 2);
602
636
  rows.sort((a, b) => {
603
637
  if (a.focus !== b.focus) return focusRank(a.focus) - focusRank(b.focus);
604
638
  const ai = recommendedOrder.get(a.short) ?? Number.POSITIVE_INFINITY;
@@ -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),