@openparachute/hub 0.6.3 → 0.6.4-rc.1

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 (72) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +609 -0
  3. package/src/__tests__/account-usage.test.ts +137 -0
  4. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  5. package/src/__tests__/account-vault-token.test.ts +53 -1
  6. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  7. package/src/__tests__/admin-vaults.test.ts +20 -0
  8. package/src/__tests__/api-account.test.ts +125 -4
  9. package/src/__tests__/api-invites.test.ts +180 -0
  10. package/src/__tests__/api-mint-token.test.ts +259 -10
  11. package/src/__tests__/api-modules-ops.test.ts +187 -1
  12. package/src/__tests__/api-modules.test.ts +40 -4
  13. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  14. package/src/__tests__/auto-wire.test.ts +101 -1
  15. package/src/__tests__/cli.test.ts +188 -2
  16. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  17. package/src/__tests__/expose-cloudflare.test.ts +5 -4
  18. package/src/__tests__/expose.test.ts +10 -5
  19. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  20. package/src/__tests__/hub-server.test.ts +628 -13
  21. package/src/__tests__/hub-unit.test.ts +4 -0
  22. package/src/__tests__/invites.test.ts +220 -0
  23. package/src/__tests__/launchctl-guard.test.ts +185 -0
  24. package/src/__tests__/migrate-cutover.test.ts +32 -0
  25. package/src/__tests__/module-ops-client.test.ts +68 -0
  26. package/src/__tests__/scope-explanations.test.ts +16 -0
  27. package/src/__tests__/serve-boot.test.ts +74 -1
  28. package/src/__tests__/serve.test.ts +121 -7
  29. package/src/__tests__/spawn-path.test.ts +191 -0
  30. package/src/__tests__/status.test.ts +64 -0
  31. package/src/__tests__/supervisor.test.ts +177 -0
  32. package/src/__tests__/users.test.ts +27 -0
  33. package/src/account-home-ui.ts +82 -9
  34. package/src/account-setup.ts +342 -0
  35. package/src/account-usage.ts +118 -0
  36. package/src/account-vault-admin-token.ts +242 -0
  37. package/src/account-vault-token.ts +27 -2
  38. package/src/admin-login-ui.ts +94 -0
  39. package/src/admin-vault-admin-token.ts +8 -2
  40. package/src/admin-vaults.ts +137 -29
  41. package/src/api-account.ts +54 -1
  42. package/src/api-invites.ts +347 -0
  43. package/src/api-mint-token.ts +81 -0
  44. package/src/api-modules-ops.ts +168 -53
  45. package/src/api-modules.ts +36 -0
  46. package/src/auto-wire.ts +87 -0
  47. package/src/cli.ts +122 -32
  48. package/src/commands/expose-2fa-warning.ts +17 -13
  49. package/src/commands/migrate-cutover.ts +12 -5
  50. package/src/commands/serve-boot.ts +33 -3
  51. package/src/commands/serve.ts +158 -37
  52. package/src/commands/status.ts +9 -1
  53. package/src/hub-db.ts +70 -2
  54. package/src/hub-server.ts +399 -41
  55. package/src/hub-unit.ts +4 -9
  56. package/src/invites.ts +291 -0
  57. package/src/launchctl-guard.ts +131 -0
  58. package/src/managed-unit.ts +13 -3
  59. package/src/migrate-offer.ts +15 -6
  60. package/src/module-ops-client.ts +47 -22
  61. package/src/scope-attenuation.ts +19 -0
  62. package/src/scope-explanations.ts +9 -1
  63. package/src/service-spec.ts +8 -3
  64. package/src/spawn-path.ts +148 -0
  65. package/src/supervisor.ts +84 -7
  66. package/src/users.ts +42 -4
  67. package/src/vault-hub-origin-env.ts +28 -0
  68. package/src/vault-name.ts +13 -1
  69. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  70. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
@@ -323,6 +323,13 @@ describe("POST /api/modules/:short/install", () => {
323
323
  expect(manifest.services.some((s) => s.name === "parachute-vault")).toBe(true);
324
324
  // Supervisor was handed the spawn.
325
325
  expect(spawns.find((s) => s.short === "vault")?.cmd).toEqual(["parachute-vault", "serve"]);
326
+ // The install-time spawn path (spawnSupervised) injects the enriched PATH
327
+ // so a module installed via /admin/modules can find operator tools (scribe's
328
+ // parakeet-mlx / ffmpeg) — same fix as the serve-boot path, second spawn
329
+ // site (hub launchd-PATH regression). It must carry the inherited PATH.
330
+ const vaultEnv = spawns.find((s) => s.short === "vault")?.env;
331
+ expect(vaultEnv?.PATH).toBeDefined();
332
+ expect(vaultEnv?.PATH?.length).toBeGreaterThan(0);
326
333
  });
327
334
 
328
335
  test("idempotent: already-installed + running returns succeeded immediately", async () => {
@@ -842,6 +849,31 @@ describe("POST /api/modules/:short/start", () => {
842
849
  expect(res.status).toBe(403);
843
850
  });
844
851
 
852
+ test("supervisor.start throw → structured 500 module_op_failed, not a naked 500 (hub#536)", async () => {
853
+ seedVault();
854
+ // Models the hub#536 wedge: Bun.spawn throwing because the module's
855
+ // installDir/cwd no longer exists (NOT a missing binary — the preflight
856
+ // + rethrowIfMissing nets don't catch it, so it propagates out of
857
+ // supervisor.start). Pre-fix this escaped the handler as a bodyless 500
858
+ // and the CLI showed an opaque "request failed".
859
+ const supervisor = new Supervisor({
860
+ spawnFn: () => {
861
+ throw new Error("simulated spawn failure: cwd /gone/parachute-app does not exist");
862
+ },
863
+ });
864
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
865
+ const res = await handleStart(
866
+ postReq("/api/modules/vault/start", { authorization: `Bearer ${bearer}` }),
867
+ "vault",
868
+ { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath, configDir: h.dir, supervisor },
869
+ );
870
+ expect(res.status).toBe(500);
871
+ const body = (await res.json()) as { error: string; error_description: string };
872
+ expect(body.error).toBe("module_op_failed");
873
+ expect(body.error_description).toContain("vault start failed");
874
+ expect(body.error_description).toContain("cwd /gone/parachute-app does not exist");
875
+ });
876
+
845
877
  test("pure supervisor.start of an installed module — NOT install (no bun add)", async () => {
846
878
  seedVault();
847
879
  const { supervisor, spawns } = makeIdleSupervisor();
@@ -1096,7 +1128,44 @@ describe("POST /api/modules/:short/restart", () => {
1096
1128
  });
1097
1129
  afterEach(() => h.cleanup());
1098
1130
 
1099
- test("404 not_supervised when module isn't running", async () => {
1131
+ /** Seed a minimal installed vault row (in services.json). */
1132
+ function seedVault(port = 1940): void {
1133
+ writeManifest(h.manifestPath, [
1134
+ {
1135
+ name: "parachute-vault",
1136
+ port,
1137
+ paths: ["/vault/default"],
1138
+ health: "/vault/default/health",
1139
+ version: "0.0.0-linked",
1140
+ },
1141
+ ]);
1142
+ }
1143
+
1144
+ test("400 not_installed when the module isn't in services.json", async () => {
1145
+ // restart rebuilds the spawn req from services.json (hub#532), so a
1146
+ // missing row is a `not_installed` 400 (mirrors `start`) — before the
1147
+ // supervisor is even consulted.
1148
+ const { supervisor } = makeIdleSupervisor();
1149
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
1150
+ const res = await handleRestart(
1151
+ postReq("/api/modules/vault/restart", { authorization: `Bearer ${bearer}` }),
1152
+ "vault",
1153
+ {
1154
+ db: h.db,
1155
+ issuer: ISSUER,
1156
+ manifestPath: h.manifestPath,
1157
+ configDir: h.dir,
1158
+ supervisor,
1159
+ run: async () => 0,
1160
+ },
1161
+ );
1162
+ expect(res.status).toBe(400);
1163
+ const body = (await res.json()) as { error: string };
1164
+ expect(body.error).toBe("not_installed");
1165
+ });
1166
+
1167
+ test("404 not_supervised when installed but not currently running", async () => {
1168
+ seedVault();
1100
1169
  const { supervisor } = makeIdleSupervisor();
1101
1170
  const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
1102
1171
  const res = await handleRestart(
@@ -1117,6 +1186,7 @@ describe("POST /api/modules/:short/restart", () => {
1117
1186
  });
1118
1187
 
1119
1188
  test("returns new state on success", async () => {
1189
+ seedVault();
1120
1190
  const { supervisor } = makeIdleSupervisor();
1121
1191
  await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
1122
1192
 
@@ -1140,6 +1210,122 @@ describe("POST /api/modules/:short/restart", () => {
1140
1210
  // on timing — either is acceptable here as long as it's not crashed/stopped.
1141
1211
  expect(["restarting", "running", "starting"]).toContain(body.state.status);
1142
1212
  });
1213
+
1214
+ test("re-injects the CURRENT hub origin on restart (hub#532)", async () => {
1215
+ // The core hub#532 fix: a module first started under one issuer, then
1216
+ // restarted after the canonical origin changed (e.g. post-expose), must
1217
+ // re-spawn with the NEW PARACHUTE_HUB_ORIGIN — not replay the stale
1218
+ // first-start snapshot. We drive the supervisor directly to control the
1219
+ // first-start env, then restart through the handler with a different
1220
+ // `deps.issuer` and assert the recorded spawn carries the new origin.
1221
+ seedVault(1940);
1222
+ const { supervisor, spawns } = makeIdleSupervisor();
1223
+ // First start with the OLD origin baked in (as boot would have).
1224
+ await supervisor.start({
1225
+ short: "vault",
1226
+ cmd: ["parachute-vault", "serve"],
1227
+ env: { PORT: "1940", PARACHUTE_HUB_ORIGIN: "https://old.example" },
1228
+ });
1229
+ const spawnsBefore = spawns.length;
1230
+
1231
+ const NEW_ORIGIN = "https://new.example";
1232
+ // The bearer was minted at the prior (loopback) ISSUER; the canonical
1233
+ // origin has since moved to NEW_ORIGIN. `knownIssuers` accepts the older
1234
+ // bearer iss while `deps.issuer` is the current canonical origin — exactly
1235
+ // the post-expose scenario hub#532 describes.
1236
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
1237
+ const res = await handleRestart(
1238
+ postReq("/api/modules/vault/restart", { authorization: `Bearer ${bearer}` }),
1239
+ "vault",
1240
+ {
1241
+ db: h.db,
1242
+ issuer: NEW_ORIGIN,
1243
+ knownIssuers: [ISSUER, NEW_ORIGIN],
1244
+ manifestPath: h.manifestPath,
1245
+ configDir: h.dir,
1246
+ supervisor,
1247
+ run: async () => 0,
1248
+ },
1249
+ );
1250
+ expect(res.status).toBe(200);
1251
+ // A fresh spawn happened, and it carries the NEW origin (rebuilt from the
1252
+ // live deps.issuer), not the stale one from first start.
1253
+ const reSpawn = spawns[spawnsBefore];
1254
+ expect(reSpawn?.env?.PARACHUTE_HUB_ORIGIN).toBe(NEW_ORIGIN);
1255
+ expect(reSpawn?.env?.PORT).toBe("1940");
1256
+ });
1257
+
1258
+ test("crash-restart after a restart reuses the REFRESHED req (hub#532)", async () => {
1259
+ // The refreshed SpawnRequest must become the supervisor entry's `req` so
1260
+ // a SUBSEQUENT crash-restart (handleExit → spawnAndWatch, which replays
1261
+ // `entry.req`) also carries the current env — not the original snapshot.
1262
+ seedVault(1940);
1263
+ // Controllable supervisor: spawns record env; each fake exposes a `crash()`
1264
+ // resolving `exited` with code 1 (a crash, not an operator stop). `kill()`
1265
+ // (driven by the injected group-aware `killFn`) resolves with 0 so the
1266
+ // handler's restart-stop completes promptly. Distinct codes let us crash a
1267
+ // specific child without tripping the stop path.
1268
+ const spawns: SpawnRequest[] = [];
1269
+ const procs: Array<{ pid: number; crash: () => void; kill: () => void }> = [];
1270
+ let nextPid = 8000;
1271
+ const spawnFn = (req: SpawnRequest): SupervisedProc => {
1272
+ spawns.push(req);
1273
+ const pid = nextPid++;
1274
+ let resolveExit!: (c: number | null) => void;
1275
+ const exited = new Promise<number | null>((r) => {
1276
+ resolveExit = r;
1277
+ });
1278
+ const proc = {
1279
+ pid,
1280
+ exited,
1281
+ stdout: null,
1282
+ stderr: null,
1283
+ kill: () => resolveExit(0),
1284
+ };
1285
+ procs.push({ pid, crash: () => resolveExit(1), kill: proc.kill });
1286
+ return proc;
1287
+ };
1288
+ const killFn = (pid: number): void => {
1289
+ procs.find((p) => p.pid === Math.abs(pid))?.kill();
1290
+ };
1291
+ const supervisor = new Supervisor({ spawnFn, killFn, restartDelayMs: 1, startReadyMs: 0 });
1292
+
1293
+ // First start with the OLD origin.
1294
+ await supervisor.start({
1295
+ short: "vault",
1296
+ cmd: ["parachute-vault", "serve"],
1297
+ env: { PORT: "1940", PARACHUTE_HUB_ORIGIN: "https://old.example" },
1298
+ });
1299
+
1300
+ const NEW_ORIGIN = "https://new.example";
1301
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
1302
+ await handleRestart(
1303
+ postReq("/api/modules/vault/restart", { authorization: `Bearer ${bearer}` }),
1304
+ "vault",
1305
+ {
1306
+ db: h.db,
1307
+ issuer: NEW_ORIGIN,
1308
+ knownIssuers: [ISSUER, NEW_ORIGIN],
1309
+ manifestPath: h.manifestPath,
1310
+ configDir: h.dir,
1311
+ supervisor,
1312
+ run: async () => 0,
1313
+ },
1314
+ );
1315
+ // The restart re-spawn is the most recent — confirm the NEW origin landed.
1316
+ const restartSpawnIdx = spawns.length - 1;
1317
+ expect(spawns[restartSpawnIdx]?.env?.PARACHUTE_HUB_ORIGIN).toBe(NEW_ORIGIN);
1318
+
1319
+ // Crash the restart-spawned child → the supervisor's crash-restart replays
1320
+ // entry.req (which `start` stored from the refreshed req).
1321
+ procs[restartSpawnIdx]?.crash();
1322
+ // Wait for the crash watcher's restartDelay + respawn.
1323
+ await new Promise((r) => setTimeout(r, 40));
1324
+ expect(spawns.length).toBeGreaterThan(restartSpawnIdx + 1);
1325
+ const crashRespawn = spawns[spawns.length - 1];
1326
+ // The crash-restart inherited the REFRESHED req → still the new origin.
1327
+ expect(crashRespawn?.env?.PARACHUTE_HUB_ORIGIN).toBe(NEW_ORIGIN);
1328
+ });
1143
1329
  });
1144
1330
 
1145
1331
  describe("POST /api/modules/:short/upgrade", () => {
@@ -317,6 +317,45 @@ describe("GET /api/modules", () => {
317
317
  expect(shorts).not.toContain("surface");
318
318
  });
319
319
 
320
+ test("non-curated supervised modules appear in `supervised` (not `modules`) — hub#539", async () => {
321
+ // surface (the UI host) is supervised but not curated. Its run-state must
322
+ // surface in `supervised` so `parachute status` reads it `active`, while it
323
+ // stays OUT of the curated `modules` catalog (which drives the install UI).
324
+ writeManifest(h.manifestPath, [
325
+ {
326
+ name: "parachute-vault",
327
+ port: 1940,
328
+ paths: ["/vault/default"],
329
+ health: "/vault/default/health",
330
+ version: "0.4.5",
331
+ },
332
+ ]);
333
+ const { supervisor } = makeIdleSupervisor();
334
+ await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
335
+ await supervisor.start({ short: "surface", cmd: ["parachute-surface", "serve"] });
336
+
337
+ const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
338
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
339
+ db: h.db,
340
+ issuer: ISSUER,
341
+ manifestPath: h.manifestPath,
342
+ supervisor,
343
+ fetchLatestVersion: async () => null,
344
+ });
345
+ const body = (await res.json()) as {
346
+ modules: Array<{ short: string }>;
347
+ supervised: Array<{ short: string; supervisor_status: string | null; pid: number | null }>;
348
+ };
349
+ // surface stays out of the curated catalog…
350
+ expect(body.modules.map((m) => m.short)).not.toContain("surface");
351
+ // …but its run-state is in `supervised`, marked running with a pid.
352
+ const surf = body.supervised.find((m) => m.short === "surface");
353
+ expect(surf?.supervisor_status).toBe("running");
354
+ expect(typeof surf?.pid).toBe("number");
355
+ // Curated modules appear in `supervised` too (consumers dedupe by short).
356
+ expect(body.supervised.find((m) => m.short === "vault")?.supervisor_status).toBe("running");
357
+ });
358
+
320
359
  test("surfaces installed_version from services.json", async () => {
321
360
  writeManifest(h.manifestPath, [
322
361
  {
@@ -716,10 +755,7 @@ describe("GET /api/modules", () => {
716
755
  // Second back-to-back request must not re-hit the registry. The
717
756
  // UI may poll this endpoint; we don't want it to slam npm.
718
757
  let calls = 0;
719
- const probe = async (
720
- _pkg: string,
721
- _channel: "latest" | "rc",
722
- ): Promise<string | null> => {
758
+ const probe = async (_pkg: string, _channel: "latest" | "rc"): Promise<string | null> => {
723
759
  calls++;
724
760
  return "0.5.0";
725
761
  };
@@ -83,6 +83,11 @@ function putReq(body: unknown, headers: Record<string, string> = {}): Request {
83
83
  });
84
84
  }
85
85
 
86
+ // Stub "no exposure recorded" so resolveIssuer / resolveIssuerSource don't
87
+ // pick up the host's real ~/.parachute/expose-state.json (the #531 expose
88
+ // tier would otherwise shadow the request-origin source these tests assert).
89
+ const noExpose = (): string | undefined => undefined;
90
+
86
91
  function deps(
87
92
  h: Harness,
88
93
  overrides: Partial<Parameters<typeof handleApiSettingsHubOrigin>[1]> = {},
@@ -90,8 +95,8 @@ function deps(
90
95
  return {
91
96
  db: h.db,
92
97
  issuer: ISSUER,
93
- resolvedIssuer: resolveIssuer(getReq(), h.db, undefined),
94
- resolvedSource: resolveIssuerSource(h.db, undefined),
98
+ resolvedIssuer: resolveIssuer(getReq(), h.db, undefined, noExpose),
99
+ resolvedSource: resolveIssuerSource(h.db, undefined, noExpose),
95
100
  ...overrides,
96
101
  };
97
102
  }
@@ -414,8 +419,8 @@ describe("change takes effect on the next request (no restart needed)", () => {
414
419
  const g1 = await handleApiSettingsHubOrigin(getReq({ authorization: `Bearer ${bearer}` }), {
415
420
  db: h.db,
416
421
  issuer: ISSUER,
417
- resolvedIssuer: resolveIssuer(getReq(), h.db, undefined),
418
- resolvedSource: resolveIssuerSource(h.db, undefined),
422
+ resolvedIssuer: resolveIssuer(getReq(), h.db, undefined, noExpose),
423
+ resolvedSource: resolveIssuerSource(h.db, undefined, noExpose),
419
424
  });
420
425
  const b1 = (await g1.json()) as { source: string; resolved_issuer: string };
421
426
  expect(b1.source).toBe("request");
@@ -426,8 +431,8 @@ describe("change takes effect on the next request (no restart needed)", () => {
426
431
  {
427
432
  db: h.db,
428
433
  issuer: ISSUER,
429
- resolvedIssuer: resolveIssuer(putReq({}), h.db, undefined),
430
- resolvedSource: resolveIssuerSource(h.db, undefined),
434
+ resolvedIssuer: resolveIssuer(putReq({}), h.db, undefined, noExpose),
435
+ resolvedSource: resolveIssuerSource(h.db, undefined, noExpose),
431
436
  },
432
437
  );
433
438
  expect(p.status).toBe(200);
@@ -437,8 +442,8 @@ describe("change takes effect on the next request (no restart needed)", () => {
437
442
  const g2 = await handleApiSettingsHubOrigin(getReq({ authorization: `Bearer ${bearer}` }), {
438
443
  db: h.db,
439
444
  issuer: ISSUER,
440
- resolvedIssuer: resolveIssuer(getReq(), h.db, undefined),
441
- resolvedSource: resolveIssuerSource(h.db, undefined),
445
+ resolvedIssuer: resolveIssuer(getReq(), h.db, undefined, noExpose),
446
+ resolvedSource: resolveIssuerSource(h.db, undefined, noExpose),
442
447
  });
443
448
  const b2 = (await g2.json()) as {
444
449
  hub_origin: string | null;
@@ -2,7 +2,12 @@ import { describe, expect, test } from "bun:test";
2
2
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { SCRIBE_AUTH_ENV_KEY, SCRIBE_URL_ENV_KEY, autoWireScribeAuth } from "../auto-wire.ts";
5
+ import {
6
+ SCRIBE_AUTH_ENV_KEY,
7
+ SCRIBE_URL_ENV_KEY,
8
+ autoWireScribeAuth,
9
+ selfHealScribeAuth,
10
+ } from "../auto-wire.ts";
6
11
  import { writePid } from "../process-state.ts";
7
12
 
8
13
  function makeHarness(): { dir: string; cleanup: () => void } {
@@ -281,3 +286,98 @@ describe("autoWireScribeAuth", () => {
281
286
  }
282
287
  });
283
288
  });
289
+
290
+ // Item H — serve-boot self-heal of scribe's auth token from vault's .env.
291
+ describe("selfHealScribeAuth", () => {
292
+ function seedVaultToken(dir: string, token: string): void {
293
+ mkdirSync(join(dir, "vault"), { recursive: true });
294
+ writeFileSync(join(dir, "vault", ".env"), `${SCRIBE_AUTH_ENV_KEY}=${token}\n`);
295
+ }
296
+ function seedScribeConfig(dir: string, config: Record<string, unknown>): void {
297
+ mkdirSync(join(dir, "scribe"), { recursive: true });
298
+ writeFileSync(join(dir, "scribe", "config.json"), `${JSON.stringify(config, null, 2)}\n`);
299
+ }
300
+ function readScribeConfig(dir: string): Record<string, unknown> {
301
+ return JSON.parse(readFileSync(join(dir, "scribe", "config.json"), "utf8"));
302
+ }
303
+
304
+ test("vault .env has token + scribe config missing auth → self-heal writes it", () => {
305
+ const h = makeHarness();
306
+ try {
307
+ seedVaultToken(h.dir, "shared-secret-xyz");
308
+ seedScribeConfig(h.dir, { provider: "openai", model: "whisper-1" });
309
+ const logs: string[] = [];
310
+ const result = selfHealScribeAuth({ configDir: h.dir, log: (l) => logs.push(l) });
311
+ expect(result.healed).toBe(true);
312
+ const cfg = readScribeConfig(h.dir);
313
+ expect((cfg.auth as Record<string, unknown>).required_token).toBe("shared-secret-xyz");
314
+ // Other config keys preserved (merge-don't-clobber).
315
+ expect(cfg.provider).toBe("openai");
316
+ expect(cfg.model).toBe("whisper-1");
317
+ expect(logs.join("\n")).toMatch(/Self-healed scribe auth/);
318
+ } finally {
319
+ h.cleanup();
320
+ }
321
+ });
322
+
323
+ test("scribe config wholly absent → self-heal creates it with the token", () => {
324
+ const h = makeHarness();
325
+ try {
326
+ seedVaultToken(h.dir, "shared-secret-xyz");
327
+ // No scribe/config.json at all.
328
+ const result = selfHealScribeAuth({ configDir: h.dir });
329
+ expect(result.healed).toBe(true);
330
+ expect((readScribeConfig(h.dir).auth as Record<string, unknown>).required_token).toBe(
331
+ "shared-secret-xyz",
332
+ );
333
+ } finally {
334
+ h.cleanup();
335
+ }
336
+ });
337
+
338
+ test("scribe token mismatches vault → re-synced to vault's value", () => {
339
+ const h = makeHarness();
340
+ try {
341
+ seedVaultToken(h.dir, "vault-token-NEW");
342
+ seedScribeConfig(h.dir, { auth: { required_token: "stale-token-OLD" }, model: "x" });
343
+ const result = selfHealScribeAuth({ configDir: h.dir });
344
+ expect(result.healed).toBe(true);
345
+ const cfg = readScribeConfig(h.dir);
346
+ expect((cfg.auth as Record<string, unknown>).required_token).toBe("vault-token-NEW");
347
+ expect(cfg.model).toBe("x");
348
+ } finally {
349
+ h.cleanup();
350
+ }
351
+ });
352
+
353
+ test("already in sync → no-op (idempotent), config untouched", () => {
354
+ const h = makeHarness();
355
+ try {
356
+ seedVaultToken(h.dir, "same-token");
357
+ seedScribeConfig(h.dir, { auth: { required_token: "same-token" }, extra: "keep" });
358
+ const before = readFileSync(join(h.dir, "scribe", "config.json"), "utf8");
359
+ const result = selfHealScribeAuth({ configDir: h.dir });
360
+ expect(result.healed).toBe(false);
361
+ expect(result.reason).toBe("already-synced");
362
+ // Byte-identical — no rewrite.
363
+ expect(readFileSync(join(h.dir, "scribe", "config.json"), "utf8")).toBe(before);
364
+ } finally {
365
+ h.cleanup();
366
+ }
367
+ });
368
+
369
+ test("vault has no SCRIBE_AUTH_TOKEN → no-op (nothing to sync)", () => {
370
+ const h = makeHarness();
371
+ try {
372
+ mkdirSync(join(h.dir, "vault"), { recursive: true });
373
+ writeFileSync(join(h.dir, "vault", ".env"), "FOO=bar\n");
374
+ const result = selfHealScribeAuth({ configDir: h.dir });
375
+ expect(result.healed).toBe(false);
376
+ expect(result.reason).toBe("no-token");
377
+ // Scribe config not created.
378
+ expect(existsSync(join(h.dir, "scribe", "config.json"))).toBe(false);
379
+ } finally {
380
+ h.cleanup();
381
+ }
382
+ });
383
+ });
@@ -1,9 +1,10 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { appendFileSync, cpSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
6
  const CLI = join(import.meta.dir, "..", "cli.ts");
7
+ const REPO_ROOT = join(import.meta.dir, "..", "..");
7
8
 
8
9
  async function runCli(
9
10
  args: string[],
@@ -277,6 +278,191 @@ describe("cli per-subcommand help", () => {
277
278
  });
278
279
  });
279
280
 
281
+ describe("cli lazy-import isolation (feedback #9)", () => {
282
+ // Regression for the eager-import fragility: `cli.ts` used to import every
283
+ // command module at top-level, so a module that THREW at eval-time (the 0.6.2
284
+ // `migrate-cutover.ts` ReferenceError) aborted the entire CLI load — even
285
+ // `parachute --help` — because top-level import evaluation runs before
286
+ // `run()`'s try/catch is reached. Per-arm lazy `await import()` isolates a
287
+ // broken module to its own command.
288
+ //
289
+ // We exercise the REAL dispatcher: copy the live `src/` tree (plus the repo
290
+ // `package.json`, which `cli.ts` imports as `../package.json`) into a sandbox
291
+ // *inside the repo* so workspace `node_modules` resolution still works, then
292
+ // corrupt one command module so it throws at module-eval. `node_modules` is
293
+ // NOT copied — Bun walks up to the repo's. The corruption never touches the
294
+ // real source tree, so concurrent suites are unaffected.
295
+ let sandbox: string;
296
+ let sandboxCli: string;
297
+
298
+ beforeAll(() => {
299
+ // The sandbox lives INSIDE the repo (`<repo>/.tmp-cli-iso-*`) on purpose: it
300
+ // copies `src/` + `package.json` but NOT `node_modules`. The sandboxed CLI
301
+ // still resolves workspace packages (`@openparachute/depcheck`, etc.) by Bun
302
+ // walking up the directory tree to the **repo-root** `node_modules` — the same
303
+ // walk a nested file uses. So this suite REQUIRES `node_modules` installed at
304
+ // the repo root. CI must `bun install` before running it; a fresh worktree
305
+ // without an install will see `Cannot find module '@openparachute/...'`
306
+ // failures that are worktree-resolution artifacts, NOT a regression in the
307
+ // code under test. (A temp dir under `os.tmpdir()` would break this walk and
308
+ // also break `cli.ts`'s `../package.json` import, hence the in-repo sandbox.)
309
+ sandbox = mkdtempSync(join(REPO_ROOT, ".tmp-cli-iso-"));
310
+ cpSync(join(REPO_ROOT, "src"), join(sandbox, "src"), { recursive: true });
311
+ cpSync(join(REPO_ROOT, "package.json"), join(sandbox, "package.json"));
312
+ sandboxCli = join(sandbox, "src", "cli.ts");
313
+ // Append an unconditional throw so the module fails at eval. `migrate-cutover`
314
+ // is the canonical real-world case (the 0.6.2 bug) AND it's reachable by both
315
+ // eager paths the fix addresses: the direct `cli.ts` import and the transitive
316
+ // `cli.ts → lifecycle.ts → migrate-offer.ts → migrate-cutover.ts` chain.
317
+ appendFileSync(
318
+ join(sandbox, "src", "commands", "migrate-cutover.ts"),
319
+ '\nthrow new ReferenceError("boom: migrate-cutover failed at module eval");\n',
320
+ );
321
+ });
322
+
323
+ afterAll(() => {
324
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
325
+ });
326
+
327
+ async function runSandbox(
328
+ args: string[],
329
+ ): Promise<{ code: number; stdout: string; stderr: string }> {
330
+ const proc = Bun.spawn([process.execPath, sandboxCli, ...args], {
331
+ stdout: "pipe",
332
+ stderr: "pipe",
333
+ env: {
334
+ ...process.env,
335
+ HOME: "/tmp/parachute-hub-nonexistent-home",
336
+ PARACHUTE_HOME: "/tmp/parachute-hub-nonexistent-home",
337
+ },
338
+ });
339
+ const [stdout, stderr, code] = await Promise.all([
340
+ new Response(proc.stdout).text(),
341
+ new Response(proc.stderr).text(),
342
+ proc.exited,
343
+ ]);
344
+ return { code, stdout, stderr };
345
+ }
346
+
347
+ test("a command module that throws at eval does NOT abort --help", async () => {
348
+ const { code, stdout } = await runSandbox(["--help"]);
349
+ expect(code).toBe(0);
350
+ expect(stdout).toMatch(/parachute install/);
351
+ });
352
+
353
+ test("an unrelated command still dispatches when one module is broken", async () => {
354
+ // `status` doesn't touch migrate-cutover at all — it must still run to
355
+ // completion (exit 0) rather than dying at top-level import.
356
+ const { code } = await runSandbox(["status"]);
357
+ expect(code).toBe(0);
358
+ });
359
+
360
+ test("lifecycle commands survive the broken transitive path", async () => {
361
+ // `stop` pulls in `migrate-offer.ts` (for the §7.5 detect-and-offer), which
362
+ // used to EAGERLY import the broken `migrate-cutover.ts`. With the import now
363
+ // `import type` + lazy, `stop --help` must not crash.
364
+ const { code, stdout } = await runSandbox(["stop", "--help"]);
365
+ expect(code).toBe(0);
366
+ expect(stdout).toMatch(/parachute stop/);
367
+ });
368
+
369
+ test("the broken command itself exits 1 with a 'failed to load' message", async () => {
370
+ const { code, stderr } = await runSandbox(["migrate", "--to-supervised"]);
371
+ expect(code).toBe(1);
372
+ expect(stderr).toMatch(/parachute migrate: failed to load/);
373
+ expect(stderr).toMatch(/boom: migrate-cutover failed at module eval/);
374
+ });
375
+ });
376
+
377
+ // hub#534: `migrate --teardown` must surface teardownHubUnit's outcome — pre-fix
378
+ // it ignored `removed` + `messages` and always exited 0, so a non-removal looked
379
+ // like success. We exercise the REAL CLI arm via the in-repo sandbox (same shape
380
+ // as the lazy-import suite above) so the arm's exit-code mapping runs end-to-end
381
+ // without shelling out to real launchctl/systemctl: the sandboxed
382
+ // `teardownHubUnit` is replaced with a stub keyed off an env var.
383
+ describe("cli migrate --teardown exit-code policy (hub#534)", () => {
384
+ let sandbox: string;
385
+ let sandboxCli: string;
386
+
387
+ beforeAll(() => {
388
+ sandbox = mkdtempSync(join(REPO_ROOT, ".tmp-cli-teardown-"));
389
+ cpSync(join(REPO_ROOT, "src"), join(sandbox, "src"), { recursive: true });
390
+ cpSync(join(REPO_ROOT, "package.json"), join(sandbox, "package.json"));
391
+ sandboxCli = join(sandbox, "src", "cli.ts");
392
+ // Replace migrate-cutover.ts entirely with a minimal stub exporting only the
393
+ // `teardownHubUnit` the CLI arm calls. Its result is driven by
394
+ // `TEARDOWN_FAKE` so one rewrite covers all three outcomes. It logs the same
395
+ // human-facing lines the real function would (so the stdout assertions match
396
+ // real behavior), and the CLI owns the exit code.
397
+ writeFileSync(
398
+ join(sandbox, "src", "commands", "migrate-cutover.ts"),
399
+ [
400
+ "export function teardownHubUnit() {",
401
+ ' const mode = process.env.TEARDOWN_FAKE ?? "removed";',
402
+ ' if (mode === "removed") {',
403
+ ' console.log("Removed systemd unit parachute-hub.service — the hub no longer starts on boot.");',
404
+ " return { removed: true, messages: [] };",
405
+ " }",
406
+ ' if (mode === "failure") {',
407
+ ' console.log("Hub-unit teardown did not complete:");',
408
+ ' console.log(" systemctl disable failed: permission denied");',
409
+ ' return { removed: false, messages: ["systemctl disable failed: permission denied"] };',
410
+ " }",
411
+ ' console.log("No hub unit was installed — nothing to tear down.");',
412
+ " return { removed: false, messages: [] };",
413
+ "}",
414
+ "",
415
+ ].join("\n"),
416
+ );
417
+ });
418
+
419
+ afterAll(() => {
420
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
421
+ });
422
+
423
+ async function runTeardown(
424
+ fake: string,
425
+ ): Promise<{ code: number; stdout: string; stderr: string }> {
426
+ const proc = Bun.spawn([process.execPath, sandboxCli, "migrate", "--teardown"], {
427
+ stdout: "pipe",
428
+ stderr: "pipe",
429
+ env: {
430
+ ...process.env,
431
+ HOME: "/tmp/parachute-hub-nonexistent-home",
432
+ PARACHUTE_HOME: "/tmp/parachute-hub-nonexistent-home",
433
+ TEARDOWN_FAKE: fake,
434
+ },
435
+ });
436
+ const [stdout, stderr, code] = await Promise.all([
437
+ new Response(proc.stdout).text(),
438
+ new Response(proc.stderr).text(),
439
+ proc.exited,
440
+ ]);
441
+ return { code, stdout, stderr };
442
+ }
443
+
444
+ test("removed → exit 0 with the removal message", async () => {
445
+ const { code, stdout } = await runTeardown("removed");
446
+ expect(code).toBe(0);
447
+ expect(stdout).toMatch(/Removed systemd unit/);
448
+ });
449
+
450
+ test("nothing installed → informational exit 0", async () => {
451
+ const { code, stdout } = await runTeardown("nothing");
452
+ expect(code).toBe(0);
453
+ expect(stdout).toMatch(/nothing to tear down/);
454
+ });
455
+
456
+ test("removal failure (messages present) → exit 1, reason on stderr", async () => {
457
+ const { code, stdout, stderr } = await runTeardown("failure");
458
+ expect(code).toBe(1);
459
+ // The function logged the failure header to stdout; the CLI re-surfaces the
460
+ // detail on stderr so a script's `2>` capture sees the reason.
461
+ expect(stdout).toMatch(/did not complete/);
462
+ expect(stderr).toMatch(/permission denied/);
463
+ });
464
+ });
465
+
280
466
  describe("cli friendly errors", () => {
281
467
  test("malformed services.json prints friendly error not stack trace", async () => {
282
468
  const dir = mkdtempSync(join(tmpdir(), "pcli-bad-"));