@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.
- package/package.json +1 -1
- package/src/__tests__/admin-agent-grants.test.ts +113 -0
- package/src/__tests__/admin-vaults.test.ts +20 -5
- package/src/__tests__/api-modules.test.ts +65 -10
- package/src/__tests__/api-vault-caps.test.ts +232 -0
- package/src/__tests__/cli.test.ts +20 -0
- package/src/__tests__/hub-command.test.ts +136 -0
- package/src/__tests__/hub-origins-env-set.test.ts +273 -0
- package/src/__tests__/init.test.ts +148 -0
- package/src/__tests__/jwt-sign.test.ts +79 -0
- package/src/__tests__/module-manifest.test.ts +3 -1
- package/src/__tests__/oauth-client.test.ts +75 -0
- package/src/__tests__/oauth-handlers.test.ts +413 -5
- package/src/__tests__/public-signup.test.ts +619 -0
- package/src/__tests__/rate-limit.test.ts +86 -0
- package/src/__tests__/scribe-config.test.ts +117 -0
- package/src/__tests__/serve-boot.test.ts +45 -0
- package/src/__tests__/serve.test.ts +67 -1
- 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__/two-factor-flow.test.ts +94 -0
- package/src/__tests__/users.test.ts +33 -0
- package/src/__tests__/vault-caps.test.ts +89 -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/account-setup.ts +118 -32
- package/src/admin-agent-grants.ts +51 -3
- package/src/admin-handlers.ts +53 -16
- package/src/admin-login-ui.ts +30 -4
- package/src/admin-vaults.ts +9 -1
- package/src/api-invites.ts +163 -3
- package/src/api-modules-ops.ts +12 -0
- package/src/api-modules.ts +47 -13
- package/src/api-users.ts +3 -0
- package/src/api-vault-caps.ts +206 -0
- package/src/cli.ts +35 -1
- package/src/commands/hub.ts +173 -0
- package/src/commands/init.ts +73 -2
- package/src/commands/serve-boot.ts +16 -2
- package/src/commands/serve.ts +39 -3
- package/src/commands/setup.ts +31 -6
- package/src/commands/wizard-transcription.ts +296 -0
- package/src/commands/wizard.ts +73 -3
- package/src/help.ts +8 -0
- package/src/hub-db.ts +108 -1
- package/src/hub-origin.ts +64 -0
- package/src/hub-server.ts +27 -0
- package/src/invites.ts +155 -31
- package/src/jwt-sign.ts +72 -3
- package/src/module-manifest.ts +23 -9
- package/src/oauth-client.ts +102 -12
- package/src/oauth-flows-store.ts +12 -0
- package/src/oauth-handlers.ts +145 -20
- package/src/rate-limit.ts +111 -20
- package/src/scribe-config.ts +145 -0
- package/src/service-spec.ts +31 -12
- package/src/setup-wizard.ts +37 -4
- package/src/users.ts +62 -3
- package/src/vault-caps.ts +109 -0
- package/src/vault-hub-origin-env.ts +109 -16
- package/web/ui/dist/assets/{index-DR6R8EFf.css → index--728BX3j.css} +1 -1
- package/web/ui/dist/assets/index-DZzX_Enf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- 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
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
+
});
|