@openparachute/app 0.2.0-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/.parachute/config/schema +62 -0
- package/.parachute/info +14 -0
- package/.parachute/module.json +14 -0
- package/CHANGELOG.md +405 -0
- package/LICENSE +661 -0
- package/bin/parachute-app.ts +525 -0
- package/dist/admin/assets/index-BXlRNPxk.js +60 -0
- package/dist/admin/assets/index-DaGP1hmw.css +1 -0
- package/dist/admin/index.html +14 -0
- package/package.json +51 -0
- package/src/admin-routes.ts +884 -0
- package/src/auth.ts +212 -0
- package/src/bootstrap.ts +153 -0
- package/src/cache-headers.ts +106 -0
- package/src/config.ts +289 -0
- package/src/dcr.ts +334 -0
- package/src/dev-injection.ts +166 -0
- package/src/dev-mode.ts +205 -0
- package/src/dev-routes.ts +380 -0
- package/src/dev-watcher.ts +479 -0
- package/src/http-server.ts +533 -0
- package/src/index.ts +394 -0
- package/src/meta-schema.ts +662 -0
- package/src/npm-fetch.ts +320 -0
- package/src/operator-token.ts +95 -0
- package/src/provision-schema.ts +180 -0
- package/src/self-register.ts +155 -0
- package/src/services-manifest.ts +104 -0
- package/src/ui-registry.ts +202 -0
|
@@ -0,0 +1,155 @@
|
|
|
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 * as path from "node:path";
|
|
35
|
+
|
|
36
|
+
import pkg from "../package.json" with { type: "json" };
|
|
37
|
+
import { type ServiceEntry, readServiceEntry, upsertService } from "./services-manifest.ts";
|
|
38
|
+
|
|
39
|
+
export type SelfRegisterOpts = {
|
|
40
|
+
/**
|
|
41
|
+
* The port app just bound. Used only as the first-run fallback — if
|
|
42
|
+
* services.json already has an entry, we re-stamp the existing port
|
|
43
|
+
* unchanged to preserve operator/hub overrides.
|
|
44
|
+
*/
|
|
45
|
+
boundPort: number;
|
|
46
|
+
/**
|
|
47
|
+
* Absolute path to the app package root (where `.parachute/` and
|
|
48
|
+
* `package.json` live). Stamped as `installDir` so hub can resolve
|
|
49
|
+
* `parachute restart app` back to this checkout.
|
|
50
|
+
*/
|
|
51
|
+
installDir: string;
|
|
52
|
+
/**
|
|
53
|
+
* Additional fields to merge into the row — used for the per-UI `uis` map
|
|
54
|
+
* (Phase 1.2) and any future schema extensions without touching this
|
|
55
|
+
* function's signature.
|
|
56
|
+
*/
|
|
57
|
+
extraFields?: Record<string, unknown>;
|
|
58
|
+
/**
|
|
59
|
+
* Override the services.json location (tests). Defaults to
|
|
60
|
+
* `$PARACHUTE_HOME/services.json`.
|
|
61
|
+
*/
|
|
62
|
+
manifestPath?: string;
|
|
63
|
+
/** Logger override; default console. */
|
|
64
|
+
logger?: Pick<Console, "log" | "warn" | "error">;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type SelfRegisterResult = {
|
|
68
|
+
ok: boolean;
|
|
69
|
+
/** The path we wrote to (or attempted to write to). */
|
|
70
|
+
manifestPath: string;
|
|
71
|
+
/** True when services.json already had a row for `app` before we wrote. */
|
|
72
|
+
hadExistingEntry: boolean;
|
|
73
|
+
/** The port we ended up stamping (existing-entry port or boundPort). */
|
|
74
|
+
portWritten: number;
|
|
75
|
+
/** Set when ok=false — the error swallowed by the caller. */
|
|
76
|
+
error?: Error;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Self-register app's services.json entry. Best-effort: returns
|
|
81
|
+
* `{ok: false, error}` on any failure rather than throwing, so the caller's
|
|
82
|
+
* "log + continue" branch is one shape regardless of failure mode.
|
|
83
|
+
*
|
|
84
|
+
* Idempotent against repeated calls — the canonical case is `serve()`
|
|
85
|
+
* invoking this once per boot, but if the daemon restarts in-process or a
|
|
86
|
+
* Phase 1.2 UI add/remove re-runs the registration to refresh the `uis`
|
|
87
|
+
* map, repeated calls converge to the same disk state.
|
|
88
|
+
*/
|
|
89
|
+
export function selfRegister(opts: SelfRegisterOpts): SelfRegisterResult {
|
|
90
|
+
const logger = opts.logger ?? console;
|
|
91
|
+
const manifestPath = opts.manifestPath; // undefined → resolveManifestPath() default
|
|
92
|
+
|
|
93
|
+
let existing: ServiceEntry | undefined;
|
|
94
|
+
try {
|
|
95
|
+
existing = readServiceEntry("app", manifestPath);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
const err = e as Error;
|
|
98
|
+
logger.warn(`[app] skipped self-register: ${err.message}`);
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
manifestPath: manifestPath ?? "~/.parachute/services.json",
|
|
102
|
+
hadExistingEntry: false,
|
|
103
|
+
portWritten: opts.boundPort,
|
|
104
|
+
error: err,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const portToWrite = existing?.port ?? opts.boundPort;
|
|
109
|
+
const entry: ServiceEntry = {
|
|
110
|
+
name: "app",
|
|
111
|
+
port: portToWrite,
|
|
112
|
+
paths: ["/app", "/.parachute"],
|
|
113
|
+
health: "/app/healthz",
|
|
114
|
+
version: pkg.version,
|
|
115
|
+
displayName: "App",
|
|
116
|
+
tagline:
|
|
117
|
+
"Host module for custom Parachute UIs — drop a built bundle in and serve it under one origin.",
|
|
118
|
+
installDir: opts.installDir,
|
|
119
|
+
...(opts.extraFields ?? {}),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
upsertService(entry, manifestPath);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
const err = e as Error;
|
|
126
|
+
logger.warn(`[app] skipped self-register: ${err.message}`);
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
manifestPath: manifestPath ?? "~/.parachute/services.json",
|
|
130
|
+
hadExistingEntry: existing !== undefined,
|
|
131
|
+
portWritten: portToWrite,
|
|
132
|
+
error: err,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
logger.log(
|
|
137
|
+
`[app] self-registered services.json entry (port=${portToWrite}, installDir=${opts.installDir}${existing ? ", existing entry merged" : ", first boot"})`,
|
|
138
|
+
);
|
|
139
|
+
return {
|
|
140
|
+
ok: true,
|
|
141
|
+
manifestPath: manifestPath ?? "~/.parachute/services.json",
|
|
142
|
+
hadExistingEntry: existing !== undefined,
|
|
143
|
+
portWritten: portToWrite,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Resolve the app package root — the directory containing
|
|
149
|
+
* `.parachute/module.json` + `package.json`. `import.meta.dir` points at
|
|
150
|
+
* `src/`; walk up one level. Matches the resolver in `http-server.ts`'s
|
|
151
|
+
* `defaultParachuteDir()`.
|
|
152
|
+
*/
|
|
153
|
+
export function resolveProjectRoot(): string {
|
|
154
|
+
return path.resolve(import.meta.dir, "..");
|
|
155
|
+
}
|
|
@@ -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,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
|
+
}
|