@openparachute/hub 0.5.13 → 0.5.14-rc.2

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 (47) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/account-home-ui.test.ts +163 -0
  3. package/src/__tests__/admin-handlers.test.ts +74 -0
  4. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  5. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  6. package/src/__tests__/api-account.test.ts +191 -1
  7. package/src/__tests__/api-modules-ops.test.ts +97 -0
  8. package/src/__tests__/api-modules.test.ts +32 -32
  9. package/src/__tests__/api-users.test.ts +383 -11
  10. package/src/__tests__/chrome-strip.test.ts +15 -15
  11. package/src/__tests__/hub-db.test.ts +194 -29
  12. package/src/__tests__/hub-server.test.ts +23 -23
  13. package/src/__tests__/notes-redirect.test.ts +20 -20
  14. package/src/__tests__/oauth-handlers.test.ts +722 -28
  15. package/src/__tests__/serve.test.ts +9 -9
  16. package/src/__tests__/services-manifest.test.ts +40 -40
  17. package/src/__tests__/setup-wizard.test.ts +493 -25
  18. package/src/__tests__/setup.test.ts +1 -1
  19. package/src/__tests__/status.test.ts +39 -0
  20. package/src/__tests__/users.test.ts +396 -9
  21. package/src/__tests__/well-known.test.ts +9 -9
  22. package/src/account-home-ui.ts +434 -0
  23. package/src/admin-handlers.ts +49 -17
  24. package/src/admin-host-admin-token.ts +25 -0
  25. package/src/admin-vault-admin-token.ts +17 -0
  26. package/src/api-account.ts +72 -6
  27. package/src/api-modules-ops.ts +52 -16
  28. package/src/api-modules.ts +3 -3
  29. package/src/api-users.ts +468 -55
  30. package/src/bun-link.ts +55 -0
  31. package/src/chrome-strip.ts +6 -6
  32. package/src/commands/install.ts +8 -21
  33. package/src/commands/status.ts +10 -1
  34. package/src/help.ts +2 -2
  35. package/src/hub-db.ts +42 -0
  36. package/src/hub-server.ts +69 -10
  37. package/src/hub-settings.ts +2 -2
  38. package/src/hub.ts +6 -6
  39. package/src/notes-redirect.ts +5 -5
  40. package/src/oauth-handlers.ts +278 -173
  41. package/src/oauth-ui.ts +18 -2
  42. package/src/service-spec.ts +39 -18
  43. package/src/setup-wizard.ts +489 -42
  44. package/src/users.ts +307 -29
  45. package/web/ui/dist/assets/index-tRmPbbC7.js +61 -0
  46. package/web/ui/dist/index.html +1 -1
  47. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
@@ -126,6 +126,19 @@ function alwaysOkRun(): {
126
126
  };
127
127
  }
128
128
 
129
+ /**
130
+ * Test default for `isLinked`: assume the package is NOT bun-linked so
131
+ * `runInstall` exercises the `bun add -g` path (which the stubbed runner
132
+ * captures into `calls`). The production default at
133
+ * `src/bun-link.ts` reads the contributor's real `~/.bun/install/global/`
134
+ * symlinks; on Aaron's machine vault/scribe/notes/hub are all linked
135
+ * (the canonical local-dev shape — smoke 2026-05-27 finding 1 caps the
136
+ * fix). Tests asserting "bun add WAS called" must opt out of that leakage
137
+ * by passing this stub. Tests specifically exercising the skip path use
138
+ * an inline `isLinked: () => true` or a per-pkg discriminator.
139
+ */
140
+ const TEST_DEFAULT_NOT_LINKED = (_pkg: string): boolean => false;
141
+
129
142
  describe("parseModulesPath", () => {
130
143
  test("recognizes curated short + action", () => {
131
144
  expect(parseModulesPath("/api/modules/vault/install")).toEqual({
@@ -231,6 +244,7 @@ describe("POST /api/modules/:short/install", () => {
231
244
  configDir: h.dir,
232
245
  supervisor,
233
246
  run,
247
+ isLinked: TEST_DEFAULT_NOT_LINKED,
234
248
  },
235
249
  );
236
250
  expect(res.status).toBe(202);
@@ -280,6 +294,7 @@ describe("POST /api/modules/:short/install", () => {
280
294
  configDir: h.dir,
281
295
  supervisor,
282
296
  run,
297
+ isLinked: TEST_DEFAULT_NOT_LINKED,
283
298
  },
284
299
  );
285
300
  expect(res.status).toBe(202);
@@ -301,6 +316,7 @@ describe("POST /api/modules/:short/install", () => {
301
316
  configDir: h.dir,
302
317
  supervisor,
303
318
  run,
319
+ isLinked: TEST_DEFAULT_NOT_LINKED,
304
320
  },
305
321
  );
306
322
  const op = (await opRes.json()) as { status: string };
@@ -324,6 +340,7 @@ describe("POST /api/modules/:short/install", () => {
324
340
  configDir: h.dir,
325
341
  supervisor,
326
342
  run,
343
+ isLinked: TEST_DEFAULT_NOT_LINKED,
327
344
  },
328
345
  );
329
346
  expect(res.status).toBe(202);
@@ -348,6 +365,7 @@ describe("POST /api/modules/:short/install", () => {
348
365
  configDir: h.dir,
349
366
  supervisor,
350
367
  run,
368
+ isLinked: TEST_DEFAULT_NOT_LINKED,
351
369
  },
352
370
  );
353
371
  await new Promise((r) => setTimeout(r, 10));
@@ -379,6 +397,7 @@ describe("POST /api/modules/:short/install", () => {
379
397
  configDir: h.dir,
380
398
  supervisor,
381
399
  run,
400
+ isLinked: TEST_DEFAULT_NOT_LINKED,
382
401
  },
383
402
  );
384
403
  expect(res.status).toBe(202);
@@ -406,6 +425,7 @@ describe("POST /api/modules/:short/install", () => {
406
425
  configDir: h.dir,
407
426
  supervisor,
408
427
  run,
428
+ isLinked: TEST_DEFAULT_NOT_LINKED,
409
429
  },
410
430
  );
411
431
  await new Promise((r) => setTimeout(r, 10));
@@ -433,6 +453,7 @@ describe("POST /api/modules/:short/install", () => {
433
453
  configDir: h.dir,
434
454
  supervisor,
435
455
  run,
456
+ isLinked: TEST_DEFAULT_NOT_LINKED,
436
457
  },
437
458
  );
438
459
  expect(res.status).toBe(400);
@@ -458,6 +479,7 @@ describe("POST /api/modules/:short/install", () => {
458
479
  configDir: h.dir,
459
480
  supervisor,
460
481
  run,
482
+ isLinked: TEST_DEFAULT_NOT_LINKED,
461
483
  },
462
484
  );
463
485
  await new Promise((r) => setTimeout(r, 10));
@@ -487,6 +509,7 @@ describe("POST /api/modules/:short/install", () => {
487
509
  configDir: h.dir,
488
510
  supervisor,
489
511
  run,
512
+ isLinked: TEST_DEFAULT_NOT_LINKED,
490
513
  },
491
514
  );
492
515
  await new Promise((r) => setTimeout(r, 10));
@@ -524,6 +547,7 @@ describe("POST /api/modules/:short/install", () => {
524
547
  configDir: h.dir,
525
548
  supervisor,
526
549
  run,
550
+ isLinked: TEST_DEFAULT_NOT_LINKED,
527
551
  },
528
552
  );
529
553
  await new Promise((r) => setTimeout(r, 10));
@@ -557,6 +581,7 @@ describe("POST /api/modules/:short/install", () => {
557
581
  configDir: h.dir,
558
582
  supervisor,
559
583
  run,
584
+ isLinked: TEST_DEFAULT_NOT_LINKED,
560
585
  },
561
586
  );
562
587
  expect(res.status).toBe(202);
@@ -583,6 +608,7 @@ describe("POST /api/modules/:short/install", () => {
583
608
  supervisor,
584
609
  run: async () => 1,
585
610
  findGlobalInstall: () => null,
611
+ isLinked: TEST_DEFAULT_NOT_LINKED,
586
612
  };
587
613
  const res = await handleInstall(
588
614
  postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
@@ -602,6 +628,71 @@ describe("POST /api/modules/:short/install", () => {
602
628
  expect(op.status).toBe("failed");
603
629
  expect(op.error).toMatch(/bun add -g exited 1/);
604
630
  });
631
+
632
+ test("skips bun add -g when package is already bun-linked (smoke 2026-05-27 finding 1)", async () => {
633
+ // Smoke finding 1: the wizard's parallel install path was unconditionally
634
+ // invoking `bun add -g <pkg>` even when the package was already linked
635
+ // via `bun link <abspath>` (the standard local-dev shape). At best a
636
+ // wasted ~3s npm round-trip per install; at worst the global bun.lock
637
+ // had unrelated noise and the install failed outright, taking the
638
+ // wizard's vault step with it. Fix: mirror the CLI install path's
639
+ // `isLinked` short-circuit. Regression guard.
640
+ const { supervisor, spawns } = makeIdleSupervisor();
641
+ const { run, calls } = alwaysOkRun();
642
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
643
+ const res = await handleInstall(
644
+ postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
645
+ "vault",
646
+ {
647
+ db: h.db,
648
+ issuer: ISSUER,
649
+ manifestPath: h.manifestPath,
650
+ configDir: h.dir,
651
+ supervisor,
652
+ run,
653
+ // The bug shape: package IS linked locally. Without the
654
+ // short-circuit, runInstall would still call bun add -g.
655
+ isLinked: (pkg) => pkg === "@openparachute/vault",
656
+ },
657
+ );
658
+ expect(res.status).toBe(202);
659
+ await new Promise((r) => setTimeout(r, 10));
660
+
661
+ // The fix: bun add -g was NOT invoked for the linked package.
662
+ expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
663
+ // Downstream of the skip, the seed + spawn still happen — the install
664
+ // op completes successfully against the locally-linked checkout.
665
+ const manifest = JSON.parse(readFileSync(h.manifestPath, "utf8")) as {
666
+ services: Array<{ name: string }>;
667
+ };
668
+ expect(manifest.services.some((s) => s.name === "parachute-vault")).toBe(true);
669
+ expect(spawns.find((s) => s.short === "vault")?.cmd).toEqual(["parachute-vault", "serve"]);
670
+ });
671
+
672
+ test("still runs bun add -g when package is NOT bun-linked", async () => {
673
+ // Companion to the above — confirms the short-circuit doesn't
674
+ // unconditionally skip. On a friend's fresh machine (no bun link),
675
+ // bun add -g IS what installs the package from npm. `isLinked: () => false`
676
+ // is the production default behavior for a non-linked package.
677
+ const { supervisor } = makeIdleSupervisor();
678
+ const { run, calls } = alwaysOkRun();
679
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
680
+ await handleInstall(
681
+ postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
682
+ "vault",
683
+ {
684
+ db: h.db,
685
+ issuer: ISSUER,
686
+ manifestPath: h.manifestPath,
687
+ configDir: h.dir,
688
+ supervisor,
689
+ run,
690
+ isLinked: () => false,
691
+ },
692
+ );
693
+ await new Promise((r) => setTimeout(r, 10));
694
+ expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
695
+ });
605
696
  });
606
697
 
607
698
  describe("POST /api/modules/:short/restart", () => {
@@ -682,6 +773,7 @@ describe("POST /api/modules/:short/upgrade", () => {
682
773
  configDir: h.dir,
683
774
  supervisor,
684
775
  run,
776
+ isLinked: TEST_DEFAULT_NOT_LINKED,
685
777
  },
686
778
  );
687
779
  expect(res.status).toBe(202);
@@ -706,6 +798,7 @@ describe("POST /api/modules/:short/upgrade", () => {
706
798
  configDir: h.dir,
707
799
  supervisor,
708
800
  run,
801
+ isLinked: TEST_DEFAULT_NOT_LINKED,
709
802
  },
710
803
  );
711
804
  expect(res.status).toBe(202);
@@ -736,6 +829,7 @@ describe("POST /api/modules/:short/upgrade", () => {
736
829
  configDir: h.dir,
737
830
  supervisor,
738
831
  run,
832
+ isLinked: TEST_DEFAULT_NOT_LINKED,
739
833
  },
740
834
  );
741
835
  await new Promise((r) => setTimeout(r, 10));
@@ -846,6 +940,7 @@ describe("POST /api/modules/:short/uninstall", () => {
846
940
  configDir: h.dir,
847
941
  supervisor,
848
942
  run,
943
+ isLinked: TEST_DEFAULT_NOT_LINKED,
849
944
  },
850
945
  );
851
946
  expect(res.status).toBe(200);
@@ -878,6 +973,7 @@ describe("POST /api/modules/:short/uninstall", () => {
878
973
  configDir: h.dir,
879
974
  supervisor,
880
975
  run,
976
+ isLinked: TEST_DEFAULT_NOT_LINKED,
881
977
  },
882
978
  );
883
979
  expect(res.status).toBe(200);
@@ -1099,6 +1195,7 @@ describe("well-known regen after module ops", () => {
1099
1195
  supervisor,
1100
1196
  run: async () => 1,
1101
1197
  findGlobalInstall: () => null,
1198
+ isLinked: TEST_DEFAULT_NOT_LINKED,
1102
1199
  wellKnownPath: wkPath,
1103
1200
  };
1104
1201
  const res = await handleInstall(
@@ -171,7 +171,7 @@ describe("GET /api/modules", () => {
171
171
  supervisor_available: boolean;
172
172
  };
173
173
  // Curated order is preserved: vault → app → notes → scribe → runner.
174
- expect(body.modules.map((m) => m.short)).toEqual(["vault", "app", "notes", "scribe", "runner"]);
174
+ expect(body.modules.map((m) => m.short)).toEqual(["vault", "surface", "notes", "scribe", "runner"]);
175
175
  expect(body.modules.every((m) => m.available)).toBe(true);
176
176
  expect(body.modules.every((m) => !m.installed)).toBe(true);
177
177
  expect(body.modules.every((m) => m.latest_version === "0.9.9")).toBe(true);
@@ -202,12 +202,12 @@ describe("GET /api/modules", () => {
202
202
  available: boolean;
203
203
  }>;
204
204
  };
205
- const app = body.modules.find((m) => m.short === "app");
206
- expect(app).toBeDefined();
207
- expect(app?.package).toBe("@openparachute/app");
208
- expect(app?.display_name).toBe("App");
209
- expect(app?.tagline).toContain("auto-installs Notes");
210
- expect(app?.available).toBe(true);
205
+ const surface = body.modules.find((m) => m.short === "surface");
206
+ expect(surface).toBeDefined();
207
+ expect(surface?.package).toBe("@openparachute/surface");
208
+ expect(surface?.display_name).toBe("Surface");
209
+ expect(surface?.tagline).toContain("auto-installs Notes");
210
+ expect(surface?.available).toBe(true);
211
211
  });
212
212
 
213
213
  test("runner row carries package + display props from FIRST_PARTY_FALLBACKS (#305)", async () => {
@@ -366,9 +366,9 @@ describe("GET /api/modules", () => {
366
366
  });
367
367
 
368
368
  test("management_url does not double-prepend mount when managementUrl is already mount-prefixed (hub#380)", async () => {
369
- // Audit caught 2026-05-25: app declares `managementUrl: "/app/admin/"`
370
- // (full hub-origin path) and `paths: ["/app", "/.parachute"]`. The
371
- // SPA's Services dropdown was navigating to `/app/app/admin/` (404)
369
+ // Audit caught 2026-05-25: app declares `managementUrl: "/surface/admin/"`
370
+ // (full hub-origin path) and `paths: ["/surface", "/.parachute"]`. The
371
+ // SPA's Services dropdown was navigating to `/app/surface/admin/` (404)
372
372
  // because api-modules unconditionally prepended the mount onto the
373
373
  // candidate. Fix: detect already-mount-prefixed paths and pass through.
374
374
  //
@@ -376,12 +376,12 @@ describe("GET /api/modules", () => {
376
376
  // multi-instance modules (vault) use the per-instance relative form.
377
377
  writeManifest(h.manifestPath, [
378
378
  {
379
- name: "parachute-app",
379
+ name: "parachute-surface",
380
380
  port: 1946,
381
- paths: ["/app", "/.parachute"],
382
- health: "/app/healthz",
381
+ paths: ["/surface", "/.parachute"],
382
+ health: "/surface/healthz",
383
383
  version: "0.2.0-rc.13",
384
- installDir: "/install/dir/app",
384
+ installDir: "/install/dir/surface",
385
385
  },
386
386
  ]);
387
387
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
@@ -391,17 +391,17 @@ describe("GET /api/modules", () => {
391
391
  manifestPath: h.manifestPath,
392
392
  fetchLatestVersion: async () => null,
393
393
  readModuleManifest: async (installDir) => {
394
- if (installDir === "/install/dir/app") {
394
+ if (installDir === "/install/dir/surface") {
395
395
  return {
396
- name: "app",
397
- manifestName: "parachute-app",
398
- displayName: "App",
396
+ name: "surface",
397
+ manifestName: "parachute-surface",
398
+ displayName: "Surface",
399
399
  tagline: "",
400
400
  port: 1946,
401
- paths: ["/app", "/.parachute"],
402
- health: "/app/healthz",
403
- uiUrl: "/app/admin/",
404
- managementUrl: "/app/admin/",
401
+ paths: ["/surface", "/.parachute"],
402
+ health: "/surface/healthz",
403
+ uiUrl: "/surface/admin/",
404
+ managementUrl: "/surface/admin/",
405
405
  } as unknown as Awaited<
406
406
  ReturnType<typeof import("../module-manifest.ts").readModuleManifest>
407
407
  >;
@@ -413,9 +413,9 @@ describe("GET /api/modules", () => {
413
413
  const body = (await res.json()) as {
414
414
  modules: Array<{ short: string; management_url: string | null }>;
415
415
  };
416
- const app = body.modules.find((m) => m.short === "app");
417
- // Single `/app/`, not `/app/app/`.
418
- expect(app?.management_url).toBe("/app/admin/");
416
+ const surface = body.modules.find((m) => m.short === "surface");
417
+ // Single `/surface/`, not `/surface/surface/`.
418
+ expect(surface?.management_url).toBe("/surface/admin/");
419
419
  });
420
420
 
421
421
  test("management_url prefix-ish names don't collide (hub#380 — /app vs /app-foo)", async () => {
@@ -430,8 +430,8 @@ describe("GET /api/modules", () => {
430
430
  {
431
431
  name: "parachute-vault",
432
432
  port: 1940,
433
- paths: ["/app"], // mount is /app (using vault as a stand-in installable)
434
- health: "/app/health",
433
+ paths: ["/surface"], // mount is /app (using vault as a stand-in installable)
434
+ health: "/surface/health",
435
435
  version: "0.4.5",
436
436
  installDir: "/install/dir/contrived",
437
437
  },
@@ -450,8 +450,8 @@ describe("GET /api/modules", () => {
450
450
  displayName: "Vault",
451
451
  tagline: "",
452
452
  port: 1940,
453
- paths: ["/app"],
454
- health: "/app/health",
453
+ paths: ["/surface"],
454
+ health: "/surface/health",
455
455
  // candidate looks like a sibling-name prefix but is NOT a
456
456
  // mount-prefix of /app — should still get prepended.
457
457
  managementUrl: "/app-foo/admin",
@@ -467,9 +467,9 @@ describe("GET /api/modules", () => {
467
467
  modules: Array<{ short: string; management_url: string | null }>;
468
468
  };
469
469
  const vault = body.modules.find((m) => m.short === "vault");
470
- // /app + /app-foo/admin → /app/app-foo/admin (prepend fires; not treated
471
- // as already-mount-prefixed because /app-foo/ doesn't start with /app/).
472
- expect(vault?.management_url).toBe("/app/app-foo/admin");
470
+ // /surface + /app-foo/admin → /surface/app-foo/admin (prepend fires; not
471
+ // treated as already-mount-prefixed because /app-foo/ doesn't start with /surface/).
472
+ expect(vault?.management_url).toBe("/surface/app-foo/admin");
473
473
  });
474
474
 
475
475
  test("management_url equality edge: tail equals mount exactly (hub#380)", async () => {