@openparachute/hub 0.5.12-rc.4 → 0.5.13-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.12-rc.4",
3
+ "version": "0.5.13-rc.4",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -490,3 +490,252 @@ describe("handleApiModulesConfig — stripPrefix=false (notes-shape)", () => {
490
490
  expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1941/notes/.parachute/config/schema");
491
491
  });
492
492
  });
493
+
494
+ /**
495
+ * hub#307: modules that declare `/.parachute` in their `paths[]` host the
496
+ * universal protocol endpoints at the bare URL — runner is the first
497
+ * example. Before this fix the proxy built `/runner/.parachute/config`
498
+ * (mount-prefixed because stripPrefix is false) and runner returned 404.
499
+ *
500
+ * The fix detects the `/.parachute` declaration in `paths[]` and routes
501
+ * to the bare URL regardless of `stripPrefix`. These tests pin that
502
+ * behavior + verify vault (mount-routed per-vault) keeps its prefixed
503
+ * path so the fix doesn't regress vault config.
504
+ */
505
+ describe("handleApiModulesConfig — hostsBareParachute (hub#307)", () => {
506
+ let h: Harness;
507
+ beforeEach(async () => {
508
+ h = await makeHarness();
509
+ });
510
+ afterEach(() => h.cleanup());
511
+
512
+ test("runner (stripPrefix:false + /.parachute in paths) → bare /.parachute/config", async () => {
513
+ // Runner's FIRST_PARTY_FALLBACKS shape: paths includes `/.parachute`
514
+ // explicitly because runner serves the universal protocol at the bare
515
+ // URL. The services.json entry can carry either path first; we put
516
+ // `/runner` first to mirror what `parachute install runner` writes
517
+ // (matches the FIRST_PARTY_FALLBACKS manifest paths order).
518
+ writeManifest(h.manifestPath, [
519
+ {
520
+ name: "parachute-runner",
521
+ port: 1945,
522
+ paths: ["/runner", "/.parachute"],
523
+ health: "/runner/healthz",
524
+ version: "0.1.0",
525
+ stripPrefix: false,
526
+ },
527
+ ]);
528
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
529
+ const upstream = makeFakeUpstream(() =>
530
+ Response.json({ type: "object", properties: { intervalSeconds: { type: "number" } } }),
531
+ );
532
+ const res = await handleApiModulesConfig(
533
+ makeReq("/api/modules/runner/config/schema", {
534
+ headers: { authorization: `Bearer ${bearer}` },
535
+ }),
536
+ { short: "runner", suffix: "schema" },
537
+ {
538
+ db: h.db,
539
+ issuer: ISSUER,
540
+ manifestPath: h.manifestPath,
541
+ upstreamFetch: upstream.fetchFn,
542
+ },
543
+ );
544
+ expect(res.status).toBe(200);
545
+ // No /runner prefix — bare /.parachute/config/schema. This is the
546
+ // hub#307 fix: pre-fix the URL was http://127.0.0.1:1945/runner/.parachute/config/schema
547
+ // and runner returned 404.
548
+ expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1945/.parachute/config/schema");
549
+ });
550
+
551
+ test("runner GET /config (no schema) also routes bare", async () => {
552
+ writeManifest(h.manifestPath, [
553
+ {
554
+ name: "parachute-runner",
555
+ port: 1945,
556
+ paths: ["/runner", "/.parachute"],
557
+ health: "/runner/healthz",
558
+ version: "0.1.0",
559
+ stripPrefix: false,
560
+ },
561
+ ]);
562
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
563
+ const upstream = makeFakeUpstream(() => Response.json({ intervalSeconds: 60 }));
564
+ await handleApiModulesConfig(
565
+ makeReq("/api/modules/runner/config", {
566
+ headers: { authorization: `Bearer ${bearer}` },
567
+ }),
568
+ { short: "runner", suffix: "" },
569
+ {
570
+ db: h.db,
571
+ issuer: ISSUER,
572
+ manifestPath: h.manifestPath,
573
+ upstreamFetch: upstream.fetchFn,
574
+ },
575
+ );
576
+ expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1945/.parachute/config");
577
+ });
578
+
579
+ test("runner PUT /config also routes bare with body", async () => {
580
+ writeManifest(h.manifestPath, [
581
+ {
582
+ name: "parachute-runner",
583
+ port: 1945,
584
+ paths: ["/runner", "/.parachute"],
585
+ health: "/runner/healthz",
586
+ version: "0.1.0",
587
+ stripPrefix: false,
588
+ },
589
+ ]);
590
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
591
+ const upstream = makeFakeUpstream(() => Response.json({ restart_required: [] }));
592
+ await handleApiModulesConfig(
593
+ makeReq("/api/modules/runner/config", {
594
+ method: "PUT",
595
+ headers: {
596
+ authorization: `Bearer ${bearer}`,
597
+ "content-type": "application/json",
598
+ },
599
+ body: JSON.stringify({ intervalSeconds: 120 }),
600
+ }),
601
+ { short: "runner", suffix: "" },
602
+ {
603
+ db: h.db,
604
+ issuer: ISSUER,
605
+ manifestPath: h.manifestPath,
606
+ upstreamFetch: upstream.fetchFn,
607
+ },
608
+ );
609
+ const call = upstream.calls[0];
610
+ if (!call) throw new Error("upstream not called");
611
+ expect(call.url).toBe("http://127.0.0.1:1945/.parachute/config");
612
+ expect(call.method).toBe("PUT");
613
+ expect(call.body).toBe(JSON.stringify({ intervalSeconds: 120 }));
614
+ });
615
+
616
+ test("runner fallback (no services.json entry) — picks up /.parachute from FIRST_PARTY_FALLBACKS paths", async () => {
617
+ // bun-link / fresh-install case: the runner row isn't in services.json
618
+ // yet but the fallback declares the shape. resolveUpstream returns
619
+ // not-installed when neither the row nor the fallback can prove the
620
+ // module is up — so this case actually 404s. Pinned as the expected
621
+ // shape: hub#307 only changes the upstream-URL math, not the
622
+ // installed-detection contract.
623
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
624
+ const res = await handleApiModulesConfig(
625
+ makeReq("/api/modules/runner/config", {
626
+ headers: { authorization: `Bearer ${bearer}` },
627
+ }),
628
+ { short: "runner", suffix: "" },
629
+ {
630
+ db: h.db,
631
+ issuer: ISSUER,
632
+ manifestPath: h.manifestPath,
633
+ },
634
+ );
635
+ expect(res.status).toBe(404);
636
+ const body = (await res.json()) as { error: string };
637
+ expect(body.error).toBe("module_not_installed");
638
+ });
639
+
640
+ test("vault (stripPrefix:false, no /.parachute in paths) — keeps /vault/<name> prefix (unchanged)", async () => {
641
+ // Vault's `.parachute/config` is per-vault, scoped under the
642
+ // `/vault/<name>` mount. Routing it bare would lose the vault-name
643
+ // context. This test pins that hub#307 doesn't regress vault.
644
+ writeManifest(h.manifestPath, [
645
+ {
646
+ name: "parachute-vault",
647
+ port: 1940,
648
+ paths: ["/vault/default"],
649
+ health: "/vault/default/health",
650
+ version: "0.5.0",
651
+ },
652
+ ]);
653
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
654
+ const upstream = makeFakeUpstream(() => Response.json({ type: "object", properties: {} }));
655
+ await handleApiModulesConfig(
656
+ makeReq("/api/modules/vault/config/schema", {
657
+ headers: { authorization: `Bearer ${bearer}` },
658
+ }),
659
+ { short: "vault", suffix: "schema" },
660
+ {
661
+ db: h.db,
662
+ issuer: ISSUER,
663
+ manifestPath: h.manifestPath,
664
+ upstreamFetch: upstream.fetchFn,
665
+ },
666
+ );
667
+ // Preserved mount — same as pre-hub#307.
668
+ expect(upstream.calls[0]?.url).toBe(
669
+ "http://127.0.0.1:1940/vault/default/.parachute/config/schema",
670
+ );
671
+ });
672
+
673
+ test("scribe (stripPrefix:true) — bare URL preserved (unchanged)", async () => {
674
+ // Pre-hub#307: stripPrefix:true produced /.parachute/config (via the
675
+ // stripPrefix branch). Post-fix: same result via the hostsBareParachute
676
+ // branch when /.parachute is in paths, or via the stripPrefix branch
677
+ // when it isn't. Scribe ships `paths: ["/scribe"]` (no /.parachute),
678
+ // so it takes the stripPrefix branch. Either way, the upstream URL is
679
+ // identical to pre-fix behavior.
680
+ writeManifest(h.manifestPath, [
681
+ {
682
+ name: "parachute-scribe",
683
+ port: 1943,
684
+ paths: ["/scribe"],
685
+ health: "/health",
686
+ version: "0.4.0",
687
+ stripPrefix: true,
688
+ },
689
+ ]);
690
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
691
+ const upstream = makeFakeUpstream(() => Response.json({ type: "object", properties: {} }));
692
+ await handleApiModulesConfig(
693
+ makeReq("/api/modules/scribe/config/schema", {
694
+ headers: { authorization: `Bearer ${bearer}` },
695
+ }),
696
+ { short: "scribe", suffix: "schema" },
697
+ {
698
+ db: h.db,
699
+ issuer: ISSUER,
700
+ manifestPath: h.manifestPath,
701
+ upstreamFetch: upstream.fetchFn,
702
+ },
703
+ );
704
+ // Unchanged from pre-hub#307.
705
+ expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1943/.parachute/config/schema");
706
+ });
707
+
708
+ test("mixed: stripPrefix:false module with both /custom and /.parachute → bare for protocol, prefix for others", async () => {
709
+ // The hostsBareParachute branch only governs the `/.parachute/config*`
710
+ // proxy here. Other proxy code-paths (the generic services-proxy in
711
+ // hub-server.ts) handle non-protocol requests; this surface only ever
712
+ // forwards to `/.parachute/config[/schema]`, so verifying just that
713
+ // route is the right scope.
714
+ writeManifest(h.manifestPath, [
715
+ {
716
+ name: "parachute-runner",
717
+ port: 1945,
718
+ // Order doesn't matter for hostsBareParachute detection.
719
+ paths: ["/.parachute", "/runner"],
720
+ health: "/runner/healthz",
721
+ version: "0.1.0",
722
+ stripPrefix: false,
723
+ },
724
+ ]);
725
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
726
+ const upstream = makeFakeUpstream(() => Response.json({ type: "object", properties: {} }));
727
+ await handleApiModulesConfig(
728
+ makeReq("/api/modules/runner/config/schema", {
729
+ headers: { authorization: `Bearer ${bearer}` },
730
+ }),
731
+ { short: "runner", suffix: "schema" },
732
+ {
733
+ db: h.db,
734
+ issuer: ISSUER,
735
+ manifestPath: h.manifestPath,
736
+ upstreamFetch: upstream.fetchFn,
737
+ },
738
+ );
739
+ expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1945/.parachute/config/schema");
740
+ });
741
+ });
@@ -149,8 +149,8 @@ describe("GET /api/modules", () => {
149
149
 
150
150
  test("200 + curated list on fresh container (empty services.json)", async () => {
151
151
  // The v0.6 hot path: brand-new Render container, no services.json
152
- // yet. UI must render "install vault / notes / scribe" cards even
153
- // though nothing's installed.
152
+ // yet. UI must render "install vault / notes / scribe / runner"
153
+ // cards even though nothing's installed.
154
154
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
155
155
  const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
156
156
  db: h.db,
@@ -168,8 +168,8 @@ describe("GET /api/modules", () => {
168
168
  }>;
169
169
  supervisor_available: boolean;
170
170
  };
171
- // Curated order is preserved: vault → notes → scribe.
172
- expect(body.modules.map((m) => m.short)).toEqual(["vault", "notes", "scribe"]);
171
+ // Curated order is preserved: vault → notes → scribe → runner.
172
+ expect(body.modules.map((m) => m.short)).toEqual(["vault", "notes", "scribe", "runner"]);
173
173
  expect(body.modules.every((m) => m.available)).toBe(true);
174
174
  expect(body.modules.every((m) => !m.installed)).toBe(true);
175
175
  expect(body.modules.every((m) => m.latest_version === "0.9.9")).toBe(true);
@@ -177,6 +177,36 @@ describe("GET /api/modules", () => {
177
177
  expect(body.supervisor_available).toBe(false);
178
178
  });
179
179
 
180
+ test("runner row carries package + display props from FIRST_PARTY_FALLBACKS (#305)", async () => {
181
+ // hub#305 added runner to CURATED_MODULES + FIRST_PARTY_FALLBACKS so
182
+ // the admin SPA install catalog surfaces it. Spot-check the wire
183
+ // shape resolves the runner-specific fields (package, displayName,
184
+ // tagline) from the vendored fallback rather than a stale default.
185
+ const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
186
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
187
+ db: h.db,
188
+ issuer: ISSUER,
189
+ manifestPath: h.manifestPath,
190
+ fetchLatestVersion: async () => "0.1.0",
191
+ });
192
+ expect(res.status).toBe(200);
193
+ const body = (await res.json()) as {
194
+ modules: Array<{
195
+ short: string;
196
+ package: string;
197
+ display_name: string;
198
+ tagline: string;
199
+ available: boolean;
200
+ }>;
201
+ };
202
+ const runner = body.modules.find((m) => m.short === "runner");
203
+ expect(runner).toBeDefined();
204
+ expect(runner?.package).toBe("@openparachute/runner");
205
+ expect(runner?.display_name).toBe("Runner");
206
+ expect(runner?.tagline).toContain("Vault-as-job-substrate");
207
+ expect(runner?.available).toBe(true);
208
+ });
209
+
180
210
  test("surfaces installed_version from services.json", async () => {
181
211
  writeManifest(h.manifestPath, [
182
212
  {
@@ -0,0 +1,294 @@
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 { finalizeModuleInstall, refreshWellKnown, stampInstallDirOnRow } from "../post-install.ts";
6
+ import { findService, upsertService } from "../services-manifest.ts";
7
+
8
+ /**
9
+ * Tail-end helpers shared between CLI install (`commands/install.ts`) and
10
+ * API install (`api-modules-ops.ts`). Cover the two responsibilities
11
+ * independently + the paired `finalizeModuleInstall` entry point so a
12
+ * future drift in either call site (the failure mode hub#292 / hub#298
13
+ * traced) surfaces here.
14
+ */
15
+
16
+ function makeHarness(): {
17
+ dir: string;
18
+ servicesJsonPath: string;
19
+ wellKnownPath: string;
20
+ cleanup: () => void;
21
+ } {
22
+ const dir = mkdtempSync(join(tmpdir(), "pcli-post-install-"));
23
+ return {
24
+ dir,
25
+ servicesJsonPath: join(dir, "services.json"),
26
+ wellKnownPath: join(dir, "well-known.json"),
27
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
28
+ };
29
+ }
30
+
31
+ describe("stampInstallDirOnRow", () => {
32
+ test("stamps installDir on an existing row + returns true", () => {
33
+ const h = makeHarness();
34
+ try {
35
+ upsertService(
36
+ {
37
+ name: "parachute-vault",
38
+ port: 1940,
39
+ paths: ["/vault/default"],
40
+ health: "/vault/default/health",
41
+ version: "0.2.4",
42
+ },
43
+ h.servicesJsonPath,
44
+ );
45
+ const wrote = stampInstallDirOnRow({
46
+ manifestName: "parachute-vault",
47
+ installDir: "/fake/install/vault",
48
+ servicesJsonPath: h.servicesJsonPath,
49
+ });
50
+ expect(wrote).toBe(true);
51
+ const row = findService("parachute-vault", h.servicesJsonPath);
52
+ expect(row?.installDir).toBe("/fake/install/vault");
53
+ } finally {
54
+ h.cleanup();
55
+ }
56
+ });
57
+
58
+ test("no-ops when the row already carries the same installDir", () => {
59
+ const h = makeHarness();
60
+ try {
61
+ upsertService(
62
+ {
63
+ name: "parachute-vault",
64
+ port: 1940,
65
+ paths: ["/vault/default"],
66
+ health: "/vault/default/health",
67
+ version: "0.2.4",
68
+ installDir: "/fake/install/vault",
69
+ },
70
+ h.servicesJsonPath,
71
+ );
72
+ const wrote = stampInstallDirOnRow({
73
+ manifestName: "parachute-vault",
74
+ installDir: "/fake/install/vault",
75
+ servicesJsonPath: h.servicesJsonPath,
76
+ });
77
+ expect(wrote).toBe(false);
78
+ } finally {
79
+ h.cleanup();
80
+ }
81
+ });
82
+
83
+ test("no-ops + returns false when the row doesn't exist", () => {
84
+ const h = makeHarness();
85
+ try {
86
+ const wrote = stampInstallDirOnRow({
87
+ manifestName: "parachute-vault",
88
+ installDir: "/fake/install/vault",
89
+ servicesJsonPath: h.servicesJsonPath,
90
+ });
91
+ expect(wrote).toBe(false);
92
+ expect(findService("parachute-vault", h.servicesJsonPath)).toBeUndefined();
93
+ } finally {
94
+ h.cleanup();
95
+ }
96
+ });
97
+ });
98
+
99
+ describe("refreshWellKnown", () => {
100
+ test("writes the on-disk doc + logs the regenerated path", async () => {
101
+ const h = makeHarness();
102
+ try {
103
+ upsertService(
104
+ {
105
+ name: "parachute-vault",
106
+ port: 1940,
107
+ paths: ["/vault/default"],
108
+ health: "/vault/default/health",
109
+ version: "0.2.4",
110
+ },
111
+ h.servicesJsonPath,
112
+ );
113
+ const logs: string[] = [];
114
+ await refreshWellKnown({
115
+ servicesJsonPath: h.servicesJsonPath,
116
+ canonicalOrigin: "https://hub.example.com",
117
+ wellKnownPath: h.wellKnownPath,
118
+ log: (msg) => logs.push(msg),
119
+ });
120
+ expect(existsSync(h.wellKnownPath)).toBe(true);
121
+ expect(logs).toEqual([`regenerated ${h.wellKnownPath}`]);
122
+ const doc = JSON.parse(readFileSync(h.wellKnownPath, "utf8")) as {
123
+ services: Array<{ name: string }>;
124
+ vaults: Array<{ name: string }>;
125
+ };
126
+ expect(doc.services.some((s) => s.name === "parachute-vault")).toBe(true);
127
+ expect(doc.vaults.some((v) => v.name === "default")).toBe(true);
128
+ } finally {
129
+ h.cleanup();
130
+ }
131
+ });
132
+
133
+ test("logs failure + does not throw when the well-known write itself errors", async () => {
134
+ const h = makeHarness();
135
+ try {
136
+ // Point wellKnownPath at a directory rather than a file — writeFileSync
137
+ // → renameSync surfaces as EISDIR (or platform-equivalent), which the
138
+ // helper must catch and report rather than letting it propagate up
139
+ // and abort an otherwise-successful install op.
140
+ const logs: string[] = [];
141
+ await refreshWellKnown({
142
+ servicesJsonPath: h.servicesJsonPath,
143
+ canonicalOrigin: "https://hub.example.com",
144
+ wellKnownPath: h.dir, // tempdir itself, not a file inside it
145
+ log: (msg) => logs.push(msg),
146
+ });
147
+ expect(logs.some((l) => l.startsWith("well-known regen failed:"))).toBe(true);
148
+ expect(logs.some((l) => l.startsWith("regenerated"))).toBe(false);
149
+ } finally {
150
+ h.cleanup();
151
+ }
152
+ });
153
+
154
+ test("threads readModuleManifest through to the regen so uiUrl lands", async () => {
155
+ const h = makeHarness();
156
+ try {
157
+ const installDir = "/fake/install/notes";
158
+ upsertService(
159
+ {
160
+ name: "parachute-notes",
161
+ port: 5173,
162
+ paths: ["/notes"],
163
+ health: "/notes/health",
164
+ version: "0.1.0",
165
+ installDir,
166
+ },
167
+ h.servicesJsonPath,
168
+ );
169
+ await refreshWellKnown({
170
+ servicesJsonPath: h.servicesJsonPath,
171
+ canonicalOrigin: "https://hub.example.com",
172
+ wellKnownPath: h.wellKnownPath,
173
+ log: () => {},
174
+ readModuleManifest: async (dir) =>
175
+ dir === installDir
176
+ ? {
177
+ name: "notes",
178
+ manifestName: "parachute-notes",
179
+ kind: "frontend",
180
+ port: 5173,
181
+ paths: ["/notes"],
182
+ health: "/notes/health",
183
+ uiUrl: "/notes",
184
+ displayName: "Notes",
185
+ }
186
+ : null,
187
+ });
188
+ const doc = JSON.parse(readFileSync(h.wellKnownPath, "utf8")) as {
189
+ services: Array<{ name: string; uiUrl?: string; displayName?: string }>;
190
+ };
191
+ const row = doc.services.find((s) => s.name === "parachute-notes");
192
+ expect(row?.uiUrl).toBe("https://hub.example.com/notes");
193
+ expect(row?.displayName).toBe("Notes");
194
+ } finally {
195
+ h.cleanup();
196
+ }
197
+ });
198
+ });
199
+
200
+ describe("finalizeModuleInstall", () => {
201
+ test("stamps installDir AND regenerates well-known in one call", async () => {
202
+ const h = makeHarness();
203
+ try {
204
+ const installDir = "/fake/install/vault";
205
+ upsertService(
206
+ {
207
+ name: "parachute-vault",
208
+ port: 1940,
209
+ paths: ["/vault/default"],
210
+ health: "/vault/default/health",
211
+ version: "0.2.4",
212
+ },
213
+ h.servicesJsonPath,
214
+ );
215
+ const logs: string[] = [];
216
+ await finalizeModuleInstall({
217
+ manifestName: "parachute-vault",
218
+ installDir,
219
+ servicesJsonPath: h.servicesJsonPath,
220
+ canonicalOrigin: "https://hub.example.com",
221
+ wellKnownPath: h.wellKnownPath,
222
+ log: (msg) => logs.push(msg),
223
+ });
224
+ // Stamp landed.
225
+ const row = findService("parachute-vault", h.servicesJsonPath);
226
+ expect(row?.installDir).toBe(installDir);
227
+ // Well-known reflects the stamped row.
228
+ expect(existsSync(h.wellKnownPath)).toBe(true);
229
+ const doc = JSON.parse(readFileSync(h.wellKnownPath, "utf8")) as {
230
+ services: Array<{ name: string; version: string }>;
231
+ };
232
+ expect(doc.services.some((s) => s.name === "parachute-vault")).toBe(true);
233
+ // The regen-success log fired exactly once — no double-regen from
234
+ // the helper.
235
+ expect(logs.filter((l) => l.startsWith("regenerated"))).toHaveLength(1);
236
+ } finally {
237
+ h.cleanup();
238
+ }
239
+ });
240
+
241
+ test("CLI- and API-path inputs produce byte-identical well-known docs", async () => {
242
+ // The PR's pure-refactor invariant (hub#293): both paths funnel
243
+ // through this helper, so given the same row state + same origin +
244
+ // same module manifest, the on-disk doc must be identical regardless
245
+ // of which call site drove it. Regression canary against the helper
246
+ // silently growing per-caller branching.
247
+ const cli = makeHarness();
248
+ const api = makeHarness();
249
+ try {
250
+ const installDir = "/fake/install/vault";
251
+ const row = {
252
+ name: "parachute-vault",
253
+ port: 1940,
254
+ paths: ["/vault/default"],
255
+ health: "/vault/default/health",
256
+ version: "0.2.4",
257
+ };
258
+ upsertService(row, cli.servicesJsonPath);
259
+ upsertService(row, api.servicesJsonPath);
260
+ const manifest = {
261
+ name: "vault",
262
+ manifestName: "parachute-vault",
263
+ kind: "api" as const,
264
+ port: 1940,
265
+ paths: ["/vault/default"],
266
+ health: "/vault/default/health",
267
+ managementUrl: "/vault/default/admin",
268
+ };
269
+ const opts = {
270
+ manifestName: "parachute-vault",
271
+ installDir,
272
+ canonicalOrigin: "https://hub.example.com",
273
+ log: () => {},
274
+ readModuleManifest: async (dir: string) => (dir === installDir ? manifest : null),
275
+ };
276
+ await finalizeModuleInstall({
277
+ ...opts,
278
+ servicesJsonPath: cli.servicesJsonPath,
279
+ wellKnownPath: cli.wellKnownPath,
280
+ });
281
+ await finalizeModuleInstall({
282
+ ...opts,
283
+ servicesJsonPath: api.servicesJsonPath,
284
+ wellKnownPath: api.wellKnownPath,
285
+ });
286
+ const cliDoc = readFileSync(cli.wellKnownPath, "utf8");
287
+ const apiDoc = readFileSync(api.wellKnownPath, "utf8");
288
+ expect(cliDoc).toEqual(apiDoc);
289
+ } finally {
290
+ cli.cleanup();
291
+ api.cleanup();
292
+ }
293
+ });
294
+ });
@@ -122,6 +122,7 @@ describe("setup", () => {
122
122
  { name: "parachute-notes", port: 1942 },
123
123
  { name: "parachute-scribe", port: 1943 },
124
124
  { name: "parachute-channel", port: 1941 },
125
+ { name: "parachute-runner", port: 1945 },
125
126
  ];
126
127
  for (const s of seeds) {
127
128
  upsertService(