@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 +1 -1
- package/src/__tests__/api-modules-config.test.ts +249 -0
- package/src/__tests__/api-modules.test.ts +34 -4
- package/src/__tests__/post-install.test.ts +294 -0
- package/src/__tests__/setup.test.ts +1 -0
- package/src/api-modules-config.ts +69 -10
- package/src/api-modules-ops.ts +65 -77
- package/src/api-modules.ts +9 -7
- package/src/commands/install.ts +62 -7
- package/src/post-install.ts +130 -0
- package/src/service-spec.ts +50 -0
- package/web/ui/dist/assets/{index-DRszQoIL.css → index-C2vGcXFG.css} +1 -1
- package/web/ui/dist/assets/index-DmUVTI8I.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-BJsR-q53.js +0 -61
package/package.json
CHANGED
|
@@ -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
|
|
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(
|