@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.
- package/package.json +1 -1
- package/src/__tests__/api-modules.test.ts +65 -10
- package/src/__tests__/module-manifest.test.ts +3 -1
- package/src/__tests__/scribe-config.test.ts +117 -0
- package/src/__tests__/service-spec-discovery.test.ts +22 -2
- package/src/__tests__/setup-wizard.test.ts +18 -0
- package/src/__tests__/setup.test.ts +129 -15
- package/src/__tests__/wizard-transcription.test.ts +334 -0
- package/src/__tests__/wizard.test.ts +146 -0
- package/src/api-modules.ts +47 -13
- package/src/commands/init.ts +10 -2
- package/src/commands/setup.ts +31 -6
- package/src/commands/wizard-transcription.ts +296 -0
- package/src/commands/wizard.ts +73 -3
- package/src/module-manifest.ts +23 -9
- package/src/scribe-config.ts +145 -0
- package/src/service-spec.ts +31 -12
- package/src/setup-wizard.ts +37 -4
- package/web/ui/dist/assets/{index-DR6R8EFf.css → index--728BX3j.css} +1 -1
- package/web/ui/dist/assets/{index-CKv8qCCQ.js → index-DZzX_Enf.js} +10 -10
- package/web/ui/dist/index.html +2 -2
|
@@ -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
|
});
|
package/src/api-modules.ts
CHANGED
|
@@ -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
|
|
19
|
-
* when declared, else `focusForShort`'s default map. The SPA
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
203
|
-
* in the headline group; `experimental`
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
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
|
|
566
|
-
// never hides
|
|
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;
|
package/src/commands/init.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
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),
|