@openparachute/hub 0.6.2 → 0.6.3-rc.2

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 (58) hide show
  1. package/README.md +87 -35
  2. package/package.json +1 -1
  3. package/src/__tests__/api-hub-upgrade.test.ts +690 -0
  4. package/src/__tests__/api-modules-ops.test.ts +359 -3
  5. package/src/__tests__/api-modules.test.ts +54 -0
  6. package/src/__tests__/expose-cloudflare.test.ts +163 -72
  7. package/src/__tests__/expose-off-auto.test.ts +26 -1
  8. package/src/__tests__/expose.test.ts +260 -240
  9. package/src/__tests__/hub-control.test.ts +1 -242
  10. package/src/__tests__/hub-server.test.ts +64 -0
  11. package/src/__tests__/hub-unit.test.ts +574 -0
  12. package/src/__tests__/init.test.ts +219 -2
  13. package/src/__tests__/lifecycle.test.ts +416 -1448
  14. package/src/__tests__/managed-unit.test.ts +575 -0
  15. package/src/__tests__/migrate-cutover.test.ts +840 -0
  16. package/src/__tests__/migrate-offer.test.ts +240 -0
  17. package/src/__tests__/migrate.test.ts +132 -0
  18. package/src/__tests__/module-ops-client.test.ts +556 -0
  19. package/src/__tests__/port-probe.test.ts +23 -0
  20. package/src/__tests__/setup-wizard.test.ts +130 -0
  21. package/src/__tests__/status-supervisor.test.ts +504 -0
  22. package/src/__tests__/status.test.ts +157 -708
  23. package/src/__tests__/supervisor.test.ts +471 -6
  24. package/src/__tests__/upgrade.test.ts +351 -5
  25. package/src/api-hub-upgrade.ts +384 -0
  26. package/src/api-hub.ts +2 -1
  27. package/src/api-modules-ops.ts +221 -0
  28. package/src/api-modules.ts +18 -2
  29. package/src/cli.ts +97 -12
  30. package/src/cloudflare/connector-service.ts +117 -322
  31. package/src/commands/expose-cloudflare.ts +63 -71
  32. package/src/commands/expose-supervisor.ts +247 -0
  33. package/src/commands/expose.ts +59 -48
  34. package/src/commands/init.ts +225 -12
  35. package/src/commands/lifecycle.ts +455 -816
  36. package/src/commands/migrate-cutover.ts +837 -0
  37. package/src/commands/migrate.ts +71 -2
  38. package/src/commands/serve-boot.ts +71 -25
  39. package/src/commands/status.ts +535 -235
  40. package/src/commands/upgrade.ts +100 -2
  41. package/src/help.ts +128 -68
  42. package/src/hub-control.ts +23 -162
  43. package/src/hub-server.ts +39 -0
  44. package/src/hub-unit.ts +735 -0
  45. package/src/hub-upgrade-helper.ts +306 -0
  46. package/src/hub-upgrade-mode.ts +209 -0
  47. package/src/hub-upgrade-status.ts +150 -0
  48. package/src/managed-unit.ts +692 -0
  49. package/src/migrate-offer.ts +186 -0
  50. package/src/module-ops-client.ts +457 -0
  51. package/src/port-probe.ts +50 -0
  52. package/src/process-state.ts +19 -3
  53. package/src/setup-wizard.ts +80 -1
  54. package/src/supervisor.ts +389 -38
  55. package/web/ui/dist/assets/index-D_6AFvZy.js +61 -0
  56. package/web/ui/dist/assets/{index-BiBlvEaj.css → index-mz8XcVPP.css} +1 -1
  57. package/web/ui/dist/index.html +2 -2
  58. package/web/ui/dist/assets/index-CIN3mnmf.js +0 -61
@@ -3,9 +3,26 @@ import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { status } from "../commands/status.ts";
6
- import { writePid } from "../process-state.ts";
6
+ import type { HubUnitDeps, HubUnitStateResult } from "../hub-unit.ts";
7
+ import type { ModuleStatesResult } from "../module-ops-client.ts";
7
8
  import { upsertService } from "../services-manifest.ts";
8
9
 
10
+ /**
11
+ * Phase 5b: `status` reads the hub row from the platform manager + `/health` and
12
+ * the module rows from the running supervisor (`GET /api/modules`). The detached
13
+ * pidfile/HTTP-probe arm was retired, so these tests — the table-rendering /
14
+ * per-module URL deep-link / persisted-start-error / state-rollup coverage that
15
+ * used to live on the detached arm — drive the supervised arm instead. The
16
+ * detached-specific cases that no longer exist (HTTP probe success/failure, the
17
+ * http-401-healthy carve-out, known-stopped-skips-probe) are not re-asserted: a
18
+ * module's run-state comes from the supervisor now, not an HTTP probe.
19
+ *
20
+ * The hub-row state machine + module-state degradation paths are covered in
21
+ * `status-supervisor.test.ts`; this file focuses on the manifest-derived
22
+ * rendering (URLs, version, persisted start-error) that `manifestRowBase`
23
+ * produces for each module row.
24
+ */
25
+
9
26
  function makeTempPath(): { path: string; cleanup: () => void; configDir: string } {
10
27
  const dir = mkdtempSync(join(tmpdir(), "pcli-status-"));
11
28
  return {
@@ -15,276 +32,120 @@ function makeTempPath(): { path: string; cleanup: () => void; configDir: string
15
32
  };
16
33
  }
17
34
 
18
- describe("status", () => {
19
- test("empty manifest prints hint and exits 0", async () => {
20
- const { path, cleanup } = makeTempPath();
21
- try {
22
- const lines: string[] = [];
23
- const code = await status({
24
- manifestPath: path,
25
- fetchImpl: async () => new Response(null, { status: 200 }),
26
- print: (l) => lines.push(l),
27
- });
28
- expect(code).toBe(0);
29
- expect(lines.join("\n")).toMatch(/No services installed/);
30
- } finally {
31
- cleanup();
32
- }
33
- });
35
+ /** Install-source deps that never touch the real filesystem. */
36
+ const STUB_INSTALL_SOURCE = {
37
+ bunGlobalPrefixes: () => [] as string[],
38
+ resolveBunGlobal: () => null,
39
+ readJson: () => ({ version: "0.6.2" }),
40
+ readGitHead: () => undefined,
41
+ };
34
42
 
35
- test("all-healthy returns 0 and prints table", async () => {
36
- const { path, cleanup } = makeTempPath();
37
- try {
38
- upsertService(
39
- { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.2.4" },
40
- path,
41
- );
42
- upsertService(
43
- {
44
- name: "parachute-scribe",
45
- port: 3200,
46
- paths: ["/scribe"],
47
- health: "/scribe/health",
48
- version: "0.1.0",
49
- },
50
- path,
51
- );
52
- const seen: string[] = [];
53
- const lines: string[] = [];
54
- const code = await status({
55
- manifestPath: path,
56
- fetchImpl: async (url) => {
57
- seen.push(String(url));
58
- return new Response(null, { status: 200 });
59
- },
60
- print: (l) => lines.push(l),
61
- });
62
- expect(code).toBe(0);
63
- expect(seen).toContain("http://localhost:1940/health");
64
- expect(seen).toContain("http://localhost:3200/scribe/health");
65
- // Header reflects the post-workstream-F column shape:
66
- // SERVICE PORT VERSION STATE PID UPTIME LATENCY SOURCE
67
- expect(lines[0]).toMatch(/SERVICE/);
68
- expect(lines[0]).toMatch(/STATE/);
69
- expect(lines[0]).not.toMatch(/PROCESS/);
70
- expect(lines[0]).not.toMatch(/HEALTH/);
71
- expect(lines.some((l) => l.includes("parachute-vault"))).toBe(true);
72
- // Healthy probe rolls up to `active` per design-system.md §6.
73
- expect(lines.some((l) => /\bactive\b/.test(l))).toBe(true);
74
- } finally {
75
- cleanup();
76
- }
77
- });
43
+ const FAKE_HUB_UNIT_DEPS = {
44
+ platform: "linux",
45
+ getuid: () => 1000,
46
+ homeDir: () => "/home/op",
47
+ userName: () => "op",
48
+ which: () => "/usr/bin/systemctl",
49
+ run: () => ({ code: 0, stdout: "", stderr: "" }),
50
+ writeFile: () => {},
51
+ removeFile: () => {},
52
+ readFile: () => undefined,
53
+ exists: () => false,
54
+ probeHealth: async () => true,
55
+ portListening: async () => true,
56
+ sleep: async () => {},
57
+ } as unknown as HubUnitDeps;
78
58
 
79
- test("persisted lastStartError surfaces on a continuation line", async () => {
80
- const { path, cleanup } = makeTempPath();
81
- try {
82
- upsertService(
83
- {
84
- name: "parachute-vault",
85
- port: 1940,
86
- paths: ["/"],
87
- health: "/health",
88
- version: "0.2.4",
89
- lastStartError: {
90
- error_type: "missing_dependency",
91
- error_description: "parachute-vault is required ...",
92
- binary: "parachute-vault",
93
- install: { generic: "parachute install vault" },
94
- },
95
- },
96
- path,
97
- );
98
- const lines: string[] = [];
99
- await status({
100
- manifestPath: path,
101
- // Probe refuses (service down) — the row is failing, and the
102
- // start-error note explains why.
103
- fetchImpl: async () => {
104
- throw new Error("ECONNREFUSED");
105
- },
106
- print: (l) => lines.push(l),
107
- });
108
- const out = lines.join("\n");
109
- expect(out).toMatch(/failed to start: parachute-vault not installed/);
110
- } finally {
111
- cleanup();
112
- }
113
- });
59
+ function fakeOpenDb(): { close: () => void } {
60
+ return { close: () => {} };
61
+ }
114
62
 
115
- test("any-failing returns 1 and surfaces probe detail on continuation line", async () => {
116
- const { path, cleanup } = makeTempPath();
117
- try {
118
- upsertService(
119
- { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.2.4" },
120
- path,
121
- );
122
- const lines: string[] = [];
123
- const code = await status({
124
- manifestPath: path,
125
- fetchImpl: async () => {
126
- throw new Error("ECONNREFUSED");
127
- },
128
- print: (l) => lines.push(l),
129
- });
130
- expect(code).toBe(1);
131
- // STATE column rolls up to `failing`.
132
- expect(lines.some((l) => /\bfailing\b/.test(l))).toBe(true);
133
- // The pre-F HEALTH column's detail survives on a continuation line
134
- // (" ! probe: ECONNREFUSED") so the operator can still diagnose.
135
- expect(lines.some((l) => l.includes("probe:") && l.includes("ECONNREFUSED"))).toBe(true);
136
- } finally {
137
- cleanup();
138
- }
139
- });
63
+ interface ArmOpts {
64
+ managerState?: HubUnitStateResult;
65
+ hubHealthy?: boolean;
66
+ moduleStates?: ModuleStatesResult;
67
+ }
68
+
69
+ /**
70
+ * Drive `status` through the supervised arm with a healthy hub + the given module
71
+ * states. Defaults: manager `active`, hub `/health` OK, no module rows.
72
+ */
73
+ function supervisorOpts(configDir: string, path: string, o: ArmOpts = {}) {
74
+ return {
75
+ manifestPath: path,
76
+ configDir,
77
+ installSourceDeps: STUB_INSTALL_SOURCE,
78
+ hubSrcDir: "/nonexistent/hub/src",
79
+ supervisor: {
80
+ hubUnitDeps: FAKE_HUB_UNIT_DEPS,
81
+ queryHubUnitState: () => o.managerState ?? { state: "active" as const },
82
+ probeHubHealth: async () => o.hubHealthy ?? true,
83
+ fetchModuleStates: async () => o.moduleStates ?? { supervisorAvailable: true, modules: [] },
84
+ openDb: fakeOpenDb as unknown as (configDir: string) => import("bun:sqlite").Database,
85
+ },
86
+ };
87
+ }
88
+
89
+ /** A `running` supervisor snapshot for a short name (the happy-path module row). */
90
+ function runningModule(short: string, version = "0.6.2") {
91
+ return {
92
+ short,
93
+ installed: true,
94
+ installed_version: version,
95
+ supervisor_status: "running" as const,
96
+ pid: 5151,
97
+ supervisor_start_error: null,
98
+ };
99
+ }
140
100
 
141
- test("http non-2xx counts as failing and surfaces the code on the probe line", async () => {
142
- const { path, cleanup } = makeTempPath();
101
+ describe("status table + hub row", () => {
102
+ test("empty manifest still renders the hub row (a unit-managed hub runs with zero modules)", async () => {
103
+ const { path, configDir, cleanup } = makeTempPath();
143
104
  try {
144
- upsertService(
145
- { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.2.4" },
146
- path,
147
- );
148
105
  const lines: string[] = [];
149
106
  const code = await status({
150
- manifestPath: path,
151
- fetchImpl: async () => new Response(null, { status: 503 }),
107
+ ...supervisorOpts(configDir, path),
152
108
  print: (l) => lines.push(l),
153
109
  });
154
- expect(code).toBe(1);
155
- expect(lines.some((l) => /\bfailing\b/.test(l))).toBe(true);
156
- expect(lines.some((l) => l.includes("probe: http 503"))).toBe(true);
110
+ expect(code).toBe(0);
111
+ // The hub row is meaningful even with no modules installed.
112
+ expect(lines.some((l) => l.includes("parachute-hub (internal)"))).toBe(true);
157
113
  } finally {
158
114
  cleanup();
159
115
  }
160
116
  });
161
117
 
162
- test("http 401 counts as HEALTHY (auth-gated endpoint is responsive)", async () => {
163
- // Vault's canonical health path `/vault/<name>/health` returns 401
164
- // without an API key — that's the server replying "I'm up but you
165
- // need auth," not "I'm down." `parachute status` used to roll 401
166
- // into the failing bucket via `res.ok`, surfacing "failing" on every
167
- // fresh install (vault was fine — the probe was just confused).
168
- // Now: 401 specifically counts as healthy. Other 4xx (404, 400) stay
169
- // unhealthy — those mean the configured health path is misshapen.
118
+ test("all-running modules return 0 and render the table with versions + state", async () => {
170
119
  const { path, configDir, cleanup } = makeTempPath();
171
120
  try {
172
121
  upsertService(
173
122
  {
174
123
  name: "parachute-vault",
175
124
  port: 1940,
176
- paths: ["/"],
125
+ paths: ["/vault/default"],
177
126
  health: "/vault/default/health",
178
- version: "0.2.4",
127
+ version: "0.6.2",
179
128
  },
180
129
  path,
181
130
  );
182
- writePid("vault", 4242, configDir);
183
- const lines: string[] = [];
184
- const code = await status({
185
- manifestPath: path,
186
- configDir,
187
- alive: () => true,
188
- fetchImpl: async () => new Response(null, { status: 401 }),
189
- print: (l) => lines.push(l),
190
- });
191
- expect(code).toBe(0);
192
- expect(lines.some((l) => /\bactive\b/.test(l))).toBe(true);
193
- // No "failing" rollup, no `! probe: http 401` continuation line.
194
- expect(lines.some((l) => /\bfailing\b/.test(l))).toBe(false);
195
- expect(lines.some((l) => l.includes("probe: http 401"))).toBe(false);
196
- } finally {
197
- cleanup();
198
- }
199
- });
200
-
201
- test("running + healthy probe shows STATE=active, pid + uptime", async () => {
202
- const { path, configDir, cleanup } = makeTempPath();
203
- try {
204
- upsertService(
205
- { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.2.4" },
206
- path,
207
- );
208
- writePid("vault", 4242, configDir);
209
- const lines: string[] = [];
210
- const code = await status({
211
- manifestPath: path,
212
- configDir,
213
- alive: () => true,
214
- fetchImpl: async () => new Response(null, { status: 200 }),
215
- print: (l) => lines.push(l),
216
- });
217
- expect(code).toBe(0);
218
- // Pre-F: STATE was a two-column (PROCESS=running, HEALTH=ok) split.
219
- // Post-F: collapsed to one column showing `active`.
220
- expect(lines.some((l) => /\bactive\b/.test(l))).toBe(true);
221
- expect(lines.some((l) => l.includes("4242"))).toBe(true);
222
- // Probe-detail continuation line is suppressed for active rows
223
- // (the rollup is sufficient — no need to repeat "ok").
224
- expect(lines.some((l) => l.includes("probe:"))).toBe(false);
225
- } finally {
226
- cleanup();
227
- }
228
- });
229
-
230
- test("known-stopped process renders STATE=inactive, skips probe, exits 0", async () => {
231
- const { path, configDir, cleanup } = makeTempPath();
232
- try {
233
- upsertService(
234
- { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.2.4" },
235
- path,
236
- );
237
- writePid("vault", 4242, configDir);
238
- let probed = false;
239
131
  const lines: string[] = [];
240
132
  const code = await status({
241
- manifestPath: path,
242
- configDir,
243
- alive: () => false,
244
- fetchImpl: async () => {
245
- probed = true;
246
- return new Response(null, { status: 200 });
247
- },
133
+ ...supervisorOpts(configDir, path, {
134
+ moduleStates: { supervisorAvailable: true, modules: [runningModule("vault")] },
135
+ }),
248
136
  print: (l) => lines.push(l),
249
137
  });
250
138
  expect(code).toBe(0);
251
- expect(probed).toBe(false);
252
- // Pre-F: PROCESS=stopped. Post-F: STATE=inactive.
253
- expect(lines.some((l) => /\binactive\b/.test(l))).toBe(true);
139
+ const vaultLine = lines.find((l) => l.includes("parachute-vault"));
140
+ expect(vaultLine).toMatch(/\bactive\b/);
141
+ expect(vaultLine).toMatch(/0\.6\.2/);
254
142
  } finally {
255
143
  cleanup();
256
144
  }
257
145
  });
258
146
 
259
- test("unknown process state (no pid file) still probes — externally managed OK", async () => {
147
+ test("persisted lastStartError surfaces on a continuation line", async () => {
260
148
  const { path, configDir, cleanup } = makeTempPath();
261
- try {
262
- upsertService(
263
- { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.2.4" },
264
- path,
265
- );
266
- let probed = false;
267
- const code = await status({
268
- manifestPath: path,
269
- configDir,
270
- fetchImpl: async () => {
271
- probed = true;
272
- return new Response(null, { status: 200 });
273
- },
274
- print: () => {},
275
- });
276
- expect(code).toBe(0);
277
- expect(probed).toBe(true);
278
- } finally {
279
- cleanup();
280
- }
281
- });
282
-
283
- // URL column: the launch-day pain was a user staring at the table not
284
- // knowing where to point Claude.ai or curl. Each row gets a " → URL"
285
- // continuation line so the next step is obvious.
286
- test("vault row prints MCP URL beneath it (path + /mcp suffix)", async () => {
287
- const { path, cleanup } = makeTempPath();
288
149
  try {
289
150
  upsertService(
290
151
  {
@@ -292,519 +153,107 @@ describe("status", () => {
292
153
  port: 1940,
293
154
  paths: ["/vault/default"],
294
155
  health: "/vault/default/health",
295
- version: "0.2.4",
156
+ version: "0.6.2",
157
+ lastStartError: {
158
+ error_type: "missing_dependency",
159
+ error_description: "parachute-vault not installed",
160
+ binary: "parachute-vault",
161
+ },
296
162
  },
297
163
  path,
298
164
  );
299
165
  const lines: string[] = [];
300
- await status({
301
- manifestPath: path,
302
- fetchImpl: async () => new Response(null, { status: 200 }),
166
+ const code = await status({
167
+ ...supervisorOpts(configDir, path, {
168
+ // No live supervisor start-error falls back to the persisted manifest note.
169
+ moduleStates: {
170
+ supervisorAvailable: true,
171
+ modules: [
172
+ {
173
+ short: "vault",
174
+ installed: true,
175
+ installed_version: "0.6.2",
176
+ supervisor_status: "crashed",
177
+ pid: null,
178
+ supervisor_start_error: null,
179
+ },
180
+ ],
181
+ },
182
+ }),
303
183
  print: (l) => lines.push(l),
304
184
  });
305
- expect(lines.some((l) => l.includes("http://127.0.0.1:1940/vault/default/mcp"))).toBe(true);
185
+ // crashedfailing → exit 1; the persisted missing-dependency note shows.
186
+ expect(code).toBe(1);
187
+ expect(lines.join("\n")).toMatch(/failed to start: parachute-vault not installed/);
306
188
  } finally {
307
189
  cleanup();
308
190
  }
309
191
  });
192
+ });
310
193
 
311
- test("scribe row prints root URL (API is at /, ignore path prefix)", async () => {
312
- const { path, cleanup } = makeTempPath();
194
+ describe("status per-module URL deep-links (manifestRowBase / urlForEntry)", () => {
195
+ async function urlFor(name: string, port: number, paths: string[]): Promise<string> {
196
+ const { path, configDir, cleanup } = makeTempPath();
313
197
  try {
314
- upsertService(
315
- {
316
- name: "parachute-scribe",
317
- port: 1943,
318
- paths: ["/scribe"],
319
- health: "/scribe/health",
320
- version: "0.1.0",
321
- },
322
- path,
323
- );
198
+ const short = name.replace(/^parachute-/, "");
199
+ upsertService({ name, port, paths, health: `${paths[0]}/health`, version: "0.6.2" }, path);
324
200
  const lines: string[] = [];
325
201
  await status({
326
- manifestPath: path,
327
- fetchImpl: async () => new Response(null, { status: 200 }),
202
+ ...supervisorOpts(configDir, path, {
203
+ moduleStates: { supervisorAvailable: true, modules: [runningModule(short)] },
204
+ }),
328
205
  print: (l) => lines.push(l),
329
206
  });
330
- expect(lines.some((l) => l === " → http://127.0.0.1:1943")).toBe(true);
207
+ return lines.join("\n");
331
208
  } finally {
332
209
  cleanup();
333
210
  }
334
- });
211
+ }
335
212
 
336
- test("notes row prints UI URL (port + /notes mount)", async () => {
337
- const { path, cleanup } = makeTempPath();
338
- try {
339
- upsertService(
340
- {
341
- name: "parachute-notes",
342
- port: 1942,
343
- paths: ["/notes"],
344
- health: "/notes/health",
345
- version: "0.0.1",
346
- },
347
- path,
348
- );
349
- const lines: string[] = [];
350
- await status({
351
- manifestPath: path,
352
- fetchImpl: async () => new Response(null, { status: 200 }),
353
- print: (l) => lines.push(l),
354
- });
355
- expect(lines.some((l) => l === " → http://127.0.0.1:1942/notes")).toBe(true);
356
- } finally {
357
- cleanup();
358
- }
213
+ test("vault row prints the MCP URL (path + /mcp suffix)", async () => {
214
+ const out = await urlFor("parachute-vault", 1940, ["/vault/default"]);
215
+ expect(out).toMatch(/\/vault\/default\/mcp/);
359
216
  });
360
217
 
361
- test("channel row prints port + /channel mount", async () => {
362
- const { path, cleanup } = makeTempPath();
363
- try {
364
- upsertService(
365
- {
366
- name: "parachute-channel",
367
- port: 1941,
368
- paths: ["/channel"],
369
- health: "/channel/health",
370
- version: "0.1.0",
371
- },
372
- path,
373
- );
374
- const lines: string[] = [];
375
- await status({
376
- manifestPath: path,
377
- fetchImpl: async () => new Response(null, { status: 200 }),
378
- print: (l) => lines.push(l),
379
- });
380
- expect(lines.some((l) => l === " → http://127.0.0.1:1941/channel")).toBe(true);
381
- } finally {
382
- cleanup();
383
- }
218
+ test("scribe row prints the root URL (API is at /, ignore path prefix)", async () => {
219
+ const out = await urlFor("parachute-scribe", 3200, ["/scribe"]);
220
+ expect(out).toMatch(/127\.0\.0\.1:3200|localhost:3200/);
384
221
  });
385
222
 
386
- test("unknown service falls back to bare host:port + paths[0]", async () => {
387
- const { path, cleanup } = makeTempPath();
388
- try {
389
- upsertService(
390
- {
391
- name: "third-party-thing",
392
- port: 9000,
393
- paths: ["/widget"],
394
- health: "/health",
395
- version: "1.0.0",
396
- },
397
- path,
398
- );
399
- const lines: string[] = [];
400
- await status({
401
- manifestPath: path,
402
- fetchImpl: async () => new Response(null, { status: 200 }),
403
- print: (l) => lines.push(l),
404
- });
405
- expect(lines.some((l) => l === " → http://127.0.0.1:9000/widget")).toBe(true);
406
- } finally {
407
- cleanup();
408
- }
223
+ test("notes row prints the UI URL (port + /notes mount)", async () => {
224
+ const out = await urlFor("parachute-notes", 5173, ["/notes"]);
225
+ expect(out).toMatch(/:5173\/notes/);
409
226
  });
410
227
 
411
- // Canonical-port drift warning (hub#195). When a known service ends up at
412
- // a non-canonical port (because of an upgrade rewrite, a port-walk fallback,
413
- // or an operator edit), surface it in `parachute status` so a silent miswire
414
- // is operator-visible. Warning, not error — operators may have moved the
415
- // service deliberately to dodge a third-party clash.
416
- describe("canonical-port drift warning", () => {
417
- test("warns when scribe is at non-canonical port (1944 instead of 1943)", async () => {
418
- const { path, cleanup } = makeTempPath();
419
- try {
420
- upsertService(
421
- {
422
- name: "parachute-scribe",
423
- port: 1944,
424
- paths: ["/scribe"],
425
- health: "/scribe/health",
426
- version: "0.4.0",
427
- },
428
- path,
429
- );
430
- const lines: string[] = [];
431
- await status({
432
- manifestPath: path,
433
- fetchImpl: async () => new Response(null, { status: 200 }),
434
- print: (l) => lines.push(l),
435
- });
436
- expect(lines.some((l) => l.includes("canonical port is 1943"))).toBe(true);
437
- } finally {
438
- cleanup();
439
- }
440
- });
441
-
442
- test("does not warn when service is on its canonical port", async () => {
443
- const { path, cleanup } = makeTempPath();
444
- try {
445
- upsertService(
446
- {
447
- name: "parachute-scribe",
448
- port: 1943,
449
- paths: ["/scribe"],
450
- health: "/scribe/health",
451
- version: "0.4.0",
452
- },
453
- path,
454
- );
455
- const lines: string[] = [];
456
- await status({
457
- manifestPath: path,
458
- fetchImpl: async () => new Response(null, { status: 200 }),
459
- print: (l) => lines.push(l),
460
- });
461
- expect(lines.some((l) => l.includes("canonical port"))).toBe(false);
462
- } finally {
463
- cleanup();
464
- }
465
- });
466
-
467
- test("does not warn for third-party services with no canonical port", async () => {
468
- const { path, cleanup } = makeTempPath();
469
- try {
470
- upsertService(
471
- {
472
- name: "third-party-thing",
473
- port: 9000,
474
- paths: ["/widget"],
475
- health: "/health",
476
- version: "1.0.0",
477
- },
478
- path,
479
- );
480
- const lines: string[] = [];
481
- await status({
482
- manifestPath: path,
483
- fetchImpl: async () => new Response(null, { status: 200 }),
484
- print: (l) => lines.push(l),
485
- });
486
- expect(lines.some((l) => l.includes("canonical port"))).toBe(false);
487
- } finally {
488
- cleanup();
489
- }
490
- });
491
-
492
- test("warning does not affect exit code (status stays 0 when healthy)", async () => {
493
- const { path, cleanup } = makeTempPath();
494
- try {
495
- upsertService(
496
- {
497
- name: "parachute-scribe",
498
- port: 1944,
499
- paths: ["/scribe"],
500
- health: "/scribe/health",
501
- version: "0.4.0",
502
- },
503
- path,
504
- );
505
- const code = await status({
506
- manifestPath: path,
507
- fetchImpl: async () => new Response(null, { status: 200 }),
508
- print: () => {},
509
- });
510
- // Drift is informational. A healthy probed service still returns 0
511
- // even when the port has drifted off canonical.
512
- expect(code).toBe(0);
513
- } finally {
514
- cleanup();
515
- }
516
- });
517
-
518
- test("warning still fires when service is stopped (probe skipped)", async () => {
519
- const { path, configDir, cleanup } = makeTempPath();
520
- try {
521
- upsertService(
522
- {
523
- name: "parachute-scribe",
524
- port: 1944,
525
- paths: ["/scribe"],
526
- health: "/scribe/health",
527
- version: "0.4.0",
528
- },
529
- path,
530
- );
531
- writePid("scribe", 4242, configDir);
532
- const lines: string[] = [];
533
- await status({
534
- manifestPath: path,
535
- configDir,
536
- alive: () => false,
537
- fetchImpl: async () => new Response(null, { status: 200 }),
538
- print: (l) => lines.push(l),
539
- });
540
- // Drift is computed from services.json, not from the probe — a
541
- // stopped service with a drifted port should still surface the
542
- // warning so operators see the miswire even before they start it.
543
- expect(lines.some((l) => l.includes("canonical port is 1943"))).toBe(true);
544
- } finally {
545
- cleanup();
546
- }
547
- });
548
-
549
- test("multi-vault instance rows do not surface a drift warning (intentional gap)", async () => {
550
- // Pinning the documented gap: `parachute-vault-default` is not
551
- // a canonical manifest name in FIRST_PARTY_FALLBACKS, so
552
- // `canonicalPortForManifest` returns undefined and no drift
553
- // warning fires — even when the row's port differs from the
554
- // canonical `parachute-vault` port (1940). Rationale lives on
555
- // `canonicalPortForManifest` in service-spec.ts; this test pins
556
- // the behavior so a future change to the lookup shape doesn't
557
- // accidentally start emitting drift on every multi-vault row
558
- // without an explicit decision.
559
- const { path, cleanup } = makeTempPath();
560
- try {
561
- upsertService(
562
- {
563
- name: "parachute-vault-default",
564
- port: 1944,
565
- paths: ["/vault/default"],
566
- health: "/vault/default/health",
567
- version: "0.2.4",
568
- },
569
- path,
570
- );
571
- const lines: string[] = [];
572
- await status({
573
- manifestPath: path,
574
- fetchImpl: async () => new Response(null, { status: 200 }),
575
- print: (l) => lines.push(l),
576
- });
577
- expect(lines.some((l) => l.includes("canonical port"))).toBe(false);
578
- } finally {
579
- cleanup();
580
- }
581
- });
228
+ test("channel row prints port + /channel mount", async () => {
229
+ const out = await urlFor("parachute-channel", 1943, ["/channel"]);
230
+ expect(out).toMatch(/:1943\/channel/);
582
231
  });
583
232
 
584
- test("stopped services still render a URL line so the user knows where to point clients post-start", async () => {
233
+ test("unknown third-party service falls back to bare host:port + paths[0]", async () => {
585
234
  const { path, configDir, cleanup } = makeTempPath();
586
235
  try {
587
236
  upsertService(
588
237
  {
589
- name: "parachute-vault",
590
- port: 1940,
591
- paths: ["/vault/default"],
592
- health: "/vault/default/health",
593
- version: "0.2.4",
238
+ name: "acme-widget",
239
+ port: 4321,
240
+ paths: ["/widget"],
241
+ health: "/widget/health",
242
+ version: "1.0.0",
243
+ installDir: "/tmp/acme",
594
244
  },
595
245
  path,
596
246
  );
597
- writePid("vault", 4242, configDir);
598
247
  const lines: string[] = [];
599
248
  await status({
600
- manifestPath: path,
601
- configDir,
602
- alive: () => false,
603
- fetchImpl: async () => new Response(null, { status: 200 }),
249
+ ...supervisorOpts(configDir, path, {
250
+ moduleStates: { supervisorAvailable: true, modules: [runningModule("acme-widget")] },
251
+ }),
604
252
  print: (l) => lines.push(l),
605
253
  });
606
- expect(lines.some((l) => l.includes("→ http://127.0.0.1:1940/vault/default/mcp"))).toBe(true);
254
+ expect(lines.join("\n")).toMatch(/:4321\/widget/);
607
255
  } finally {
608
256
  cleanup();
609
257
  }
610
258
  });
611
-
612
- describe("install-source surface (hub#243)", () => {
613
- test("renders SOURCE column header + per-row label", async () => {
614
- const { path, cleanup } = makeTempPath();
615
- try {
616
- upsertService(
617
- {
618
- name: "parachute-vault",
619
- port: 1940,
620
- paths: ["/vault/default"],
621
- health: "/vault/default/health",
622
- version: "0.4.4-rc.3",
623
- installDir: "/Users/me/code/parachute-vault",
624
- },
625
- path,
626
- );
627
- const lines: string[] = [];
628
- await status({
629
- manifestPath: path,
630
- fetchImpl: async () => new Response(null, { status: 200 }),
631
- print: (l) => lines.push(l),
632
- installSourceDeps: {
633
- bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
634
- resolveBunGlobal: () => null,
635
- readJson: (p) =>
636
- p === "/Users/me/code/parachute-vault/package.json"
637
- ? { name: "@openparachute/vault", version: "0.4.4-rc.3" }
638
- : (() => {
639
- throw new Error("nope");
640
- })(),
641
- readGitHead: () => "8aa167b",
642
- },
643
- });
644
- expect(lines[0]).toMatch(/SOURCE/);
645
- expect(lines.some((l) => l.includes("bun-linked → parachute-vault @ 8aa167b"))).toBe(true);
646
- } finally {
647
- cleanup();
648
- }
649
- });
650
-
651
- test("STALE continuation line fires when bun-linked live version != cached version", async () => {
652
- // Reproduces hub#243's motivating case: services.json says 0.3.11-rc.1
653
- // but the live source has been rebuilt to 0.3.15-rc.1. Operator should
654
- // see STALE in one glance from `parachute status` output.
655
- const { path, cleanup } = makeTempPath();
656
- try {
657
- upsertService(
658
- {
659
- name: "parachute-notes",
660
- port: 1942,
661
- paths: ["/notes"],
662
- health: "/notes/health",
663
- version: "0.3.11-rc.1",
664
- installDir: "/Users/me/code/parachute-notes",
665
- },
666
- path,
667
- );
668
- const lines: string[] = [];
669
- await status({
670
- manifestPath: path,
671
- fetchImpl: async () => new Response(null, { status: 200 }),
672
- print: (l) => lines.push(l),
673
- installSourceDeps: {
674
- bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
675
- resolveBunGlobal: () => null,
676
- readJson: (p) =>
677
- p === "/Users/me/code/parachute-notes/package.json"
678
- ? { name: "@openparachute/notes", version: "0.3.15-rc.1" }
679
- : (() => {
680
- throw new Error("nope");
681
- })(),
682
- readGitHead: () => "051c404",
683
- },
684
- });
685
- expect(
686
- lines.some((l) =>
687
- l.includes("STALE: services.json cached 0.3.11-rc.1; live package.json 0.3.15-rc.1"),
688
- ),
689
- ).toBe(true);
690
- } finally {
691
- cleanup();
692
- }
693
- });
694
-
695
- test("npm-installed services render as `npm (<version>)` and never STALE", async () => {
696
- const { path, cleanup } = makeTempPath();
697
- try {
698
- upsertService(
699
- {
700
- name: "parachute-scribe",
701
- port: 1943,
702
- paths: ["/scribe"],
703
- health: "/scribe/health",
704
- version: "0.4.2-rc.1",
705
- installDir: "/home/test/.bun/install/global/node_modules/@openparachute/scribe",
706
- },
707
- path,
708
- );
709
- const lines: string[] = [];
710
- await status({
711
- manifestPath: path,
712
- fetchImpl: async () => new Response(null, { status: 200 }),
713
- print: (l) => lines.push(l),
714
- installSourceDeps: {
715
- bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
716
- resolveBunGlobal: () => null,
717
- readJson: (p) =>
718
- p === "/home/test/.bun/install/global/node_modules/@openparachute/scribe/package.json"
719
- ? { name: "@openparachute/scribe", version: "0.4.2-rc.1" }
720
- : (() => {
721
- throw new Error("nope");
722
- })(),
723
- readGitHead: () => undefined,
724
- },
725
- });
726
- expect(lines.some((l) => l.includes("npm (0.4.2-rc.1)"))).toBe(true);
727
- expect(lines.some((l) => l.includes("STALE:"))).toBe(false);
728
- } finally {
729
- cleanup();
730
- }
731
- });
732
-
733
- test("entries without installDir fall back to bun-global symlink lookup", async () => {
734
- // Some services.json entries (older first-party rows, or rows written
735
- // by a service that doesn't echo installDir) leave the field absent.
736
- // detectInstallSource maps the entry name → first-party package and
737
- // probes bun globals for the symlink. Pins that fallback path.
738
- const { path, cleanup } = makeTempPath();
739
- try {
740
- upsertService(
741
- {
742
- name: "parachute-vault",
743
- port: 1940,
744
- paths: ["/vault/default"],
745
- health: "/vault/default/health",
746
- version: "0.4.4-rc.3",
747
- // No installDir.
748
- },
749
- path,
750
- );
751
- const lines: string[] = [];
752
- await status({
753
- manifestPath: path,
754
- fetchImpl: async () => new Response(null, { status: 200 }),
755
- print: (l) => lines.push(l),
756
- installSourceDeps: {
757
- bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
758
- resolveBunGlobal: (pkg) =>
759
- pkg === "@openparachute/vault" ? "/Users/me/code/parachute-vault" : null,
760
- readJson: (p) =>
761
- p === "/Users/me/code/parachute-vault/package.json"
762
- ? { name: "@openparachute/vault", version: "0.4.4-rc.3" }
763
- : (() => {
764
- throw new Error("nope");
765
- })(),
766
- readGitHead: () => "8aa167b",
767
- },
768
- });
769
- expect(lines.some((l) => l.includes("bun-linked → parachute-vault @ 8aa167b"))).toBe(true);
770
- } finally {
771
- cleanup();
772
- }
773
- });
774
-
775
- test("third-party row without installDir + no mapping renders as 'unknown'", async () => {
776
- const { path, cleanup } = makeTempPath();
777
- try {
778
- upsertService(
779
- {
780
- name: "someapp",
781
- port: 1946,
782
- paths: ["/someapp"],
783
- health: "/someapp/health",
784
- version: "0.1.4-rc.1",
785
- // No installDir; someapp isn't in FIRST_PARTY_FALLBACKS by short name,
786
- // and the fallback bun-global lookup needs a known package name.
787
- },
788
- path,
789
- );
790
- const lines: string[] = [];
791
- await status({
792
- manifestPath: path,
793
- fetchImpl: async () => new Response(null, { status: 200 }),
794
- print: (l) => lines.push(l),
795
- installSourceDeps: {
796
- bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
797
- resolveBunGlobal: () => null,
798
- readJson: () => {
799
- throw new Error("not reached");
800
- },
801
- readGitHead: () => undefined,
802
- },
803
- });
804
- expect(lines.some((l) => l.includes("unknown"))).toBe(true);
805
- } finally {
806
- cleanup();
807
- }
808
- });
809
- });
810
259
  });