@openparachute/hub 0.5.10 → 0.5.11-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.
- package/package.json +1 -1
- package/src/__tests__/api-modules-ops.test.ts +283 -1
- package/src/api-modules-ops.ts +120 -1
- package/src/well-known.ts +82 -1
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import {
|
|
@@ -656,3 +656,285 @@ describe("GET /api/modules/operations/:id", () => {
|
|
|
656
656
|
expect(res.status).toBe(404);
|
|
657
657
|
});
|
|
658
658
|
});
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Well-known regen + installDir stamping coverage. These exercise the
|
|
662
|
+
* fix for the "newly installed module doesn't appear on discovery" bug:
|
|
663
|
+
* the live HTTP build at `/.well-known/parachute.json` reads each
|
|
664
|
+
* module's `installDir/.parachute/module.json` to find `uiUrl` (which
|
|
665
|
+
* the discovery page needs to render a tile). Without an installDir
|
|
666
|
+
* stamp post-install, the resolver skips the entry. We assert both
|
|
667
|
+
* the disk regen lands and the row carries installDir afterwards.
|
|
668
|
+
*/
|
|
669
|
+
describe("well-known regen after module ops", () => {
|
|
670
|
+
let h: Harness;
|
|
671
|
+
beforeEach(async () => {
|
|
672
|
+
h = await makeHarness();
|
|
673
|
+
_resetOperationsRegistryForTests();
|
|
674
|
+
});
|
|
675
|
+
afterEach(() => h.cleanup());
|
|
676
|
+
|
|
677
|
+
/** Stub findGlobalInstall + readModuleManifest pair for in-memory installs. */
|
|
678
|
+
function fakeInstall(
|
|
679
|
+
pkg: string,
|
|
680
|
+
manifest: {
|
|
681
|
+
name: string;
|
|
682
|
+
manifestName: string;
|
|
683
|
+
kind: "api" | "frontend" | "tool";
|
|
684
|
+
port: number;
|
|
685
|
+
paths: string[];
|
|
686
|
+
health: string;
|
|
687
|
+
uiUrl?: string;
|
|
688
|
+
displayName?: string;
|
|
689
|
+
},
|
|
690
|
+
): {
|
|
691
|
+
findGlobalInstall: (p: string) => string | null;
|
|
692
|
+
readModuleManifest: (dir: string) => Promise<typeof manifest | null>;
|
|
693
|
+
installDir: string;
|
|
694
|
+
} {
|
|
695
|
+
const installDir = join(h.dir, "fake-install", ...pkg.split("/"));
|
|
696
|
+
const pkgJson = join(installDir, "package.json");
|
|
697
|
+
return {
|
|
698
|
+
findGlobalInstall: (p) => (p === pkg ? pkgJson : null),
|
|
699
|
+
readModuleManifest: async (dir) => (dir === installDir ? manifest : null),
|
|
700
|
+
installDir,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
test("runInstall happy path: regenerates well-known + stamps installDir", async () => {
|
|
705
|
+
const { supervisor } = makeIdleSupervisor();
|
|
706
|
+
const { run } = alwaysOkRun();
|
|
707
|
+
const wkPath = join(h.dir, "well-known.json");
|
|
708
|
+
const install = fakeInstall("@openparachute/vault", {
|
|
709
|
+
name: "vault",
|
|
710
|
+
manifestName: "parachute-vault",
|
|
711
|
+
kind: "api",
|
|
712
|
+
port: 1940,
|
|
713
|
+
paths: ["/vault/default"],
|
|
714
|
+
health: "/vault/default/health",
|
|
715
|
+
});
|
|
716
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
717
|
+
const deps = {
|
|
718
|
+
db: h.db,
|
|
719
|
+
issuer: ISSUER,
|
|
720
|
+
manifestPath: h.manifestPath,
|
|
721
|
+
configDir: h.dir,
|
|
722
|
+
supervisor,
|
|
723
|
+
run,
|
|
724
|
+
findGlobalInstall: install.findGlobalInstall,
|
|
725
|
+
readModuleManifest: install.readModuleManifest,
|
|
726
|
+
wellKnownPath: wkPath,
|
|
727
|
+
};
|
|
728
|
+
const res = await handleInstall(
|
|
729
|
+
postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
|
|
730
|
+
"vault",
|
|
731
|
+
deps,
|
|
732
|
+
);
|
|
733
|
+
expect(res.status).toBe(202);
|
|
734
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
735
|
+
|
|
736
|
+
// installDir landed on the row (so the live well-known build's
|
|
737
|
+
// `uiUrl` resolver can find the module's manifest).
|
|
738
|
+
const manifest = JSON.parse(readFileSync(h.manifestPath, "utf8")) as {
|
|
739
|
+
services: Array<{ name: string; installDir?: string }>;
|
|
740
|
+
};
|
|
741
|
+
const vaultRow = manifest.services.find((s) => s.name === "parachute-vault");
|
|
742
|
+
expect(vaultRow?.installDir).toBe(install.installDir);
|
|
743
|
+
|
|
744
|
+
// The on-disk well-known doc reflects the new module.
|
|
745
|
+
const doc = JSON.parse(readFileSync(wkPath, "utf8")) as {
|
|
746
|
+
services: Array<{ name: string; version: string }>;
|
|
747
|
+
vaults: Array<{ name: string }>;
|
|
748
|
+
};
|
|
749
|
+
expect(doc.services.some((s) => s.name === "parachute-vault")).toBe(true);
|
|
750
|
+
expect(doc.vaults.some((v) => v.name === "default")).toBe(true);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test("runInstall failure: bun add fails -> no well-known regen (no partial state)", async () => {
|
|
754
|
+
const { supervisor } = makeIdleSupervisor();
|
|
755
|
+
const wkPath = join(h.dir, "well-known.json");
|
|
756
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
757
|
+
const deps = {
|
|
758
|
+
db: h.db,
|
|
759
|
+
issuer: ISSUER,
|
|
760
|
+
manifestPath: h.manifestPath,
|
|
761
|
+
configDir: h.dir,
|
|
762
|
+
supervisor,
|
|
763
|
+
run: async () => 1,
|
|
764
|
+
findGlobalInstall: () => null,
|
|
765
|
+
wellKnownPath: wkPath,
|
|
766
|
+
};
|
|
767
|
+
const res = await handleInstall(
|
|
768
|
+
postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
|
|
769
|
+
"vault",
|
|
770
|
+
deps,
|
|
771
|
+
);
|
|
772
|
+
expect(res.status).toBe(202);
|
|
773
|
+
const body = (await res.json()) as { operation_id: string };
|
|
774
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
775
|
+
|
|
776
|
+
// Operation failed.
|
|
777
|
+
const opRes = await handleOperationGet(
|
|
778
|
+
getReq(`/api/modules/operations/${body.operation_id}`, {
|
|
779
|
+
authorization: `Bearer ${bearer}`,
|
|
780
|
+
}),
|
|
781
|
+
body.operation_id,
|
|
782
|
+
deps,
|
|
783
|
+
);
|
|
784
|
+
const op = (await opRes.json()) as { status: string; log: string[] };
|
|
785
|
+
expect(op.status).toBe("failed");
|
|
786
|
+
|
|
787
|
+
// No well-known doc was written — the regen step only runs after a
|
|
788
|
+
// successful spawn, not on failure paths.
|
|
789
|
+
expect(existsSync(wkPath)).toBe(false);
|
|
790
|
+
// And the operation log carries no regen line (defensive — confirms
|
|
791
|
+
// the early-return short-circuit, not just an absent file).
|
|
792
|
+
expect(op.log.join(" ")).not.toMatch(/regenerated/);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test("runUpgrade regenerates well-known with the new version on the row", async () => {
|
|
796
|
+
// Seed services.json with an existing vault row at the old version,
|
|
797
|
+
// and make the supervisor's restart return a state (success path).
|
|
798
|
+
writeManifest(h.manifestPath, [
|
|
799
|
+
{
|
|
800
|
+
name: "parachute-vault",
|
|
801
|
+
port: 1940,
|
|
802
|
+
paths: ["/vault/default"],
|
|
803
|
+
health: "/vault/default/health",
|
|
804
|
+
version: "0.4.5",
|
|
805
|
+
},
|
|
806
|
+
]);
|
|
807
|
+
const { supervisor } = makeIdleSupervisor();
|
|
808
|
+
await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
|
|
809
|
+
|
|
810
|
+
const { run } = alwaysOkRun();
|
|
811
|
+
const wkPath = join(h.dir, "well-known.json");
|
|
812
|
+
const install = fakeInstall("@openparachute/vault", {
|
|
813
|
+
name: "vault",
|
|
814
|
+
manifestName: "parachute-vault",
|
|
815
|
+
kind: "api",
|
|
816
|
+
port: 1940,
|
|
817
|
+
paths: ["/vault/default"],
|
|
818
|
+
health: "/vault/default/health",
|
|
819
|
+
});
|
|
820
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
821
|
+
const deps = {
|
|
822
|
+
db: h.db,
|
|
823
|
+
issuer: ISSUER,
|
|
824
|
+
manifestPath: h.manifestPath,
|
|
825
|
+
configDir: h.dir,
|
|
826
|
+
supervisor,
|
|
827
|
+
run,
|
|
828
|
+
findGlobalInstall: install.findGlobalInstall,
|
|
829
|
+
readModuleManifest: install.readModuleManifest,
|
|
830
|
+
wellKnownPath: wkPath,
|
|
831
|
+
};
|
|
832
|
+
const res = await handleUpgrade(
|
|
833
|
+
postReq("/api/modules/vault/upgrade", { authorization: `Bearer ${bearer}` }),
|
|
834
|
+
"vault",
|
|
835
|
+
deps,
|
|
836
|
+
);
|
|
837
|
+
expect(res.status).toBe(202);
|
|
838
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
839
|
+
|
|
840
|
+
const doc = JSON.parse(readFileSync(wkPath, "utf8")) as {
|
|
841
|
+
services: Array<{ name: string; version: string }>;
|
|
842
|
+
};
|
|
843
|
+
const row = doc.services.find((s) => s.name === "parachute-vault");
|
|
844
|
+
expect(row?.version).toBe("0.4.5");
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
test("runUninstall regenerates well-known without the removed module", async () => {
|
|
848
|
+
writeManifest(h.manifestPath, [
|
|
849
|
+
{
|
|
850
|
+
name: "parachute-vault",
|
|
851
|
+
port: 1940,
|
|
852
|
+
paths: ["/vault/default"],
|
|
853
|
+
health: "/vault/default/health",
|
|
854
|
+
version: "0.4.5",
|
|
855
|
+
},
|
|
856
|
+
{
|
|
857
|
+
name: "parachute-notes",
|
|
858
|
+
port: 1942,
|
|
859
|
+
paths: ["/notes"],
|
|
860
|
+
health: "/notes/health",
|
|
861
|
+
version: "0.4.0",
|
|
862
|
+
},
|
|
863
|
+
]);
|
|
864
|
+
const { supervisor } = makeIdleSupervisor();
|
|
865
|
+
const { run } = alwaysOkRun();
|
|
866
|
+
const wkPath = join(h.dir, "well-known.json");
|
|
867
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
868
|
+
const res = await handleUninstall(
|
|
869
|
+
postReq("/api/modules/vault/uninstall", { authorization: `Bearer ${bearer}` }),
|
|
870
|
+
"vault",
|
|
871
|
+
{
|
|
872
|
+
db: h.db,
|
|
873
|
+
issuer: ISSUER,
|
|
874
|
+
manifestPath: h.manifestPath,
|
|
875
|
+
configDir: h.dir,
|
|
876
|
+
supervisor,
|
|
877
|
+
run,
|
|
878
|
+
wellKnownPath: wkPath,
|
|
879
|
+
},
|
|
880
|
+
);
|
|
881
|
+
expect(res.status).toBe(200);
|
|
882
|
+
const body = (await res.json()) as { log: string[] };
|
|
883
|
+
expect(body.log.join(" ")).toMatch(/regenerated/);
|
|
884
|
+
|
|
885
|
+
const doc = JSON.parse(readFileSync(wkPath, "utf8")) as {
|
|
886
|
+
services: Array<{ name: string }>;
|
|
887
|
+
vaults: Array<{ name: string }>;
|
|
888
|
+
};
|
|
889
|
+
// Vault gone, notes still present.
|
|
890
|
+
expect(doc.services.some((s) => s.name === "parachute-vault")).toBe(false);
|
|
891
|
+
expect(doc.vaults.some((v) => v.name === "default")).toBe(false);
|
|
892
|
+
expect(doc.services.some((s) => s.name === "parachute-notes")).toBe(true);
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
test("well-known regen is idempotent across two consecutive install ops", async () => {
|
|
896
|
+
// Two installs in a row of the same module produce the same on-disk
|
|
897
|
+
// doc — no drift from the regen path itself (e.g. extra entries,
|
|
898
|
+
// duplicated rows, non-deterministic ordering).
|
|
899
|
+
const { supervisor } = makeIdleSupervisor();
|
|
900
|
+
const { run } = alwaysOkRun();
|
|
901
|
+
const wkPath = join(h.dir, "well-known.json");
|
|
902
|
+
const install = fakeInstall("@openparachute/vault", {
|
|
903
|
+
name: "vault",
|
|
904
|
+
manifestName: "parachute-vault",
|
|
905
|
+
kind: "api",
|
|
906
|
+
port: 1940,
|
|
907
|
+
paths: ["/vault/default"],
|
|
908
|
+
health: "/vault/default/health",
|
|
909
|
+
});
|
|
910
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
911
|
+
const deps = {
|
|
912
|
+
db: h.db,
|
|
913
|
+
issuer: ISSUER,
|
|
914
|
+
manifestPath: h.manifestPath,
|
|
915
|
+
configDir: h.dir,
|
|
916
|
+
supervisor,
|
|
917
|
+
run,
|
|
918
|
+
findGlobalInstall: install.findGlobalInstall,
|
|
919
|
+
readModuleManifest: install.readModuleManifest,
|
|
920
|
+
wellKnownPath: wkPath,
|
|
921
|
+
};
|
|
922
|
+
await handleInstall(
|
|
923
|
+
postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
|
|
924
|
+
"vault",
|
|
925
|
+
deps,
|
|
926
|
+
);
|
|
927
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
928
|
+
const first = readFileSync(wkPath, "utf8");
|
|
929
|
+
|
|
930
|
+
await handleInstall(
|
|
931
|
+
postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
|
|
932
|
+
"vault",
|
|
933
|
+
deps,
|
|
934
|
+
);
|
|
935
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
936
|
+
const second = readFileSync(wkPath, "utf8");
|
|
937
|
+
|
|
938
|
+
expect(second).toBe(first);
|
|
939
|
+
});
|
|
940
|
+
});
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -34,12 +34,14 @@
|
|
|
34
34
|
|
|
35
35
|
import type { Database } from "bun:sqlite";
|
|
36
36
|
import { randomUUID } from "node:crypto";
|
|
37
|
+
import { dirname } from "node:path";
|
|
37
38
|
import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
|
|
38
39
|
import { getModuleInstallChannel } from "./hub-settings.ts";
|
|
39
40
|
import { validateAccessToken } from "./jwt-sign.ts";
|
|
40
41
|
import { FIRST_PARTY_FALLBACKS, type ServiceSpec, composeServiceSpec } from "./service-spec.ts";
|
|
41
|
-
import { findService, readManifest, removeService } from "./services-manifest.ts";
|
|
42
|
+
import { findService, readManifest, removeService, upsertService } from "./services-manifest.ts";
|
|
42
43
|
import type { ModuleState, SpawnRequest, Supervisor } from "./supervisor.ts";
|
|
44
|
+
import { regenerateWellKnown } from "./well-known.ts";
|
|
43
45
|
|
|
44
46
|
/**
|
|
45
47
|
* Scope required for every POST + operation-poll endpoint here.
|
|
@@ -186,6 +188,20 @@ export interface ApiModulesOpsDeps {
|
|
|
186
188
|
* inside `Bun.spawn` at child spawn time; we don't mutate `process.env`.
|
|
187
189
|
*/
|
|
188
190
|
spawnEnv?: Record<string, string>;
|
|
191
|
+
/**
|
|
192
|
+
* Override the on-disk path for the regenerated `/.well-known/parachute.json`
|
|
193
|
+
* (test seam). Production writes to `WELL_KNOWN_PATH`; tests point at a
|
|
194
|
+
* tmp file so assertions can read the resulting doc without touching the
|
|
195
|
+
* operator's real config dir.
|
|
196
|
+
*/
|
|
197
|
+
wellKnownPath?: string;
|
|
198
|
+
/**
|
|
199
|
+
* Reader for `<installDir>/.parachute/module.json` used by the well-known
|
|
200
|
+
* regen. Production reads from disk; tests inject a fake. Mirrors the
|
|
201
|
+
* hub-server `readModuleManifest` seam so the disk regen and the
|
|
202
|
+
* per-request HTTP build stay aligned.
|
|
203
|
+
*/
|
|
204
|
+
readModuleManifest?: Parameters<typeof regenerateWellKnown>[0]["readModuleManifest"];
|
|
189
205
|
}
|
|
190
206
|
|
|
191
207
|
interface PathMatch {
|
|
@@ -266,6 +282,61 @@ function defaultRun(cmd: readonly string[]): Promise<number> {
|
|
|
266
282
|
return proc.exited;
|
|
267
283
|
}
|
|
268
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Stamp `installDir` on the services.json row for `spec.manifestName` when
|
|
287
|
+
* the package's globally-installed path can be resolved. Idempotent —
|
|
288
|
+
* no-ops when the row already carries the same `installDir`, when no row
|
|
289
|
+
* exists, or when `findGlobalInstall` can't locate the package (e.g. in
|
|
290
|
+
* tests with no global install).
|
|
291
|
+
*
|
|
292
|
+
* Mirrors the same stamp `parachute install <svc>` does in
|
|
293
|
+
* `commands/install.ts`. Without it, the discovery page's
|
|
294
|
+
* `loadServiceUiMetadata` resolver in `hub-server.ts` skips the entry (it
|
|
295
|
+
* reads `module.json` from `installDir`), so `uiUrl` never lands on the
|
|
296
|
+
* row + the new module's tile never renders on `/`.
|
|
297
|
+
*/
|
|
298
|
+
function stampInstallDir(spec: ServiceSpec, deps: ApiModulesOpsDeps): void {
|
|
299
|
+
const findGlobalInstall = deps.findGlobalInstall;
|
|
300
|
+
if (!findGlobalInstall) return;
|
|
301
|
+
const pkgJson = findGlobalInstall(spec.package);
|
|
302
|
+
if (!pkgJson) return;
|
|
303
|
+
const installDir = dirname(pkgJson);
|
|
304
|
+
const entry = findService(spec.manifestName, deps.manifestPath);
|
|
305
|
+
if (!entry || entry.installDir === installDir) return;
|
|
306
|
+
upsertService({ ...entry, installDir }, deps.manifestPath);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Regenerate `/.well-known/parachute.json` on disk after a state-changing
|
|
311
|
+
* module op (install / upgrade / uninstall). Wraps `regenerateWellKnown`
|
|
312
|
+
* with the deps-aware defaults — issuer for canonicalOrigin, deps-overridable
|
|
313
|
+
* paths + manifest reader. Errors land in the operation log rather than
|
|
314
|
+
* throwing back to the caller; the on-disk doc is a debug / inspection
|
|
315
|
+
* artifact, not the live discovery source, so a regen failure shouldn't
|
|
316
|
+
* mask the op's actual outcome.
|
|
317
|
+
*/
|
|
318
|
+
async function regenAfterOp(
|
|
319
|
+
opId: string,
|
|
320
|
+
registry: OperationsRegistry,
|
|
321
|
+
deps: ApiModulesOpsDeps,
|
|
322
|
+
): Promise<void> {
|
|
323
|
+
try {
|
|
324
|
+
const regenOpts: Parameters<typeof regenerateWellKnown>[0] = {
|
|
325
|
+
manifestPath: deps.manifestPath,
|
|
326
|
+
canonicalOrigin: deps.issuer,
|
|
327
|
+
};
|
|
328
|
+
if (deps.wellKnownPath !== undefined) regenOpts.wellKnownPath = deps.wellKnownPath;
|
|
329
|
+
if (deps.readModuleManifest !== undefined) {
|
|
330
|
+
regenOpts.readModuleManifest = deps.readModuleManifest;
|
|
331
|
+
}
|
|
332
|
+
const { path } = await regenerateWellKnown(regenOpts);
|
|
333
|
+
registry.update(opId, {}, `regenerated ${path}`);
|
|
334
|
+
} catch (err) {
|
|
335
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
336
|
+
registry.update(opId, {}, `well-known regen failed: ${msg}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
269
340
|
/**
|
|
270
341
|
* Spawn the supervised child for `short`, using the spec's startCmd
|
|
271
342
|
* and the current services.json entry (so notes' port-derived
|
|
@@ -392,6 +463,14 @@ export async function runInstall(
|
|
|
392
463
|
}
|
|
393
464
|
}
|
|
394
465
|
|
|
466
|
+
// Stamp installDir on the row so the discovery page's `uiUrl` /
|
|
467
|
+
// `displayName` resolver can find `<installDir>/.parachute/module.json`.
|
|
468
|
+
// Mirrors `parachute install <svc>` — without this, the new module's
|
|
469
|
+
// tile never renders on `/` because `loadServiceUiMetadata` skips
|
|
470
|
+
// installDir-less rows. (Doing this BEFORE the spawn so the supervisor
|
|
471
|
+
// also sees the updated row if it consults services.json post-spawn.)
|
|
472
|
+
stampInstallDir(spec, deps);
|
|
473
|
+
|
|
395
474
|
// Spawn the child via the supervisor. Boot-spawn semantics apply.
|
|
396
475
|
const state = await spawnSupervised(short, spec, deps);
|
|
397
476
|
if (!state) {
|
|
@@ -402,6 +481,14 @@ export async function runInstall(
|
|
|
402
481
|
);
|
|
403
482
|
return;
|
|
404
483
|
}
|
|
484
|
+
|
|
485
|
+
// Regenerate the on-disk well-known doc so the inspection artifact
|
|
486
|
+
// tracks the just-installed module's row. The HTTP path serves a
|
|
487
|
+
// per-request build, so the discovery page picks up the new module
|
|
488
|
+
// either way; this keeps `~/.parachute/well-known/parachute.json` in
|
|
489
|
+
// sync for tooling that reads it directly.
|
|
490
|
+
await regenAfterOp(opId, registry, deps);
|
|
491
|
+
|
|
405
492
|
registry.update(opId, { status: "succeeded" }, `${short} installed + spawned (pid ${state.pid})`);
|
|
406
493
|
}
|
|
407
494
|
|
|
@@ -485,6 +572,11 @@ async function runUpgrade(
|
|
|
485
572
|
registry.update(opId, {}, `bun add reported exit ${code} but package landed at ${probed}`);
|
|
486
573
|
}
|
|
487
574
|
|
|
575
|
+
// Re-stamp installDir after upgrade — a major-version bump may relocate
|
|
576
|
+
// the package on disk (e.g. node_modules layout change). Idempotent
|
|
577
|
+
// when the path is stable.
|
|
578
|
+
stampInstallDir(spec, deps);
|
|
579
|
+
|
|
488
580
|
const state = await deps.supervisor.restart(short);
|
|
489
581
|
if (!state) {
|
|
490
582
|
registry.update(
|
|
@@ -494,6 +586,13 @@ async function runUpgrade(
|
|
|
494
586
|
);
|
|
495
587
|
return;
|
|
496
588
|
}
|
|
589
|
+
|
|
590
|
+
// Refresh the on-disk well-known so the version field on the upgraded
|
|
591
|
+
// module's row reflects the new install. The HTTP path rebuilds per
|
|
592
|
+
// request, so the discovery page tracks the new version regardless;
|
|
593
|
+
// this keeps the inspection artifact aligned.
|
|
594
|
+
await regenAfterOp(opId, registry, deps);
|
|
595
|
+
|
|
497
596
|
registry.update(
|
|
498
597
|
opId,
|
|
499
598
|
{ status: "succeeded" },
|
|
@@ -540,6 +639,26 @@ export async function handleUninstall(
|
|
|
540
639
|
const code = await run(["bun", "remove", "-g", spec.package]);
|
|
541
640
|
log.push(`bun remove -g ${spec.package} exited ${code}`);
|
|
542
641
|
|
|
642
|
+
// 4. Refresh the on-disk well-known so the uninstalled module no
|
|
643
|
+
// longer appears in the inspection artifact. The HTTP path rebuilds
|
|
644
|
+
// per request, so live discovery drops the entry immediately; this
|
|
645
|
+
// is the disk-side equivalent.
|
|
646
|
+
try {
|
|
647
|
+
const regenOpts: Parameters<typeof regenerateWellKnown>[0] = {
|
|
648
|
+
manifestPath: deps.manifestPath,
|
|
649
|
+
canonicalOrigin: deps.issuer,
|
|
650
|
+
};
|
|
651
|
+
if (deps.wellKnownPath !== undefined) regenOpts.wellKnownPath = deps.wellKnownPath;
|
|
652
|
+
if (deps.readModuleManifest !== undefined) {
|
|
653
|
+
regenOpts.readModuleManifest = deps.readModuleManifest;
|
|
654
|
+
}
|
|
655
|
+
const { path } = await regenerateWellKnown(regenOpts);
|
|
656
|
+
log.push(`regenerated ${path}`);
|
|
657
|
+
} catch (err) {
|
|
658
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
659
|
+
log.push(`well-known regen failed: ${msg}`);
|
|
660
|
+
}
|
|
661
|
+
|
|
543
662
|
return jsonOk({ short, log });
|
|
544
663
|
}
|
|
545
664
|
|
package/src/well-known.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { CONFIG_DIR } from "./config.ts";
|
|
4
|
-
import type
|
|
4
|
+
import { type ModuleManifest, readModuleManifest } from "./module-manifest.ts";
|
|
5
|
+
import { type ServiceEntry, readManifest } from "./services-manifest.ts";
|
|
5
6
|
|
|
6
7
|
export interface WellKnownServiceEntry {
|
|
7
8
|
url: string;
|
|
@@ -214,3 +215,83 @@ export function writeWellKnownFile(doc: WellKnownDocument, path: string = WELL_K
|
|
|
214
215
|
renameSync(tmp, path);
|
|
215
216
|
return path;
|
|
216
217
|
}
|
|
218
|
+
|
|
219
|
+
export interface RegenerateWellKnownOpts {
|
|
220
|
+
/** Path to services.json. Defaults to `SERVICES_MANIFEST_PATH`. */
|
|
221
|
+
manifestPath: string;
|
|
222
|
+
/**
|
|
223
|
+
* Origin to embed in the doc's `url` fields. The hub HTTP path uses
|
|
224
|
+
* `configuredIssuer ?? new URL(req.url).origin`; module-ops callers don't
|
|
225
|
+
* have a request, so they pass `issuer` (the configured hub origin from
|
|
226
|
+
* `ApiModulesOpsDeps`) — same canonical URL the per-request build would
|
|
227
|
+
* emit.
|
|
228
|
+
*/
|
|
229
|
+
canonicalOrigin: string;
|
|
230
|
+
/** Override the on-disk well-known path (test seam). Defaults to `WELL_KNOWN_PATH`. */
|
|
231
|
+
wellKnownPath?: string;
|
|
232
|
+
/**
|
|
233
|
+
* Reader for a module's `.parachute/module.json`. Production uses
|
|
234
|
+
* `readModuleManifest`; tests inject a stub. Mirrors hub-server's
|
|
235
|
+
* `readModuleManifest` seam so the disk regen and the per-request build
|
|
236
|
+
* stay in lockstep.
|
|
237
|
+
*/
|
|
238
|
+
readModuleManifest?: (installDir: string) => Promise<ModuleManifest | null>;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Regenerate `/.well-known/parachute.json` on disk from current services.json.
|
|
243
|
+
*
|
|
244
|
+
* Mirrors the dynamic build inside `hub-server.ts`'s
|
|
245
|
+
* `/.well-known/parachute.json` handler so the on-disk doc tracks the same
|
|
246
|
+
* `uiUrl` / `displayName` / `managementUrl` shape the live discovery page
|
|
247
|
+
* fetches. Returns the path written + the resulting doc so callers can log
|
|
248
|
+
* and tests can assert without re-reading from disk.
|
|
249
|
+
*
|
|
250
|
+
* Used by `/api/modules/:short/{install,upgrade,uninstall}` post-mutation so
|
|
251
|
+
* the on-disk doc stays current after lifecycle ops. The per-request build
|
|
252
|
+
* in hub-server.ts remains the source of truth for live HTTP reads — this
|
|
253
|
+
* disk write is the inspection / debug artifact (and a belt-and-suspenders
|
|
254
|
+
* canary for anything that reads the file directly).
|
|
255
|
+
*/
|
|
256
|
+
export async function regenerateWellKnown(
|
|
257
|
+
opts: RegenerateWellKnownOpts,
|
|
258
|
+
): Promise<{ path: string; doc: WellKnownDocument }> {
|
|
259
|
+
const read = opts.readModuleManifest ?? readModuleManifest;
|
|
260
|
+
const path = opts.wellKnownPath ?? WELL_KNOWN_PATH;
|
|
261
|
+
const services = readManifest(opts.manifestPath).services;
|
|
262
|
+
// Build the resolver maps the same way hub-server does — read each
|
|
263
|
+
// module's `.parachute/module.json` from `installDir` and harvest
|
|
264
|
+
// managementUrl (vault rows) + uiUrl + displayName (non-vault rows).
|
|
265
|
+
// Per-entry errors land in console.warn so one malformed manifest doesn't
|
|
266
|
+
// block the regen for everyone else.
|
|
267
|
+
const managementUrls = new Map<string, string>();
|
|
268
|
+
const uiUrls = new Map<string, string>();
|
|
269
|
+
const displayNames = new Map<string, string>();
|
|
270
|
+
await Promise.all(
|
|
271
|
+
services.map(async (s) => {
|
|
272
|
+
if (!s.installDir) return;
|
|
273
|
+
try {
|
|
274
|
+
const m = await read(s.installDir);
|
|
275
|
+
if (!m) return;
|
|
276
|
+
if (isVaultEntry(s)) {
|
|
277
|
+
if (m.managementUrl) managementUrls.set(s.name, m.managementUrl);
|
|
278
|
+
} else {
|
|
279
|
+
if (m.uiUrl) uiUrls.set(s.name, m.uiUrl);
|
|
280
|
+
if (m.displayName) displayNames.set(s.name, m.displayName);
|
|
281
|
+
}
|
|
282
|
+
} catch (err) {
|
|
283
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
284
|
+
console.warn(`well-known regen: skipping module metadata for ${s.name}: ${msg}`);
|
|
285
|
+
}
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
288
|
+
const doc = buildWellKnown({
|
|
289
|
+
services,
|
|
290
|
+
canonicalOrigin: opts.canonicalOrigin,
|
|
291
|
+
managementUrlFor: (entry) => managementUrls.get(entry.name),
|
|
292
|
+
uiUrlFor: (entry) => uiUrls.get(entry.name),
|
|
293
|
+
displayNameFor: (entry) => displayNames.get(entry.name),
|
|
294
|
+
});
|
|
295
|
+
writeWellKnownFile(doc, path);
|
|
296
|
+
return { path, doc };
|
|
297
|
+
}
|