@openparachute/hub 0.3.0-rc.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 (76) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +284 -0
  3. package/package.json +31 -0
  4. package/src/__tests__/auth.test.ts +101 -0
  5. package/src/__tests__/auto-wire.test.ts +283 -0
  6. package/src/__tests__/cli.test.ts +192 -0
  7. package/src/__tests__/cloudflare-config.test.ts +54 -0
  8. package/src/__tests__/cloudflare-detect.test.ts +68 -0
  9. package/src/__tests__/cloudflare-state.test.ts +92 -0
  10. package/src/__tests__/cloudflare-tunnel.test.ts +207 -0
  11. package/src/__tests__/config.test.ts +18 -0
  12. package/src/__tests__/env-file.test.ts +125 -0
  13. package/src/__tests__/expose-auth-preflight.test.ts +201 -0
  14. package/src/__tests__/expose-cloudflare.test.ts +484 -0
  15. package/src/__tests__/expose-interactive.test.ts +703 -0
  16. package/src/__tests__/expose-last-provider.test.ts +113 -0
  17. package/src/__tests__/expose-off-auto.test.ts +269 -0
  18. package/src/__tests__/expose-state.test.ts +101 -0
  19. package/src/__tests__/expose.test.ts +1581 -0
  20. package/src/__tests__/hub-control.test.ts +346 -0
  21. package/src/__tests__/hub-server.test.ts +157 -0
  22. package/src/__tests__/hub.test.ts +116 -0
  23. package/src/__tests__/install.test.ts +1145 -0
  24. package/src/__tests__/lifecycle.test.ts +608 -0
  25. package/src/__tests__/migrate.test.ts +422 -0
  26. package/src/__tests__/notes-serve.test.ts +135 -0
  27. package/src/__tests__/port-assign.test.ts +178 -0
  28. package/src/__tests__/process-state.test.ts +140 -0
  29. package/src/__tests__/scribe-config.test.ts +193 -0
  30. package/src/__tests__/scribe-provider-interactive.test.ts +361 -0
  31. package/src/__tests__/services-manifest.test.ts +177 -0
  32. package/src/__tests__/status.test.ts +347 -0
  33. package/src/__tests__/tailscale-commands.test.ts +111 -0
  34. package/src/__tests__/tailscale-detect.test.ts +64 -0
  35. package/src/__tests__/vault-auth-status.test.ts +164 -0
  36. package/src/__tests__/vault-tokens-create-interactive.test.ts +183 -0
  37. package/src/__tests__/well-known.test.ts +214 -0
  38. package/src/auto-wire.ts +184 -0
  39. package/src/cli.ts +482 -0
  40. package/src/cloudflare/config.ts +58 -0
  41. package/src/cloudflare/detect.ts +58 -0
  42. package/src/cloudflare/state.ts +96 -0
  43. package/src/cloudflare/tunnel.ts +135 -0
  44. package/src/commands/auth.ts +69 -0
  45. package/src/commands/expose-auth-preflight.ts +217 -0
  46. package/src/commands/expose-cloudflare.ts +329 -0
  47. package/src/commands/expose-interactive.ts +428 -0
  48. package/src/commands/expose-off-auto.ts +199 -0
  49. package/src/commands/expose.ts +522 -0
  50. package/src/commands/install.ts +422 -0
  51. package/src/commands/lifecycle.ts +324 -0
  52. package/src/commands/migrate.ts +253 -0
  53. package/src/commands/scribe-provider-interactive.ts +269 -0
  54. package/src/commands/status.ts +238 -0
  55. package/src/commands/vault-tokens-create-interactive.ts +137 -0
  56. package/src/commands/vault.ts +17 -0
  57. package/src/config.ts +16 -0
  58. package/src/env-file.ts +76 -0
  59. package/src/expose-last-provider.ts +71 -0
  60. package/src/expose-state.ts +125 -0
  61. package/src/help.ts +279 -0
  62. package/src/hub-control.ts +254 -0
  63. package/src/hub-origin.ts +44 -0
  64. package/src/hub-server.ts +113 -0
  65. package/src/hub.ts +674 -0
  66. package/src/notes-serve.ts +135 -0
  67. package/src/port-assign.ts +125 -0
  68. package/src/process-state.ts +111 -0
  69. package/src/scribe-config.ts +149 -0
  70. package/src/service-spec.ts +296 -0
  71. package/src/services-manifest.ts +171 -0
  72. package/src/tailscale/commands.ts +41 -0
  73. package/src/tailscale/detect.ts +107 -0
  74. package/src/tailscale/run.ts +28 -0
  75. package/src/vault/auth-status.ts +179 -0
  76. package/src/well-known.ts +127 -0
@@ -0,0 +1,608 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { logs, restart, start, stop } from "../commands/lifecycle.ts";
6
+ import { writeHubPort } from "../hub-control.ts";
7
+ import { ensureLogPath, logPath, readPid, writePid } from "../process-state.ts";
8
+ import { upsertService } from "../services-manifest.ts";
9
+
10
+ interface Harness {
11
+ configDir: string;
12
+ manifestPath: string;
13
+ cleanup: () => void;
14
+ }
15
+
16
+ function makeHarness(): Harness {
17
+ const dir = mkdtempSync(join(tmpdir(), "pcli-life-"));
18
+ return {
19
+ configDir: dir,
20
+ manifestPath: join(dir, "services.json"),
21
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
22
+ };
23
+ }
24
+
25
+ function seedVault(manifestPath: string): void {
26
+ upsertService(
27
+ {
28
+ name: "parachute-vault",
29
+ port: 1940,
30
+ paths: ["/vault/default"],
31
+ health: "/vault/default/health",
32
+ version: "0.2.4",
33
+ },
34
+ manifestPath,
35
+ );
36
+ }
37
+
38
+ function seedNotes(manifestPath: string): void {
39
+ upsertService(
40
+ {
41
+ name: "parachute-notes",
42
+ port: 5173,
43
+ paths: ["/notes"],
44
+ health: "/notes/health",
45
+ version: "0.0.1",
46
+ },
47
+ manifestPath,
48
+ );
49
+ }
50
+
51
+ interface SpawnerStub {
52
+ spawn: (cmd: readonly string[], logFile: string, env?: Record<string, string>) => number;
53
+ calls: Array<{
54
+ cmd: readonly string[];
55
+ logFile: string;
56
+ env?: Record<string, string>;
57
+ }>;
58
+ }
59
+
60
+ function makeSpawner(pidSequence: number[]): SpawnerStub {
61
+ const calls: Array<{
62
+ cmd: readonly string[];
63
+ logFile: string;
64
+ env?: Record<string, string>;
65
+ }> = [];
66
+ let i = 0;
67
+ return {
68
+ calls,
69
+ spawn(cmd, logFile, env) {
70
+ calls.push({ cmd: [...cmd], logFile, env });
71
+ return pidSequence[i++] ?? 99999;
72
+ },
73
+ };
74
+ }
75
+
76
+ describe("parachute start", () => {
77
+ test("errors cleanly when no services installed", async () => {
78
+ const h = makeHarness();
79
+ try {
80
+ const logs: string[] = [];
81
+ const code = await start(undefined, {
82
+ configDir: h.configDir,
83
+ manifestPath: h.manifestPath,
84
+ log: (l) => logs.push(l),
85
+ });
86
+ expect(code).toBe(1);
87
+ expect(logs.join("\n")).toMatch(/No services installed/);
88
+ } finally {
89
+ h.cleanup();
90
+ }
91
+ });
92
+
93
+ test("errors cleanly when targeting an uninstalled service", async () => {
94
+ const h = makeHarness();
95
+ try {
96
+ seedVault(h.manifestPath);
97
+ const logs: string[] = [];
98
+ const code = await start("notes", {
99
+ configDir: h.configDir,
100
+ manifestPath: h.manifestPath,
101
+ log: (l) => logs.push(l),
102
+ });
103
+ expect(code).toBe(1);
104
+ expect(logs.join("\n")).toMatch(/notes isn't installed/);
105
+ } finally {
106
+ h.cleanup();
107
+ }
108
+ });
109
+
110
+ test("spawns vault with parachute-vault serve, writes PID", async () => {
111
+ const h = makeHarness();
112
+ try {
113
+ seedVault(h.manifestPath);
114
+ const spawner = makeSpawner([4242]);
115
+ const logs: string[] = [];
116
+ const code = await start("vault", {
117
+ configDir: h.configDir,
118
+ manifestPath: h.manifestPath,
119
+ spawner,
120
+ log: (l) => logs.push(l),
121
+ });
122
+ expect(code).toBe(0);
123
+ expect(spawner.calls).toHaveLength(1);
124
+ expect(spawner.calls[0]?.cmd).toEqual(["parachute-vault", "serve"]);
125
+ expect(spawner.calls[0]?.logFile).toBe(logPath("vault", h.configDir));
126
+ expect(readPid("vault", h.configDir)).toBe(4242);
127
+ expect(logs.join("\n")).toMatch(/vault started \(pid 4242\)/);
128
+ } finally {
129
+ h.cleanup();
130
+ }
131
+ });
132
+
133
+ test("notes start command includes configured port and notes-serve shim path", async () => {
134
+ const h = makeHarness();
135
+ try {
136
+ seedNotes(h.manifestPath);
137
+ const spawner = makeSpawner([5151]);
138
+ const code = await start("notes", {
139
+ configDir: h.configDir,
140
+ manifestPath: h.manifestPath,
141
+ spawner,
142
+ log: () => {},
143
+ });
144
+ expect(code).toBe(0);
145
+ const cmd = spawner.calls[0]?.cmd ?? [];
146
+ expect(cmd[0]).toBe("bun");
147
+ expect(cmd.some((a) => a.endsWith("notes-serve.ts"))).toBe(true);
148
+ const portIdx = cmd.indexOf("--port");
149
+ expect(portIdx).toBeGreaterThan(-1);
150
+ expect(cmd[portIdx + 1]).toBe("5173");
151
+ const mountIdx = cmd.indexOf("--mount");
152
+ expect(mountIdx).toBeGreaterThan(-1);
153
+ expect(cmd[mountIdx + 1]).toBe("/notes");
154
+ } finally {
155
+ h.cleanup();
156
+ }
157
+ });
158
+
159
+ test("no-op when already running", async () => {
160
+ const h = makeHarness();
161
+ try {
162
+ seedVault(h.manifestPath);
163
+ writePid("vault", 4242, h.configDir);
164
+ const spawner = makeSpawner([9999]);
165
+ const logs: string[] = [];
166
+ const code = await start("vault", {
167
+ configDir: h.configDir,
168
+ manifestPath: h.manifestPath,
169
+ spawner,
170
+ alive: () => true,
171
+ log: (l) => logs.push(l),
172
+ });
173
+ expect(code).toBe(0);
174
+ expect(spawner.calls).toHaveLength(0);
175
+ expect(logs.join("\n")).toMatch(/already running \(pid 4242\)/);
176
+ expect(readPid("vault", h.configDir)).toBe(4242);
177
+ } finally {
178
+ h.cleanup();
179
+ }
180
+ });
181
+
182
+ test("clears stale PID file before spawning fresh", async () => {
183
+ const h = makeHarness();
184
+ try {
185
+ seedVault(h.manifestPath);
186
+ writePid("vault", 4242, h.configDir);
187
+ const spawner = makeSpawner([7777]);
188
+ const code = await start("vault", {
189
+ configDir: h.configDir,
190
+ manifestPath: h.manifestPath,
191
+ spawner,
192
+ alive: () => false,
193
+ log: () => {},
194
+ });
195
+ expect(code).toBe(0);
196
+ expect(spawner.calls).toHaveLength(1);
197
+ expect(readPid("vault", h.configDir)).toBe(7777);
198
+ } finally {
199
+ h.cleanup();
200
+ }
201
+ });
202
+
203
+ test("start (no svc) targets every installed + known service", async () => {
204
+ const h = makeHarness();
205
+ try {
206
+ seedVault(h.manifestPath);
207
+ seedNotes(h.manifestPath);
208
+ const spawner = makeSpawner([4242, 5151]);
209
+ const code = await start(undefined, {
210
+ configDir: h.configDir,
211
+ manifestPath: h.manifestPath,
212
+ spawner,
213
+ log: () => {},
214
+ });
215
+ expect(code).toBe(0);
216
+ expect(spawner.calls).toHaveLength(2);
217
+ expect(readPid("vault", h.configDir)).toBe(4242);
218
+ expect(readPid("notes", h.configDir)).toBe(5151);
219
+ } finally {
220
+ h.cleanup();
221
+ }
222
+ });
223
+
224
+ test("legacy parachute-lens manifest entry still starts under the notes spec", async () => {
225
+ // Users who installed during the brief Notes→Lens window (Apr 19–22)
226
+ // will still have `parachute-lens` in services.json until their notes
227
+ // package next boots and rewrites the row. Without the manifest alias,
228
+ // shortNameForManifest returns undefined, resolveTargets skips the
229
+ // entry, and they get "No manageable services" with no hint.
230
+ const h = makeHarness();
231
+ try {
232
+ upsertService(
233
+ {
234
+ name: "parachute-lens",
235
+ port: 5173,
236
+ paths: ["/lens"],
237
+ health: "/lens/health",
238
+ version: "0.0.1",
239
+ },
240
+ h.manifestPath,
241
+ );
242
+ const spawner = makeSpawner([5151]);
243
+ const code = await start(undefined, {
244
+ configDir: h.configDir,
245
+ manifestPath: h.manifestPath,
246
+ spawner,
247
+ log: () => {},
248
+ });
249
+ expect(code).toBe(0);
250
+ expect(spawner.calls).toHaveLength(1);
251
+ expect(spawner.calls[0]?.cmd.some((a) => a.endsWith("notes-serve.ts"))).toBe(true);
252
+ expect(readPid("notes", h.configDir)).toBe(5151);
253
+ } finally {
254
+ h.cleanup();
255
+ }
256
+ });
257
+
258
+ test("passes PARACHUTE_HUB_ORIGIN from expose-state when set", async () => {
259
+ const h = makeHarness();
260
+ try {
261
+ seedVault(h.manifestPath);
262
+ writeFileSync(
263
+ join(h.configDir, "expose-state.json"),
264
+ JSON.stringify({
265
+ version: 1,
266
+ layer: "tailnet",
267
+ mode: "path",
268
+ canonicalFqdn: "parachute.taildf9ce2.ts.net",
269
+ port: 443,
270
+ funnel: false,
271
+ entries: [],
272
+ hubOrigin: "https://parachute.taildf9ce2.ts.net",
273
+ }),
274
+ );
275
+ const spawner = makeSpawner([4242]);
276
+ const code = await start("vault", {
277
+ configDir: h.configDir,
278
+ manifestPath: h.manifestPath,
279
+ spawner,
280
+ log: () => {},
281
+ });
282
+ expect(code).toBe(0);
283
+ expect(spawner.calls[0]?.env).toEqual({
284
+ PARACHUTE_HUB_ORIGIN: "https://parachute.taildf9ce2.ts.net",
285
+ });
286
+ } finally {
287
+ h.cleanup();
288
+ }
289
+ });
290
+
291
+ test("falls back to loopback origin from hub.port when not exposed", async () => {
292
+ const h = makeHarness();
293
+ try {
294
+ seedVault(h.manifestPath);
295
+ writeHubPort(1939, h.configDir);
296
+ const spawner = makeSpawner([4242]);
297
+ const code = await start("vault", {
298
+ configDir: h.configDir,
299
+ manifestPath: h.manifestPath,
300
+ spawner,
301
+ log: () => {},
302
+ });
303
+ expect(code).toBe(0);
304
+ expect(spawner.calls[0]?.env).toEqual({
305
+ PARACHUTE_HUB_ORIGIN: "http://127.0.0.1:1939",
306
+ });
307
+ } finally {
308
+ h.cleanup();
309
+ }
310
+ });
311
+
312
+ test("--hub-origin override wins over expose-state", async () => {
313
+ const h = makeHarness();
314
+ try {
315
+ seedVault(h.manifestPath);
316
+ writeFileSync(
317
+ join(h.configDir, "expose-state.json"),
318
+ JSON.stringify({
319
+ version: 1,
320
+ layer: "tailnet",
321
+ mode: "path",
322
+ canonicalFqdn: "parachute.taildf9ce2.ts.net",
323
+ port: 443,
324
+ funnel: false,
325
+ entries: [],
326
+ hubOrigin: "https://parachute.taildf9ce2.ts.net",
327
+ }),
328
+ );
329
+ const spawner = makeSpawner([4242]);
330
+ const code = await start("vault", {
331
+ configDir: h.configDir,
332
+ manifestPath: h.manifestPath,
333
+ spawner,
334
+ hubOrigin: "https://override.example.com/",
335
+ log: () => {},
336
+ });
337
+ expect(code).toBe(0);
338
+ expect(spawner.calls[0]?.env).toEqual({
339
+ PARACHUTE_HUB_ORIGIN: "https://override.example.com",
340
+ });
341
+ } finally {
342
+ h.cleanup();
343
+ }
344
+ });
345
+
346
+ test("omits env when no override, no exposure, no hub port", async () => {
347
+ const h = makeHarness();
348
+ try {
349
+ seedVault(h.manifestPath);
350
+ const spawner = makeSpawner([4242]);
351
+ const code = await start("vault", {
352
+ configDir: h.configDir,
353
+ manifestPath: h.manifestPath,
354
+ spawner,
355
+ log: () => {},
356
+ });
357
+ expect(code).toBe(0);
358
+ expect(spawner.calls[0]?.env).toBeUndefined();
359
+ } finally {
360
+ h.cleanup();
361
+ }
362
+ });
363
+
364
+ test("merges <configDir>/<svc>/.env into the spawn env", async () => {
365
+ // Scribe's API key prompt writes GROQ_API_KEY into ~/.parachute/scribe/.env.
366
+ // Scribe itself doesn't auto-load .env, so `parachute start scribe` has to
367
+ // forward the values into the child env or the API key won't take effect.
368
+ const h = makeHarness();
369
+ try {
370
+ upsertService(
371
+ {
372
+ name: "parachute-scribe",
373
+ port: 1943,
374
+ paths: ["/scribe"],
375
+ health: "/scribe/health",
376
+ version: "0.1.0",
377
+ },
378
+ h.manifestPath,
379
+ );
380
+ ensureLogPath("scribe", h.configDir);
381
+ writeFileSync(
382
+ join(h.configDir, "scribe", ".env"),
383
+ 'GROQ_API_KEY=gsk_real_value\nQUOTED="quoted_val"\n',
384
+ );
385
+ const spawner = makeSpawner([7777]);
386
+ const code = await start("scribe", {
387
+ configDir: h.configDir,
388
+ manifestPath: h.manifestPath,
389
+ spawner,
390
+ log: () => {},
391
+ });
392
+ expect(code).toBe(0);
393
+ expect(spawner.calls[0]?.env).toEqual({
394
+ GROQ_API_KEY: "gsk_real_value",
395
+ QUOTED: "quoted_val",
396
+ });
397
+ } finally {
398
+ h.cleanup();
399
+ }
400
+ });
401
+
402
+ test("hub-origin override wins over conflicting key in service .env", async () => {
403
+ // Defense: `start --hub-origin <url>` is the authoritative source for
404
+ // PARACHUTE_HUB_ORIGIN. If a service .env happens to have the same key
405
+ // (e.g. an old hand-edit), the live override should still apply.
406
+ const h = makeHarness();
407
+ try {
408
+ seedVault(h.manifestPath);
409
+ ensureLogPath("vault", h.configDir);
410
+ writeFileSync(
411
+ join(h.configDir, "vault", ".env"),
412
+ "SCRIBE_AUTH_TOKEN=secret\nPARACHUTE_HUB_ORIGIN=http://stale.local\n",
413
+ );
414
+ const spawner = makeSpawner([4242]);
415
+ const code = await start("vault", {
416
+ configDir: h.configDir,
417
+ manifestPath: h.manifestPath,
418
+ spawner,
419
+ hubOrigin: "https://live.example.com",
420
+ log: () => {},
421
+ });
422
+ expect(code).toBe(0);
423
+ expect(spawner.calls[0]?.env).toEqual({
424
+ SCRIBE_AUTH_TOKEN: "secret",
425
+ PARACHUTE_HUB_ORIGIN: "https://live.example.com",
426
+ });
427
+ } finally {
428
+ h.cleanup();
429
+ }
430
+ });
431
+ });
432
+
433
+ describe("parachute stop", () => {
434
+ test("no-op when nothing is running", async () => {
435
+ const h = makeHarness();
436
+ try {
437
+ seedVault(h.manifestPath);
438
+ const killed: Array<[number, string | number]> = [];
439
+ const logs: string[] = [];
440
+ const code = await stop("vault", {
441
+ configDir: h.configDir,
442
+ manifestPath: h.manifestPath,
443
+ kill: (pid, sig) => killed.push([pid, sig]),
444
+ log: (l) => logs.push(l),
445
+ });
446
+ expect(code).toBe(0);
447
+ expect(killed).toHaveLength(0);
448
+ expect(logs.join("\n")).toMatch(/wasn't running/);
449
+ } finally {
450
+ h.cleanup();
451
+ }
452
+ });
453
+
454
+ test("cleans stale PID file without sending any signal", async () => {
455
+ const h = makeHarness();
456
+ try {
457
+ seedVault(h.manifestPath);
458
+ writePid("vault", 4242, h.configDir);
459
+ const killed: Array<[number, string | number]> = [];
460
+ const code = await stop("vault", {
461
+ configDir: h.configDir,
462
+ manifestPath: h.manifestPath,
463
+ kill: (pid, sig) => killed.push([pid, sig]),
464
+ alive: () => false,
465
+ log: () => {},
466
+ });
467
+ expect(code).toBe(0);
468
+ expect(killed).toHaveLength(0);
469
+ expect(readPid("vault", h.configDir)).toBeUndefined();
470
+ } finally {
471
+ h.cleanup();
472
+ }
473
+ });
474
+
475
+ test("SIGTERM + clean exit within window clears PID", async () => {
476
+ const h = makeHarness();
477
+ try {
478
+ seedVault(h.manifestPath);
479
+ writePid("vault", 4242, h.configDir);
480
+ const killed: Array<[number, string | number]> = [];
481
+ let aliveCall = 0;
482
+ const code = await stop("vault", {
483
+ configDir: h.configDir,
484
+ manifestPath: h.manifestPath,
485
+ kill: (pid, sig) => killed.push([pid, sig]),
486
+ alive: () => {
487
+ aliveCall++;
488
+ return aliveCall === 1;
489
+ },
490
+ sleep: async () => {},
491
+ log: () => {},
492
+ });
493
+ expect(code).toBe(0);
494
+ expect(killed).toEqual([[4242, "SIGTERM"]]);
495
+ expect(readPid("vault", h.configDir)).toBeUndefined();
496
+ } finally {
497
+ h.cleanup();
498
+ }
499
+ });
500
+
501
+ test("escalates to SIGKILL when SIGTERM doesn't land", async () => {
502
+ const h = makeHarness();
503
+ try {
504
+ seedVault(h.manifestPath);
505
+ writePid("vault", 4242, h.configDir);
506
+ const killed: Array<[number, string | number]> = [];
507
+ let t = 0;
508
+ const code = await stop("vault", {
509
+ configDir: h.configDir,
510
+ manifestPath: h.manifestPath,
511
+ kill: (pid, sig) => killed.push([pid, sig]),
512
+ alive: () => true,
513
+ sleep: async () => {},
514
+ now: () => {
515
+ // Jump past the kill-wait window so the polling loop exits fast.
516
+ t += 20_000;
517
+ return t;
518
+ },
519
+ killWaitMs: 10_000,
520
+ log: () => {},
521
+ });
522
+ expect(code).toBe(0);
523
+ expect(killed[0]).toEqual([4242, "SIGTERM"]);
524
+ expect(killed[killed.length - 1]).toEqual([4242, "SIGKILL"]);
525
+ expect(readPid("vault", h.configDir)).toBeUndefined();
526
+ } finally {
527
+ h.cleanup();
528
+ }
529
+ });
530
+ });
531
+
532
+ describe("parachute restart", () => {
533
+ test("stops then starts in sequence", async () => {
534
+ const h = makeHarness();
535
+ try {
536
+ seedVault(h.manifestPath);
537
+ writePid("vault", 4242, h.configDir);
538
+ const spawner = makeSpawner([7777]);
539
+ const killed: Array<[number, string | number]> = [];
540
+ const code = await restart("vault", {
541
+ configDir: h.configDir,
542
+ manifestPath: h.manifestPath,
543
+ spawner,
544
+ kill: (pid, sig) => killed.push([pid, sig]),
545
+ alive: () => false,
546
+ sleep: async () => {},
547
+ log: () => {},
548
+ });
549
+ expect(code).toBe(0);
550
+ expect(killed).toHaveLength(0); // stale pid → cleanup without kill
551
+ expect(spawner.calls).toHaveLength(1);
552
+ expect(readPid("vault", h.configDir)).toBe(7777);
553
+ } finally {
554
+ h.cleanup();
555
+ }
556
+ });
557
+ });
558
+
559
+ describe("parachute logs", () => {
560
+ test("hint when no log file exists", async () => {
561
+ const h = makeHarness();
562
+ try {
563
+ const lines: string[] = [];
564
+ const code = await logs("vault", {
565
+ configDir: h.configDir,
566
+ log: (l) => lines.push(l),
567
+ });
568
+ expect(code).toBe(0);
569
+ expect(lines.join("\n")).toMatch(/no logs yet/);
570
+ } finally {
571
+ h.cleanup();
572
+ }
573
+ });
574
+
575
+ test("prints last N lines in one-shot mode", async () => {
576
+ const h = makeHarness();
577
+ try {
578
+ const p = ensureLogPath("vault", h.configDir);
579
+ const content = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`).join("\n");
580
+ writeFileSync(p, `${content}\n`);
581
+ const lines: string[] = [];
582
+ const code = await logs("vault", {
583
+ configDir: h.configDir,
584
+ lines: 3,
585
+ log: (l) => lines.push(l),
586
+ });
587
+ expect(code).toBe(0);
588
+ expect(lines).toEqual(["line 8", "line 9", "line 10"]);
589
+ } finally {
590
+ h.cleanup();
591
+ }
592
+ });
593
+
594
+ test("unknown service errors cleanly", async () => {
595
+ const h = makeHarness();
596
+ try {
597
+ const lines: string[] = [];
598
+ const code = await logs("nope", {
599
+ configDir: h.configDir,
600
+ log: (l) => lines.push(l),
601
+ });
602
+ expect(code).toBe(1);
603
+ expect(lines.join("\n")).toMatch(/unknown service/);
604
+ } finally {
605
+ h.cleanup();
606
+ }
607
+ });
608
+ });