@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.10",
3
+ "version": "0.5.11-rc.1",
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": {
@@ -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
+ });
@@ -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 { ServiceEntry } from "./services-manifest.ts";
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
+ }