@openparachute/hub 0.5.2 → 0.5.9-rc.6

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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +159 -320
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +123 -0
  12. package/src/__tests__/expose-cloudflare.test.ts +101 -0
  13. package/src/__tests__/expose.test.ts +199 -340
  14. package/src/__tests__/hub-server.test.ts +986 -66
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/install.test.ts +50 -31
  18. package/src/__tests__/jwt-sign.test.ts +205 -0
  19. package/src/__tests__/lifecycle.test.ts +97 -2
  20. package/src/__tests__/module-manifest.test.ts +48 -0
  21. package/src/__tests__/notes-serve.test.ts +154 -2
  22. package/src/__tests__/oauth-handlers.test.ts +1000 -3
  23. package/src/__tests__/operator-token.test.ts +379 -3
  24. package/src/__tests__/origin-check.test.ts +220 -0
  25. package/src/__tests__/port-assign.test.ts +41 -52
  26. package/src/__tests__/rate-limit.test.ts +190 -0
  27. package/src/__tests__/services-manifest.test.ts +341 -0
  28. package/src/__tests__/setup.test.ts +12 -9
  29. package/src/__tests__/status.test.ts +372 -0
  30. package/src/__tests__/well-known.test.ts +69 -0
  31. package/src/admin-clients.ts +139 -0
  32. package/src/admin-handlers.ts +63 -260
  33. package/src/admin-host-admin-token.ts +25 -10
  34. package/src/admin-login-ui.ts +256 -0
  35. package/src/admin-vault-admin-token.ts +1 -1
  36. package/src/api-me.ts +124 -0
  37. package/src/api-mint-token.ts +239 -0
  38. package/src/api-revocation-list.ts +59 -0
  39. package/src/api-revoke-token.ts +153 -0
  40. package/src/api-tokens.ts +224 -0
  41. package/src/commands/auth.ts +408 -51
  42. package/src/commands/expose-2fa-warning.ts +82 -0
  43. package/src/commands/expose-cloudflare.ts +27 -0
  44. package/src/commands/expose-public-auto.ts +3 -7
  45. package/src/commands/expose.ts +88 -173
  46. package/src/commands/install.ts +11 -13
  47. package/src/commands/lifecycle.ts +53 -4
  48. package/src/commands/status.ts +99 -8
  49. package/src/csrf.ts +6 -3
  50. package/src/help.ts +13 -7
  51. package/src/hub-db.ts +63 -0
  52. package/src/hub-server.ts +572 -106
  53. package/src/hub.ts +272 -149
  54. package/src/install-source.ts +291 -0
  55. package/src/jwt-sign.ts +265 -5
  56. package/src/module-manifest.ts +48 -10
  57. package/src/notes-serve.ts +70 -9
  58. package/src/oauth-handlers.ts +395 -29
  59. package/src/oauth-ui.ts +188 -0
  60. package/src/operator-token.ts +272 -18
  61. package/src/origin-check.ts +127 -0
  62. package/src/port-assign.ts +28 -35
  63. package/src/rate-limit.ts +166 -0
  64. package/src/scope-explanations.ts +33 -2
  65. package/src/service-spec.ts +58 -13
  66. package/src/services-manifest.ts +62 -3
  67. package/src/sessions.ts +19 -0
  68. package/src/well-known.ts +54 -1
  69. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  70. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/__tests__/admin-config.test.ts +0 -281
  73. package/src/admin-config-ui.ts +0 -534
  74. package/src/admin-config.ts +0 -226
  75. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  76. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -9,8 +9,13 @@ import {
9
9
  REFRESH_TOKEN_TTL_MS,
10
10
  RefreshTokenInsertError,
11
11
  findRefreshToken,
12
+ findTokenRowByJti,
13
+ listActiveRevocations,
14
+ recordTokenMint,
15
+ revokeTokenByJti,
12
16
  signAccessToken,
13
17
  signRefreshToken,
18
+ tokenRowIdentity,
14
19
  validateAccessToken,
15
20
  } from "../jwt-sign.ts";
16
21
  import { getActiveSigningKey, rotateSigningKey } from "../signing-keys.ts";
@@ -359,3 +364,203 @@ describe("validateAccessToken", () => {
359
364
  }
360
365
  });
361
366
  });
367
+
368
+ // closes #212 Phase 1 — unified token registry helpers (recordTokenMint,
369
+ // revokeTokenByJti, listActiveRevocations) and the v6 schema shape.
370
+ describe("token registry (hub#212 Phase 1)", () => {
371
+ test("v6 schema: tokens has user_id NULLABLE + permissions/created_via/subject", () => {
372
+ const { db, cleanup } = makeDb();
373
+ try {
374
+ // SQLite PRAGMA table_info reports column nullability + defaults; the
375
+ // bun:sqlite driver maps the row shape onto our type. The columns are
376
+ // (cid, name, type, notnull, dflt_value, pk) per SQLite docs.
377
+ type ColInfo = {
378
+ cid: number;
379
+ name: string;
380
+ type: string;
381
+ notnull: number;
382
+ dflt_value: string | null;
383
+ pk: number;
384
+ };
385
+ const cols = db.query<ColInfo, []>("PRAGMA table_info(tokens)").all();
386
+ const byName = new Map(cols.map((c) => [c.name, c]));
387
+ // Pre-v6: user_id NOT NULL. Post-v6: user_id NULLABLE.
388
+ expect(byName.get("user_id")?.notnull).toBe(0);
389
+ // New columns.
390
+ expect(byName.has("permissions")).toBe(true);
391
+ expect(byName.has("created_via")).toBe(true);
392
+ expect(byName.has("subject")).toBe(true);
393
+ // created_via has the back-compat default for pre-v6 rows.
394
+ expect(byName.get("created_via")?.dflt_value).toMatch(/oauth_refresh/);
395
+ } finally {
396
+ cleanup();
397
+ }
398
+ });
399
+
400
+ test("recordTokenMint inserts a registry row matching the inputs", () => {
401
+ const { db, cleanup } = makeDb();
402
+ try {
403
+ const expiresAt = new Date(Date.now() + 86400_000).toISOString();
404
+ recordTokenMint(db, {
405
+ jti: "jti-cli-1",
406
+ createdVia: "cli_mint",
407
+ subject: "operator",
408
+ clientId: "parachute-hub",
409
+ scopes: ["vault:read", "scribe:transcribe"],
410
+ expiresAt,
411
+ permissions: '{"vault":{"default":{"read_tags":["public"]}}}',
412
+ });
413
+ const row = findTokenRowByJti(db, "jti-cli-1");
414
+ expect(row).not.toBeNull();
415
+ expect(row?.userId).toBeNull();
416
+ expect(row?.subject).toBe("operator");
417
+ expect(row?.createdVia).toBe("cli_mint");
418
+ expect(row?.scopes).toEqual(["vault:read", "scribe:transcribe"]);
419
+ expect(row?.expiresAt).toBe(expiresAt);
420
+ expect(row?.permissions).toBe('{"vault":{"default":{"read_tags":["public"]}}}');
421
+ expect(row?.revokedAt).toBeNull();
422
+ } finally {
423
+ cleanup();
424
+ }
425
+ });
426
+
427
+ test("recordTokenMint with a duplicate jti throws RefreshTokenInsertError", () => {
428
+ const { db, cleanup } = makeDb();
429
+ try {
430
+ const expiresAt = new Date(Date.now() + 86400_000).toISOString();
431
+ recordTokenMint(db, {
432
+ jti: "jti-dup",
433
+ createdVia: "operator_mint",
434
+ subject: "operator",
435
+ clientId: "parachute-hub",
436
+ scopes: ["hub:admin"],
437
+ expiresAt,
438
+ });
439
+ expect(() =>
440
+ recordTokenMint(db, {
441
+ jti: "jti-dup",
442
+ createdVia: "cli_mint",
443
+ subject: "operator",
444
+ clientId: "parachute-hub",
445
+ scopes: ["vault:read"],
446
+ expiresAt,
447
+ }),
448
+ ).toThrow(RefreshTokenInsertError);
449
+ } finally {
450
+ cleanup();
451
+ }
452
+ });
453
+
454
+ test("revokeTokenByJti flips revoked_at; second call returns false (idempotent)", () => {
455
+ const { db, cleanup } = makeDb();
456
+ try {
457
+ const expiresAt = new Date(Date.now() + 86400_000).toISOString();
458
+ recordTokenMint(db, {
459
+ jti: "jti-rev",
460
+ createdVia: "cli_mint",
461
+ subject: "operator",
462
+ clientId: "parachute-hub",
463
+ scopes: ["vault:read"],
464
+ expiresAt,
465
+ });
466
+ const now = new Date();
467
+ expect(revokeTokenByJti(db, "jti-rev", now)).toBe(true);
468
+ expect(revokeTokenByJti(db, "jti-rev", now)).toBe(false);
469
+ const row = findTokenRowByJti(db, "jti-rev");
470
+ expect(row?.revokedAt).toBe(now.toISOString());
471
+ } finally {
472
+ cleanup();
473
+ }
474
+ });
475
+
476
+ test("listActiveRevocations filters by revoked_at AND expires_at>now", () => {
477
+ const { db, cleanup } = makeDb();
478
+ try {
479
+ const past = new Date(Date.now() - 86400_000).toISOString();
480
+ const future = new Date(Date.now() + 86400_000).toISOString();
481
+ // Two revoked rows: one expired, one active.
482
+ recordTokenMint(db, {
483
+ jti: "jti-revoked-expired",
484
+ createdVia: "cli_mint",
485
+ subject: "operator",
486
+ clientId: "parachute-hub",
487
+ scopes: ["vault:read"],
488
+ expiresAt: past,
489
+ });
490
+ recordTokenMint(db, {
491
+ jti: "jti-revoked-active",
492
+ createdVia: "cli_mint",
493
+ subject: "operator",
494
+ clientId: "parachute-hub",
495
+ scopes: ["vault:read"],
496
+ expiresAt: future,
497
+ });
498
+ // One non-revoked active row (control — must NOT appear).
499
+ recordTokenMint(db, {
500
+ jti: "jti-not-revoked",
501
+ createdVia: "cli_mint",
502
+ subject: "operator",
503
+ clientId: "parachute-hub",
504
+ scopes: ["vault:read"],
505
+ expiresAt: future,
506
+ });
507
+ const now = new Date();
508
+ revokeTokenByJti(db, "jti-revoked-expired", now);
509
+ revokeTokenByJti(db, "jti-revoked-active", now);
510
+ const list = listActiveRevocations(db, now);
511
+ expect(list).toEqual(["jti-revoked-active"]);
512
+ } finally {
513
+ cleanup();
514
+ }
515
+ });
516
+
517
+ test("tokenRowIdentity returns userId when present, else subject", async () => {
518
+ const { db, cleanup } = makeDb();
519
+ try {
520
+ rotateSigningKey(db);
521
+ const u = await createUser(db, "owner", "pw");
522
+ // OAuth refresh row: userId set, subject NULL.
523
+ const refresh = signRefreshToken(db, {
524
+ jti: "jti-oauth",
525
+ userId: u.id,
526
+ clientId: "parachute-hub",
527
+ scopes: ["vault:read"],
528
+ });
529
+ expect(refresh.familyId).toBeDefined();
530
+ const oauthRow = findTokenRowByJti(db, "jti-oauth")!;
531
+ expect(tokenRowIdentity(oauthRow)).toBe(u.id);
532
+
533
+ // CLI mint row: userId NULL, subject set.
534
+ recordTokenMint(db, {
535
+ jti: "jti-cli",
536
+ createdVia: "cli_mint",
537
+ subject: "operator",
538
+ clientId: "parachute-hub",
539
+ scopes: ["vault:read"],
540
+ expiresAt: new Date(Date.now() + 86400_000).toISOString(),
541
+ });
542
+ const cliRow = findTokenRowByJti(db, "jti-cli")!;
543
+ expect(tokenRowIdentity(cliRow)).toBe("operator");
544
+ } finally {
545
+ cleanup();
546
+ }
547
+ });
548
+
549
+ test("signRefreshToken explicitly stamps created_via='oauth_refresh'", async () => {
550
+ const { db, cleanup } = makeDb();
551
+ try {
552
+ rotateSigningKey(db);
553
+ const u = await createUser(db, "owner", "pw");
554
+ signRefreshToken(db, {
555
+ jti: "jti-oauth-stamped",
556
+ userId: u.id,
557
+ clientId: "parachute-hub",
558
+ scopes: ["vault:read"],
559
+ });
560
+ const row = findTokenRowByJti(db, "jti-oauth-stamped");
561
+ expect(row?.createdVia).toBe("oauth_refresh");
562
+ } finally {
563
+ cleanup();
564
+ }
565
+ });
566
+ });
@@ -248,7 +248,11 @@ describe("parachute start", () => {
248
248
  configDir: h.configDir,
249
249
  manifestPath: h.manifestPath,
250
250
  spawner,
251
- alive: () => false,
251
+ // Stale 4242 is dead; the freshly spawned 7777 is alive the
252
+ // post-spawn settle (hub#194) calls alive(pid) on the new pid,
253
+ // so we differentiate per-pid rather than blanket-false.
254
+ alive: (pid) => pid === 7777,
255
+ sleep: async () => {},
252
256
  log: () => {},
253
257
  });
254
258
  expect(code).toBe(0);
@@ -629,6 +633,94 @@ describe("parachute start", () => {
629
633
  }
630
634
  });
631
635
 
636
+ test("hub#194: reports failure when child dies before the settle window", async () => {
637
+ // The bug: `parachute start notes` reported `✓ notes started (pid X)`
638
+ // but notes-serve crashed milliseconds later on a Bun.resolveSync
639
+ // failure, leaving tailnet `/notes/` 502'ing. Fix: after spawn, sleep
640
+ // ~250ms then re-check alive(pid). If dead, clear pidfile, log
641
+ // failure, return non-zero. This regression test pins the post-fix
642
+ // shape with a stub alive that always reports dead and a fast settle.
643
+ const h = makeHarness();
644
+ try {
645
+ seedVault(h.manifestPath);
646
+ const spawner = makeSpawner([4242]);
647
+ const lines: string[] = [];
648
+ const code = await start("vault", {
649
+ configDir: h.configDir,
650
+ manifestPath: h.manifestPath,
651
+ spawner,
652
+ alive: () => false, // child dies immediately after spawn
653
+ sleep: async () => {}, // skip the real wait in tests
654
+ startSettleMs: 1, // any non-zero value engages the check
655
+ log: (l) => lines.push(l),
656
+ });
657
+ expect(code).toBe(1);
658
+ expect(spawner.calls).toHaveLength(1);
659
+ // pidfile is cleared so a follow-up `start` doesn't report
660
+ // already-running against a corpse.
661
+ expect(readPid("vault", h.configDir)).toBeUndefined();
662
+ const out = lines.join("\n");
663
+ expect(out).toMatch(/✗ vault failed to start/);
664
+ expect(out).toMatch(/exited within 1ms/);
665
+ expect(out).toMatch(/Tail the log/);
666
+ expect(out).not.toMatch(/✓ vault started/);
667
+ } finally {
668
+ h.cleanup();
669
+ }
670
+ });
671
+
672
+ test("hub#194: settle path passes when child stays alive past the window", async () => {
673
+ // Companion to the above — verifies the success-path shape doesn't
674
+ // regress. Stub alive returns true so the post-spawn check passes,
675
+ // and we still see the `✓ ... started` line.
676
+ const h = makeHarness();
677
+ try {
678
+ seedVault(h.manifestPath);
679
+ const spawner = makeSpawner([4242]);
680
+ const lines: string[] = [];
681
+ const code = await start("vault", {
682
+ configDir: h.configDir,
683
+ manifestPath: h.manifestPath,
684
+ spawner,
685
+ alive: () => true,
686
+ sleep: async () => {},
687
+ startSettleMs: 1,
688
+ log: (l) => lines.push(l),
689
+ });
690
+ expect(code).toBe(0);
691
+ expect(readPid("vault", h.configDir)).toBe(4242);
692
+ expect(lines.join("\n")).toMatch(/✓ vault started \(pid 4242\)/);
693
+ } finally {
694
+ h.cleanup();
695
+ }
696
+ });
697
+
698
+ test("hub#194: settle skipped when startSettleMs is 0", async () => {
699
+ // Defense — don't regress the test-default policy. With a stub
700
+ // spawner and no `alive` override, the resolved settle is 0 (see
701
+ // resolve() in lifecycle.ts), so the post-spawn check is bypassed
702
+ // entirely and even an `alive: () => false` doesn't matter.
703
+ const h = makeHarness();
704
+ try {
705
+ seedVault(h.manifestPath);
706
+ const spawner = makeSpawner([4242]);
707
+ const code = await start("vault", {
708
+ configDir: h.configDir,
709
+ manifestPath: h.manifestPath,
710
+ spawner,
711
+ startSettleMs: 0,
712
+ // intentionally omit alive — defaultAlive against a fake pid
713
+ // would normally report dead, but startSettleMs: 0 skips the
714
+ // call entirely.
715
+ log: () => {},
716
+ });
717
+ expect(code).toBe(0);
718
+ expect(readPid("vault", h.configDir)).toBe(4242);
719
+ } finally {
720
+ h.cleanup();
721
+ }
722
+ });
723
+
632
724
  test("third-party with no startCmd in module.json reports lifecycle-unsupported", async () => {
633
725
  const h = makeHarness();
634
726
  try {
@@ -802,7 +894,10 @@ describe("parachute restart", () => {
802
894
  manifestPath: h.manifestPath,
803
895
  spawner,
804
896
  kill: (pid, sig) => killed.push([pid, sig]),
805
- alive: () => false,
897
+ // Stale 4242 is dead (stop's stale-pid path skips the kill);
898
+ // freshly spawned 7777 is alive past the post-spawn settle
899
+ // (hub#194). Per-pid differentiation rather than blanket-false.
900
+ alive: (pid) => pid === 7777,
806
901
  sleep: async () => {},
807
902
  log: () => {},
808
903
  });
@@ -126,6 +126,54 @@ describe("validateModuleManifest", () => {
126
126
  ).toThrow(/http:.*https:/);
127
127
  });
128
128
 
129
+ test("uiUrl accepts a leading-slash path (Phase D)", () => {
130
+ const m = validateModuleManifest({ ...VALID, uiUrl: "/notes" }, "x");
131
+ expect(m.uiUrl).toBe("/notes");
132
+ });
133
+
134
+ test("uiUrl accepts an absolute https URL", () => {
135
+ const m = validateModuleManifest({ ...VALID, uiUrl: "https://app.example.com/" }, "x");
136
+ expect(m.uiUrl).toBe("https://app.example.com/");
137
+ });
138
+
139
+ test("uiUrl rejects empty / non-string / non-url-or-path (mirrors managementUrl)", () => {
140
+ expect(() => validateModuleManifest({ ...VALID, uiUrl: "" }, "x")).toThrow(/uiUrl/);
141
+ expect(() => validateModuleManifest({ ...VALID, uiUrl: 7 }, "x")).toThrow(/uiUrl/);
142
+ expect(() => validateModuleManifest({ ...VALID, uiUrl: "no-slash" }, "x")).toThrow(
143
+ /path starting with "\/" or a full http\(s\) URL/,
144
+ );
145
+ expect(() => validateModuleManifest({ ...VALID, uiUrl: "ftp://example.com" }, "x")).toThrow(
146
+ /http:.*https:/,
147
+ );
148
+ });
149
+
150
+ test("uiUrl absent stays absent", () => {
151
+ const m = validateModuleManifest(VALID, "x");
152
+ expect(m.uiUrl).toBeUndefined();
153
+ });
154
+
155
+ // Open-redirect regression: protocol-relative paths like "//evil.com" pass
156
+ // a naive `startsWith("/")` check but `new URL("//evil.com", base)` resolves
157
+ // to the foreign origin. A malicious third-party module could plant such a
158
+ // value in module.json:uiUrl and turn a discovery tile into an off-origin
159
+ // redirect. Both uiUrl and managementUrl are validated by the shared
160
+ // asPathOrUrl helper, so cover both.
161
+ test("uiUrl rejects protocol-relative paths (open-redirect regression)", () => {
162
+ expect(() => validateModuleManifest({ ...VALID, uiUrl: "//evil.com" }, "x")).toThrow(/uiUrl/);
163
+ expect(() => validateModuleManifest({ ...VALID, uiUrl: "//evil.com/path" }, "x")).toThrow(
164
+ /uiUrl/,
165
+ );
166
+ });
167
+
168
+ test("managementUrl rejects protocol-relative paths (open-redirect regression)", () => {
169
+ expect(() => validateModuleManifest({ ...VALID, managementUrl: "//evil.com" }, "x")).toThrow(
170
+ /managementUrl/,
171
+ );
172
+ expect(() =>
173
+ validateModuleManifest({ ...VALID, managementUrl: "//evil.com/admin" }, "x"),
174
+ ).toThrow(/managementUrl/);
175
+ });
176
+
129
177
  test("managementUrl absent stays absent", () => {
130
178
  const m = validateModuleManifest(VALID, "x");
131
179
  expect(m.managementUrl).toBeUndefined();
@@ -1,8 +1,13 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { normalizeMount, notesFetch } from "../notes-serve.ts";
5
+ import {
6
+ normalizeMount,
7
+ notesDistCandidates,
8
+ notesFetch,
9
+ resolveNotesDistFrom,
10
+ } from "../notes-serve.ts";
6
11
 
7
12
  interface Harness {
8
13
  dir: string;
@@ -133,3 +138,150 @@ describe("notesFetch with empty mount (root deployment)", () => {
133
138
  }
134
139
  });
135
140
  });
141
+
142
+ describe("notesDistCandidates", () => {
143
+ test("returns cwd, then global node_modules, then global root", () => {
144
+ const cands = notesDistCandidates("/some/cwd", "/home/user");
145
+ expect(cands).toEqual([
146
+ "/some/cwd",
147
+ "/home/user/.bun/install/global/node_modules",
148
+ "/home/user/.bun/install/global",
149
+ ]);
150
+ });
151
+ });
152
+
153
+ /**
154
+ * `resolveNotesDistFrom` is the hub#194 fix — when the cwd-relative resolve
155
+ * fails (hub repo dir doesn't depend on @openparachute/notes), we walk down
156
+ * to bun's global install dirs before giving up. Tests use a stub
157
+ * `resolveSync` so we can drive the candidate order without writing real
158
+ * fixtures into `~/.bun/install/global`.
159
+ */
160
+ describe("resolveNotesDistFrom (hub#194)", () => {
161
+ function makeFixture(): { home: string; cleanup: () => void; pkgRoot: string; dist: string } {
162
+ // realpathSync — on macOS `mkdtempSync` returns a /var/folders path
163
+ // that resolves to /private/var/folders; we want the resolved form so
164
+ // string comparisons against `Bun.resolveSync` output line up.
165
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "pcli-notes-resolve-")));
166
+ const home = join(root, "home");
167
+ const pkgRoot = join(home, ".bun/install/global/node_modules/@openparachute/notes");
168
+ mkdirSync(pkgRoot, { recursive: true });
169
+ const dist = join(pkgRoot, "dist");
170
+ mkdirSync(dist, { recursive: true });
171
+ writeFileSync(join(pkgRoot, "package.json"), '{"name":"@openparachute/notes"}');
172
+ return { home, pkgRoot, dist, cleanup: () => rmSync(root, { recursive: true, force: true }) };
173
+ }
174
+
175
+ test("first-candidate (cwd) hit returns its dist immediately", () => {
176
+ const f = makeFixture();
177
+ try {
178
+ const calls: string[] = [];
179
+ const out = resolveNotesDistFrom({
180
+ cwd: "/cwd-with-notes",
181
+ home: f.home,
182
+ resolveSync: (specifier, base) => {
183
+ calls.push(base);
184
+ if (base === "/cwd-with-notes") {
185
+ return "/cwd-with-notes/node_modules/@openparachute/notes/package.json";
186
+ }
187
+ throw new Error(`unexpected base: ${base}`);
188
+ },
189
+ existsSync: (p) => p === "/cwd-with-notes/node_modules/@openparachute/notes/dist",
190
+ });
191
+ expect(out).toBe("/cwd-with-notes/node_modules/@openparachute/notes/dist");
192
+ // Only the cwd candidate should be probed — we short-circuit on hit.
193
+ expect(calls).toEqual(["/cwd-with-notes"]);
194
+ } finally {
195
+ f.cleanup();
196
+ }
197
+ });
198
+
199
+ test("falls through to global node_modules when cwd resolve fails (hub#194 root cause)", () => {
200
+ // The exact scenario from hub#194: hub repo's cwd has no dependency on
201
+ // notes, so the first candidate throws ResolveMessage. Bun does NOT
202
+ // auto-consult ~/.bun/install/global, so we have to try it explicitly.
203
+ const f = makeFixture();
204
+ try {
205
+ const calls: string[] = [];
206
+ const out = resolveNotesDistFrom({
207
+ cwd: "/hub-repo-cwd-without-notes",
208
+ home: f.home,
209
+ resolveSync: (specifier, base) => {
210
+ calls.push(base);
211
+ if (base === "/hub-repo-cwd-without-notes") {
212
+ throw new Error(`Cannot find module '${specifier}' from '${base}'`);
213
+ }
214
+ // Real Bun.resolveSync against the global node_modules dir
215
+ // resolves into the package's package.json.
216
+ return Bun.resolveSync(specifier, base);
217
+ },
218
+ // Use real existsSync — the fixture has dist/ on disk.
219
+ });
220
+ expect(out).toBe(f.dist);
221
+ // Both candidates probed, in order.
222
+ expect(calls[0]).toBe("/hub-repo-cwd-without-notes");
223
+ expect(calls[1]).toBe(join(f.home, ".bun/install/global/node_modules"));
224
+ } finally {
225
+ f.cleanup();
226
+ }
227
+ });
228
+
229
+ test("falls through past global node_modules to the older global root layout", () => {
230
+ // Defensive: older Bun versions used a flatter global layout. We probe
231
+ // both. This test forces the first two candidates to fail and pins
232
+ // that the third is reached.
233
+ const probed: string[] = [];
234
+ expect(() =>
235
+ resolveNotesDistFrom({
236
+ cwd: "/cwd",
237
+ home: "/h",
238
+ resolveSync: (_specifier, base) => {
239
+ probed.push(base);
240
+ throw new Error(`Cannot find module from '${base}'`);
241
+ },
242
+ }),
243
+ ).toThrow(/Could not resolve @openparachute\/notes from any of/);
244
+ expect(probed).toEqual([
245
+ "/cwd",
246
+ "/h/.bun/install/global/node_modules",
247
+ "/h/.bun/install/global",
248
+ ]);
249
+ });
250
+
251
+ test("error message names every candidate that was tried", () => {
252
+ let caught: unknown;
253
+ try {
254
+ resolveNotesDistFrom({
255
+ cwd: "/probe-cwd",
256
+ home: "/probe-home",
257
+ resolveSync: (_specifier, base) => {
258
+ throw new Error(`Cannot find module from '${base}'`);
259
+ },
260
+ });
261
+ } catch (err) {
262
+ caught = err;
263
+ }
264
+ expect(caught).toBeInstanceOf(Error);
265
+ const msg = (caught as Error).message;
266
+ expect(msg).toContain("/probe-cwd");
267
+ expect(msg).toContain("/probe-home/.bun/install/global/node_modules");
268
+ expect(msg).toContain("/probe-home/.bun/install/global");
269
+ // Hint operators at the actionable next step.
270
+ expect(msg).toMatch(/bun add -g @openparachute\/notes|parachute install notes/);
271
+ });
272
+
273
+ test("resolved package without dist/ throws a hard error (no fallthrough)", () => {
274
+ // If the package resolves but lacks a dist/ directory, that's a
275
+ // packaging issue — falling through to other candidates would just
276
+ // re-resolve the same package. Surface the problem with the resolved
277
+ // path so the operator can file the right issue against the package.
278
+ expect(() =>
279
+ resolveNotesDistFrom({
280
+ cwd: "/cwd-with-notes",
281
+ home: "/h",
282
+ resolveSync: () => "/cwd-with-notes/node_modules/@openparachute/notes/package.json",
283
+ existsSync: () => false,
284
+ }),
285
+ ).toThrow(/has no dist\/ directory/);
286
+ });
287
+ });