@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.
@@ -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
+ }