@openparachute/hub 0.5.14-rc.13 → 0.5.14-rc.15

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.14-rc.13",
3
+ "version": "0.5.14-rc.15",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -57,6 +57,23 @@ describe("renderAccountHome", () => {
57
57
  expect(html).not.toContain("Authorization: Bearer");
58
58
  // Copy-button progressive-enhancement script is present.
59
59
  expect(html).toContain("navigator.clipboard");
60
+ // Friendlier framing: the block leads with "connect your AI assistant"
61
+ // rather than MCP jargon up top.
62
+ expect(html).toContain('data-testid="connect-ai-heading"');
63
+ expect(html).toContain("Connect your AI");
64
+ // BOTH connect methods render as distinct, labelled blocks.
65
+ expect(html).toContain('data-testid="connect-method-claude-code"');
66
+ expect(html).toContain("Claude Code");
67
+ expect(html).toContain('data-testid="connect-method-claude-ai"');
68
+ expect(html).toContain("Claude.ai");
69
+ // The Claude.ai path mirrors the install.njk canonical phrasing
70
+ // (Settings → Connectors → Add custom connector, paste the endpoint).
71
+ expect(html).toContain("Connectors");
72
+ expect(html).toContain("Add custom connector");
73
+ // A brief "any other MCP client" line is present (no bloat — just one).
74
+ expect(html).toContain('data-testid="connect-any-client-hint"');
75
+ // Notes CTA still present, now framed as the browser-UI option.
76
+ expect(html).toContain('data-testid="open-notes-cta"');
60
77
  });
61
78
 
62
79
  test("assigned-vault branch — trailing slash on hubOrigin is normalized", () => {
@@ -112,11 +129,16 @@ describe("renderAccountHome", () => {
112
129
  twoFactorEnabled: false,
113
130
  });
114
131
  expect(html).toContain("Welcome, ghost");
115
- expect(html).toContain("Ask the hub operator");
132
+ // The message explains WHY there's nothing to connect (no vault yet) and
133
+ // gives a clear next step — not just a bare "ask your admin".
134
+ expect(html).toContain("Ask the hub operator to assign you a vault");
135
+ expect(html).toContain("don't have a vault yet");
116
136
  // No /admin/ link in this branch — they have no admin role.
117
137
  expect(html).not.toContain('href="/admin/"');
118
138
  // No Notes CTA.
119
139
  expect(html).not.toContain("notes.parachute.computer/add");
140
+ // No connect block — you can't connect a vault you don't have.
141
+ expect(html).not.toContain('data-testid="mcp-connect"');
120
142
  });
121
143
 
122
144
  test("account card — change-password link and sign-out form are present", () => {
@@ -551,6 +551,191 @@ describe("exposeCloudflareUp", () => {
551
551
  }
552
552
  });
553
553
 
554
+ test("hub#487: kills orphan connectors found by pgrep before spawning, not just the state pid", async () => {
555
+ // The orphan-accumulation bug: each re-expose spawned a fresh connector
556
+ // without killing prior ones, and state only tracked the most-recent pid.
557
+ // Orphans the state file lost track of (crashed mid-rewrite, started by
558
+ // hand) must still be swept — `connectorPids` finds them by UUID/config
559
+ // path. Here state knows pid 99999, but pgrep also surfaces 88888 + 77777
560
+ // serving the same tunnel; all three get SIGTERM before the new spawn.
561
+ const env = makeEnv();
562
+ try {
563
+ const uuid = "cccccccc-0000-0000-0000-000000000003";
564
+ const priorRecord: CloudflaredTunnelRecord = {
565
+ pid: 99999,
566
+ tunnelUuid: uuid,
567
+ tunnelName: "parachute",
568
+ hostname: "vault.example.com",
569
+ startedAt: "2026-04-21T00:00:00.000Z",
570
+ configPath: env.configPath,
571
+ };
572
+ writeCloudflaredState({ version: 2, tunnels: { parachute: priorRecord } }, env.statePath);
573
+
574
+ const { runner } = queueRunner([
575
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
576
+ { code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
577
+ { code: 0, stdout: "", stderr: "" }, // route dns
578
+ ]);
579
+ const { spawner, seen } = fakeSpawner(42010);
580
+ const killed: number[] = [];
581
+
582
+ const code = await exposeCloudflareUp("vault.example.com", {
583
+ runner,
584
+ spawner,
585
+ alive: () => true, // all candidate pids report alive
586
+ kill: (pid) => killed.push(pid),
587
+ // pgrep surfaces two orphans the state record didn't track.
588
+ connectorPids: () => [88888, 77777],
589
+ resolveHost: async () => ["104.16.0.1"], // Cloudflare — no DNS warning
590
+ log: () => {},
591
+ manifestPath: env.manifestPath,
592
+ statePath: env.statePath,
593
+ exposeStatePath: env.exposeStatePath,
594
+ configPath: env.configPath,
595
+ logPath: env.logPath,
596
+ cloudflaredHome: env.cloudflaredHome,
597
+ configDir: env.configDir,
598
+ skipHub: true,
599
+ });
600
+
601
+ expect(code).toBe(0);
602
+ // Every prior connector (state pid + both pgrep orphans) is stopped
603
+ // before the new one spawns.
604
+ expect(killed.sort()).toEqual([77777, 88888, 99999]);
605
+ // Exactly one fresh connector spawned, and it's the one recorded.
606
+ expect(seen).toHaveLength(1);
607
+ expect(findTunnelRecord(readCloudflaredState(env.statePath), "parachute")?.pid).toBe(42010);
608
+ } finally {
609
+ env.cleanup();
610
+ }
611
+ });
612
+
613
+ test("hub#487: warns when DNS doesn't resolve yet (pending zone)", async () => {
614
+ // route dns succeeded but the hostname doesn't resolve — the "pending"
615
+ // zone shape (NS not switched at the registrar). Non-fatal: still exit 0,
616
+ // still print the URLs, but add the nameserver-switch nudge.
617
+ const env = makeEnv();
618
+ try {
619
+ const uuid = "dddddddd-0000-0000-0000-000000000004";
620
+ const { runner } = queueRunner([
621
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
622
+ { code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
623
+ { code: 0, stdout: "", stderr: "" },
624
+ ]);
625
+ const { spawner } = fakeSpawner(42020);
626
+ const logs: string[] = [];
627
+
628
+ const code = await exposeCloudflareUp("vault.newzone.com", {
629
+ runner,
630
+ spawner,
631
+ alive: () => false,
632
+ kill: () => {},
633
+ connectorPids: () => [],
634
+ resolveHost: async () => [], // NXDOMAIN / not live yet
635
+ log: (l) => logs.push(l),
636
+ manifestPath: env.manifestPath,
637
+ statePath: env.statePath,
638
+ exposeStatePath: env.exposeStatePath,
639
+ configPath: env.configPath,
640
+ logPath: env.logPath,
641
+ cloudflaredHome: env.cloudflaredHome,
642
+ configDir: env.configDir,
643
+ skipHub: true,
644
+ });
645
+
646
+ expect(code).toBe(0); // non-fatal — the expose still completes
647
+ const joined = logs.join("\n");
648
+ expect(joined).toContain("DNS isn't live yet for vault.newzone.com");
649
+ expect(joined).toContain("dig +short newzone.com NS");
650
+ expect(joined).toContain("ns.cloudflare.com");
651
+ // The success URLs still print.
652
+ expect(joined).toContain("https://vault.newzone.com/admin/");
653
+ } finally {
654
+ env.cleanup();
655
+ }
656
+ });
657
+
658
+ test("hub#487: warns when hostname resolves but not to Cloudflare (shadowed)", async () => {
659
+ // route dns succeeded but the hostname resolves to a non-Cloudflare IP —
660
+ // a Pages project / grey-cloud A record shadowing the tunnel → edge 404.
661
+ const env = makeEnv();
662
+ try {
663
+ const uuid = "eeeeeeee-0000-0000-0000-000000000006";
664
+ const { runner } = queueRunner([
665
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
666
+ { code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
667
+ { code: 0, stdout: "", stderr: "" },
668
+ ]);
669
+ const { spawner } = fakeSpawner(42021);
670
+ const logs: string[] = [];
671
+
672
+ const code = await exposeCloudflareUp("docs.parachute.computer", {
673
+ runner,
674
+ spawner,
675
+ alive: () => false,
676
+ kill: () => {},
677
+ connectorPids: () => [],
678
+ resolveHost: async () => ["203.0.113.10"], // not a Cloudflare range
679
+ log: (l) => logs.push(l),
680
+ manifestPath: env.manifestPath,
681
+ statePath: env.statePath,
682
+ exposeStatePath: env.exposeStatePath,
683
+ configPath: env.configPath,
684
+ logPath: env.logPath,
685
+ cloudflaredHome: env.cloudflaredHome,
686
+ configDir: env.configDir,
687
+ skipHub: true,
688
+ });
689
+
690
+ expect(code).toBe(0);
691
+ const joined = logs.join("\n");
692
+ expect(joined).toContain("not to Cloudflare's edge");
693
+ expect(joined).toContain("shadowed");
694
+ expect(joined).toContain("Pages project");
695
+ } finally {
696
+ env.cleanup();
697
+ }
698
+ });
699
+
700
+ test("hub#487: no DNS warning when hostname resolves at Cloudflare's edge", async () => {
701
+ const env = makeEnv();
702
+ try {
703
+ const uuid = "ffffffff-0000-0000-0000-000000000007";
704
+ const { runner } = queueRunner([
705
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
706
+ { code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
707
+ { code: 0, stdout: "", stderr: "" },
708
+ ]);
709
+ const { spawner } = fakeSpawner(42022);
710
+ const logs: string[] = [];
711
+
712
+ const code = await exposeCloudflareUp("vault.example.com", {
713
+ runner,
714
+ spawner,
715
+ alive: () => false,
716
+ kill: () => {},
717
+ connectorPids: () => [],
718
+ resolveHost: async () => ["104.18.32.7"], // 104.16.0.0/13 — Cloudflare
719
+ log: (l) => logs.push(l),
720
+ manifestPath: env.manifestPath,
721
+ statePath: env.statePath,
722
+ exposeStatePath: env.exposeStatePath,
723
+ configPath: env.configPath,
724
+ logPath: env.logPath,
725
+ cloudflaredHome: env.cloudflaredHome,
726
+ configDir: env.configDir,
727
+ skipHub: true,
728
+ });
729
+
730
+ expect(code).toBe(0);
731
+ const joined = logs.join("\n");
732
+ expect(joined).not.toContain("DNS isn't live yet");
733
+ expect(joined).not.toContain("not to Cloudflare's edge");
734
+ } finally {
735
+ env.cleanup();
736
+ }
737
+ });
738
+
554
739
  test("two tunnels with different --tunnel-name coexist in state", async () => {
555
740
  const env = makeEnv();
556
741
  try {
@@ -929,6 +1114,46 @@ describe("exposeCloudflareOff", () => {
929
1114
  }
930
1115
  });
931
1116
 
1117
+ test("hub#487: off sweeps orphan connectors the state record didn't track", async () => {
1118
+ const env = makeEnv();
1119
+ try {
1120
+ const uuid = "abababab-0000-0000-0000-000000000009";
1121
+ writeCloudflaredState(
1122
+ {
1123
+ version: 2,
1124
+ tunnels: {
1125
+ parachute: {
1126
+ pid: 55555,
1127
+ tunnelUuid: uuid,
1128
+ tunnelName: "parachute",
1129
+ hostname: "vault.example.com",
1130
+ startedAt: "2026-04-22T12:00:00.000Z",
1131
+ configPath: env.configPath,
1132
+ },
1133
+ },
1134
+ },
1135
+ env.statePath,
1136
+ );
1137
+ const killed: number[] = [];
1138
+ const code = await exposeCloudflareOff({
1139
+ statePath: env.statePath,
1140
+ exposeStatePath: env.exposeStatePath,
1141
+ alive: () => true,
1142
+ kill: (pid) => killed.push(pid),
1143
+ // pgrep finds the tracked pid (skipped — already signalled) plus an
1144
+ // untracked orphan 66666 serving the same tunnel.
1145
+ connectorPids: () => [55555, 66666],
1146
+ log: () => {},
1147
+ });
1148
+ expect(code).toBe(0);
1149
+ // Tracked pid stopped once, orphan also stopped — no double-kill of 55555.
1150
+ expect(killed.sort()).toEqual([55555, 66666]);
1151
+ expect(existsSync(env.statePath)).toBe(false);
1152
+ } finally {
1153
+ env.cleanup();
1154
+ }
1155
+ });
1156
+
932
1157
  test("targets the named tunnel and leaves siblings intact", async () => {
933
1158
  const env = makeEnv();
934
1159
  try {
@@ -5,7 +5,15 @@ import { join } from "node:path";
5
5
  import { renderHub, writeHubFile } from "../hub.ts";
6
6
 
7
7
  describe("renderHub", () => {
8
- const html = renderHub();
8
+ // The verbose discovery body (Get started / Services / Admin) + its
9
+ // data-loading script render only for a signed-in visitor (the signed-out
10
+ // landing is slimmed — see the "signed-out slimming" describe block below).
11
+ // Assertions about that verbose body therefore run against a signed-in
12
+ // render; assertions about the page shell (doctype, styles, brand) hold for
13
+ // both and use whichever render is convenient.
14
+ const html = renderHub({
15
+ session: { displayName: "operator", csrfToken: "csrf-shell" },
16
+ });
9
17
 
10
18
  test("is a self-contained HTML document with inline styles and script", () => {
11
19
  expect(html).toStartWith("<!doctype html>");
@@ -162,12 +170,72 @@ describe("renderHub", () => {
162
170
  });
163
171
 
164
172
  test("default render (no session) emits the 'Sign in' affordance", () => {
165
- expect(html).toContain('class="auth-indicator"');
166
- expect(html).toContain("Sign in");
167
- expect(html).toContain('href="/login?next=/"');
173
+ const out = renderHub();
174
+ expect(out).toContain('class="auth-indicator"');
175
+ expect(out).toContain("Sign in");
176
+ expect(out).toContain('href="/login?next=/"');
168
177
  // No POST form, no CSRF input — those only appear when signed in.
169
- expect(html).not.toContain('action="/logout"');
170
- expect(html).not.toContain("__csrf");
178
+ expect(out).not.toContain('action="/logout"');
179
+ expect(out).not.toContain("__csrf");
180
+ });
181
+ });
182
+
183
+ describe("renderHub — signed-out slimming (operator feedback)", () => {
184
+ // A signed-out visitor should see a clean, minimal landing: brand +
185
+ // tagline (in the header) + a single clear "Sign in" call. The hub's
186
+ // internal detail — the service catalog, vault listings, admin surfaces,
187
+ // and the well-known-driven loading script — must NOT render until the
188
+ // visitor authenticates. The signed-in render is unchanged.
189
+ const signedOut = renderHub();
190
+ const signedIn = renderHub({
191
+ session: { displayName: "operator", csrfToken: "csrf-xyz" },
192
+ });
193
+
194
+ test("signed-out: brand wordmark + tagline still render (the slim landing keeps the brand)", () => {
195
+ expect(signedOut).toContain("<h1>Parachute</h1>");
196
+ expect(signedOut).toContain("Truly personal computing. Your knowledge belongs with you.");
197
+ });
198
+
199
+ test("signed-out: a clear 'Sign in' call is the primary affordance", () => {
200
+ expect(signedOut).toContain('data-testid="signed-out-signin"');
201
+ expect(signedOut).toContain('href="/login?next=/"');
202
+ expect(signedOut).toContain("Sign in");
203
+ });
204
+
205
+ test("signed-out: the verbose Services / Admin / Get started sections are absent", () => {
206
+ expect(signedOut).not.toContain('id="services-section"');
207
+ expect(signedOut).not.toContain('id="admin-section"');
208
+ expect(signedOut).not.toContain('id="get-started-section"');
209
+ expect(signedOut).not.toContain("<h2>Services</h2>");
210
+ expect(signedOut).not.toContain("<h2>Admin</h2>");
211
+ // Admin links / token surface must not be exposed pre-auth.
212
+ expect(signedOut).not.toContain("/admin/vaults");
213
+ expect(signedOut).not.toContain("/admin/tokens");
214
+ });
215
+
216
+ test("signed-out: the well-known service-catalog loading script is not emitted", () => {
217
+ // No data-driven discovery body to populate when signed out → no script.
218
+ // (The brand mark is an inline SVG, not a <script>; assert on the IIFE's
219
+ // load function rather than a blanket "no <script>".) The footer's
220
+ // public "discovery" anchor → /.well-known/parachute.json stays — it's a
221
+ // plain link, not the catalog-fetching script — so assert on the fetch
222
+ // call + the loader function, not the URL string.
223
+ expect(signedOut).not.toContain("loadServices");
224
+ expect(signedOut).not.toContain("renderServices");
225
+ expect(signedOut).not.toContain("fetch('/.well-known/parachute.json'");
226
+ expect(signedOut).not.toContain("<script>");
227
+ });
228
+
229
+ test("signed-in: the verbose sections + loading script DO render (signed-in view unchanged)", () => {
230
+ expect(signedIn).toContain('id="services-section"');
231
+ expect(signedIn).toContain('id="admin-section"');
232
+ expect(signedIn).toContain('id="get-started-section"');
233
+ expect(signedIn).toContain("/admin/vaults");
234
+ expect(signedIn).toContain("/.well-known/parachute.json");
235
+ expect(signedIn).toContain("loadServices");
236
+ // And the signed-out lede / standalone Sign-in CTA is gone (the
237
+ // auth-indicator carries sign-out instead).
238
+ expect(signedIn).not.toContain('data-testid="signed-out-signin"');
171
239
  });
172
240
  });
173
241
 
@@ -783,6 +783,159 @@ describe("parachute start", () => {
783
783
  h.cleanup();
784
784
  }
785
785
  });
786
+
787
+ // hub#487 — readiness gating beyond the bare liveness settle. Aaron hit this
788
+ // on a fresh EC2 box: `parachute start vault` printed "✓ vault started" while
789
+ // the process died ~instantly on EADDRINUSE (an orphan held 1940), and
790
+ // `parachute status` then showed it inactive.
791
+
792
+ /**
793
+ * A stub spawner that also seeds the service's log file with `content`, so
794
+ * the readiness-failure path's log-tail + EADDRINUSE detection can read a
795
+ * realistic boot error. Mirrors how the real spawner appends stdout/stderr
796
+ * to the logfile.
797
+ */
798
+ function makeSpawnerWithLog(pid: number, content: string): SpawnerStub {
799
+ const calls: SpawnerStub["calls"] = [];
800
+ return {
801
+ calls,
802
+ spawn(cmd, logFile, opts) {
803
+ calls.push({ cmd: [...cmd], logFile, env: opts?.env, cwd: opts?.cwd });
804
+ // The start path calls ensureLogPath() before spawn, so logFile's
805
+ // parent dir already exists — just write the simulated boot output.
806
+ writeFileSync(logFile, content);
807
+ return pid;
808
+ },
809
+ };
810
+ }
811
+
812
+ test("hub#487: EADDRINUSE in the log → port-in-use message + log tail, not ✓", async () => {
813
+ const h = makeHarness();
814
+ try {
815
+ seedVault(h.manifestPath);
816
+ const spawner = makeSpawnerWithLog(
817
+ 4242,
818
+ "booting vault…\nerror: listen EADDRINUSE: address already in use 0.0.0.0:1940\n",
819
+ );
820
+ const lines: string[] = [];
821
+ const code = await start("vault", {
822
+ configDir: h.configDir,
823
+ manifestPath: h.manifestPath,
824
+ spawner,
825
+ alive: () => false, // process died right after the EADDRINUSE throw
826
+ sleep: async () => {},
827
+ startSettleMs: 1,
828
+ log: (l) => lines.push(l),
829
+ });
830
+ expect(code).toBe(1);
831
+ expect(readPid("vault", h.configDir)).toBeUndefined();
832
+ const out = lines.join("\n");
833
+ expect(out).toMatch(/port 1940 is already in use/);
834
+ expect(out).toMatch(/lsof -ti:1940/);
835
+ // The real boot error is surfaced inline so the operator doesn't have to
836
+ // go tail the log themselves.
837
+ expect(out).toMatch(/EADDRINUSE/);
838
+ expect(out).not.toMatch(/✓ vault started/);
839
+ } finally {
840
+ h.cleanup();
841
+ }
842
+ });
843
+
844
+ test("hub#487: process survives settle but never binds its port → failure with log tail", async () => {
845
+ const h = makeHarness();
846
+ try {
847
+ seedVault(h.manifestPath);
848
+ const spawner = makeSpawnerWithLog(4242, "vault crashed mid-boot\n");
849
+ const lines: string[] = [];
850
+ let aliveCalls = 0;
851
+ const code = await start("vault", {
852
+ configDir: h.configDir,
853
+ manifestPath: h.manifestPath,
854
+ spawner,
855
+ // Alive through the settle + first readiness poll, then dies — the
856
+ // slow-EADDRINUSE / crash-after-boot shape.
857
+ alive: () => {
858
+ aliveCalls++;
859
+ return aliveCalls <= 1;
860
+ },
861
+ sleep: async () => {},
862
+ startSettleMs: 1,
863
+ startReadyMs: 50,
864
+ startReadyPollMs: 1,
865
+ portListening: async () => false, // never binds
866
+ log: (l) => lines.push(l),
867
+ });
868
+ expect(code).toBe(1);
869
+ expect(readPid("vault", h.configDir)).toBeUndefined();
870
+ const out = lines.join("\n");
871
+ expect(out).toMatch(/✗ vault failed to start/);
872
+ expect(out).toMatch(/exited during startup/);
873
+ expect(out).not.toMatch(/✓ vault started/);
874
+ } finally {
875
+ h.cleanup();
876
+ }
877
+ });
878
+
879
+ test("hub#487: alive but port silent past the window → non-fatal warning, exit 0", async () => {
880
+ const h = makeHarness();
881
+ try {
882
+ seedVault(h.manifestPath);
883
+ const spawner = makeSpawner([4242]);
884
+ const lines: string[] = [];
885
+ const code = await start("vault", {
886
+ configDir: h.configDir,
887
+ manifestPath: h.manifestPath,
888
+ spawner,
889
+ alive: () => true, // stays up the whole time
890
+ sleep: async () => {},
891
+ startSettleMs: 1,
892
+ startReadyMs: 10,
893
+ startReadyPollMs: 1,
894
+ portListening: async () => false, // slow boot — not listening yet
895
+ log: (l) => lines.push(l),
896
+ });
897
+ // A slow-but-alive daemon isn't a hard failure — we warn rather than fail.
898
+ expect(code).toBe(0);
899
+ expect(readPid("vault", h.configDir)).toBe(4242);
900
+ const out = lines.join("\n");
901
+ expect(out).toMatch(/port 1940 isn't accepting connections yet/);
902
+ expect(out).not.toMatch(/✓ vault started/);
903
+ } finally {
904
+ h.cleanup();
905
+ }
906
+ });
907
+
908
+ test("hub#487: alive + port listening → success", async () => {
909
+ const h = makeHarness();
910
+ try {
911
+ seedVault(h.manifestPath);
912
+ const spawner = makeSpawner([4242]);
913
+ const lines: string[] = [];
914
+ let probeCalls = 0;
915
+ const code = await start("vault", {
916
+ configDir: h.configDir,
917
+ manifestPath: h.manifestPath,
918
+ spawner,
919
+ alive: () => true,
920
+ sleep: async () => {},
921
+ startSettleMs: 1,
922
+ startReadyMs: 50,
923
+ startReadyPollMs: 1,
924
+ // Not listening on the first poll, bound on the second — exercises the
925
+ // poll loop rather than an instant true.
926
+ portListening: async () => {
927
+ probeCalls++;
928
+ return probeCalls >= 2;
929
+ },
930
+ log: (l) => lines.push(l),
931
+ });
932
+ expect(code).toBe(0);
933
+ expect(readPid("vault", h.configDir)).toBe(4242);
934
+ expect(lines.join("\n")).toMatch(/✓ vault started \(pid 4242\)/);
935
+ } finally {
936
+ h.cleanup();
937
+ }
938
+ });
786
939
  });
787
940
 
788
941
  describe("parachute stop", () => {