@openparachute/hub 0.7.3-rc.7 → 0.7.3-rc.9
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__/hub-origins-env-set.test.ts +199 -0
- package/src/__tests__/scribe-config.test.ts +117 -0
- package/src/__tests__/serve-boot.test.ts +45 -0
- package/src/__tests__/setup-wizard.test.ts +18 -0
- package/src/__tests__/vault-hub-origin-env.test.ts +38 -4
- package/src/__tests__/wizard-transcription.test.ts +334 -0
- package/src/__tests__/wizard.test.ts +146 -0
- package/src/api-modules-ops.ts +12 -0
- package/src/commands/init.ts +10 -2
- package/src/commands/serve-boot.ts +16 -2
- package/src/commands/wizard-transcription.ts +296 -0
- package/src/commands/wizard.ts +73 -3
- package/src/hub-origin.ts +64 -0
- package/src/jwt-sign.ts +14 -3
- package/src/scribe-config.ts +145 -0
- package/src/setup-wizard.ts +37 -4
- package/src/vault-hub-origin-env.ts +109 -16
|
@@ -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-ops.ts
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
import { findService, readManifestLenient, removeService } from "./services-manifest.ts";
|
|
56
56
|
import { enrichedPath } from "./spawn-path.ts";
|
|
57
57
|
import type { ModuleState, SpawnRequest, Supervisor } from "./supervisor.ts";
|
|
58
|
+
import { buildHubOriginsEnvValue } from "./vault-hub-origin-env.ts";
|
|
58
59
|
import { WELL_KNOWN_PATH, type regenerateWellKnown } from "./well-known.ts";
|
|
59
60
|
|
|
60
61
|
/**
|
|
@@ -609,10 +610,21 @@ async function spawnSupervised(
|
|
|
609
610
|
// `process.env.PATH` may ALREADY be enriched by serve startup (serve.ts);
|
|
610
611
|
// re-enriching here is a harmless no-op — `enrichedPath` is idempotent
|
|
611
612
|
// (dedupe + append-only), so double-enrichment can't duplicate or reorder.
|
|
613
|
+
// PARACHUTE_HUB_ORIGINS (multi-origin iss-set, onboarding-streamline
|
|
614
|
+
// 2026-06-25): alongside the single canonical PARACHUTE_HUB_ORIGIN, inject
|
|
615
|
+
// the SET of origins the hub legitimately answers on (issuer ∪ loopback ∪
|
|
616
|
+
// expose-state ∪ platform). A resource server on scope-guard ≥0.5.0 widens
|
|
617
|
+
// its accepted-`iss` check to this set so a token minted under one URL of a
|
|
618
|
+
// multi-URL box validates via another URL of the SAME box. Mirrors
|
|
619
|
+
// buildModuleSpawnRequest (serve-boot.ts) — keep the two in sync. SECURITY:
|
|
620
|
+
// the set is hub-controlled config/disk state ONLY, never a request Host
|
|
621
|
+
// (see buildHubOriginsEnvValue). `deps.spawnEnv` still wins.
|
|
622
|
+
const hubOrigins = deps.issuer ? buildHubOriginsEnvValue(deps.configDir, deps.issuer) : undefined;
|
|
612
623
|
const childEnv: Record<string, string> = {
|
|
613
624
|
PATH: enrichedPath(),
|
|
614
625
|
PORT: String(entry.port),
|
|
615
626
|
...(deps.issuer ? { PARACHUTE_HUB_ORIGIN: deps.issuer } : {}),
|
|
627
|
+
...(hubOrigins ? { PARACHUTE_HUB_ORIGINS: hubOrigins } : {}),
|
|
616
628
|
...(deps.spawnEnv ?? {}),
|
|
617
629
|
};
|
|
618
630
|
const req: SpawnRequest = {
|
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),
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
import { readEnvFileValues } from "../env-file.ts";
|
|
21
|
-
import { HUB_ORIGIN_ENV } from "../hub-origin.ts";
|
|
21
|
+
import { HUB_ORIGINS_ENV, HUB_ORIGIN_ENV } from "../hub-origin.ts";
|
|
22
22
|
import { ModuleManifestError } from "../module-manifest.ts";
|
|
23
23
|
import {
|
|
24
24
|
type ServiceSpec,
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
import { type ServiceEntry, readManifestLenient, upsertService } from "../services-manifest.ts";
|
|
31
31
|
import { enrichedPath } from "../spawn-path.ts";
|
|
32
32
|
import type { Supervisor } from "../supervisor.ts";
|
|
33
|
+
import { buildHubOriginsEnvValue } from "../vault-hub-origin-env.ts";
|
|
33
34
|
|
|
34
35
|
export interface BootOpts {
|
|
35
36
|
/** Path to services.json. */
|
|
@@ -197,7 +198,20 @@ export function buildModuleSpawnRequest(
|
|
|
197
198
|
PORT: String(entry.port),
|
|
198
199
|
...fileEnvSansPort,
|
|
199
200
|
};
|
|
200
|
-
if (opts.hubOrigin)
|
|
201
|
+
if (opts.hubOrigin) {
|
|
202
|
+
env[HUB_ORIGIN_ENV] = opts.hubOrigin;
|
|
203
|
+
// Multi-origin iss-set (onboarding-streamline 2026-06-25): alongside the
|
|
204
|
+
// single canonical origin, inject the SET of origins this hub legitimately
|
|
205
|
+
// answers on (issuer ∪ loopback aliases ∪ expose-state ∪ platform). A
|
|
206
|
+
// resource server on scope-guard ≥0.5.0 widens its accepted-`iss` check to
|
|
207
|
+
// this set, so a token minted under one URL of a multi-URL box validates
|
|
208
|
+
// when the resource is reached via another URL of the SAME box. SECURITY:
|
|
209
|
+
// the set is hub-controlled config/disk state only, NEVER a request Host —
|
|
210
|
+
// see `buildHubOriginsEnvValue`. The single `PARACHUTE_HUB_ORIGIN` above
|
|
211
|
+
// stays for back-compat with older scope-guard.
|
|
212
|
+
const originsValue = buildHubOriginsEnvValue(opts.configDir, opts.hubOrigin);
|
|
213
|
+
if (originsValue) env[HUB_ORIGINS_ENV] = originsValue;
|
|
214
|
+
}
|
|
201
215
|
if (opts.extraEnv) Object.assign(env, opts.extraEnv);
|
|
202
216
|
|
|
203
217
|
const req: SpawnReqShape = { short, cmd };
|