@openparachute/hub 0.3.0-rc.1 → 0.5.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 (91) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +1063 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +616 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +851 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +269 -38
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-audience.ts +40 -0
  75. package/src/jwt-sign.ts +275 -0
  76. package/src/module-manifest.ts +435 -0
  77. package/src/oauth-handlers.ts +1175 -0
  78. package/src/oauth-ui.ts +582 -0
  79. package/src/operator-token.ts +129 -0
  80. package/src/providers/detect.ts +97 -0
  81. package/src/scope-explanations.ts +137 -0
  82. package/src/scope-registry.ts +158 -0
  83. package/src/service-spec.ts +270 -97
  84. package/src/services-manifest.ts +57 -1
  85. package/src/sessions.ts +115 -0
  86. package/src/signing-keys.ts +120 -0
  87. package/src/users.ts +144 -0
  88. package/src/well-known.ts +62 -26
  89. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  90. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  91. package/web/ui/dist/index.html +14 -0
@@ -1,8 +1,16 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { logs, restart, start, stop } from "../commands/lifecycle.ts";
5
+ import {
6
+ defaultAlive,
7
+ defaultKill,
8
+ defaultSpawner,
9
+ logs,
10
+ restart,
11
+ start,
12
+ stop,
13
+ } from "../commands/lifecycle.ts";
6
14
  import { writeHubPort } from "../hub-control.ts";
7
15
  import { ensureLogPath, logPath, readPid, writePid } from "../process-state.ts";
8
16
  import { upsertService } from "../services-manifest.ts";
@@ -48,12 +56,62 @@ function seedNotes(manifestPath: string): void {
48
56
  );
49
57
  }
50
58
 
59
+ interface ThirdPartySeed {
60
+ installDir: string;
61
+ manifestName?: string;
62
+ startCmd?: readonly string[];
63
+ port?: number;
64
+ }
65
+
66
+ /**
67
+ * Seed a third-party services.json row + write a `.parachute/module.json` at
68
+ * `installDir`. Mirrors what `parachute install /tmp/foo` produces in
69
+ * production: row carries `installDir`, lifecycle resolves spec from the
70
+ * filesystem.
71
+ */
72
+ function seedThirdParty(
73
+ manifestPath: string,
74
+ configDirRoot: string,
75
+ name: string,
76
+ opts: ThirdPartySeed,
77
+ ): string {
78
+ const installDir = opts.installDir;
79
+ mkdirSync(join(installDir, ".parachute"), { recursive: true });
80
+ const manifest = {
81
+ name,
82
+ manifestName: opts.manifestName ?? name,
83
+ kind: "api" as const,
84
+ port: opts.port ?? 1944,
85
+ paths: [`/${name}`],
86
+ health: `/${name}/health`,
87
+ ...(opts.startCmd ? { startCmd: opts.startCmd } : {}),
88
+ };
89
+ writeFileSync(join(installDir, ".parachute", "module.json"), JSON.stringify(manifest));
90
+ upsertService(
91
+ {
92
+ name: opts.manifestName ?? name,
93
+ port: opts.port ?? 1944,
94
+ paths: [`/${name}`],
95
+ health: `/${name}/health`,
96
+ version: "0.0.1",
97
+ installDir,
98
+ },
99
+ manifestPath,
100
+ );
101
+ return configDirRoot;
102
+ }
103
+
51
104
  interface SpawnerStub {
52
- spawn: (cmd: readonly string[], logFile: string, env?: Record<string, string>) => number;
105
+ spawn: (
106
+ cmd: readonly string[],
107
+ logFile: string,
108
+ opts?: { env?: Record<string, string>; cwd?: string },
109
+ ) => number;
53
110
  calls: Array<{
54
111
  cmd: readonly string[];
55
112
  logFile: string;
56
113
  env?: Record<string, string>;
114
+ cwd?: string;
57
115
  }>;
58
116
  }
59
117
 
@@ -62,12 +120,13 @@ function makeSpawner(pidSequence: number[]): SpawnerStub {
62
120
  cmd: readonly string[];
63
121
  logFile: string;
64
122
  env?: Record<string, string>;
123
+ cwd?: string;
65
124
  }> = [];
66
125
  let i = 0;
67
126
  return {
68
127
  calls,
69
- spawn(cmd, logFile, env) {
70
- calls.push({ cmd: [...cmd], logFile, env });
128
+ spawn(cmd, logFile, opts) {
129
+ calls.push({ cmd: [...cmd], logFile, env: opts?.env, cwd: opts?.cwd });
71
130
  return pidSequence[i++] ?? 99999;
72
131
  },
73
132
  };
@@ -428,6 +487,168 @@ describe("parachute start", () => {
428
487
  h.cleanup();
429
488
  }
430
489
  });
490
+
491
+ test("third-party module starts via installDir module.json with cwd", async () => {
492
+ // hub#83: services.json rows that carry installDir resolve their spec
493
+ // from `<installDir>/.parachute/module.json` at lifecycle time. Spawn
494
+ // gets cwd=installDir so manifest-declared relative paths work.
495
+ const h = makeHarness();
496
+ try {
497
+ const installDir = join(h.configDir, "_pkg-agent");
498
+ seedThirdParty(h.manifestPath, h.configDir, "agent", {
499
+ installDir,
500
+ startCmd: ["bun", "web/server/src/server.ts"],
501
+ port: 1944,
502
+ });
503
+ const spawner = makeSpawner([8080]);
504
+ const code = await start("agent", {
505
+ configDir: h.configDir,
506
+ manifestPath: h.manifestPath,
507
+ spawner,
508
+ log: () => {},
509
+ });
510
+ expect(code).toBe(0);
511
+ expect(spawner.calls).toHaveLength(1);
512
+ expect(spawner.calls[0]?.cmd).toEqual(["bun", "web/server/src/server.ts"]);
513
+ expect(spawner.calls[0]?.cwd).toBe(installDir);
514
+ expect(readPid("agent", h.configDir)).toBe(8080);
515
+ } finally {
516
+ h.cleanup();
517
+ }
518
+ });
519
+
520
+ test("start: installDir-less third-party row surfaces an actionable error", async () => {
521
+ // A services.json row whose name isn't first-party AND has no installDir
522
+ // can't yield a startCmd. Pre-fix this hit the generic "unknown service"
523
+ // path (misleading — the row exists, just with stale shape). Post-fix
524
+ // resolveTargets returns the entry with spec=undefined and start prints
525
+ // an actionable message that points at the real fix (re-install or
526
+ // upgrade-the-module).
527
+ const h = makeHarness();
528
+ try {
529
+ upsertService(
530
+ {
531
+ name: "mystery",
532
+ port: 1944,
533
+ paths: ["/mystery"],
534
+ health: "/mystery/health",
535
+ version: "0.0.1",
536
+ },
537
+ h.manifestPath,
538
+ );
539
+ const lines: string[] = [];
540
+ const code = await start("mystery", {
541
+ configDir: h.configDir,
542
+ manifestPath: h.manifestPath,
543
+ log: (l) => lines.push(l),
544
+ });
545
+ expect(code).toBe(1);
546
+ const out = lines.join("\n");
547
+ expect(out).toMatch(/services\.json entry has no installDir/);
548
+ expect(out).toMatch(/parachute install <path-to-mystery>/);
549
+ expect(out).not.toMatch(/unknown service/);
550
+ } finally {
551
+ h.cleanup();
552
+ }
553
+ });
554
+
555
+ test("start: name absent from services.json still errors as unknown service", async () => {
556
+ // The genuinely-unknown path: no first-party fallback, no row in
557
+ // services.json. Distinguish from the above (row exists but lacks
558
+ // installDir) so the error message is right-shaped for each.
559
+ const h = makeHarness();
560
+ try {
561
+ seedVault(h.manifestPath);
562
+ const lines: string[] = [];
563
+ const code = await start("ghost", {
564
+ configDir: h.configDir,
565
+ manifestPath: h.manifestPath,
566
+ log: (l) => lines.push(l),
567
+ });
568
+ expect(code).toBe(1);
569
+ expect(lines.join("\n")).toMatch(/unknown service "ghost"/);
570
+ } finally {
571
+ h.cleanup();
572
+ }
573
+ });
574
+
575
+ test("start (no svc) sweeps both first-party and third-party rows", async () => {
576
+ const h = makeHarness();
577
+ try {
578
+ seedVault(h.manifestPath);
579
+ const installDir = join(h.configDir, "_pkg-agent");
580
+ seedThirdParty(h.manifestPath, h.configDir, "agent", {
581
+ installDir,
582
+ startCmd: ["bun", "server.ts"],
583
+ port: 1944,
584
+ });
585
+ const spawner = makeSpawner([4242, 8080]);
586
+ const code = await start(undefined, {
587
+ configDir: h.configDir,
588
+ manifestPath: h.manifestPath,
589
+ spawner,
590
+ log: () => {},
591
+ });
592
+ expect(code).toBe(0);
593
+ expect(spawner.calls).toHaveLength(2);
594
+ const cmds = spawner.calls.map((c) => c.cmd);
595
+ expect(cmds).toContainEqual(["parachute-vault", "serve"]);
596
+ expect(cmds).toContainEqual(["bun", "server.ts"]);
597
+ } finally {
598
+ h.cleanup();
599
+ }
600
+ });
601
+
602
+ test("third-party with malformed module.json fails clearly", async () => {
603
+ const h = makeHarness();
604
+ try {
605
+ const installDir = join(h.configDir, "_pkg-broken");
606
+ mkdirSync(join(installDir, ".parachute"), { recursive: true });
607
+ writeFileSync(join(installDir, ".parachute", "module.json"), "{ not valid json");
608
+ upsertService(
609
+ {
610
+ name: "broken",
611
+ port: 1944,
612
+ paths: ["/broken"],
613
+ health: "/broken/health",
614
+ version: "0.0.1",
615
+ installDir,
616
+ },
617
+ h.manifestPath,
618
+ );
619
+ const lines: string[] = [];
620
+ const code = await start("broken", {
621
+ configDir: h.configDir,
622
+ manifestPath: h.manifestPath,
623
+ log: (l) => lines.push(l),
624
+ });
625
+ expect(code).toBe(1);
626
+ expect(lines.join("\n")).toMatch(/broken: invalid module\.json/);
627
+ } finally {
628
+ h.cleanup();
629
+ }
630
+ });
631
+
632
+ test("third-party with no startCmd in module.json reports lifecycle-unsupported", async () => {
633
+ const h = makeHarness();
634
+ try {
635
+ const installDir = join(h.configDir, "_pkg-noop");
636
+ seedThirdParty(h.manifestPath, h.configDir, "noop", {
637
+ installDir,
638
+ port: 1945,
639
+ });
640
+ const lines: string[] = [];
641
+ const code = await start("noop", {
642
+ configDir: h.configDir,
643
+ manifestPath: h.manifestPath,
644
+ log: (l) => lines.push(l),
645
+ });
646
+ expect(code).toBe(1);
647
+ expect(lines.join("\n")).toMatch(/lifecycle not yet supported/);
648
+ } finally {
649
+ h.cleanup();
650
+ }
651
+ });
431
652
  });
432
653
 
433
654
  describe("parachute stop", () => {
@@ -527,6 +748,45 @@ describe("parachute stop", () => {
527
748
  h.cleanup();
528
749
  }
529
750
  });
751
+
752
+ test("third-party row without installDir: stops via pidfile", async () => {
753
+ // Graceful-degradation path: an installed-but-stale third-party row
754
+ // (no installDir field — pre-installDir-contract self-registration)
755
+ // should still be stoppable. stop only needs the short name to find
756
+ // the pidfile; spec resolution isn't on the critical path for stop.
757
+ const h = makeHarness();
758
+ try {
759
+ upsertService(
760
+ {
761
+ name: "mystery",
762
+ port: 1944,
763
+ paths: ["/mystery"],
764
+ health: "/mystery/health",
765
+ version: "0.0.1",
766
+ },
767
+ h.manifestPath,
768
+ );
769
+ writePid("mystery", 4242, h.configDir);
770
+ const killed: Array<[number, string | number]> = [];
771
+ let aliveCall = 0;
772
+ const code = await stop("mystery", {
773
+ configDir: h.configDir,
774
+ manifestPath: h.manifestPath,
775
+ kill: (pid, sig) => killed.push([pid, sig]),
776
+ alive: () => {
777
+ aliveCall++;
778
+ return aliveCall === 1;
779
+ },
780
+ sleep: async () => {},
781
+ log: () => {},
782
+ });
783
+ expect(code).toBe(0);
784
+ expect(killed).toEqual([[4242, "SIGTERM"]]);
785
+ expect(readPid("mystery", h.configDir)).toBeUndefined();
786
+ } finally {
787
+ h.cleanup();
788
+ }
789
+ });
530
790
  });
531
791
 
532
792
  describe("parachute restart", () => {
@@ -605,4 +865,355 @@ describe("parachute logs", () => {
605
865
  h.cleanup();
606
866
  }
607
867
  });
868
+
869
+ test("third-party module name with installDir is recognised", async () => {
870
+ const h = makeHarness();
871
+ try {
872
+ const installDir = join(h.configDir, "_pkg-agent");
873
+ seedThirdParty(h.manifestPath, h.configDir, "agent", {
874
+ installDir,
875
+ startCmd: ["bun", "server.ts"],
876
+ });
877
+ const p = ensureLogPath("agent", h.configDir);
878
+ writeFileSync(p, "agent line 1\nagent line 2\n");
879
+ const lines: string[] = [];
880
+ const code = await logs("agent", {
881
+ configDir: h.configDir,
882
+ manifestPath: h.manifestPath,
883
+ log: (l) => lines.push(l),
884
+ });
885
+ expect(code).toBe(0);
886
+ expect(lines).toEqual(["agent line 1", "agent line 2"]);
887
+ } finally {
888
+ h.cleanup();
889
+ }
890
+ });
891
+
892
+ test("third-party row without installDir: tails by short name", async () => {
893
+ // Graceful-degradation path: log file is keyed by short name, written by
894
+ // start. installDir is irrelevant for tailing — the entry just needs to
895
+ // exist in services.json.
896
+ const h = makeHarness();
897
+ try {
898
+ upsertService(
899
+ {
900
+ name: "mystery",
901
+ port: 1944,
902
+ paths: ["/mystery"],
903
+ health: "/mystery/health",
904
+ version: "0.0.1",
905
+ },
906
+ h.manifestPath,
907
+ );
908
+ const p = ensureLogPath("mystery", h.configDir);
909
+ writeFileSync(p, "mystery line 1\nmystery line 2\n");
910
+ const lines: string[] = [];
911
+ const code = await logs("mystery", {
912
+ configDir: h.configDir,
913
+ manifestPath: h.manifestPath,
914
+ log: (l) => lines.push(l),
915
+ });
916
+ expect(code).toBe(0);
917
+ expect(lines).toEqual(["mystery line 1", "mystery line 2"]);
918
+ } finally {
919
+ h.cleanup();
920
+ }
921
+ });
922
+ });
923
+
924
+ describe("process-group lifecycle (hub#88)", () => {
925
+ // Spawn a wrapper that forks a long-running grandchild (sleep), wait for
926
+ // both to come up, then check that the wrapper PID equals its PGID — the
927
+ // post-fix invariant that makes group-kill safe. Without `detached: true`
928
+ // the child inherits the test runner's PGID and group-kill would target
929
+ // the wrong tree.
930
+ test("defaultSpawner puts child in its own process group", async () => {
931
+ const h = makeHarness();
932
+ try {
933
+ const logFile = ensureLogPath("test", h.configDir);
934
+ const pid = defaultSpawner.spawn(["sh", "-c", "sleep 2 & wait"], logFile);
935
+ try {
936
+ // Resolve the child's PGID via ps; the kernel reports it as a
937
+ // numeric column. PGID == PID means our setsid-equivalent worked.
938
+ const ps = Bun.spawnSync(["ps", "-o", "pgid=", "-p", String(pid)]);
939
+ const pgid = Number.parseInt(ps.stdout.toString().trim(), 10);
940
+ expect(pgid).toBe(pid);
941
+ } finally {
942
+ try {
943
+ process.kill(-pid, "SIGKILL");
944
+ } catch {}
945
+ }
946
+ } finally {
947
+ h.cleanup();
948
+ }
949
+ });
950
+
951
+ // The smoking-gun scenario from #88: a wrapper (sh) forks a grandchild
952
+ // (sleep) that keeps a resource — here, just stays alive. SIGKILL on the
953
+ // wrapper PID alone leaves the grandchild running. With detached spawn +
954
+ // group-kill, both go down. We assert by checking the grandchild's PID
955
+ // is no longer kill-able after `defaultKill`.
956
+ test("defaultKill takes down the wrapper and its grandchildren together", async () => {
957
+ const h = makeHarness();
958
+ try {
959
+ const logFile = ensureLogPath("test", h.configDir);
960
+ // Wrapper sh forks `sleep 30 & echo $!` so we capture the grandchild
961
+ // PID via the log file, then `wait` so the wrapper sticks around as
962
+ // a parent (mirrors `pnpm exec tsx`'s shape).
963
+ const wrapperPid = defaultSpawner.spawn(
964
+ ["sh", "-c", "sleep 30 & echo $! >&2; wait"],
965
+ logFile,
966
+ );
967
+ // Give the grandchild time to start and the log line to flush.
968
+ await new Promise((r) => setTimeout(r, 200));
969
+ const log = await Bun.file(logFile).text();
970
+ const grandchildPid = Number.parseInt(log.trim().split("\n").pop() ?? "", 10);
971
+ expect(grandchildPid).toBeGreaterThan(0);
972
+ expect(grandchildPid).not.toBe(wrapperPid);
973
+ // Both should be alive before kill.
974
+ expect(() => process.kill(grandchildPid, 0)).not.toThrow();
975
+
976
+ defaultKill(wrapperPid, "SIGKILL");
977
+
978
+ // Reap + wait for the grandchild to exit; on macOS the kernel may
979
+ // take a tick to deliver the signal.
980
+ await new Promise((r) => setTimeout(r, 200));
981
+ let grandchildStillAlive = true;
982
+ try {
983
+ process.kill(grandchildPid, 0);
984
+ } catch {
985
+ grandchildStillAlive = false;
986
+ }
987
+ expect(grandchildStillAlive).toBe(false);
988
+ } finally {
989
+ h.cleanup();
990
+ }
991
+ });
992
+
993
+ // defaultAlive's post-fix semantics: returns true while any group member
994
+ // is alive (the wrapper stays in the group as long as it's running),
995
+ // false after the group drains.
996
+ test("defaultAlive reports group liveness for detached children", async () => {
997
+ const h = makeHarness();
998
+ try {
999
+ const logFile = ensureLogPath("test", h.configDir);
1000
+ const pid = defaultSpawner.spawn(["sh", "-c", "sleep 2"], logFile);
1001
+ try {
1002
+ expect(defaultAlive(pid)).toBe(true);
1003
+ } finally {
1004
+ try {
1005
+ process.kill(-pid, "SIGKILL");
1006
+ } catch {}
1007
+ }
1008
+ // Wait for the kill to drain the group, then re-check.
1009
+ await new Promise((r) => setTimeout(r, 100));
1010
+ expect(defaultAlive(pid)).toBe(false);
1011
+ } finally {
1012
+ h.cleanup();
1013
+ }
1014
+ });
1015
+
1016
+ // Legacy pidfile compatibility: a pre-detached pidfile holds a positive
1017
+ // PID whose pgid is the parent shell, not the pid itself. defaultAlive
1018
+ // must fall back to a bare-pid check so the next `stop` actually runs;
1019
+ // defaultKill must fall back to a bare-pid signal so it can be reaped.
1020
+ test("defaultAlive + defaultKill fall back to bare-pid for legacy (non-detached) processes", async () => {
1021
+ // Spawn a non-detached child to simulate a legacy pidfile (pre-fix
1022
+ // start). It shares the test runner's pgid, so kill(-pid, 0) will
1023
+ // ESRCH and we should fall back.
1024
+ const proc = Bun.spawn(["sh", "-c", "sleep 5"], { stdio: ["ignore", "ignore", "ignore"] });
1025
+ const pid = proc.pid;
1026
+ try {
1027
+ expect(defaultAlive(pid)).toBe(true);
1028
+ defaultKill(pid, "SIGKILL");
1029
+ await new Promise((r) => setTimeout(r, 100));
1030
+ expect(defaultAlive(pid)).toBe(false);
1031
+ } finally {
1032
+ try {
1033
+ process.kill(pid, "SIGKILL");
1034
+ } catch {}
1035
+ }
1036
+ });
1037
+ });
1038
+
1039
+ /**
1040
+ * `parachute start|stop|restart hub` — the bug Aaron filed as hub#166. Hub
1041
+ * isn't a row in services.json, so the generic services-manifest path
1042
+ * surfaced "unknown service: hub". The fix dispatches `svc === "hub"`
1043
+ * straight to hub-control.ts. These tests inject `ensureRunning`/`stop`
1044
+ * stubs so we don't actually fork bun.
1045
+ */
1046
+ describe("parachute start|stop|restart hub", () => {
1047
+ test("start hub: dispatches to ensureHubRunning, propagates configDir + issuer", async () => {
1048
+ const h = makeHarness();
1049
+ try {
1050
+ const log: string[] = [];
1051
+ const ensureCalls: Array<{ configDir?: string; issuer?: string }> = [];
1052
+ const code = await start("hub", {
1053
+ configDir: h.configDir,
1054
+ manifestPath: h.manifestPath,
1055
+ hubOrigin: "https://hub.example.com",
1056
+ hub: {
1057
+ ensureRunning: async (opts) => {
1058
+ ensureCalls.push({ configDir: opts.configDir, issuer: opts.issuer });
1059
+ return { pid: 4711, port: 1939, started: true };
1060
+ },
1061
+ },
1062
+ log: (l) => log.push(l),
1063
+ });
1064
+ expect(code).toBe(0);
1065
+ expect(ensureCalls).toHaveLength(1);
1066
+ expect(ensureCalls[0]).toEqual({
1067
+ configDir: h.configDir,
1068
+ issuer: "https://hub.example.com",
1069
+ });
1070
+ } finally {
1071
+ h.cleanup();
1072
+ }
1073
+ });
1074
+
1075
+ test("start hub: reports already-running cleanly when ensureHubRunning returns started=false", async () => {
1076
+ const h = makeHarness();
1077
+ try {
1078
+ const log: string[] = [];
1079
+ const code = await start("hub", {
1080
+ configDir: h.configDir,
1081
+ manifestPath: h.manifestPath,
1082
+ hub: {
1083
+ ensureRunning: async () => ({ pid: 8888, port: 1939, started: false }),
1084
+ },
1085
+ log: (l) => log.push(l),
1086
+ });
1087
+ expect(code).toBe(0);
1088
+ expect(log.join("\n")).toMatch(/hub already running \(pid 8888\) on port 1939/);
1089
+ } finally {
1090
+ h.cleanup();
1091
+ }
1092
+ });
1093
+
1094
+ test("start hub: surfaces ensureHubRunning errors as exit 1", async () => {
1095
+ const h = makeHarness();
1096
+ try {
1097
+ const log: string[] = [];
1098
+ const code = await start("hub", {
1099
+ configDir: h.configDir,
1100
+ manifestPath: h.manifestPath,
1101
+ hub: {
1102
+ ensureRunning: async () => {
1103
+ throw new Error("hub: port 1939 unavailable");
1104
+ },
1105
+ },
1106
+ log: (l) => log.push(l),
1107
+ });
1108
+ expect(code).toBe(1);
1109
+ expect(log.join("\n")).toMatch(/hub failed to start.*port 1939 unavailable/);
1110
+ } finally {
1111
+ h.cleanup();
1112
+ }
1113
+ });
1114
+
1115
+ test("stop hub: dispatches to stopHub, true → '✓ hub stopped'", async () => {
1116
+ const h = makeHarness();
1117
+ try {
1118
+ const log: string[] = [];
1119
+ const stopCalls: Array<{ configDir?: string }> = [];
1120
+ const code = await stop("hub", {
1121
+ configDir: h.configDir,
1122
+ manifestPath: h.manifestPath,
1123
+ hub: {
1124
+ stop: async (opts) => {
1125
+ stopCalls.push({ configDir: opts.configDir });
1126
+ return true;
1127
+ },
1128
+ },
1129
+ log: (l) => log.push(l),
1130
+ });
1131
+ expect(code).toBe(0);
1132
+ expect(stopCalls).toHaveLength(1);
1133
+ expect(stopCalls[0]?.configDir).toBe(h.configDir);
1134
+ expect(log.join("\n")).toMatch(/✓ hub stopped/);
1135
+ } finally {
1136
+ h.cleanup();
1137
+ }
1138
+ });
1139
+
1140
+ test("stop hub: false → 'wasn't running' (still exit 0)", async () => {
1141
+ const h = makeHarness();
1142
+ try {
1143
+ const log: string[] = [];
1144
+ const code = await stop("hub", {
1145
+ configDir: h.configDir,
1146
+ manifestPath: h.manifestPath,
1147
+ hub: { stop: async () => false },
1148
+ log: (l) => log.push(l),
1149
+ });
1150
+ expect(code).toBe(0);
1151
+ expect(log.join("\n")).toMatch(/hub wasn't running/);
1152
+ } finally {
1153
+ h.cleanup();
1154
+ }
1155
+ });
1156
+
1157
+ test("restart hub: chains stop then start through the same hub seam", async () => {
1158
+ const h = makeHarness();
1159
+ try {
1160
+ const log: string[] = [];
1161
+ const order: string[] = [];
1162
+ const code = await restart("hub", {
1163
+ configDir: h.configDir,
1164
+ manifestPath: h.manifestPath,
1165
+ hub: {
1166
+ stop: async () => {
1167
+ order.push("stop");
1168
+ return true;
1169
+ },
1170
+ ensureRunning: async () => {
1171
+ order.push("start");
1172
+ return { pid: 5151, port: 1939, started: true };
1173
+ },
1174
+ },
1175
+ log: (l) => log.push(l),
1176
+ });
1177
+ expect(code).toBe(0);
1178
+ expect(order).toEqual(["stop", "start"]);
1179
+ } finally {
1180
+ h.cleanup();
1181
+ }
1182
+ });
1183
+
1184
+ test("logs hub: doesn't reject 'hub' as an unknown service", async () => {
1185
+ const h = makeHarness();
1186
+ try {
1187
+ // No log file yet — exercise the "no logs yet" branch, which still
1188
+ // returns 0. Goal of this test is just the unknown-service guard.
1189
+ const log: string[] = [];
1190
+ const code = await logs("hub", {
1191
+ configDir: h.configDir,
1192
+ manifestPath: h.manifestPath,
1193
+ log: (l) => log.push(l),
1194
+ });
1195
+ expect(code).toBe(0);
1196
+ expect(log.join("\n")).toMatch(/no logs yet for hub/);
1197
+ } finally {
1198
+ h.cleanup();
1199
+ }
1200
+ });
1201
+
1202
+ test("logs hub: prints the tail when a log file exists", async () => {
1203
+ const h = makeHarness();
1204
+ try {
1205
+ const path = ensureLogPath("hub", h.configDir);
1206
+ writeFileSync(path, "hub line one\nhub line two\n");
1207
+ const log: string[] = [];
1208
+ const code = await logs("hub", {
1209
+ configDir: h.configDir,
1210
+ manifestPath: h.manifestPath,
1211
+ log: (l) => log.push(l),
1212
+ });
1213
+ expect(code).toBe(0);
1214
+ expect(log).toEqual(["hub line one", "hub line two"]);
1215
+ } finally {
1216
+ h.cleanup();
1217
+ }
1218
+ });
608
1219
  });