@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,1145 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { install } from "../commands/install.ts";
6
+ import { findService, upsertService } from "../services-manifest.ts";
7
+
8
+ function makeTempPath(): { path: string; configDir: string; cleanup: () => void } {
9
+ const dir = mkdtempSync(join(tmpdir(), "pcli-install-"));
10
+ return {
11
+ path: join(dir, "services.json"),
12
+ configDir: dir,
13
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
14
+ };
15
+ }
16
+
17
+ describe("install", () => {
18
+ test("rejects unknown service with exit 1", async () => {
19
+ const { path, cleanup } = makeTempPath();
20
+ try {
21
+ const logs: string[] = [];
22
+ const code = await install("mystery", {
23
+ runner: async () => 0,
24
+ manifestPath: path,
25
+ startService: async () => 0,
26
+ isLinked: () => false,
27
+ portProbe: async () => false,
28
+ log: (l) => logs.push(l),
29
+ });
30
+ expect(code).toBe(1);
31
+ expect(logs.join("\n")).toMatch(/unknown service/);
32
+ } finally {
33
+ cleanup();
34
+ }
35
+ });
36
+
37
+ test("runs bun add -g then init; seeds manifest when service didn't write one", async () => {
38
+ const { path, cleanup } = makeTempPath();
39
+ try {
40
+ const calls: string[][] = [];
41
+ const logs: string[] = [];
42
+ const code = await install("vault", {
43
+ runner: async (cmd) => {
44
+ calls.push([...cmd]);
45
+ return 0;
46
+ },
47
+ manifestPath: path,
48
+ startService: async () => 0,
49
+ isLinked: () => false,
50
+ portProbe: async () => false,
51
+ log: (l) => logs.push(l),
52
+ });
53
+ expect(code).toBe(0);
54
+ expect(calls[0]).toEqual(["bun", "add", "-g", "@openparachute/vault"]);
55
+ expect(calls[1]).toEqual(["parachute-vault", "init"]);
56
+ expect(logs.join("\n")).toMatch(/Seeded services\.json entry for parachute-vault/);
57
+ const seeded = findService("parachute-vault", path);
58
+ expect(seeded?.port).toBe(1940);
59
+ expect(seeded?.version).toBe("0.0.0-linked");
60
+ } finally {
61
+ cleanup();
62
+ }
63
+ });
64
+
65
+ test("confirms registration when manifest entry exists after init (no seeding)", async () => {
66
+ const { path, cleanup } = makeTempPath();
67
+ try {
68
+ const logs: string[] = [];
69
+ const code = await install("vault", {
70
+ runner: async (cmd) => {
71
+ if (cmd[0] === "parachute-vault") {
72
+ upsertService(
73
+ {
74
+ name: "parachute-vault",
75
+ port: 1940,
76
+ paths: ["/"],
77
+ health: "/health",
78
+ version: "0.2.4",
79
+ },
80
+ path,
81
+ );
82
+ }
83
+ return 0;
84
+ },
85
+ manifestPath: path,
86
+ startService: async () => 0,
87
+ isLinked: () => false,
88
+ portProbe: async () => false,
89
+ log: (l) => logs.push(l),
90
+ });
91
+ expect(code).toBe(0);
92
+ expect(logs.join("\n")).toMatch(/registered on port 1940/);
93
+ expect(logs.join("\n")).not.toMatch(/Seeded/);
94
+ const entry = findService("parachute-vault", path);
95
+ expect(entry?.version).toBe("0.2.4");
96
+ } finally {
97
+ cleanup();
98
+ }
99
+ });
100
+
101
+ test("propagates non-zero exit from bun add when package not present at global prefix", async () => {
102
+ const { path, cleanup } = makeTempPath();
103
+ try {
104
+ const calls: string[][] = [];
105
+ const code = await install("vault", {
106
+ runner: async (cmd) => {
107
+ calls.push([...cmd]);
108
+ return 42;
109
+ },
110
+ manifestPath: path,
111
+ startService: async () => 0,
112
+ isLinked: () => false,
113
+ portProbe: async () => false,
114
+ findGlobalInstall: () => null,
115
+ log: () => {},
116
+ });
117
+ expect(code).toBe(42);
118
+ expect(calls).toHaveLength(1);
119
+ } finally {
120
+ cleanup();
121
+ }
122
+ });
123
+
124
+ test("tolerates bun add exit 1 when the package is actually installed (bun 1.2.x lockfile quirk)", async () => {
125
+ // Repro: `bun add -g @openparachute/vault` on bun 1.2.19 can print
126
+ // "InvalidPackageResolution" + "Failed to install 1 package" and exit 1,
127
+ // while the package *is* installed (see "installed @openparachute/vault…
128
+ // with binaries" in the same output). If we bail on the exit code, init
129
+ // + seed never runs and `parachute status` shows nothing even though
130
+ // the binary is on PATH — day-one breakage for anyone on bun 1.2.x.
131
+ const { path, cleanup } = makeTempPath();
132
+ try {
133
+ const calls: string[][] = [];
134
+ const logs: string[] = [];
135
+ const code = await install("vault", {
136
+ runner: async (cmd) => {
137
+ calls.push([...cmd]);
138
+ // `bun add -g` exits 1; `parachute-vault init` succeeds.
139
+ return cmd[0] === "bun" ? 1 : 0;
140
+ },
141
+ manifestPath: path,
142
+ startService: async () => 0,
143
+ isLinked: () => false,
144
+ portProbe: async () => false,
145
+ findGlobalInstall: (pkg) =>
146
+ pkg === "@openparachute/vault"
147
+ ? "/fake/bun/global/node_modules/@openparachute/vault/package.json"
148
+ : null,
149
+ log: (l) => logs.push(l),
150
+ });
151
+ expect(code).toBe(0);
152
+ // Warning mentions the found path and the bun 1.2.x quirk.
153
+ expect(logs.join("\n")).toMatch(
154
+ /bun add reported exit 1 but @openparachute\/vault is installed at/,
155
+ );
156
+ expect(logs.join("\n")).toMatch(/bun 1\.2\.x lockfile quirk/);
157
+ // Crucially: init still ran, and the service got seeded.
158
+ expect(calls).toEqual([
159
+ ["bun", "add", "-g", "@openparachute/vault"],
160
+ ["parachute-vault", "init"],
161
+ ]);
162
+ const seeded = findService("parachute-vault", path);
163
+ expect(seeded?.port).toBe(1940);
164
+ } finally {
165
+ cleanup();
166
+ }
167
+ });
168
+
169
+ test("notes tolerance path: bun add exit 1 + package present → seedEntry still fires", async () => {
170
+ // Mirror of the vault tolerance test for notes (#44). notes has no
171
+ // spec.init, so the only path to a services.json entry on a fresh
172
+ // install is the seedEntry block. Verifies that gate is reached even
173
+ // when bun's exit code says "failed."
174
+ const { path, configDir, cleanup } = makeTempPath();
175
+ try {
176
+ const logs: string[] = [];
177
+ const code = await install("notes", {
178
+ runner: async (cmd) => (cmd[0] === "bun" ? 1 : 0),
179
+ manifestPath: path,
180
+ configDir,
181
+ startService: async () => 0,
182
+ isLinked: () => false,
183
+ portProbe: async () => false,
184
+ findGlobalInstall: (pkg) =>
185
+ pkg === "@openparachute/notes"
186
+ ? "/fake/bun/global/node_modules/@openparachute/notes/package.json"
187
+ : null,
188
+ log: (l) => logs.push(l),
189
+ });
190
+ expect(code).toBe(0);
191
+ const seeded = findService("parachute-notes", path);
192
+ expect(seeded?.port).toBe(1942);
193
+ expect(logs.join("\n")).toMatch(/Seeded services\.json entry for parachute-notes/);
194
+ expect(logs.join("\n")).toMatch(/bun 1\.2\.x lockfile quirk/);
195
+ } finally {
196
+ cleanup();
197
+ }
198
+ });
199
+
200
+ test("non-tolerance bun add failure logs the prefixes that were probed", async () => {
201
+ // Defensive logging from #44: when findGlobalInstall returns null we want
202
+ // operators on non-standard bun layouts to see WHERE we looked, so they
203
+ // can spot a BUN_INSTALL or homebrew-prefix mismatch. The prefixes line
204
+ // is the actionable signal.
205
+ const { path, cleanup } = makeTempPath();
206
+ try {
207
+ const logs: string[] = [];
208
+ const code = await install("vault", {
209
+ runner: async () => 1,
210
+ manifestPath: path,
211
+ startService: async () => 0,
212
+ isLinked: () => false,
213
+ portProbe: async () => false,
214
+ findGlobalInstall: () => null,
215
+ log: (l) => logs.push(l),
216
+ });
217
+ expect(code).toBe(1);
218
+ expect(logs.join("\n")).toMatch(/probed bun globals at:/);
219
+ } finally {
220
+ cleanup();
221
+ }
222
+ });
223
+
224
+ test("final registration check warns when the entry is missing at install exit", async () => {
225
+ // Unknown / third-party services with no spec are rejected upfront, but
226
+ // a registered service whose spec lacks both `init` AND `seedEntry` could
227
+ // exit install with no manifest entry. We can't trigger that with the
228
+ // real ServiceSpec catalog, so simulate it by removing the entry mid-
229
+ // flight via a runner side-effect.
230
+ const { path, cleanup } = makeTempPath();
231
+ try {
232
+ const logs: string[] = [];
233
+ const code = await install("vault", {
234
+ runner: async (cmd) => {
235
+ // vault's init "succeeds" but never writes services.json. seedEntry
236
+ // fires (vault has one) → entry present. Then we sabotage by
237
+ // emptying the manifest before the final check, simulating an
238
+ // external clobber that the verify-step is designed to catch.
239
+ if (cmd[0] === "parachute-vault") {
240
+ // no-op (init didn't write)
241
+ }
242
+ return 0;
243
+ },
244
+ manifestPath: path,
245
+ // After install runs through to auto-start, the startService stub
246
+ // gets called. We use it as the very last hook before the final
247
+ // check to wipe the manifest.
248
+ startService: async () => {
249
+ // Wipe services.json so the final findService comes back empty.
250
+ const { writeFileSync } = await import("node:fs");
251
+ writeFileSync(path, JSON.stringify({ services: [] }, null, 2));
252
+ return 0;
253
+ },
254
+ isLinked: () => false,
255
+ portProbe: async () => false,
256
+ log: (l) => logs.push(l),
257
+ });
258
+ expect(code).toBe(0);
259
+ expect(logs.join("\n")).toMatch(/parachute-vault is not in services\.json after install/);
260
+ } finally {
261
+ cleanup();
262
+ }
263
+ });
264
+
265
+ test("CLI overrides a non-canonical port written by init when canonical is free", async () => {
266
+ // Pre-#53 the CLI deferred to whatever port the service's init wrote
267
+ // (e.g. 5173, Vite's dev default for notes). With CLI-as-port-authority
268
+ // the canonical slot wins when free: the manifest is updated and the
269
+ // .env carries PORT=<canonical> so the next daemon boot binds it.
270
+ const { path, configDir, cleanup } = makeTempPath();
271
+ try {
272
+ const logs: string[] = [];
273
+ const code = await install("notes", {
274
+ runner: async (cmd) => {
275
+ if (cmd[0] === "bun") {
276
+ upsertService(
277
+ {
278
+ name: "parachute-notes",
279
+ port: 5173,
280
+ paths: ["/notes"],
281
+ health: "/notes/health",
282
+ version: "0.0.1",
283
+ },
284
+ path,
285
+ );
286
+ }
287
+ return 0;
288
+ },
289
+ manifestPath: path,
290
+ configDir,
291
+ startService: async () => 0,
292
+ isLinked: () => false,
293
+ portProbe: async () => false,
294
+ log: (l) => logs.push(l),
295
+ });
296
+ expect(code).toBe(0);
297
+ expect(logs.join("\n")).toMatch(/Updated services\.json port to 1942/);
298
+ expect(logs.join("\n")).toMatch(/registered on port 1942/);
299
+ expect(logs.join("\n")).not.toMatch(/outside the canonical Parachute range/);
300
+ const entry = findService("parachute-notes", path);
301
+ expect(entry?.port).toBe(1942);
302
+ } finally {
303
+ cleanup();
304
+ }
305
+ });
306
+
307
+ test("warns when canonical range is exhausted and assignment falls outside", async () => {
308
+ // Defensive: if every canonical slot 1939–1949 is occupied (probe says
309
+ // so), assignPort falls outside the range and surfaces a warning so
310
+ // operators can free a slot or accept the conflict risk.
311
+ const { path, configDir, cleanup } = makeTempPath();
312
+ try {
313
+ const logs: string[] = [];
314
+ const code = await install("vault", {
315
+ runner: async () => 0,
316
+ manifestPath: path,
317
+ configDir,
318
+ startService: async () => 0,
319
+ isLinked: () => false,
320
+ // Every canonical slot is taken.
321
+ portProbe: async () => true,
322
+ log: (l) => logs.push(l),
323
+ });
324
+ expect(code).toBe(0);
325
+ const joined = logs.join("\n");
326
+ expect(joined).toMatch(/canonical range.*1939–1949.*is full/);
327
+ expect(joined).toMatch(/outside the canonical Parachute range/);
328
+ const entry = findService("parachute-vault", path);
329
+ expect(entry?.port).toBeGreaterThan(1949);
330
+ } finally {
331
+ cleanup();
332
+ }
333
+ });
334
+
335
+ test("`install lens` aliases to notes with a rename notice", async () => {
336
+ // Transition alias for the brief Notes→Lens rename (Apr 19) that was
337
+ // reverted on launch eve (Apr 22). Accepted for one release cycle so
338
+ // anyone who ran `parachute install lens` during the ~3-day window
339
+ // keeps working; removed after launch users have re-installed.
340
+ const { path, configDir, cleanup } = makeTempPath();
341
+ try {
342
+ const calls: string[][] = [];
343
+ const logs: string[] = [];
344
+ const code = await install("lens", {
345
+ runner: async (cmd) => {
346
+ calls.push([...cmd]);
347
+ return 0;
348
+ },
349
+ manifestPath: path,
350
+ configDir,
351
+ startService: async () => 0,
352
+ isLinked: () => false,
353
+ portProbe: async () => false,
354
+ log: (l) => logs.push(l),
355
+ });
356
+ expect(code).toBe(0);
357
+ expect(logs.join("\n")).toMatch(/"lens" has been renamed to "notes"; installing notes\./);
358
+ // Downstream bun-add must use the new package name, not the old.
359
+ expect(calls[0]).toEqual(["bun", "add", "-g", "@openparachute/notes"]);
360
+ const seeded = findService("parachute-notes", path);
361
+ expect(seeded?.port).toBe(1942);
362
+ } finally {
363
+ cleanup();
364
+ }
365
+ });
366
+
367
+ test("does not warn when manifest port is in the canonical range", async () => {
368
+ const { path, cleanup } = makeTempPath();
369
+ try {
370
+ const logs: string[] = [];
371
+ await install("vault", {
372
+ runner: async (cmd) => {
373
+ if (cmd[0] === "parachute-vault") {
374
+ upsertService(
375
+ {
376
+ name: "parachute-vault",
377
+ port: 1940,
378
+ paths: ["/vault/default"],
379
+ health: "/vault/default/health",
380
+ version: "0.2.4",
381
+ },
382
+ path,
383
+ );
384
+ }
385
+ return 0;
386
+ },
387
+ manifestPath: path,
388
+ startService: async () => 0,
389
+ isLinked: () => false,
390
+ portProbe: async () => false,
391
+ log: (l) => logs.push(l),
392
+ });
393
+ expect(logs.join("\n")).not.toMatch(/outside the canonical/);
394
+ } finally {
395
+ cleanup();
396
+ }
397
+ });
398
+
399
+ test("skips init when spec has none (scribe)", async () => {
400
+ const { path, configDir, cleanup } = makeTempPath();
401
+ try {
402
+ const calls: string[][] = [];
403
+ const logs: string[] = [];
404
+ const code = await install("scribe", {
405
+ runner: async (cmd) => {
406
+ calls.push([...cmd]);
407
+ return 0;
408
+ },
409
+ manifestPath: path,
410
+ configDir,
411
+ startService: async () => 0,
412
+ isLinked: () => false,
413
+ portProbe: async () => false,
414
+ log: (l) => logs.push(l),
415
+ });
416
+ expect(code).toBe(0);
417
+ expect(calls).toHaveLength(1);
418
+ expect(calls[0]).toEqual(["bun", "add", "-g", "@openparachute/scribe"]);
419
+ // scribe has no init, so seedEntry fires — no authoritative entry to defer to.
420
+ const seeded = findService("parachute-scribe", path);
421
+ expect(seeded?.port).toBe(1943);
422
+ expect(logs.join("\n")).toMatch(/Seeded services\.json entry for parachute-scribe/);
423
+ } finally {
424
+ cleanup();
425
+ }
426
+ });
427
+
428
+ test("skips `bun add -g` when the package is already bun-linked", async () => {
429
+ // The scribe motivator: package isn't published to npm yet, so `bun add -g`
430
+ // 404s. If bun link already points the global node_modules at a local
431
+ // checkout, detect that and proceed to init + seeding.
432
+ const { path, configDir, cleanup } = makeTempPath();
433
+ try {
434
+ const calls: string[][] = [];
435
+ const logs: string[] = [];
436
+ const code = await install("scribe", {
437
+ runner: async (cmd) => {
438
+ calls.push([...cmd]);
439
+ return 0;
440
+ },
441
+ manifestPath: path,
442
+ configDir,
443
+ startService: async () => 0,
444
+ isLinked: (pkg) => pkg === "@openparachute/scribe",
445
+ portProbe: async () => false,
446
+ log: (l) => logs.push(l),
447
+ });
448
+ expect(code).toBe(0);
449
+ expect(calls).toHaveLength(0);
450
+ expect(logs.join("\n")).toMatch(/already linked globally/);
451
+ const seeded = findService("parachute-scribe", path);
452
+ expect(seeded?.port).toBe(1943);
453
+ expect(seeded?.paths).toEqual(["/scribe"]);
454
+ } finally {
455
+ cleanup();
456
+ }
457
+ });
458
+
459
+ test("--tag composes `<package>@<tag>` for the bun add call", async () => {
460
+ // RC testers pin a pre-release channel via dist-tag (e.g. `--tag rc`).
461
+ // The composed name shows up in logs so the operator knows which channel
462
+ // they're on — no surprise upgrades when the tag rolls forward.
463
+ const { path, cleanup } = makeTempPath();
464
+ try {
465
+ const calls: string[][] = [];
466
+ const logs: string[] = [];
467
+ const code = await install("vault", {
468
+ runner: async (cmd) => {
469
+ calls.push([...cmd]);
470
+ return 0;
471
+ },
472
+ manifestPath: path,
473
+ startService: async () => 0,
474
+ isLinked: () => false,
475
+ portProbe: async () => false,
476
+ log: (l) => logs.push(l),
477
+ tag: "rc",
478
+ });
479
+ expect(code).toBe(0);
480
+ expect(calls[0]).toEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
481
+ expect(logs.join("\n")).toMatch(/Installing @openparachute\/vault@rc/);
482
+ } finally {
483
+ cleanup();
484
+ }
485
+ });
486
+
487
+ test("--tag accepts an exact version string", async () => {
488
+ const { path, cleanup } = makeTempPath();
489
+ try {
490
+ const calls: string[][] = [];
491
+ const code = await install("vault", {
492
+ runner: async (cmd) => {
493
+ calls.push([...cmd]);
494
+ return 0;
495
+ },
496
+ manifestPath: path,
497
+ startService: async () => 0,
498
+ isLinked: () => false,
499
+ portProbe: async () => false,
500
+ log: () => {},
501
+ tag: "0.3.0-rc.1",
502
+ });
503
+ expect(code).toBe(0);
504
+ expect(calls[0]).toEqual(["bun", "add", "-g", "@openparachute/vault@0.3.0-rc.1"]);
505
+ } finally {
506
+ cleanup();
507
+ }
508
+ });
509
+
510
+ test("--tag is moot when the package is already bun-linked", async () => {
511
+ // The link short-circuit beats the tag — local checkout wins, no fetch.
512
+ const { path, cleanup } = makeTempPath();
513
+ try {
514
+ const calls: string[][] = [];
515
+ const logs: string[] = [];
516
+ const code = await install("scribe", {
517
+ runner: async (cmd) => {
518
+ calls.push([...cmd]);
519
+ return 0;
520
+ },
521
+ manifestPath: path,
522
+ startService: async () => 0,
523
+ isLinked: () => true,
524
+ portProbe: async () => false,
525
+ log: (l) => logs.push(l),
526
+ tag: "rc",
527
+ });
528
+ expect(code).toBe(0);
529
+ expect(calls).toHaveLength(0);
530
+ expect(logs.join("\n")).toMatch(/already linked globally/);
531
+ } finally {
532
+ cleanup();
533
+ }
534
+ });
535
+
536
+ test("error log on non-zero bun add includes the tagged spec", async () => {
537
+ const { path, cleanup } = makeTempPath();
538
+ try {
539
+ const logs: string[] = [];
540
+ const code = await install("vault", {
541
+ runner: async () => 1,
542
+ manifestPath: path,
543
+ startService: async () => 0,
544
+ isLinked: () => false,
545
+ portProbe: async () => false,
546
+ findGlobalInstall: () => null,
547
+ log: (l) => logs.push(l),
548
+ tag: "rc",
549
+ });
550
+ expect(code).toBe(1);
551
+ expect(logs.join("\n")).toMatch(/bun add -g @openparachute\/vault@rc failed/);
552
+ } finally {
553
+ cleanup();
554
+ }
555
+ });
556
+
557
+ test("linked vault still runs init and defers to init's manifest write", async () => {
558
+ const { path, cleanup } = makeTempPath();
559
+ try {
560
+ const calls: string[][] = [];
561
+ const logs: string[] = [];
562
+ const code = await install("vault", {
563
+ runner: async (cmd) => {
564
+ calls.push([...cmd]);
565
+ if (cmd[0] === "parachute-vault") {
566
+ upsertService(
567
+ {
568
+ name: "parachute-vault",
569
+ port: 1940,
570
+ paths: ["/vault/default"],
571
+ health: "/vault/default/health",
572
+ version: "0.3.0",
573
+ },
574
+ path,
575
+ );
576
+ }
577
+ return 0;
578
+ },
579
+ manifestPath: path,
580
+ startService: async () => 0,
581
+ isLinked: () => true,
582
+ portProbe: async () => false,
583
+ log: (l) => logs.push(l),
584
+ });
585
+ expect(code).toBe(0);
586
+ expect(calls).toEqual([["parachute-vault", "init"]]);
587
+ expect(logs.join("\n")).not.toMatch(/Seeded/);
588
+ expect(findService("parachute-vault", path)?.version).toBe("0.3.0");
589
+ } finally {
590
+ cleanup();
591
+ }
592
+ });
593
+
594
+ // Auto-wire: when `parachute install` lands a service that completes the
595
+ // vault↔scribe pair, generate a shared secret and persist to both sides.
596
+ // Covered in detail by auto-wire.test.ts; these tests assert the install
597
+ // command actually invokes the helper at the right moment.
598
+ test("installing scribe with vault already present auto-wires the shared secret", async () => {
599
+ const { path, cleanup } = makeTempPath();
600
+ const configDir = join(path, "..");
601
+ try {
602
+ // Pretend vault was installed previously — entry already in services.json.
603
+ upsertService(
604
+ {
605
+ name: "parachute-vault",
606
+ port: 1940,
607
+ paths: ["/vault/default"],
608
+ health: "/vault/default/health",
609
+ version: "0.2.4",
610
+ },
611
+ path,
612
+ );
613
+ const logs: string[] = [];
614
+ const code = await install("scribe", {
615
+ runner: async () => 0,
616
+ manifestPath: path,
617
+ configDir,
618
+ startService: async () => 0,
619
+ isLinked: () => false,
620
+ portProbe: async () => false,
621
+ log: (l) => logs.push(l),
622
+ randomToken: () => "test-token-value",
623
+ });
624
+ expect(code).toBe(0);
625
+
626
+ const envPath = join(configDir, "vault", ".env");
627
+ const scribeCfgPath = join(configDir, "scribe", "config.json");
628
+ expect(existsSync(envPath)).toBe(true);
629
+ expect(existsSync(scribeCfgPath)).toBe(true);
630
+
631
+ const envText = readFileSync(envPath, "utf8");
632
+ expect(envText).toContain("SCRIBE_AUTH_TOKEN=test-token-value");
633
+ const cfg = JSON.parse(readFileSync(scribeCfgPath, "utf8"));
634
+ expect(cfg.auth.required_token).toBe("test-token-value");
635
+
636
+ expect(logs.join("\n")).toMatch(/Auto-wired shared secret \+ SCRIBE_URL/);
637
+ } finally {
638
+ cleanup();
639
+ }
640
+ });
641
+
642
+ test("installing scribe without vault does NOT auto-wire (nothing to wire against)", async () => {
643
+ const { path, cleanup } = makeTempPath();
644
+ const configDir = join(path, "..");
645
+ try {
646
+ const logs: string[] = [];
647
+ const code = await install("scribe", {
648
+ runner: async () => 0,
649
+ manifestPath: path,
650
+ configDir,
651
+ startService: async () => 0,
652
+ isLinked: () => false,
653
+ portProbe: async () => false,
654
+ log: (l) => logs.push(l),
655
+ randomToken: () => "should-not-fire",
656
+ });
657
+ expect(code).toBe(0);
658
+ // No vault/.env, no scribe/config.json written by auto-wire.
659
+ expect(existsSync(join(configDir, "vault", ".env"))).toBe(false);
660
+ expect(existsSync(join(configDir, "scribe", "config.json"))).toBe(false);
661
+ expect(logs.join("\n")).not.toMatch(/Auto-wired shared secret/);
662
+ } finally {
663
+ cleanup();
664
+ }
665
+ });
666
+
667
+ test("installing vault with scribe already present auto-wires (either-order)", async () => {
668
+ const { path, cleanup } = makeTempPath();
669
+ const configDir = join(path, "..");
670
+ try {
671
+ upsertService(
672
+ {
673
+ name: "parachute-scribe",
674
+ port: 1943,
675
+ paths: ["/scribe"],
676
+ health: "/scribe/health",
677
+ version: "0.1.0",
678
+ },
679
+ path,
680
+ );
681
+ const code = await install("vault", {
682
+ runner: async () => 0,
683
+ manifestPath: path,
684
+ configDir,
685
+ startService: async () => 0,
686
+ isLinked: () => false,
687
+ portProbe: async () => false,
688
+ log: () => {},
689
+ randomToken: () => "install-vault-side-token",
690
+ });
691
+ expect(code).toBe(0);
692
+ const envText = readFileSync(join(configDir, "vault", ".env"), "utf8");
693
+ expect(envText).toContain("SCRIBE_AUTH_TOKEN=install-vault-side-token");
694
+ } finally {
695
+ cleanup();
696
+ }
697
+ });
698
+
699
+ test("repeat install preserves an existing SCRIBE_AUTH_TOKEN (idempotent)", async () => {
700
+ const { path, cleanup } = makeTempPath();
701
+ const configDir = join(path, "..");
702
+ try {
703
+ upsertService(
704
+ {
705
+ name: "parachute-vault",
706
+ port: 1940,
707
+ paths: ["/vault/default"],
708
+ health: "/vault/default/health",
709
+ version: "0.2.4",
710
+ },
711
+ path,
712
+ );
713
+ // First install: mints a token.
714
+ await install("scribe", {
715
+ runner: async () => 0,
716
+ manifestPath: path,
717
+ configDir,
718
+ startService: async () => 0,
719
+ isLinked: () => false,
720
+ portProbe: async () => false,
721
+ log: () => {},
722
+ randomToken: () => "first-token",
723
+ });
724
+ // Second install: must preserve the first token — churning it would
725
+ // break an already-running vault worker that's holding the old one.
726
+ await install("scribe", {
727
+ runner: async () => 0,
728
+ manifestPath: path,
729
+ configDir,
730
+ startService: async () => 0,
731
+ isLinked: () => false,
732
+ portProbe: async () => false,
733
+ log: () => {},
734
+ randomToken: () => "should-not-replace",
735
+ });
736
+ const envText = readFileSync(join(configDir, "vault", ".env"), "utf8");
737
+ expect(envText).toContain("SCRIBE_AUTH_TOKEN=first-token");
738
+ expect(envText).not.toContain("should-not-replace");
739
+ } finally {
740
+ cleanup();
741
+ }
742
+ });
743
+
744
+ test("installing notes doesn't trigger auto-wire even if vault + scribe are present", async () => {
745
+ // Defense: auto-wire should only fire from the scribe or vault install
746
+ // path. A parallel install of a different service shouldn't touch the
747
+ // shared-secret files.
748
+ const { path, cleanup } = makeTempPath();
749
+ const configDir = join(path, "..");
750
+ try {
751
+ upsertService(
752
+ {
753
+ name: "parachute-vault",
754
+ port: 1940,
755
+ paths: ["/vault/default"],
756
+ health: "/vault/default/health",
757
+ version: "0.2.4",
758
+ },
759
+ path,
760
+ );
761
+ upsertService(
762
+ {
763
+ name: "parachute-scribe",
764
+ port: 1943,
765
+ paths: ["/scribe"],
766
+ health: "/scribe/health",
767
+ version: "0.1.0",
768
+ },
769
+ path,
770
+ );
771
+ await install("notes", {
772
+ runner: async () => 0,
773
+ manifestPath: path,
774
+ configDir,
775
+ startService: async () => 0,
776
+ isLinked: () => false,
777
+ portProbe: async () => false,
778
+ log: () => {},
779
+ randomToken: () => "should-not-fire",
780
+ });
781
+ expect(existsSync(join(configDir, "vault", ".env"))).toBe(false);
782
+ expect(existsSync(join(configDir, "scribe", "config.json"))).toBe(false);
783
+ } finally {
784
+ cleanup();
785
+ }
786
+ });
787
+
788
+ // Auto-start: launch-day demo had Aaron running `parachute install scribe`
789
+ // and then having to remember `parachute start scribe` separately. After
790
+ // 0.2.5, install ends with the daemon running.
791
+ test("auto-starts the service after a successful install", async () => {
792
+ const { path, cleanup } = makeTempPath();
793
+ try {
794
+ const startCalls: string[] = [];
795
+ const code = await install("scribe", {
796
+ runner: async () => 0,
797
+ manifestPath: path,
798
+ startService: async (short) => {
799
+ startCalls.push(short);
800
+ return 0;
801
+ },
802
+ isLinked: () => false,
803
+ portProbe: async () => false,
804
+ log: () => {},
805
+ });
806
+ expect(code).toBe(0);
807
+ expect(startCalls).toEqual(["scribe"]);
808
+ } finally {
809
+ cleanup();
810
+ }
811
+ });
812
+
813
+ test("--no-start suppresses the auto-start", async () => {
814
+ // Piped / CI installs that own their own process model want the install
815
+ // to land but not spawn anything.
816
+ const { path, cleanup } = makeTempPath();
817
+ try {
818
+ const startCalls: string[] = [];
819
+ const code = await install("scribe", {
820
+ runner: async () => 0,
821
+ manifestPath: path,
822
+ startService: async (short) => {
823
+ startCalls.push(short);
824
+ return 0;
825
+ },
826
+ isLinked: () => false,
827
+ portProbe: async () => false,
828
+ log: () => {},
829
+ noStart: true,
830
+ });
831
+ expect(code).toBe(0);
832
+ expect(startCalls).toEqual([]);
833
+ } finally {
834
+ cleanup();
835
+ }
836
+ });
837
+
838
+ test("auto-start uses the resolved (post-alias) short name", async () => {
839
+ // `install lens` aliases to notes — the start call must target notes,
840
+ // not the alias the user typed.
841
+ const { path, cleanup } = makeTempPath();
842
+ try {
843
+ const startCalls: string[] = [];
844
+ const code = await install("lens", {
845
+ runner: async () => 0,
846
+ manifestPath: path,
847
+ startService: async (short) => {
848
+ startCalls.push(short);
849
+ return 0;
850
+ },
851
+ isLinked: () => false,
852
+ portProbe: async () => false,
853
+ log: () => {},
854
+ });
855
+ expect(code).toBe(0);
856
+ expect(startCalls).toEqual(["notes"]);
857
+ } finally {
858
+ cleanup();
859
+ }
860
+ });
861
+
862
+ test("logs a hint when auto-start fails but doesn't fail the install itself", async () => {
863
+ // The install completed; a flaky daemon launch shouldn't roll it back.
864
+ // User gets a clear pointer to retry manually.
865
+ const { path, cleanup } = makeTempPath();
866
+ try {
867
+ const logs: string[] = [];
868
+ const code = await install("scribe", {
869
+ runner: async () => 0,
870
+ manifestPath: path,
871
+ startService: async () => 1,
872
+ isLinked: () => false,
873
+ portProbe: async () => false,
874
+ log: (l) => logs.push(l),
875
+ });
876
+ expect(code).toBe(0);
877
+ expect(logs.join("\n")).toMatch(/scribe didn't start cleanly.*parachute start scribe/);
878
+ } finally {
879
+ cleanup();
880
+ }
881
+ });
882
+
883
+ test("scribe install emits the post-install footer with provider hints", async () => {
884
+ const { path, cleanup } = makeTempPath();
885
+ try {
886
+ const logs: string[] = [];
887
+ const code = await install("scribe", {
888
+ runner: async () => 0,
889
+ manifestPath: path,
890
+ startService: async () => 0,
891
+ isLinked: () => false,
892
+ portProbe: async () => false,
893
+ log: (l) => logs.push(l),
894
+ });
895
+ expect(code).toBe(0);
896
+ const joined = logs.join("\n");
897
+ expect(joined).toMatch(/Scribe is listening on http:\/\/127\.0\.0\.1:1943/);
898
+ expect(joined).toMatch(/parakeet-mlx/);
899
+ expect(joined).toMatch(/groq.*openai/);
900
+ } finally {
901
+ cleanup();
902
+ }
903
+ });
904
+
905
+ test("notes install emits the post-install footer pointing at the Notes UI", async () => {
906
+ const { path, cleanup } = makeTempPath();
907
+ try {
908
+ const logs: string[] = [];
909
+ const code = await install("notes", {
910
+ runner: async () => 0,
911
+ manifestPath: path,
912
+ startService: async () => 0,
913
+ isLinked: () => false,
914
+ portProbe: async () => false,
915
+ log: (l) => logs.push(l),
916
+ });
917
+ expect(code).toBe(0);
918
+ const joined = logs.join("\n");
919
+ expect(joined).toMatch(/Open your Notes UI at http:\/\/localhost:1942\/notes/);
920
+ expect(joined).toMatch(/http:\/\/127\.0\.0\.1:1940\/vault\/default/);
921
+ } finally {
922
+ cleanup();
923
+ }
924
+ });
925
+
926
+ test("vault install does not emit a CLI-side footer (vault prints its own)", async () => {
927
+ // PR #166 has parachute-vault init print a richer footer with the API
928
+ // token; the CLI shouldn't double up. spec.postInstallFooter is left
929
+ // undefined for vault on purpose.
930
+ const { path, cleanup } = makeTempPath();
931
+ try {
932
+ const logs: string[] = [];
933
+ await install("vault", {
934
+ runner: async () => 0,
935
+ manifestPath: path,
936
+ startService: async () => 0,
937
+ isLinked: () => false,
938
+ portProbe: async () => false,
939
+ log: (l) => logs.push(l),
940
+ });
941
+ const joined = logs.join("\n");
942
+ expect(joined).not.toMatch(/Open your Notes UI/);
943
+ expect(joined).not.toMatch(/Scribe is listening/);
944
+ } finally {
945
+ cleanup();
946
+ }
947
+ });
948
+
949
+ test("scribe install with --scribe-provider/--scribe-key writes config + .env non-interactively", async () => {
950
+ const { path, cleanup } = makeTempPath();
951
+ const configDir = join(path, "..");
952
+ try {
953
+ const code = await install("scribe", {
954
+ runner: async () => 0,
955
+ manifestPath: path,
956
+ configDir,
957
+ startService: async () => 0,
958
+ isLinked: () => false,
959
+ portProbe: async () => false,
960
+ log: () => {},
961
+ scribeProvider: "groq",
962
+ scribeKey: "gsk_test_value",
963
+ scribeAvailability: { kind: "not-tty" },
964
+ });
965
+ expect(code).toBe(0);
966
+ const cfg = JSON.parse(readFileSync(join(configDir, "scribe", "config.json"), "utf8"));
967
+ expect(cfg.transcribe).toEqual({ provider: "groq" });
968
+ const envText = readFileSync(join(configDir, "scribe", ".env"), "utf8");
969
+ expect(envText).toContain("GROQ_API_KEY=gsk_test_value");
970
+ } finally {
971
+ cleanup();
972
+ }
973
+ });
974
+
975
+ test("scribe install drives interactive prompt via the availability seam", async () => {
976
+ const { path, cleanup } = makeTempPath();
977
+ const configDir = join(path, "..");
978
+ try {
979
+ const answers = ["openai", "sk-from-prompt"];
980
+ let i = 0;
981
+ const code = await install("scribe", {
982
+ runner: async () => 0,
983
+ manifestPath: path,
984
+ configDir,
985
+ startService: async () => 0,
986
+ isLinked: () => false,
987
+ portProbe: async () => false,
988
+ log: () => {},
989
+ scribeAvailability: {
990
+ kind: "available",
991
+ prompt: async () => answers[i++] ?? "",
992
+ },
993
+ });
994
+ expect(code).toBe(0);
995
+ const cfg = JSON.parse(readFileSync(join(configDir, "scribe", "config.json"), "utf8"));
996
+ expect(cfg.transcribe).toEqual({ provider: "openai" });
997
+ const envText = readFileSync(join(configDir, "scribe", ".env"), "utf8");
998
+ expect(envText).toContain("OPENAI_API_KEY=sk-from-prompt");
999
+ } finally {
1000
+ cleanup();
1001
+ }
1002
+ });
1003
+
1004
+ test("scribe install in non-TTY without flags leaves config untouched", async () => {
1005
+ const { path, cleanup } = makeTempPath();
1006
+ const configDir = join(path, "..");
1007
+ try {
1008
+ const code = await install("scribe", {
1009
+ runner: async () => 0,
1010
+ manifestPath: path,
1011
+ configDir,
1012
+ startService: async () => 0,
1013
+ isLinked: () => false,
1014
+ portProbe: async () => false,
1015
+ log: () => {},
1016
+ scribeAvailability: { kind: "not-tty" },
1017
+ });
1018
+ expect(code).toBe(0);
1019
+ // Auto-wire didn't run (no vault), so config.json is never created.
1020
+ expect(existsSync(join(configDir, "scribe", "config.json"))).toBe(false);
1021
+ } finally {
1022
+ cleanup();
1023
+ }
1024
+ });
1025
+
1026
+ test("non-scribe service install does not invoke the provider setup", async () => {
1027
+ const { path, cleanup } = makeTempPath();
1028
+ const configDir = join(path, "..");
1029
+ try {
1030
+ const code = await install("vault", {
1031
+ runner: async () => 0,
1032
+ manifestPath: path,
1033
+ configDir,
1034
+ startService: async () => 0,
1035
+ isLinked: () => false,
1036
+ portProbe: async () => false,
1037
+ log: () => {},
1038
+ // If the installer were to call setupScribeProvider here, the absent
1039
+ // availability seam would default to detecting a real TTY and (in
1040
+ // a real test runner with no TTY) skip silently. We just assert no
1041
+ // scribe config materialized.
1042
+ });
1043
+ expect(code).toBe(0);
1044
+ expect(existsSync(join(configDir, "scribe", "config.json"))).toBe(false);
1045
+ } finally {
1046
+ cleanup();
1047
+ }
1048
+ });
1049
+
1050
+ // CLI-as-port-authority (#53). Install assigns the service's port up front
1051
+ // and writes `PORT=<port>` into `<configDir>/<svc>/.env`. lifecycle.start
1052
+ // merges that .env into spawn env, so the next daemon boot binds the port
1053
+ // the CLI picked.
1054
+ test("install writes PORT=<canonical> to .env when the slot is free", async () => {
1055
+ const { path, configDir, cleanup } = makeTempPath();
1056
+ try {
1057
+ const code = await install("vault", {
1058
+ runner: async () => 0,
1059
+ manifestPath: path,
1060
+ configDir,
1061
+ startService: async () => 0,
1062
+ isLinked: () => false,
1063
+ portProbe: async () => false,
1064
+ log: () => {},
1065
+ });
1066
+ expect(code).toBe(0);
1067
+ const envText = readFileSync(join(configDir, "vault", ".env"), "utf8");
1068
+ expect(envText).toContain("PORT=1940");
1069
+ } finally {
1070
+ cleanup();
1071
+ }
1072
+ });
1073
+
1074
+ test("install preserves a pre-existing PORT in .env across re-installs", async () => {
1075
+ const { path, configDir, cleanup } = makeTempPath();
1076
+ try {
1077
+ // First install assigns canonical 1940.
1078
+ await install("vault", {
1079
+ runner: async () => 0,
1080
+ manifestPath: path,
1081
+ configDir,
1082
+ startService: async () => 0,
1083
+ isLinked: () => false,
1084
+ portProbe: async () => false,
1085
+ log: () => {},
1086
+ });
1087
+ // Hand-edit .env to use a custom port (operator override).
1088
+ const envPath = join(configDir, "vault", ".env");
1089
+ const original = readFileSync(envPath, "utf8");
1090
+ const edited = original.replace("PORT=1940", "PORT=1947");
1091
+ const { writeFileSync } = await import("node:fs");
1092
+ writeFileSync(envPath, edited);
1093
+
1094
+ // Second install must preserve the operator's choice, not stomp it.
1095
+ await install("vault", {
1096
+ runner: async () => 0,
1097
+ manifestPath: path,
1098
+ configDir,
1099
+ startService: async () => 0,
1100
+ isLinked: () => false,
1101
+ portProbe: async () => false,
1102
+ log: () => {},
1103
+ });
1104
+ expect(readFileSync(envPath, "utf8")).toContain("PORT=1947");
1105
+ } finally {
1106
+ cleanup();
1107
+ }
1108
+ });
1109
+
1110
+ test("install falls back inside the canonical range when the slot is occupied", async () => {
1111
+ const { path, configDir, cleanup } = makeTempPath();
1112
+ try {
1113
+ // Pretend something else is on 1940.
1114
+ upsertService(
1115
+ {
1116
+ name: "squatter-on-vault-port",
1117
+ port: 1940,
1118
+ paths: ["/squatter"],
1119
+ health: "/squatter/health",
1120
+ version: "0.0.0",
1121
+ },
1122
+ path,
1123
+ );
1124
+ const logs: string[] = [];
1125
+ const code = await install("vault", {
1126
+ runner: async () => 0,
1127
+ manifestPath: path,
1128
+ configDir,
1129
+ startService: async () => 0,
1130
+ isLinked: () => false,
1131
+ portProbe: async () => false,
1132
+ log: (l) => logs.push(l),
1133
+ });
1134
+ expect(code).toBe(0);
1135
+ // First reservation slot is 1944.
1136
+ const envText = readFileSync(join(configDir, "vault", ".env"), "utf8");
1137
+ expect(envText).toContain("PORT=1944");
1138
+ const entry = findService("parachute-vault", path);
1139
+ expect(entry?.port).toBe(1944);
1140
+ expect(logs.join("\n")).toMatch(/canonical port 1940 is in use/);
1141
+ } finally {
1142
+ cleanup();
1143
+ }
1144
+ });
1145
+ });