@openparachute/app 0.2.0-rc.10

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.
@@ -0,0 +1,184 @@
1
+ /**
2
+ * `selfRegister()` — stamp app's entry into `~/.parachute/services.json`
3
+ * on `parachute-app serve` boot.
4
+ *
5
+ * Why this exists, in one sentence: hub-as-supervisor (v0.6) reads
6
+ * `~/.parachute/services.json` to know which modules exist on the host; a
7
+ * module that doesn't self-register is invisible to `parachute status`,
8
+ * `parachute restart`, the admin SPA module catalog, and the live
9
+ * `/.well-known/parachute.json` builder.
10
+ *
11
+ * Two reads from the file before we write:
12
+ * 1. The existing row's `port` is preserved on subsequent boots so an
13
+ * operator (or hub) who set `app.port = 1948` in services.json stays
14
+ * at 1948 across restarts — even if the env var that pointed app at
15
+ * 1948 is later unset. Same first-boot-vs-subsequent-boot rule
16
+ * scribe + agent + runner settled (scribe#40, paraclaw#145).
17
+ * 2. The existing row's hub-stamped fields (`installDir` from
18
+ * parachute-hub#84, future `uiUrl` / `managementUrl`) merge through
19
+ * because `upsertService` spreads `entry` last. We re-stamp our own
20
+ * `installDir = PROJECT_ROOT` regardless — hub#293/#302 made the
21
+ * runtime install path stamp installDir, and we want services.json
22
+ * to keep that resolution after a `git pull` moves the checkout.
23
+ *
24
+ * Failure mode: any error during the write is logged + swallowed by the
25
+ * caller (see `serve()` in `src/index.ts`). The daemon still serves locally
26
+ * if services.json is unwritable, malformed, or fights with a concurrent
27
+ * writer — the operator just won't see app in `parachute status` until the
28
+ * underlying issue clears.
29
+ *
30
+ * Phase 1.2 hook: this function writes only the module-level row today.
31
+ * When per-UI `uis` map lands (design doc section 12), the caller assembles
32
+ * the `uis` field and passes it through via `extraFields`.
33
+ */
34
+ import { readFileSync } from "node:fs";
35
+ import * as path from "node:path";
36
+
37
+ import pkg from "../package.json" with { type: "json" };
38
+ import { type ServiceEntry, readServiceEntry, upsertService } from "./services-manifest.ts";
39
+
40
+ /**
41
+ * The canonical services.json row key for the app module — sourced from
42
+ * `.parachute/module.json#manifestName`. Hub looks up modules in
43
+ * services.json by `manifestName` (vault + scribe use this convention), so
44
+ * self-registering under the short name "app" would create a duplicate row
45
+ * alongside the hub-installed `parachute-app` row and trip hub's
46
+ * duplicate-port detector on re-read. See parachute-patterns or the
47
+ * `manifestName` field on .parachute/module.json files. */
48
+ const ROW_NAME = resolveManifestName();
49
+
50
+ function resolveManifestName(): string {
51
+ // Read once at module load — `.parachute/module.json` ships in the
52
+ // package and doesn't change at runtime.
53
+ try {
54
+ const manifestPath = path.resolve(import.meta.dir, "..", ".parachute", "module.json");
55
+ const raw = JSON.parse(readFileSync(manifestPath, "utf8")) as { manifestName?: unknown };
56
+ if (typeof raw.manifestName === "string" && raw.manifestName.length > 0) {
57
+ return raw.manifestName;
58
+ }
59
+ } catch {
60
+ // Fall through to the conservative fallback. The module.json is part
61
+ // of the published package, so this branch is effectively unreachable
62
+ // in production — it exists so tests that mount a stub package layout
63
+ // don't hard-crash on import.
64
+ }
65
+ return "parachute-app";
66
+ }
67
+
68
+ export type SelfRegisterOpts = {
69
+ /**
70
+ * The port app just bound. Used only as the first-run fallback — if
71
+ * services.json already has an entry, we re-stamp the existing port
72
+ * unchanged to preserve operator/hub overrides.
73
+ */
74
+ boundPort: number;
75
+ /**
76
+ * Absolute path to the app package root (where `.parachute/` and
77
+ * `package.json` live). Stamped as `installDir` so hub can resolve
78
+ * `parachute restart app` back to this checkout.
79
+ */
80
+ installDir: string;
81
+ /**
82
+ * Additional fields to merge into the row — used for the per-UI `uis` map
83
+ * (Phase 1.2) and any future schema extensions without touching this
84
+ * function's signature.
85
+ */
86
+ extraFields?: Record<string, unknown>;
87
+ /**
88
+ * Override the services.json location (tests). Defaults to
89
+ * `$PARACHUTE_HOME/services.json`.
90
+ */
91
+ manifestPath?: string;
92
+ /** Logger override; default console. */
93
+ logger?: Pick<Console, "log" | "warn" | "error">;
94
+ };
95
+
96
+ export type SelfRegisterResult = {
97
+ ok: boolean;
98
+ /** The path we wrote to (or attempted to write to). */
99
+ manifestPath: string;
100
+ /** True when services.json already had a row for `parachute-app` before we wrote. */
101
+ hadExistingEntry: boolean;
102
+ /** The port we ended up stamping (existing-entry port or boundPort). */
103
+ portWritten: number;
104
+ /** Set when ok=false — the error swallowed by the caller. */
105
+ error?: Error;
106
+ };
107
+
108
+ /**
109
+ * Self-register app's services.json entry. Best-effort: returns
110
+ * `{ok: false, error}` on any failure rather than throwing, so the caller's
111
+ * "log + continue" branch is one shape regardless of failure mode.
112
+ *
113
+ * Idempotent against repeated calls — the canonical case is `serve()`
114
+ * invoking this once per boot, but if the daemon restarts in-process or a
115
+ * Phase 1.2 UI add/remove re-runs the registration to refresh the `uis`
116
+ * map, repeated calls converge to the same disk state.
117
+ */
118
+ export function selfRegister(opts: SelfRegisterOpts): SelfRegisterResult {
119
+ const logger = opts.logger ?? console;
120
+ const manifestPath = opts.manifestPath; // undefined → resolveManifestPath() default
121
+
122
+ let existing: ServiceEntry | undefined;
123
+ try {
124
+ existing = readServiceEntry(ROW_NAME, manifestPath);
125
+ } catch (e) {
126
+ const err = e as Error;
127
+ logger.warn(`[app] skipped self-register: ${err.message}`);
128
+ return {
129
+ ok: false,
130
+ manifestPath: manifestPath ?? "~/.parachute/services.json",
131
+ hadExistingEntry: false,
132
+ portWritten: opts.boundPort,
133
+ error: err,
134
+ };
135
+ }
136
+
137
+ const portToWrite = existing?.port ?? opts.boundPort;
138
+ const entry: ServiceEntry = {
139
+ name: ROW_NAME,
140
+ port: portToWrite,
141
+ paths: ["/app", "/.parachute"],
142
+ health: "/app/healthz",
143
+ version: pkg.version,
144
+ displayName: "App",
145
+ tagline:
146
+ "Host module for custom Parachute UIs — drop a built bundle in and serve it under one origin.",
147
+ installDir: opts.installDir,
148
+ ...(opts.extraFields ?? {}),
149
+ };
150
+
151
+ try {
152
+ upsertService(entry, manifestPath);
153
+ } catch (e) {
154
+ const err = e as Error;
155
+ logger.warn(`[app] skipped self-register: ${err.message}`);
156
+ return {
157
+ ok: false,
158
+ manifestPath: manifestPath ?? "~/.parachute/services.json",
159
+ hadExistingEntry: existing !== undefined,
160
+ portWritten: portToWrite,
161
+ error: err,
162
+ };
163
+ }
164
+
165
+ logger.log(
166
+ `[app] self-registered services.json entry (port=${portToWrite}, installDir=${opts.installDir}${existing ? ", existing entry merged" : ", first boot"})`,
167
+ );
168
+ return {
169
+ ok: true,
170
+ manifestPath: manifestPath ?? "~/.parachute/services.json",
171
+ hadExistingEntry: existing !== undefined,
172
+ portWritten: portToWrite,
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Resolve the app package root — the directory containing
178
+ * `.parachute/module.json` + `package.json`. `import.meta.dir` points at
179
+ * `src/`; walk up one level. Matches the resolver in `http-server.ts`'s
180
+ * `defaultParachuteDir()`.
181
+ */
182
+ export function resolveProjectRoot(): string {
183
+ return path.resolve(import.meta.dir, "..");
184
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Self-registration into `~/.parachute/services.json` on `parachute-app
3
+ * serve` boot.
4
+ *
5
+ * Mirrors `parachute-runner/src/services-manifest.ts` deliberately — the file
6
+ * shape is the contract between every Parachute module and the hub
7
+ * (`parachute-hub/src/services-manifest.ts` is the canonical reader).
8
+ *
9
+ * Failure mode: any write error is logged + swallowed by the caller. Self-
10
+ * registration is best-effort — the daemon still serves locally even if the
11
+ * manifest write fails (permissions, disk full, race with another writer,
12
+ * malformed pre-existing file).
13
+ *
14
+ * `installDir` is the third-party-module hook (parachute-hub#84): hub looks
15
+ * the field up to resolve `parachute restart app` back to the checkout it
16
+ * should drive. Self-registering it here means app doesn't need a vendored
17
+ * fallback in hub's `FIRST_PARTY_FALLBACKS` registry.
18
+ */
19
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
20
+ import * as os from "node:os";
21
+ import { dirname, join } from "node:path";
22
+
23
+ export interface ServiceEntry {
24
+ name: string;
25
+ port: number;
26
+ paths: string[];
27
+ health: string;
28
+ version: string;
29
+ displayName?: string;
30
+ tagline?: string;
31
+ installDir?: string;
32
+ /**
33
+ * Hub-stamped fields (e.g. `installDir` from parachute-hub#84, future
34
+ * uiUrl / managementUrl pass-throughs) ride on the row even though the
35
+ * module itself doesn't author them. The upsert merges rather than
36
+ * replaces so those survive a self-registration write.
37
+ *
38
+ * App-specific: the per-UI `uis` map (design doc section 12) also lands
39
+ * here once Phase 1.2 ships per-UI registration. The current shape lets
40
+ * a Phase 1.2 PR add `uis` without re-touching the storage layer.
41
+ */
42
+ [key: string]: unknown;
43
+ }
44
+
45
+ interface ServicesManifest {
46
+ services: ServiceEntry[];
47
+ }
48
+
49
+ /**
50
+ * Canonical location of `services.json`. Honors `PARACHUTE_HOME` for sandbox +
51
+ * Render deployments (matches the convention every other committed-core
52
+ * module follows).
53
+ */
54
+ export function resolveManifestPath(env: Record<string, string | undefined> = process.env): string {
55
+ const base = env.PARACHUTE_HOME ?? join(env.HOME ?? os.homedir(), ".parachute");
56
+ return join(base, "services.json");
57
+ }
58
+
59
+ function readManifest(path: string): ServicesManifest {
60
+ if (!existsSync(path)) return { services: [] };
61
+ const raw = JSON.parse(readFileSync(path, "utf8"));
62
+ if (!raw || typeof raw !== "object" || !Array.isArray((raw as { services?: unknown }).services)) {
63
+ throw new Error(`services manifest at ${path} is malformed (missing "services" array)`);
64
+ }
65
+ return raw as ServicesManifest;
66
+ }
67
+
68
+ /**
69
+ * Read an existing service entry from the manifest. Returns `undefined` when
70
+ * the file is missing or no row matches `name`.
71
+ *
72
+ * Used at boot so app can respect an operator- or hub-set port already
73
+ * recorded in services.json (same first-boot-vs-subsequent-boot discipline
74
+ * scribe + agent settled on — see paraclaw#145 / scribe#40).
75
+ */
76
+ export function readServiceEntry(
77
+ name: string,
78
+ path: string = resolveManifestPath(),
79
+ ): ServiceEntry | undefined {
80
+ const manifest = readManifest(path);
81
+ return manifest.services.find((s) => s.name === name);
82
+ }
83
+
84
+ /**
85
+ * Idempotent upsert of a service entry. Merges into any existing row rather
86
+ * than replacing it — preserves hub-stamped fields the module doesn't own
87
+ * (installDir from hub#84, future uiUrl, etc.). The module still wins for
88
+ * the fields it owns (port, paths, version, health, displayName, installDir
89
+ * — because `entry` spreads last in the merge).
90
+ *
91
+ * Atomic write: stages to `<path>.tmp-<pid>-<now>`, then renames over the
92
+ * target. A crash mid-write leaves the prior file intact rather than
93
+ * corrupting it.
94
+ */
95
+ export function upsertService(entry: ServiceEntry, path: string = resolveManifestPath()): void {
96
+ mkdirSync(dirname(path), { recursive: true });
97
+ const manifest = readManifest(path);
98
+ const idx = manifest.services.findIndex((s) => s.name === entry.name);
99
+ if (idx >= 0) manifest.services[idx] = { ...manifest.services[idx], ...entry };
100
+ else manifest.services.push(entry);
101
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
102
+ writeFileSync(tmp, `${JSON.stringify(manifest, null, 2)}\n`);
103
+ renameSync(tmp, path);
104
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Runtime tenancy contract injection — implements the host side of
3
+ * `parachute-patterns/patterns/runtime-tenancy-contract.md`.
4
+ *
5
+ * For every `index.html` parachute-app serves on behalf of a hosted UI we
6
+ * inject a small block of structured environment metadata into `<head>`:
7
+ *
8
+ * <head>
9
+ * <base href="/app/<name>/"> browser URL resolution
10
+ * <meta name="parachute-mount" content="/app/<name>"> runtime code reads this
11
+ * <meta name="parachute-hub" content="<hub-origin>"> OAuth discovery
12
+ * ... existing head ...
13
+ * </head>
14
+ *
15
+ * Two layers, deliberately:
16
+ *
17
+ * - `<base href>` is load-bearing for the BROWSER's URL resolution. Without
18
+ * it, Vite-built bundles' `./assets/...` URLs resolve against the
19
+ * document's perceived directory (`/app/` when the operator visits
20
+ * `/app/notes` with no trailing slash), and 404 on every asset.
21
+ * - `<meta name="parachute-mount">` / `<meta name="parachute-hub">` are
22
+ * read at runtime by `@openparachute/app-client` (parachute-app#22).
23
+ * Strings the bundle reads as JavaScript — no help from the browser's
24
+ * URL resolver, hence the separate meta tags.
25
+ *
26
+ * Tags deliberately deferred (out of scope for parachute-app#21):
27
+ * - `parachute-vault` — needs vault-binding-via-session design that's
28
+ * orthogonal to the mount-path concerns this PR addresses.
29
+ * - `parachute-tenant-id` — derivable on the consumer side from
30
+ * `parachute-mount`; not worth its own injection.
31
+ * - `parachute-vault-origin` — forward-looking for cross-origin vault.
32
+ *
33
+ * Why string scanning (no `cheerio`): the rationale in `dev-injection.ts`
34
+ * applies identically here. One conservative substring insertion; we don't
35
+ * want to canonicalize the operator's HTML.
36
+ *
37
+ * Idempotency: if `<meta name="parachute-mount">` is already present in
38
+ * the source HTML (e.g. some future bundle ships its own injection), we
39
+ * leave the document untouched. The marker check is regex-based — a false
40
+ * positive (a comment containing the exact attribute) suppresses injection
41
+ * harmlessly.
42
+ */
43
+
44
+ /** Regex marker for idempotency. Matches both quote styles + case. */
45
+ const MOUNT_META_REGEX = /<meta\b[^>]*\bname\s*=\s*['"]parachute-mount['"][^>]*>/i;
46
+ /** Find the opening `<head ...>` tag, case-insensitive, whitespace-tolerant. */
47
+ const HEAD_OPEN_REGEX = /<head(\s[^>]*)?>/i;
48
+
49
+ /** Minimal HTML attribute-value escape — order matters (escape `&` first). */
50
+ export function escapeHtmlAttr(s: string): string {
51
+ return s
52
+ .replace(/&/g, "&amp;")
53
+ .replace(/"/g, "&quot;")
54
+ .replace(/</g, "&lt;")
55
+ .replace(/>/g, "&gt;");
56
+ }
57
+
58
+ export type TenancyInjectionResult = {
59
+ /** The (maybe-modified) document. */
60
+ html: string;
61
+ /** Did we change anything? */
62
+ injected: boolean;
63
+ /** Why we skipped, if we did. `undefined` on the happy path. */
64
+ skipped?: "already-present" | "no-head";
65
+ };
66
+
67
+ /**
68
+ * Inject the runtime tenancy contract tags into `html`.
69
+ *
70
+ * - `mount` is the UI's mount path (`/app/<name>`, no trailing slash).
71
+ * Used unchanged in `<meta name="parachute-mount">`; appended with `/`
72
+ * in `<base href>` (the trailing slash is browser-URL-resolution
73
+ * critical).
74
+ * - `hubOrigin` is the absolute hub URL (`http://127.0.0.1:1939` or
75
+ * `https://parachute.example.com`).
76
+ *
77
+ * Returns `{ html, injected, skipped? }`:
78
+ * - `injected: true` + `skipped: undefined` on the happy path.
79
+ * - `injected: false` + `skipped: "already-present"` when the source
80
+ * already declares `parachute-mount` (idempotent).
81
+ * - `injected: false` + `skipped: "no-head"` when the document has no
82
+ * `<head>` (malformed; caller logs + serves unmodified).
83
+ *
84
+ * Note we insert AFTER the opening `<head>` tag, not before `</head>` like
85
+ * `dev-injection.ts` does. The contract tags should come BEFORE any
86
+ * existing `<base>` / `<link rel=icon>` / `<script>` so the browser's
87
+ * URL resolver picks up the injected `<base href>` for everything in the
88
+ * document. (Per HTML spec, the first `<base>` wins — if the bundle ships
89
+ * its own and we insert after it, the bundle's wins; inserting at head-top
90
+ * lets the host's mount-aware base take precedence.)
91
+ */
92
+ export function injectTenancyContract(
93
+ html: string,
94
+ mount: string,
95
+ hubOrigin: string,
96
+ ): TenancyInjectionResult {
97
+ // Idempotent: bail if the marker is already in the doc.
98
+ if (MOUNT_META_REGEX.test(html)) {
99
+ return { html, injected: false, skipped: "already-present" };
100
+ }
101
+
102
+ // Find the first <head> that's NOT inside an HTML comment. Vite-built
103
+ // HTML never has `<!-- <head> -->` comments, but be defensive — a
104
+ // false match inside a comment would inject malformed HTML.
105
+ let searchFrom = 0;
106
+ let headMatch: RegExpExecArray | null = null;
107
+ while (true) {
108
+ HEAD_OPEN_REGEX.lastIndex = 0;
109
+ const candidate = HEAD_OPEN_REGEX.exec(html.slice(searchFrom));
110
+ if (!candidate) break;
111
+ const absoluteIndex = searchFrom + candidate.index;
112
+ if (!isInsideComment(html, absoluteIndex)) {
113
+ headMatch = candidate;
114
+ headMatch.index = absoluteIndex;
115
+ break;
116
+ }
117
+ // Skip past this comment and search again.
118
+ const commentClose = html.indexOf("-->", absoluteIndex);
119
+ if (commentClose === -1) break;
120
+ searchFrom = commentClose + 3;
121
+ }
122
+ if (!headMatch) {
123
+ return { html, injected: false, skipped: "no-head" };
124
+ }
125
+
126
+ const baseHref = `${mount}/`;
127
+ const block = [
128
+ `<base href="${escapeHtmlAttr(baseHref)}">`,
129
+ `<meta name="parachute-mount" content="${escapeHtmlAttr(mount)}">`,
130
+ `<meta name="parachute-hub" content="${escapeHtmlAttr(hubOrigin)}">`,
131
+ ].join("\n ");
132
+
133
+ const insertAt = headMatch.index + headMatch[0].length;
134
+ return {
135
+ html: `${html.slice(0, insertAt)}\n ${block}${html.slice(insertAt)}`,
136
+ injected: true,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * True if `pos` falls inside an unclosed HTML comment that opens before
142
+ * it. Used to skip `<head>` matches that are really `<!-- ... <head> ... -->`.
143
+ */
144
+ function isInsideComment(html: string, pos: number): boolean {
145
+ const lastOpen = html.lastIndexOf("<!--", pos);
146
+ if (lastOpen === -1) return false;
147
+ const closeAfterOpen = html.indexOf("-->", lastOpen);
148
+ return closeAfterOpen !== -1 && closeAfterOpen > pos;
149
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * UI discovery for parachute-app.
3
+ *
4
+ * Scans `$PARACHUTE_HOME/app/uis/` for subdirectories. For each:
5
+ * 1. Look for `meta.json` — skip + warn if missing.
6
+ * 2. Look for `dist/index.html` — skip + warn if missing.
7
+ * 3. Parse + validate meta.json via `parseMeta` — skip + warn on invalid.
8
+ * 4. Compute the absolute paths app will resolve assets against at serve-time.
9
+ *
10
+ * Returns the validated `RegisteredUi[]` list. Mount-path collisions are
11
+ * resolved deterministically: alphabetical-by-name wins, others are demoted
12
+ * to `status: "collision"` and dropped from the active mount set (still
13
+ * surfaced in `parachute-app list` once that lands in Phase 1.2). Same shape
14
+ * the design doc landed in section 8, modulo the alphabetical tie-break
15
+ * which the Phase 1.1 brief asked for explicitly.
16
+ *
17
+ * `RegisteredUi.distDir` is the absolute path to the `dist/` directory; the
18
+ * HTTP layer resolves each asset request against it.
19
+ */
20
+
21
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
22
+ import * as path from "node:path";
23
+
24
+ import { resolveUisDir } from "./config.ts";
25
+ import { InvalidMetaError, type UiMeta, parseMeta } from "./meta-schema.ts";
26
+
27
+ export type UiStatus =
28
+ | "active"
29
+ | "missing-meta"
30
+ | "missing-dist"
31
+ | "invalid-meta"
32
+ | "collision"
33
+ | "reserved-path";
34
+
35
+ export type RegisteredUi = {
36
+ /** Directory name under `uis/`. May differ from `meta.name` until Phase 1.2 enforces alignment. */
37
+ dirName: string;
38
+ /** Absolute path to the `<uis>/<dirName>/` directory. */
39
+ uiDir: string;
40
+ /** Absolute path to the `<uis>/<dirName>/dist/` directory. */
41
+ distDir: string;
42
+ /** Parsed meta.json. */
43
+ meta: UiMeta;
44
+ };
45
+
46
+ export type SkippedUi = {
47
+ dirName: string;
48
+ uiDir: string;
49
+ status: Exclude<UiStatus, "active">;
50
+ reason: string;
51
+ };
52
+
53
+ export type ScanResult = {
54
+ /** UIs that mounted successfully + survived collision resolution. */
55
+ registered: RegisteredUi[];
56
+ /** UIs the scanner found but couldn't activate. */
57
+ skipped: SkippedUi[];
58
+ };
59
+
60
+ export type ScanOpts = {
61
+ /** Override the uis-dir location (tests). Defaults to `resolveUisDir()`. */
62
+ uisDir?: string;
63
+ /** Logger override; default console. */
64
+ logger?: Pick<Console, "log" | "warn" | "error">;
65
+ };
66
+
67
+ /**
68
+ * Path the admin SPA reserves once Phase 1.2 lands. A meta.json that tries
69
+ * to claim it is rejected at scan time. Kept as a const so Phase 1.2's
70
+ * `add` flow can share the same check.
71
+ */
72
+ export const RESERVED_PATHS: ReadonlySet<string> = new Set(["/app/admin", "/app/dev"]);
73
+
74
+ /**
75
+ * Scan `uisDir` for declared UIs. Best-effort: a malformed UI is skipped +
76
+ * surfaced in `skipped`; it never throws. Only an exception reading the
77
+ * uisDir itself (permissions, broken symlink at the directory level)
78
+ * propagates — that's a fatal config issue the operator needs to see.
79
+ */
80
+ export function scanUis(opts: ScanOpts = {}): ScanResult {
81
+ const uisDir = opts.uisDir ?? resolveUisDir();
82
+ const logger = opts.logger ?? console;
83
+
84
+ if (!existsSync(uisDir)) {
85
+ // Fresh install — no `uis/` directory yet. That's fine; just return empty.
86
+ logger.log(`[app] uis dir not found at ${uisDir}; no UIs to mount`);
87
+ return { registered: [], skipped: [] };
88
+ }
89
+
90
+ let entries: string[];
91
+ try {
92
+ entries = readdirSync(uisDir);
93
+ } catch (e) {
94
+ logger.error(`[app] failed to read uis dir ${uisDir}: ${(e as Error).message}`);
95
+ throw e;
96
+ }
97
+
98
+ const candidates: Array<RegisteredUi | SkippedUi> = [];
99
+
100
+ for (const dirName of entries.sort()) {
101
+ const uiDir = path.join(uisDir, dirName);
102
+ let st: ReturnType<typeof statSync>;
103
+ try {
104
+ st = statSync(uiDir);
105
+ } catch {
106
+ // Disappearing entries (race, broken symlink) — skip silently.
107
+ continue;
108
+ }
109
+ if (!st.isDirectory()) continue;
110
+
111
+ const metaPath = path.join(uiDir, "meta.json");
112
+ const distDir = path.join(uiDir, "dist");
113
+ const indexPath = path.join(distDir, "index.html");
114
+
115
+ if (!existsSync(metaPath)) {
116
+ const reason = `missing meta.json at ${metaPath}`;
117
+ logger.warn(`[app] skip ${dirName}: ${reason}`);
118
+ candidates.push({ dirName, uiDir, status: "missing-meta", reason });
119
+ continue;
120
+ }
121
+
122
+ if (!existsSync(indexPath)) {
123
+ const reason = `missing dist/index.html at ${indexPath}`;
124
+ logger.warn(`[app] skip ${dirName}: ${reason}`);
125
+ candidates.push({ dirName, uiDir, status: "missing-dist", reason });
126
+ continue;
127
+ }
128
+
129
+ let raw: unknown;
130
+ try {
131
+ raw = JSON.parse(readFileSync(metaPath, "utf8"));
132
+ } catch (e) {
133
+ const reason = `meta.json is not valid JSON: ${(e as Error).message}`;
134
+ logger.warn(`[app] skip ${dirName}: ${reason}`);
135
+ candidates.push({ dirName, uiDir, status: "invalid-meta", reason });
136
+ continue;
137
+ }
138
+
139
+ let meta: UiMeta;
140
+ try {
141
+ meta = parseMeta(raw);
142
+ } catch (e) {
143
+ const reason =
144
+ e instanceof InvalidMetaError
145
+ ? e.message
146
+ : `meta.json validation failed: ${(e as Error).message}`;
147
+ logger.warn(`[app] skip ${dirName}: ${reason}`);
148
+ candidates.push({ dirName, uiDir, status: "invalid-meta", reason });
149
+ continue;
150
+ }
151
+
152
+ if (RESERVED_PATHS.has(meta.path)) {
153
+ const reason = `meta.path "${meta.path}" is reserved (admin SPA)`;
154
+ logger.warn(`[app] skip ${dirName}: ${reason}`);
155
+ candidates.push({ dirName, uiDir, status: "reserved-path", reason });
156
+ continue;
157
+ }
158
+
159
+ candidates.push({ dirName, uiDir, distDir, meta });
160
+ }
161
+
162
+ // Resolve mount-path collisions deterministically: among UIs declaring the
163
+ // same `meta.path`, the lexicographically-smallest `meta.name` wins; the
164
+ // others are demoted to `status: "collision"`.
165
+ const byPath = new Map<string, RegisteredUi[]>();
166
+ const skipped: SkippedUi[] = [];
167
+ for (const c of candidates) {
168
+ if ("meta" in c) {
169
+ const list = byPath.get(c.meta.path) ?? [];
170
+ list.push(c);
171
+ byPath.set(c.meta.path, list);
172
+ } else {
173
+ skipped.push(c);
174
+ }
175
+ }
176
+
177
+ const registered: RegisteredUi[] = [];
178
+ for (const [mountPath, list] of byPath) {
179
+ if (list.length === 1) {
180
+ registered.push(list[0]!);
181
+ continue;
182
+ }
183
+ list.sort((a, b) => a.meta.name.localeCompare(b.meta.name));
184
+ const winner = list[0]!;
185
+ registered.push(winner);
186
+ for (let i = 1; i < list.length; i++) {
187
+ const loser = list[i]!;
188
+ const reason = `mount path ${mountPath} also claimed by "${winner.meta.name}" — alphabetical tie-break wins`;
189
+ logger.warn(`[app] skip ${loser.dirName}: ${reason}`);
190
+ skipped.push({
191
+ dirName: loser.dirName,
192
+ uiDir: loser.uiDir,
193
+ status: "collision",
194
+ reason,
195
+ });
196
+ }
197
+ }
198
+
199
+ // Stable ordering for downstream consumers (HTTP routing + admin list).
200
+ registered.sort((a, b) => a.meta.path.localeCompare(b.meta.path));
201
+ return { registered, skipped };
202
+ }