@openparachute/hub 0.7.2 → 0.7.3-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-agent-grants.test.ts +113 -0
  3. package/src/__tests__/admin-vaults.test.ts +20 -5
  4. package/src/__tests__/api-modules.test.ts +65 -10
  5. package/src/__tests__/api-vault-caps.test.ts +232 -0
  6. package/src/__tests__/cli.test.ts +20 -0
  7. package/src/__tests__/hub-command.test.ts +136 -0
  8. package/src/__tests__/hub-origins-env-set.test.ts +273 -0
  9. package/src/__tests__/init.test.ts +148 -0
  10. package/src/__tests__/jwt-sign.test.ts +79 -0
  11. package/src/__tests__/module-manifest.test.ts +3 -1
  12. package/src/__tests__/oauth-client.test.ts +75 -0
  13. package/src/__tests__/oauth-handlers.test.ts +413 -5
  14. package/src/__tests__/public-signup.test.ts +619 -0
  15. package/src/__tests__/rate-limit.test.ts +86 -0
  16. package/src/__tests__/scribe-config.test.ts +117 -0
  17. package/src/__tests__/serve-boot.test.ts +45 -0
  18. package/src/__tests__/serve.test.ts +67 -1
  19. package/src/__tests__/service-spec-discovery.test.ts +22 -2
  20. package/src/__tests__/setup-wizard.test.ts +18 -0
  21. package/src/__tests__/setup.test.ts +129 -15
  22. package/src/__tests__/two-factor-flow.test.ts +94 -0
  23. package/src/__tests__/users.test.ts +33 -0
  24. package/src/__tests__/vault-caps.test.ts +89 -0
  25. package/src/__tests__/vault-hub-origin-env.test.ts +38 -4
  26. package/src/__tests__/wizard-transcription.test.ts +334 -0
  27. package/src/__tests__/wizard.test.ts +146 -0
  28. package/src/account-setup.ts +118 -32
  29. package/src/admin-agent-grants.ts +51 -3
  30. package/src/admin-handlers.ts +53 -16
  31. package/src/admin-login-ui.ts +30 -4
  32. package/src/admin-vaults.ts +9 -1
  33. package/src/api-invites.ts +163 -3
  34. package/src/api-modules-ops.ts +12 -0
  35. package/src/api-modules.ts +47 -13
  36. package/src/api-users.ts +3 -0
  37. package/src/api-vault-caps.ts +206 -0
  38. package/src/cli.ts +35 -1
  39. package/src/commands/hub.ts +173 -0
  40. package/src/commands/init.ts +73 -2
  41. package/src/commands/serve-boot.ts +16 -2
  42. package/src/commands/serve.ts +39 -3
  43. package/src/commands/setup.ts +31 -6
  44. package/src/commands/wizard-transcription.ts +296 -0
  45. package/src/commands/wizard.ts +73 -3
  46. package/src/help.ts +8 -0
  47. package/src/hub-db.ts +108 -1
  48. package/src/hub-origin.ts +64 -0
  49. package/src/hub-server.ts +27 -0
  50. package/src/invites.ts +155 -31
  51. package/src/jwt-sign.ts +72 -3
  52. package/src/module-manifest.ts +23 -9
  53. package/src/oauth-client.ts +102 -12
  54. package/src/oauth-flows-store.ts +12 -0
  55. package/src/oauth-handlers.ts +145 -20
  56. package/src/rate-limit.ts +111 -20
  57. package/src/scribe-config.ts +145 -0
  58. package/src/service-spec.ts +31 -12
  59. package/src/setup-wizard.ts +37 -4
  60. package/src/users.ts +62 -3
  61. package/src/vault-caps.ts +109 -0
  62. package/src/vault-hub-origin-env.ts +109 -16
  63. package/web/ui/dist/assets/{index-DR6R8EFf.css → index--728BX3j.css} +1 -1
  64. package/web/ui/dist/assets/index-DZzX_Enf.js +61 -0
  65. package/web/ui/dist/index.html +2 -2
  66. package/web/ui/dist/assets/index-B5AUE359.js +0 -61
@@ -332,6 +332,100 @@ describe("login two-step (TOTP) — hub#473", () => {
332
332
  expect(denied.status).toBe(429);
333
333
  expect(denied.headers.get("retry-after")).not.toBeNull();
334
334
  });
335
+
336
+ // Fallback path: when the pending login can't be resolved (bad/expired
337
+ // token), the floor keys by IP ALONE — NOT the (rotating) pendingToken.
338
+ // Proven here by sending a DIFFERENT bogus pending cookie on every attempt
339
+ // from one IP: if the floor keyed by the token each would get a fresh bucket
340
+ // and never 429; keying by IP, the 6th still trips.
341
+ test("2FA floor with unresolvable pending tokens keys by IP, not the token", async () => {
342
+ const buildReq = (bogusToken: string) => {
343
+ const tf = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF, code: "000000", next: "/admin/vaults" });
344
+ return new Request("http://hub.test/login/2fa", {
345
+ method: "POST",
346
+ headers: {
347
+ ...tf.headers,
348
+ cookie: `${CSRF_COOKIE}; ${PENDING_LOGIN_COOKIE_NAME}=${bogusToken}`,
349
+ "cf-connecting-ip": "203.0.113.77",
350
+ },
351
+ body: tf.body,
352
+ });
353
+ };
354
+ for (let i = 0; i < 5; i++) {
355
+ // A unique bogus token per attempt → never resolves to a pending login.
356
+ const r = await handleAdminLoginTotpPost(harness.db, buildReq(`bogus-${i}`));
357
+ expect(r.status).toBe(401); // no pending login → 401, counts toward the IP floor
358
+ }
359
+ const denied = await handleAdminLoginTotpPost(harness.db, buildReq("bogus-final"));
360
+ expect(denied.status).toBe(429);
361
+ expect(denied.headers.get("retry-after")).not.toBeNull();
362
+ });
363
+
364
+ // (d) The 2FA floor is keyed by (ip,userId), NOT the rotating pendingToken.
365
+ // Re-POSTing /login mints a FRESH pendingToken for the SAME user — that must
366
+ // NOT reset the per-account TOTP floor (otherwise an attacker grinds TOTP
367
+ // forever by re-doing the password step between every 5 code attempts).
368
+ test("(d) re-POSTing /login (new pendingToken) does NOT reset the per-account TOTP floor", async () => {
369
+ const u = await createUser(harness.db, "owner", "owner-password-123", {
370
+ passwordChanged: true,
371
+ });
372
+ await persistEnrollment(harness.db, u.id, generateTotpSecret("owner").secret);
373
+ const ATTACK_IP = "203.0.113.66";
374
+
375
+ const passwordPost = async (): Promise<string> => {
376
+ const pw = formBody({
377
+ [CSRF_FIELD_NAME]: TEST_CSRF,
378
+ username: "owner",
379
+ password: "owner-password-123",
380
+ next: "/admin/vaults",
381
+ });
382
+ const res = await handleAdminLoginPost(
383
+ harness.db,
384
+ new Request("http://hub.test/login", {
385
+ method: "POST",
386
+ headers: { ...pw.headers, cookie: CSRF_COOKIE, "cf-connecting-ip": ATTACK_IP },
387
+ body: pw.body,
388
+ }),
389
+ );
390
+ const token = cookieFrom(res, PENDING_LOGIN_COOKIE_NAME);
391
+ expect(token).toBeTruthy();
392
+ return token!;
393
+ };
394
+
395
+ const wrongTotp = (pendingToken: string): Request => {
396
+ const tf = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF, code: "000000", next: "/admin/vaults" });
397
+ return new Request("http://hub.test/login/2fa", {
398
+ method: "POST",
399
+ headers: {
400
+ ...tf.headers,
401
+ cookie: `${CSRF_COOKIE}; ${PENDING_LOGIN_COOKIE_NAME}=${pendingToken}`,
402
+ "cf-connecting-ip": ATTACK_IP,
403
+ },
404
+ body: tf.body,
405
+ });
406
+ };
407
+
408
+ // First password step → pendingToken1.
409
+ const token1 = await passwordPost();
410
+ // 4 wrong TOTP attempts against pendingToken1 (all 401, all count toward
411
+ // the (ip,userId) bucket).
412
+ for (let i = 0; i < 4; i++) {
413
+ expect((await handleAdminLoginTotpPost(harness.db, wrongTotp(token1))).status).toBe(401);
414
+ }
415
+
416
+ // Re-do the password step → a BRAND-NEW pendingToken2 (same userId).
417
+ const token2 = await passwordPost();
418
+ expect(token2).not.toBe(token1);
419
+
420
+ // 5th wrong TOTP — under pendingToken2 but the SAME (ip,userId) bucket →
421
+ // still the 5th attempt → allowed (401), not reset to fresh.
422
+ expect((await handleAdminLoginTotpPost(harness.db, wrongTotp(token2))).status).toBe(401);
423
+ // 6th wrong TOTP → floor full → 429. If the floor were keyed by the
424
+ // pendingToken, token2 would have a fresh bucket and this would be a 401.
425
+ const denied = await handleAdminLoginTotpPost(harness.db, wrongTotp(token2));
426
+ expect(denied.status).toBe(429);
427
+ expect(denied.headers.get("retry-after")).not.toBeNull();
428
+ });
335
429
  });
336
430
 
337
431
  describe("/account/2fa handlers — hub#473", () => {
@@ -23,6 +23,7 @@ import {
23
23
  setPassword,
24
24
  setUserVaults,
25
25
  userCount,
26
+ validateEmail,
26
27
  validatePassword,
27
28
  validateUsername,
28
29
  vaultVerbsForRole,
@@ -431,6 +432,38 @@ describe("validatePassword", () => {
431
432
  });
432
433
  });
433
434
 
435
+ describe("validateEmail (v15, B2)", () => {
436
+ test("accepts ordinary addresses, canonicalizing to lowercase/trimmed", () => {
437
+ const r = validateEmail(" Alice@Example.com ");
438
+ expect(r.valid).toBe(true);
439
+ if (r.valid) expect(r.email).toBe("alice@example.com");
440
+ expect(validateEmail("a.b+tag@sub.domain.co.uk").valid).toBe(true);
441
+ expect(validateEmail("user_name@x.io").valid).toBe(true);
442
+ });
443
+
444
+ test("rejects malformed shapes", () => {
445
+ for (const bad of [
446
+ "",
447
+ "not-an-email",
448
+ "@example.com",
449
+ "alice@",
450
+ "alice@example",
451
+ "alice example@x.io",
452
+ "two@@x.io",
453
+ "alice@x.c",
454
+ ]) {
455
+ expect(validateEmail(bad).valid).toBe(false);
456
+ }
457
+ });
458
+
459
+ test("rejects over-length addresses (>254)", () => {
460
+ const long = `${"a".repeat(250)}@x.io`;
461
+ const r = validateEmail(long);
462
+ expect(r.valid).toBe(false);
463
+ if (!r.valid) expect(r.reason).toBe("length");
464
+ });
465
+ });
466
+
434
467
  describe("resetUserPassword", () => {
435
468
  test("returns false when user does not exist", async () => {
436
469
  const { db, cleanup } = makeDb();
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Per-vault storage cap store (`src/vault-caps.ts`, migration v15, B4). The
3
+ * cap is PERSISTED here at provision time; a separate Phase-2 PR reads + enforces
4
+ * it. These tests cover the read/write/upsert/remove primitives + the "no row
5
+ * = uncapped" contract the Phase-2 reader relies on.
6
+ */
7
+ import { describe, expect, test } from "bun:test";
8
+ import { mkdtempSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
12
+ import {
13
+ DEFAULT_VAULT_CAP_BYTES,
14
+ getVaultCap,
15
+ getVaultCapBytes,
16
+ removeVaultCap,
17
+ setVaultCap,
18
+ } from "../vault-caps.ts";
19
+
20
+ function makeDb() {
21
+ const dir = mkdtempSync(join(tmpdir(), "phub-vault-caps-"));
22
+ const db = openHubDb(hubDbPath(dir));
23
+ return {
24
+ db,
25
+ cleanup: () => {
26
+ db.close();
27
+ rmSync(dir, { recursive: true, force: true });
28
+ },
29
+ };
30
+ }
31
+
32
+ describe("vault-caps", () => {
33
+ test("default cap constant is ~1 GiB", () => {
34
+ expect(DEFAULT_VAULT_CAP_BYTES).toBe(1024 * 1024 * 1024);
35
+ });
36
+
37
+ test("no row = uncapped (null) — the Phase-2 reader's contract", () => {
38
+ const { db, cleanup } = makeDb();
39
+ try {
40
+ expect(getVaultCap(db, "nope")).toBeNull();
41
+ expect(getVaultCapBytes(db, "nope")).toBeNull();
42
+ } finally {
43
+ cleanup();
44
+ }
45
+ });
46
+
47
+ test("setVaultCap persists, getVaultCapBytes reads it back", () => {
48
+ const { db, cleanup } = makeDb();
49
+ try {
50
+ const now = new Date("2026-06-25T00:00:00Z");
51
+ const cap = setVaultCap(db, "alice", DEFAULT_VAULT_CAP_BYTES, now);
52
+ expect(cap.vaultName).toBe("alice");
53
+ expect(cap.capBytes).toBe(DEFAULT_VAULT_CAP_BYTES);
54
+ expect(cap.createdAt).toBe(now.toISOString());
55
+ expect(getVaultCapBytes(db, "alice")).toBe(DEFAULT_VAULT_CAP_BYTES);
56
+ } finally {
57
+ cleanup();
58
+ }
59
+ });
60
+
61
+ test("upsert: re-set overwrites cap_bytes, bumps updated_at, preserves created_at", () => {
62
+ const { db, cleanup } = makeDb();
63
+ try {
64
+ const t0 = new Date("2026-06-25T00:00:00Z");
65
+ const t1 = new Date("2026-06-26T00:00:00Z");
66
+ setVaultCap(db, "alice", 1000, t0);
67
+ const updated = setVaultCap(db, "alice", 2000, t1);
68
+ expect(updated.capBytes).toBe(2000);
69
+ expect(updated.createdAt).toBe(t0.toISOString()); // preserved
70
+ expect(updated.updatedAt).toBe(t1.toISOString()); // bumped
71
+ expect(getVaultCapBytes(db, "alice")).toBe(2000);
72
+ } finally {
73
+ cleanup();
74
+ }
75
+ });
76
+
77
+ test("removeVaultCap drops the row (cascade parity) — returns count", () => {
78
+ const { db, cleanup } = makeDb();
79
+ try {
80
+ setVaultCap(db, "alice", 1000);
81
+ expect(removeVaultCap(db, "alice")).toBe(1);
82
+ expect(getVaultCapBytes(db, "alice")).toBeNull();
83
+ // Idempotent — removing a missing row is 0.
84
+ expect(removeVaultCap(db, "alice")).toBe(0);
85
+ } finally {
86
+ cleanup();
87
+ }
88
+ });
89
+ });
@@ -76,13 +76,29 @@ describe("persistVaultHubOrigin", () => {
76
76
  expect(existsSync(vaultEnv())).toBe(false);
77
77
  });
78
78
 
79
- test("is idempotent — no rewrite when the value is already current", () => {
79
+ test("is idempotent — no rewrite when both the origin and the set are current", () => {
80
80
  const log: string[] = [];
81
81
  expect(persistVaultHubOrigin(dir, "https://hub.example.com", (l) => log.push(l))).toBe(true);
82
+ // First call wrote BOTH the single origin and the multi-origin set.
83
+ expect(
84
+ log.some((l) => /persisted PARACHUTE_HUB_ORIGIN=https:\/\/hub\.example\.com/.test(l)),
85
+ ).toBe(true);
86
+ expect(log.some((l) => /persisted PARACHUTE_HUB_ORIGINS=/.test(l))).toBe(true);
87
+ const writesAfterFirst = log.length;
88
+ // Second call is a no-op: both values already current → false, no new logs.
82
89
  expect(persistVaultHubOrigin(dir, "https://hub.example.com", (l) => log.push(l))).toBe(false);
83
- // Only the first call logged.
84
- expect(log).toHaveLength(1);
85
- expect(log[0]).toMatch(/persisted PARACHUTE_HUB_ORIGIN=https:\/\/hub\.example\.com/);
90
+ expect(log).toHaveLength(writesAfterFirst);
91
+ });
92
+
93
+ test("writes PARACHUTE_HUB_ORIGINS (multi-origin iss-set) alongside the single origin", () => {
94
+ expect(persistVaultHubOrigin(dir, "https://hub.example.com", () => {})).toBe(true);
95
+ const values = readEnvFileValues(vaultEnv());
96
+ expect(values.PARACHUTE_HUB_ORIGIN).toBe("https://hub.example.com");
97
+ // The set carries the issuer + loopback aliases (always present).
98
+ const set = (values.PARACHUTE_HUB_ORIGINS ?? "").split(",");
99
+ expect(set).toContain("https://hub.example.com");
100
+ expect(set.some((o) => o.startsWith("http://127.0.0.1:"))).toBe(true);
101
+ expect(set.some((o) => o.startsWith("http://localhost:"))).toBe(true);
86
102
  });
87
103
 
88
104
  test("updates a stale origin in-place and preserves sibling keys", () => {
@@ -113,6 +129,24 @@ describe("clearVaultHubOrigin", () => {
113
129
  expect(values.SCRIBE_AUTH_TOKEN).toBe("secret");
114
130
  });
115
131
 
132
+ test("removes BOTH the single origin and the multi-origin set, leaving siblings", () => {
133
+ writeFileSync(
134
+ mkVaultDir(),
135
+ "SCRIBE_AUTH_TOKEN=secret\nPARACHUTE_HUB_ORIGIN=https://hub.example.com\nPARACHUTE_HUB_ORIGINS=https://hub.example.com,http://127.0.0.1:1939\n",
136
+ );
137
+ expect(clearVaultHubOrigin(dir, () => {})).toBe(true);
138
+ const values = readEnvFileValues(vaultEnv());
139
+ expect(values.PARACHUTE_HUB_ORIGIN).toBeUndefined();
140
+ expect(values.PARACHUTE_HUB_ORIGINS).toBeUndefined();
141
+ expect(values.SCRIBE_AUTH_TOKEN).toBe("secret");
142
+ });
143
+
144
+ test("clears a lone PARACHUTE_HUB_ORIGINS even if the single origin is absent", () => {
145
+ writeFileSync(mkVaultDir(), "PARACHUTE_HUB_ORIGINS=https://hub.example.com\n");
146
+ expect(clearVaultHubOrigin(dir, () => {})).toBe(true);
147
+ expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGINS).toBeUndefined();
148
+ });
149
+
116
150
  test("no-op when no origin is present", () => {
117
151
  writeFileSync(mkVaultDir(), "SCRIBE_AUTH_TOKEN=secret\n");
118
152
  expect(clearVaultHubOrigin(dir, () => {})).toBe(false);
@@ -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
+ });