@openparachute/hub 0.3.0-rc.1 → 0.5.0

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 (90) 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 +712 -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 +519 -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 +652 -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 +242 -37
  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-sign.ts +275 -0
  75. package/src/module-manifest.ts +435 -0
  76. package/src/oauth-handlers.ts +1206 -0
  77. package/src/oauth-ui.ts +582 -0
  78. package/src/operator-token.ts +129 -0
  79. package/src/providers/detect.ts +97 -0
  80. package/src/scope-explanations.ts +137 -0
  81. package/src/scope-registry.ts +158 -0
  82. package/src/service-spec.ts +270 -97
  83. package/src/services-manifest.ts +57 -1
  84. package/src/sessions.ts +115 -0
  85. package/src/signing-keys.ts +120 -0
  86. package/src/users.ts +144 -0
  87. package/src/well-known.ts +62 -26
  88. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  89. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  90. 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,141 @@ 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("third-party with no installDir errors as unknown service", async () => {
521
+ // A row whose name isn't a known short name AND has no installDir is
522
+ // unmanageable — we have no way to find a spec for it.
523
+ const h = makeHarness();
524
+ try {
525
+ upsertService(
526
+ {
527
+ name: "mystery",
528
+ port: 1944,
529
+ paths: ["/mystery"],
530
+ health: "/mystery/health",
531
+ version: "0.0.1",
532
+ },
533
+ h.manifestPath,
534
+ );
535
+ const lines: string[] = [];
536
+ const code = await start("mystery", {
537
+ configDir: h.configDir,
538
+ manifestPath: h.manifestPath,
539
+ log: (l) => lines.push(l),
540
+ });
541
+ expect(code).toBe(1);
542
+ expect(lines.join("\n")).toMatch(/unknown service "mystery"/);
543
+ } finally {
544
+ h.cleanup();
545
+ }
546
+ });
547
+
548
+ test("start (no svc) sweeps both first-party and third-party rows", async () => {
549
+ const h = makeHarness();
550
+ try {
551
+ seedVault(h.manifestPath);
552
+ const installDir = join(h.configDir, "_pkg-agent");
553
+ seedThirdParty(h.manifestPath, h.configDir, "agent", {
554
+ installDir,
555
+ startCmd: ["bun", "server.ts"],
556
+ port: 1944,
557
+ });
558
+ const spawner = makeSpawner([4242, 8080]);
559
+ const code = await start(undefined, {
560
+ configDir: h.configDir,
561
+ manifestPath: h.manifestPath,
562
+ spawner,
563
+ log: () => {},
564
+ });
565
+ expect(code).toBe(0);
566
+ expect(spawner.calls).toHaveLength(2);
567
+ const cmds = spawner.calls.map((c) => c.cmd);
568
+ expect(cmds).toContainEqual(["parachute-vault", "serve"]);
569
+ expect(cmds).toContainEqual(["bun", "server.ts"]);
570
+ } finally {
571
+ h.cleanup();
572
+ }
573
+ });
574
+
575
+ test("third-party with malformed module.json fails clearly", async () => {
576
+ const h = makeHarness();
577
+ try {
578
+ const installDir = join(h.configDir, "_pkg-broken");
579
+ mkdirSync(join(installDir, ".parachute"), { recursive: true });
580
+ writeFileSync(join(installDir, ".parachute", "module.json"), "{ not valid json");
581
+ upsertService(
582
+ {
583
+ name: "broken",
584
+ port: 1944,
585
+ paths: ["/broken"],
586
+ health: "/broken/health",
587
+ version: "0.0.1",
588
+ installDir,
589
+ },
590
+ h.manifestPath,
591
+ );
592
+ const lines: string[] = [];
593
+ const code = await start("broken", {
594
+ configDir: h.configDir,
595
+ manifestPath: h.manifestPath,
596
+ log: (l) => lines.push(l),
597
+ });
598
+ expect(code).toBe(1);
599
+ expect(lines.join("\n")).toMatch(/broken: invalid module\.json/);
600
+ } finally {
601
+ h.cleanup();
602
+ }
603
+ });
604
+
605
+ test("third-party with no startCmd in module.json reports lifecycle-unsupported", async () => {
606
+ const h = makeHarness();
607
+ try {
608
+ const installDir = join(h.configDir, "_pkg-noop");
609
+ seedThirdParty(h.manifestPath, h.configDir, "noop", {
610
+ installDir,
611
+ port: 1945,
612
+ });
613
+ const lines: string[] = [];
614
+ const code = await start("noop", {
615
+ configDir: h.configDir,
616
+ manifestPath: h.manifestPath,
617
+ log: (l) => lines.push(l),
618
+ });
619
+ expect(code).toBe(1);
620
+ expect(lines.join("\n")).toMatch(/lifecycle not yet supported/);
621
+ } finally {
622
+ h.cleanup();
623
+ }
624
+ });
431
625
  });
432
626
 
433
627
  describe("parachute stop", () => {
@@ -605,4 +799,324 @@ describe("parachute logs", () => {
605
799
  h.cleanup();
606
800
  }
607
801
  });
802
+
803
+ test("third-party module name with installDir is recognised", async () => {
804
+ const h = makeHarness();
805
+ try {
806
+ const installDir = join(h.configDir, "_pkg-agent");
807
+ seedThirdParty(h.manifestPath, h.configDir, "agent", {
808
+ installDir,
809
+ startCmd: ["bun", "server.ts"],
810
+ });
811
+ const p = ensureLogPath("agent", h.configDir);
812
+ writeFileSync(p, "agent line 1\nagent line 2\n");
813
+ const lines: string[] = [];
814
+ const code = await logs("agent", {
815
+ configDir: h.configDir,
816
+ manifestPath: h.manifestPath,
817
+ log: (l) => lines.push(l),
818
+ });
819
+ expect(code).toBe(0);
820
+ expect(lines).toEqual(["agent line 1", "agent line 2"]);
821
+ } finally {
822
+ h.cleanup();
823
+ }
824
+ });
825
+ });
826
+
827
+ describe("process-group lifecycle (hub#88)", () => {
828
+ // Spawn a wrapper that forks a long-running grandchild (sleep), wait for
829
+ // both to come up, then check that the wrapper PID equals its PGID — the
830
+ // post-fix invariant that makes group-kill safe. Without `detached: true`
831
+ // the child inherits the test runner's PGID and group-kill would target
832
+ // the wrong tree.
833
+ test("defaultSpawner puts child in its own process group", async () => {
834
+ const h = makeHarness();
835
+ try {
836
+ const logFile = ensureLogPath("test", h.configDir);
837
+ const pid = defaultSpawner.spawn(["sh", "-c", "sleep 2 & wait"], logFile);
838
+ try {
839
+ // Resolve the child's PGID via ps; the kernel reports it as a
840
+ // numeric column. PGID == PID means our setsid-equivalent worked.
841
+ const ps = Bun.spawnSync(["ps", "-o", "pgid=", "-p", String(pid)]);
842
+ const pgid = Number.parseInt(ps.stdout.toString().trim(), 10);
843
+ expect(pgid).toBe(pid);
844
+ } finally {
845
+ try {
846
+ process.kill(-pid, "SIGKILL");
847
+ } catch {}
848
+ }
849
+ } finally {
850
+ h.cleanup();
851
+ }
852
+ });
853
+
854
+ // The smoking-gun scenario from #88: a wrapper (sh) forks a grandchild
855
+ // (sleep) that keeps a resource — here, just stays alive. SIGKILL on the
856
+ // wrapper PID alone leaves the grandchild running. With detached spawn +
857
+ // group-kill, both go down. We assert by checking the grandchild's PID
858
+ // is no longer kill-able after `defaultKill`.
859
+ test("defaultKill takes down the wrapper and its grandchildren together", async () => {
860
+ const h = makeHarness();
861
+ try {
862
+ const logFile = ensureLogPath("test", h.configDir);
863
+ // Wrapper sh forks `sleep 30 & echo $!` so we capture the grandchild
864
+ // PID via the log file, then `wait` so the wrapper sticks around as
865
+ // a parent (mirrors `pnpm exec tsx`'s shape).
866
+ const wrapperPid = defaultSpawner.spawn(
867
+ ["sh", "-c", "sleep 30 & echo $! >&2; wait"],
868
+ logFile,
869
+ );
870
+ // Give the grandchild time to start and the log line to flush.
871
+ await new Promise((r) => setTimeout(r, 200));
872
+ const log = await Bun.file(logFile).text();
873
+ const grandchildPid = Number.parseInt(log.trim().split("\n").pop() ?? "", 10);
874
+ expect(grandchildPid).toBeGreaterThan(0);
875
+ expect(grandchildPid).not.toBe(wrapperPid);
876
+ // Both should be alive before kill.
877
+ expect(() => process.kill(grandchildPid, 0)).not.toThrow();
878
+
879
+ defaultKill(wrapperPid, "SIGKILL");
880
+
881
+ // Reap + wait for the grandchild to exit; on macOS the kernel may
882
+ // take a tick to deliver the signal.
883
+ await new Promise((r) => setTimeout(r, 200));
884
+ let grandchildStillAlive = true;
885
+ try {
886
+ process.kill(grandchildPid, 0);
887
+ } catch {
888
+ grandchildStillAlive = false;
889
+ }
890
+ expect(grandchildStillAlive).toBe(false);
891
+ } finally {
892
+ h.cleanup();
893
+ }
894
+ });
895
+
896
+ // defaultAlive's post-fix semantics: returns true while any group member
897
+ // is alive (the wrapper stays in the group as long as it's running),
898
+ // false after the group drains.
899
+ test("defaultAlive reports group liveness for detached children", async () => {
900
+ const h = makeHarness();
901
+ try {
902
+ const logFile = ensureLogPath("test", h.configDir);
903
+ const pid = defaultSpawner.spawn(["sh", "-c", "sleep 2"], logFile);
904
+ try {
905
+ expect(defaultAlive(pid)).toBe(true);
906
+ } finally {
907
+ try {
908
+ process.kill(-pid, "SIGKILL");
909
+ } catch {}
910
+ }
911
+ // Wait for the kill to drain the group, then re-check.
912
+ await new Promise((r) => setTimeout(r, 100));
913
+ expect(defaultAlive(pid)).toBe(false);
914
+ } finally {
915
+ h.cleanup();
916
+ }
917
+ });
918
+
919
+ // Legacy pidfile compatibility: a pre-detached pidfile holds a positive
920
+ // PID whose pgid is the parent shell, not the pid itself. defaultAlive
921
+ // must fall back to a bare-pid check so the next `stop` actually runs;
922
+ // defaultKill must fall back to a bare-pid signal so it can be reaped.
923
+ test("defaultAlive + defaultKill fall back to bare-pid for legacy (non-detached) processes", async () => {
924
+ // Spawn a non-detached child to simulate a legacy pidfile (pre-fix
925
+ // start). It shares the test runner's pgid, so kill(-pid, 0) will
926
+ // ESRCH and we should fall back.
927
+ const proc = Bun.spawn(["sh", "-c", "sleep 5"], { stdio: ["ignore", "ignore", "ignore"] });
928
+ const pid = proc.pid;
929
+ try {
930
+ expect(defaultAlive(pid)).toBe(true);
931
+ defaultKill(pid, "SIGKILL");
932
+ await new Promise((r) => setTimeout(r, 100));
933
+ expect(defaultAlive(pid)).toBe(false);
934
+ } finally {
935
+ try {
936
+ process.kill(pid, "SIGKILL");
937
+ } catch {}
938
+ }
939
+ });
940
+ });
941
+
942
+ /**
943
+ * `parachute start|stop|restart hub` — the bug Aaron filed as hub#166. Hub
944
+ * isn't a row in services.json, so the generic services-manifest path
945
+ * surfaced "unknown service: hub". The fix dispatches `svc === "hub"`
946
+ * straight to hub-control.ts. These tests inject `ensureRunning`/`stop`
947
+ * stubs so we don't actually fork bun.
948
+ */
949
+ describe("parachute start|stop|restart hub", () => {
950
+ test("start hub: dispatches to ensureHubRunning, propagates configDir + issuer", async () => {
951
+ const h = makeHarness();
952
+ try {
953
+ const log: string[] = [];
954
+ const ensureCalls: Array<{ configDir?: string; issuer?: string }> = [];
955
+ const code = await start("hub", {
956
+ configDir: h.configDir,
957
+ manifestPath: h.manifestPath,
958
+ hubOrigin: "https://hub.example.com",
959
+ hub: {
960
+ ensureRunning: async (opts) => {
961
+ ensureCalls.push({ configDir: opts.configDir, issuer: opts.issuer });
962
+ return { pid: 4711, port: 1939, started: true };
963
+ },
964
+ },
965
+ log: (l) => log.push(l),
966
+ });
967
+ expect(code).toBe(0);
968
+ expect(ensureCalls).toHaveLength(1);
969
+ expect(ensureCalls[0]).toEqual({
970
+ configDir: h.configDir,
971
+ issuer: "https://hub.example.com",
972
+ });
973
+ } finally {
974
+ h.cleanup();
975
+ }
976
+ });
977
+
978
+ test("start hub: reports already-running cleanly when ensureHubRunning returns started=false", async () => {
979
+ const h = makeHarness();
980
+ try {
981
+ const log: string[] = [];
982
+ const code = await start("hub", {
983
+ configDir: h.configDir,
984
+ manifestPath: h.manifestPath,
985
+ hub: {
986
+ ensureRunning: async () => ({ pid: 8888, port: 1939, started: false }),
987
+ },
988
+ log: (l) => log.push(l),
989
+ });
990
+ expect(code).toBe(0);
991
+ expect(log.join("\n")).toMatch(/hub already running \(pid 8888\) on port 1939/);
992
+ } finally {
993
+ h.cleanup();
994
+ }
995
+ });
996
+
997
+ test("start hub: surfaces ensureHubRunning errors as exit 1", async () => {
998
+ const h = makeHarness();
999
+ try {
1000
+ const log: string[] = [];
1001
+ const code = await start("hub", {
1002
+ configDir: h.configDir,
1003
+ manifestPath: h.manifestPath,
1004
+ hub: {
1005
+ ensureRunning: async () => {
1006
+ throw new Error("hub: port 1939 unavailable");
1007
+ },
1008
+ },
1009
+ log: (l) => log.push(l),
1010
+ });
1011
+ expect(code).toBe(1);
1012
+ expect(log.join("\n")).toMatch(/hub failed to start.*port 1939 unavailable/);
1013
+ } finally {
1014
+ h.cleanup();
1015
+ }
1016
+ });
1017
+
1018
+ test("stop hub: dispatches to stopHub, true → '✓ hub stopped'", async () => {
1019
+ const h = makeHarness();
1020
+ try {
1021
+ const log: string[] = [];
1022
+ const stopCalls: Array<{ configDir?: string }> = [];
1023
+ const code = await stop("hub", {
1024
+ configDir: h.configDir,
1025
+ manifestPath: h.manifestPath,
1026
+ hub: {
1027
+ stop: async (opts) => {
1028
+ stopCalls.push({ configDir: opts.configDir });
1029
+ return true;
1030
+ },
1031
+ },
1032
+ log: (l) => log.push(l),
1033
+ });
1034
+ expect(code).toBe(0);
1035
+ expect(stopCalls).toHaveLength(1);
1036
+ expect(stopCalls[0]?.configDir).toBe(h.configDir);
1037
+ expect(log.join("\n")).toMatch(/✓ hub stopped/);
1038
+ } finally {
1039
+ h.cleanup();
1040
+ }
1041
+ });
1042
+
1043
+ test("stop hub: false → 'wasn't running' (still exit 0)", async () => {
1044
+ const h = makeHarness();
1045
+ try {
1046
+ const log: string[] = [];
1047
+ const code = await stop("hub", {
1048
+ configDir: h.configDir,
1049
+ manifestPath: h.manifestPath,
1050
+ hub: { stop: async () => false },
1051
+ log: (l) => log.push(l),
1052
+ });
1053
+ expect(code).toBe(0);
1054
+ expect(log.join("\n")).toMatch(/hub wasn't running/);
1055
+ } finally {
1056
+ h.cleanup();
1057
+ }
1058
+ });
1059
+
1060
+ test("restart hub: chains stop then start through the same hub seam", async () => {
1061
+ const h = makeHarness();
1062
+ try {
1063
+ const log: string[] = [];
1064
+ const order: string[] = [];
1065
+ const code = await restart("hub", {
1066
+ configDir: h.configDir,
1067
+ manifestPath: h.manifestPath,
1068
+ hub: {
1069
+ stop: async () => {
1070
+ order.push("stop");
1071
+ return true;
1072
+ },
1073
+ ensureRunning: async () => {
1074
+ order.push("start");
1075
+ return { pid: 5151, port: 1939, started: true };
1076
+ },
1077
+ },
1078
+ log: (l) => log.push(l),
1079
+ });
1080
+ expect(code).toBe(0);
1081
+ expect(order).toEqual(["stop", "start"]);
1082
+ } finally {
1083
+ h.cleanup();
1084
+ }
1085
+ });
1086
+
1087
+ test("logs hub: doesn't reject 'hub' as an unknown service", async () => {
1088
+ const h = makeHarness();
1089
+ try {
1090
+ // No log file yet — exercise the "no logs yet" branch, which still
1091
+ // returns 0. Goal of this test is just the unknown-service guard.
1092
+ const log: string[] = [];
1093
+ const code = await logs("hub", {
1094
+ configDir: h.configDir,
1095
+ manifestPath: h.manifestPath,
1096
+ log: (l) => log.push(l),
1097
+ });
1098
+ expect(code).toBe(0);
1099
+ expect(log.join("\n")).toMatch(/no logs yet for hub/);
1100
+ } finally {
1101
+ h.cleanup();
1102
+ }
1103
+ });
1104
+
1105
+ test("logs hub: prints the tail when a log file exists", async () => {
1106
+ const h = makeHarness();
1107
+ try {
1108
+ const path = ensureLogPath("hub", h.configDir);
1109
+ writeFileSync(path, "hub line one\nhub line two\n");
1110
+ const log: string[] = [];
1111
+ const code = await logs("hub", {
1112
+ configDir: h.configDir,
1113
+ manifestPath: h.manifestPath,
1114
+ log: (l) => log.push(l),
1115
+ });
1116
+ expect(code).toBe(0);
1117
+ expect(log).toEqual(["hub line one", "hub line two"]);
1118
+ } finally {
1119
+ h.cleanup();
1120
+ }
1121
+ });
608
1122
  });