@openparachute/hub 0.7.7-rc.8 → 0.7.7-rc.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/install.test.ts +187 -5
- package/src/__tests__/notes-serve.test.ts +216 -0
- package/src/__tests__/port-assign.test.ts +43 -14
- package/src/__tests__/services-manifest.test.ts +350 -40
- package/src/__tests__/setup.test.ts +5 -1
- package/src/api-modules.ts +1 -1
- package/src/commands/install.ts +44 -0
- package/src/commands/setup.ts +9 -4
- package/src/help.ts +6 -5
- package/src/notes-serve.ts +73 -31
- package/src/service-spec.ts +113 -29
- package/src/services-manifest.ts +104 -11
package/src/notes-serve.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Tiny static-file server for
|
|
4
|
+
* Tiny static-file server for a Parachute PWA bundle — originally written
|
|
5
|
+
* for @openparachute/notes, generalized in hub-parity P5 (2026-07-11) to
|
|
6
|
+
* also serve @openparachute/parachute-app (the super-surface front door,
|
|
7
|
+
* port 1944, mount `/app`) via the same shim. Any package matching this
|
|
8
|
+
* shape (a prebuilt SPA `dist/` with no server of its own) can reuse it.
|
|
5
9
|
*
|
|
6
|
-
*
|
|
7
|
-
* this shim with the installed `dist/` path so the PWA is
|
|
8
|
-
* known port and can be reverse-proxied by `parachute expose`
|
|
9
|
-
* the other services.
|
|
10
|
+
* A served bundle is a SPA — no backend of its own. `parachute start
|
|
11
|
+
* <svc>` invokes this shim with the installed `dist/` path so the PWA is
|
|
12
|
+
* served at a known port and can be reverse-proxied by `parachute expose`
|
|
13
|
+
* alongside the other services.
|
|
10
14
|
*
|
|
11
15
|
* Invoked as:
|
|
12
|
-
* bun <this-file> --port <n> [--dist <path>] [--mount <prefix>]
|
|
16
|
+
* bun <this-file> --port <n> [--dist <path>] [--mount <prefix>] [--package <npmName>]
|
|
13
17
|
*
|
|
14
18
|
* `--mount` (default `/notes`) is the path prefix the reverse proxy hands
|
|
15
19
|
* us. We strip it before resolving against `dist/` so a request for
|
|
@@ -17,27 +21,43 @@
|
|
|
17
21
|
* `{dist}/notes/sw.js`. Without the strip, the SW + .webmanifest both
|
|
18
22
|
* SPA-fall-back to index.html with content-type text/html, and the PWA
|
|
19
23
|
* install prompt never fires. Pass `--mount ""` (or `--mount /`) when the
|
|
20
|
-
* bundle is served at the origin root.
|
|
24
|
+
* bundle is served at the origin root. THIS IS LOAD-BEARING for every
|
|
25
|
+
* package served by this shim, not just notes — keep it exactly as-is.
|
|
21
26
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
27
|
+
* `--package` (default `@openparachute/notes`, back-compat) names the npm
|
|
28
|
+
* package whose `dist/` we resolve when `--dist` is omitted. Passed by
|
|
29
|
+
* FIRST_PARTY_FALLBACKS entries whose startCmd composes this shim for a
|
|
30
|
+
* package other than notes (e.g. `app`'s `--package @openparachute/parachute-app`).
|
|
31
|
+
*
|
|
32
|
+
* If --dist is omitted, we resolve the package's dist directory via
|
|
33
|
+
* Bun.resolveSync. If that fails (package not installed globally, or
|
|
24
34
|
* package doesn't ship dist/), exit 1 with a clear error.
|
|
35
|
+
*
|
|
36
|
+
* `/health` (post-mount-strip) always answers 2xx — the doctor/status
|
|
37
|
+
* probe (`probeModuleHealth`) only cares about the status code, but we
|
|
38
|
+
* answer explicitly rather than relying on the SPA-shell catch-all so a
|
|
39
|
+
* missing/corrupt `dist/index.html` can't take the health check down with it.
|
|
25
40
|
*/
|
|
26
41
|
|
|
27
42
|
import { existsSync } from "node:fs";
|
|
28
43
|
import { homedir } from "node:os";
|
|
29
44
|
import { dirname, join, resolve } from "node:path";
|
|
30
45
|
|
|
46
|
+
/** Back-compat default — the shim's original (and still primary) consumer. */
|
|
47
|
+
const DEFAULT_PACKAGE = "@openparachute/notes";
|
|
48
|
+
|
|
31
49
|
interface Args {
|
|
32
50
|
port: number;
|
|
33
51
|
dist?: string;
|
|
34
52
|
mount: string;
|
|
53
|
+
pkg: string;
|
|
35
54
|
}
|
|
36
55
|
|
|
37
56
|
function parseArgs(argv: string[]): Args {
|
|
38
57
|
let port = 5173;
|
|
39
58
|
let dist: string | undefined;
|
|
40
59
|
let mount = "/notes";
|
|
60
|
+
let pkg = DEFAULT_PACKAGE;
|
|
41
61
|
for (let i = 0; i < argv.length; i++) {
|
|
42
62
|
const a = argv[i];
|
|
43
63
|
if (a === "--port") {
|
|
@@ -56,11 +76,15 @@ function parseArgs(argv: string[]): Args {
|
|
|
56
76
|
const v = argv[++i];
|
|
57
77
|
if (v === undefined) throw new Error("--mount requires a value");
|
|
58
78
|
mount = normalizeMount(v);
|
|
79
|
+
} else if (a === "--package") {
|
|
80
|
+
const v = argv[++i];
|
|
81
|
+
if (!v) throw new Error("--package requires a value");
|
|
82
|
+
pkg = v;
|
|
59
83
|
} else {
|
|
60
84
|
throw new Error(`unknown argument: ${a}`);
|
|
61
85
|
}
|
|
62
86
|
}
|
|
63
|
-
return { port, dist, mount };
|
|
87
|
+
return { port, dist, mount, pkg };
|
|
64
88
|
}
|
|
65
89
|
|
|
66
90
|
export function normalizeMount(raw: string): string {
|
|
@@ -70,22 +94,22 @@ export function normalizeMount(raw: string): string {
|
|
|
70
94
|
|
|
71
95
|
/**
|
|
72
96
|
* Candidate base directories that `Bun.resolveSync` walks from when looking
|
|
73
|
-
* for
|
|
97
|
+
* for `<package>/package.json`. Order matters:
|
|
74
98
|
*
|
|
75
|
-
* 1. `process.cwd()` — works when
|
|
76
|
-
*
|
|
77
|
-
* any project that depends on
|
|
99
|
+
* 1. `process.cwd()` — works when the shim is invoked from inside the
|
|
100
|
+
* package's own checkout (e.g. via `installDir` cwd in lifecycle.ts) or
|
|
101
|
+
* from any project that depends on the package.
|
|
78
102
|
* 2. `~/.bun/install/global/node_modules` — modern Bun's global-install
|
|
79
|
-
* layout. This is where `bun add -g
|
|
80
|
-
*
|
|
103
|
+
* layout. This is where `bun add -g <package>` lands the package, and
|
|
104
|
+
* where `bun link <package>` symlinks it.
|
|
81
105
|
* 3. `~/.bun/install/global` — defensive fallback for older Bun layouts.
|
|
82
106
|
*
|
|
83
|
-
* Hub itself does NOT depend on
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
107
|
+
* Hub itself does NOT depend on the served package, so when `parachute
|
|
108
|
+
* start <svc>` is run from the hub repo dir, the cwd-relative resolve walks
|
|
109
|
+
* ancestral node_modules and finds nothing. Bun does not auto-consult the
|
|
110
|
+
* global install dir, so bun-linked installs fail to resolve without
|
|
111
|
+
* (2)/(3). hub#194: Aaron hit silent 502 on tailnet `/notes/` because of
|
|
112
|
+
* this — fixed by trying the global install dirs.
|
|
89
113
|
*
|
|
90
114
|
* Exported (and parameterized via `cwd`/`home`) so tests can drive the
|
|
91
115
|
* resolution order against a real fixture install without monkey-patching
|
|
@@ -98,6 +122,8 @@ export function notesDistCandidates(cwd: string, home: string): string[] {
|
|
|
98
122
|
export interface ResolveNotesDistDeps {
|
|
99
123
|
cwd?: string;
|
|
100
124
|
home?: string;
|
|
125
|
+
/** npm package name to resolve. Defaults to `@openparachute/notes` (back-compat). */
|
|
126
|
+
pkg?: string;
|
|
101
127
|
/** Override `Bun.resolveSync` for tests. */
|
|
102
128
|
resolveSync?: (specifier: string, base: string) => string;
|
|
103
129
|
existsSync?: (path: string) => boolean;
|
|
@@ -106,6 +132,7 @@ export interface ResolveNotesDistDeps {
|
|
|
106
132
|
export function resolveNotesDistFrom(deps: ResolveNotesDistDeps = {}): string {
|
|
107
133
|
const cwd = deps.cwd ?? process.cwd();
|
|
108
134
|
const home = deps.home ?? homedir();
|
|
135
|
+
const pkg = deps.pkg ?? DEFAULT_PACKAGE;
|
|
109
136
|
const resolveSync = deps.resolveSync ?? Bun.resolveSync;
|
|
110
137
|
const exists = deps.existsSync ?? existsSync;
|
|
111
138
|
const candidates = notesDistCandidates(cwd, home);
|
|
@@ -113,7 +140,7 @@ export function resolveNotesDistFrom(deps: ResolveNotesDistDeps = {}): string {
|
|
|
113
140
|
for (const base of candidates) {
|
|
114
141
|
let pkgPath: string;
|
|
115
142
|
try {
|
|
116
|
-
pkgPath = resolveSync(
|
|
143
|
+
pkgPath = resolveSync(`${pkg}/package.json`, base);
|
|
117
144
|
} catch (err) {
|
|
118
145
|
resolveErrors.push(` - ${base}: ${err instanceof Error ? err.message : String(err)}`);
|
|
119
146
|
continue;
|
|
@@ -126,18 +153,18 @@ export function resolveNotesDistFrom(deps: ResolveNotesDistDeps = {}): string {
|
|
|
126
153
|
// other candidates — they'd resolve to the same package and report
|
|
127
154
|
// the same problem.
|
|
128
155
|
throw new Error(
|
|
129
|
-
|
|
156
|
+
`${pkg} resolved at ${root} has no dist/ directory at ${dist}. The package may not ship a prebuilt bundle — ask the ${pkg} maintainer to add a prepublishOnly build step.`,
|
|
130
157
|
);
|
|
131
158
|
}
|
|
132
159
|
return dist;
|
|
133
160
|
}
|
|
134
161
|
throw new Error(
|
|
135
|
-
`Could not resolve
|
|
162
|
+
`Could not resolve ${pkg} from any of:\n${resolveErrors.join("\n")}\nIs the package installed? Try \`bun add -g ${pkg}\` or the matching \`parachute install <short>\`.`,
|
|
136
163
|
);
|
|
137
164
|
}
|
|
138
165
|
|
|
139
|
-
function resolveNotesDist(): string {
|
|
140
|
-
return resolveNotesDistFrom();
|
|
166
|
+
function resolveNotesDist(pkg: string): string {
|
|
167
|
+
return resolveNotesDistFrom({ pkg });
|
|
141
168
|
}
|
|
142
169
|
|
|
143
170
|
function mimeFor(path: string): string | undefined {
|
|
@@ -160,6 +187,17 @@ export function notesFetch(dist: string, mount: string): (req: Request) => Respo
|
|
|
160
187
|
if (mount && (pathname === mount || pathname.startsWith(`${mount}/`))) {
|
|
161
188
|
pathname = pathname.slice(mount.length) || "/";
|
|
162
189
|
}
|
|
190
|
+
if (pathname === "/health") {
|
|
191
|
+
// Explicit rather than falling through to the SPA shell: the doctor /
|
|
192
|
+
// status probe (`probeModuleHealth`) only checks for a 2xx status, but
|
|
193
|
+
// answering directly means a missing/corrupt `dist/index.html` can't
|
|
194
|
+
// take the health check down with it. Every FIRST_PARTY_FALLBACKS
|
|
195
|
+
// entry served by this shim declares `health` under its own mount
|
|
196
|
+
// (e.g. `/notes/health`, `/app/health`) — this answers all of them.
|
|
197
|
+
return new Response(JSON.stringify({ status: "ok" }), {
|
|
198
|
+
headers: { "content-type": "application/json" },
|
|
199
|
+
});
|
|
200
|
+
}
|
|
163
201
|
if (pathname === "/" || pathname.endsWith("/")) {
|
|
164
202
|
return spaShell();
|
|
165
203
|
}
|
|
@@ -200,17 +238,21 @@ export function notesServeOptions(
|
|
|
200
238
|
}
|
|
201
239
|
|
|
202
240
|
if (import.meta.main) {
|
|
203
|
-
const { port, dist: distArg, mount } = parseArgs(process.argv.slice(2));
|
|
241
|
+
const { port, dist: distArg, mount, pkg } = parseArgs(process.argv.slice(2));
|
|
204
242
|
|
|
205
243
|
let dist: string;
|
|
206
244
|
try {
|
|
207
|
-
dist = distArg ?? resolveNotesDist();
|
|
245
|
+
dist = distArg ?? resolveNotesDist(pkg);
|
|
208
246
|
} catch (err) {
|
|
209
|
-
console.error(
|
|
247
|
+
console.error(
|
|
248
|
+
`parachute-static-serve (${pkg}): ${err instanceof Error ? err.message : String(err)}`,
|
|
249
|
+
);
|
|
210
250
|
process.exit(1);
|
|
211
251
|
}
|
|
212
252
|
|
|
213
253
|
Bun.serve(notesServeOptions(port, dist, mount));
|
|
214
254
|
|
|
215
|
-
console.log(
|
|
255
|
+
console.log(
|
|
256
|
+
`static-serve listening on :${port} (pkg=${pkg}, dist=${dist}, mount=${mount || "/"})`,
|
|
257
|
+
);
|
|
216
258
|
}
|
package/src/service-spec.ts
CHANGED
|
@@ -11,7 +11,13 @@ import type { ServiceEntry } from "./services-manifest.ts";
|
|
|
11
11
|
* 1941 parachute-agent exploration (renamed from parachute-channel)
|
|
12
12
|
* 1942 parachute-notes committed core (PWA bundle)
|
|
13
13
|
* 1943 parachute-scribe committed core
|
|
14
|
-
* 1944
|
|
14
|
+
* 1944 parachute-app the super-surface front door (PWA bundle,
|
|
15
|
+
* hub-parity P5) — see the PORT_RESERVATIONS
|
|
16
|
+
* comment below for why this is a DIFFERENT
|
|
17
|
+
* module from the pre-2026-05-27 `app`
|
|
18
|
+
* 1945 unassigned
|
|
19
|
+
* 1946 parachute-surface UI host (renamed from `app` 2026-05-27)
|
|
20
|
+
* 1947–1949 unassigned
|
|
15
21
|
*
|
|
16
22
|
* Hub pins 1939: `parachute expose` composes hub targets as
|
|
17
23
|
* `http://127.0.0.1:1939/` and that URL has to be stable across machines for
|
|
@@ -67,12 +73,24 @@ export const PORT_RESERVATIONS: readonly PortReservation[] = [
|
|
|
67
73
|
{ port: 1941, name: "parachute-agent", status: "assigned" },
|
|
68
74
|
{ port: 1942, name: "parachute-notes", status: "assigned" },
|
|
69
75
|
{ port: 1943, name: "parachute-scribe", status: "assigned" },
|
|
70
|
-
|
|
76
|
+
// hub-parity P5 (2026-07-11): parachute-app's canonical slot — the NEW
|
|
77
|
+
// super-surface front door (@openparachute/parachute-app), landing as a
|
|
78
|
+
// FIRST_PARTY_FALLBACKS entry (short "app") with its own hub-side static-
|
|
79
|
+
// serve shim (notes-serve.ts --package). This is a DIFFERENT, unrelated
|
|
80
|
+
// module from the pre-2026-05-27 `app` package described in the 1946
|
|
81
|
+
// comment below — the two just happen to share the name across a rename.
|
|
82
|
+
// Status `assigned` keeps the fallback-port walker (`assignPort` in
|
|
83
|
+
// port-assign.ts) from handing this port to a colliding third-party
|
|
84
|
+
// module.
|
|
85
|
+
{ port: 1944, name: "parachute-app", status: "assigned" },
|
|
71
86
|
{ port: 1945, name: "unassigned", status: "reserved" },
|
|
72
|
-
// hub#323: parachute-app's canonical slot
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
87
|
+
// hub#323 (historical): this WAS `parachute-app`'s canonical slot back
|
|
88
|
+
// when "app" meant the UI-host module. That module renamed to
|
|
89
|
+
// `parachute-surface` 2026-05-27 (patterns#102); port 1944 above is the
|
|
90
|
+
// different, new parachute-app (super-surface) from hub-parity P5. Status
|
|
91
|
+
// `assigned` keeps the fallback-port walker from handing 1946 to a
|
|
92
|
+
// colliding third-party module. The matching KNOWN_MODULES row carries
|
|
93
|
+
// the canonicalPort + paths for status/expose surfaces.
|
|
76
94
|
{ port: 1946, name: "parachute-surface", status: "assigned" },
|
|
77
95
|
{ port: 1947, name: "unassigned", status: "reserved" },
|
|
78
96
|
{ port: 1948, name: "unassigned", status: "reserved" },
|
|
@@ -278,9 +296,15 @@ export function composeServiceSpec(opts: {
|
|
|
278
296
|
// — its startCmd is composed from the services.json entry's port + mount,
|
|
279
297
|
// which is hub-side logic, not something notes itself runs. (The archive
|
|
280
298
|
// isn't done — notes-daemon Phase 3 retirement hasn't landed.)
|
|
299
|
+
// - app: the NEW super-surface front door (@openparachute/parachute-app,
|
|
300
|
+
// hub-parity P5, 2026-07-11) — same shape as notes: a frontend bundle
|
|
301
|
+
// with no server of its own, served by the SAME hub-side shim
|
|
302
|
+
// (`notes-serve.ts --package @openparachute/parachute-app`). Unrelated
|
|
303
|
+
// to the pre-2026-05-27 `app` package that renamed to `surface` (see
|
|
304
|
+
// the KNOWN_MODULES.surface tagline + the RETIRED_MODULES note below).
|
|
281
305
|
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
306
|
+
// Each entry keeps its "FALLBACK: Delete when …" marker so the next cleanup
|
|
307
|
+
// pass is a one-grep operation.
|
|
284
308
|
// ---------------------------------------------------------------------------
|
|
285
309
|
|
|
286
310
|
// FALLBACK: Delete when @openparachute/notes ships .parachute/module.json AND
|
|
@@ -317,11 +341,58 @@ const NOTES_FALLBACK: FirstPartyFallback = {
|
|
|
317
341
|
},
|
|
318
342
|
};
|
|
319
343
|
|
|
344
|
+
// FALLBACK: Delete when @openparachute/parachute-app ships
|
|
345
|
+
// .parachute/module.json AND self-registers its services.json row at boot.
|
|
346
|
+
// As of hub-parity P5 the app repo doesn't carry the real module.json yet —
|
|
347
|
+
// only a stale `dist/.parachute/info` artifact (name: "parachute-notes")
|
|
348
|
+
// left over from seeding the app off notes-ui, which is a DIFFERENT file
|
|
349
|
+
// (module-manifest.ts's runtime `/.parachute/info`, not the static
|
|
350
|
+
// `.parachute/module.json` contract this fallback stands in for) and
|
|
351
|
+
// doesn't block this fallback path. parachute-app is a frontend bundle (the
|
|
352
|
+
// super-surface front door) with no server of its own, so — same as notes —
|
|
353
|
+
// its startCmd is hub-side logic composed from the services.json entry
|
|
354
|
+
// (port + mount), running through the SAME `notes-serve.ts` shim
|
|
355
|
+
// generalized in hub-parity P5 via `--package`.
|
|
356
|
+
const APP_FALLBACK: FirstPartyFallback = {
|
|
357
|
+
package: "@openparachute/parachute-app",
|
|
358
|
+
manifest: {
|
|
359
|
+
name: "app",
|
|
360
|
+
manifestName: "parachute-app",
|
|
361
|
+
displayName: "Parachute",
|
|
362
|
+
tagline: "The Parachute app — your parachute's front door.",
|
|
363
|
+
port: 1944,
|
|
364
|
+
paths: ["/app"],
|
|
365
|
+
health: "/app/health",
|
|
366
|
+
},
|
|
367
|
+
extras: {
|
|
368
|
+
startCmd: (entry) => {
|
|
369
|
+
const first = entry.paths[0] ?? "/app";
|
|
370
|
+
const mount = first === "/" ? "" : first.replace(/\/+$/, "");
|
|
371
|
+
return [
|
|
372
|
+
"bun",
|
|
373
|
+
NOTES_SERVE_PATH,
|
|
374
|
+
"--port",
|
|
375
|
+
String(entry.port),
|
|
376
|
+
"--mount",
|
|
377
|
+
mount,
|
|
378
|
+
"--package",
|
|
379
|
+
"@openparachute/parachute-app",
|
|
380
|
+
];
|
|
381
|
+
},
|
|
382
|
+
postInstallFooter: () => [
|
|
383
|
+
"",
|
|
384
|
+
"Open your Parachute at <origin>/app — it signs in with your hub account.",
|
|
385
|
+
"The hub's front page (`/`) now opens the app too, unless you've already",
|
|
386
|
+
"set a custom root redirect (`parachute hub set-root-redirect` or the admin SPA).",
|
|
387
|
+
],
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
|
|
320
391
|
/**
|
|
321
392
|
* Vendored manifests + extras for first-party modules that still need them.
|
|
322
393
|
* Indexed by short name (the `parachute install <X>` token).
|
|
323
394
|
*
|
|
324
|
-
*
|
|
395
|
+
* notes + app remain — see the block comment above for the rationale
|
|
325
396
|
* (vault/scribe/agent now self-register and ship their own
|
|
326
397
|
* module.json). Other code paths consult both this table AND `KNOWN_MODULES`
|
|
327
398
|
* (which carries the post-self-register-retirement entries) via the helpers
|
|
@@ -329,6 +400,7 @@ const NOTES_FALLBACK: FirstPartyFallback = {
|
|
|
329
400
|
*/
|
|
330
401
|
export const FIRST_PARTY_FALLBACKS: Record<string, FirstPartyFallback> = {
|
|
331
402
|
notes: NOTES_FALLBACK,
|
|
403
|
+
app: APP_FALLBACK,
|
|
332
404
|
};
|
|
333
405
|
|
|
334
406
|
/**
|
|
@@ -520,8 +592,15 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
|
|
|
520
592
|
* legacy operators. `notes` (the daemon) is the canonical
|
|
521
593
|
* "deprecating-but-not-retired" case as of 2026-05-22: do not add
|
|
522
594
|
* until its Phase 3 retirement lands.
|
|
523
|
-
* - Entries stay forever
|
|
524
|
-
*
|
|
595
|
+
* - Entries stay forever — UNLESS the retired name is later reclaimed by a
|
|
596
|
+
* genuinely new, unrelated module that needs to self-register under it
|
|
597
|
+
* for real (see the `app` / `parachute-app` un-retirement note below).
|
|
598
|
+
* That's the one case where removal is correct: keeping a retirement
|
|
599
|
+
* entry alongside a live FIRST_PARTY_FALLBACKS/KNOWN_MODULES entry of
|
|
600
|
+
* the SAME name would make `dropRetiredModuleRows` GC the live module's
|
|
601
|
+
* own services.json row on every read — an outright regression, not a
|
|
602
|
+
* conservative default. Ordinary "this module is genuinely gone"
|
|
603
|
+
* entries (no successor reusing the name) still stay forever.
|
|
525
604
|
*/
|
|
526
605
|
export const RETIRED_MODULES: Record<string, { retiredAt: string; replacement?: string }> = {
|
|
527
606
|
// NOTE (2026-06-17, channel→agent rename): the `agent` / `parachute-agent`
|
|
@@ -538,21 +617,24 @@ export const RETIRED_MODULES: Record<string, { retiredAt: string; replacement?:
|
|
|
538
617
|
// which is harmless / arguably correct since paraclaw was the original
|
|
539
618
|
// "agent".)
|
|
540
619
|
//
|
|
541
|
-
//
|
|
542
|
-
// app
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
// the
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
620
|
+
// NOTE (2026-07-11, hub-parity P5 — `app` / `parachute-app` UN-RETIRED):
|
|
621
|
+
// this table carried `app` + `parachute-app` (retired 2026-05-27, the
|
|
622
|
+
// app → surface rename, patterns#102) from hub#219 through hub-parity P4.
|
|
623
|
+
// They are REMOVED as of this note: a NEW, unrelated module — the real
|
|
624
|
+
// `@openparachute/parachute-app` super-surface front door (see
|
|
625
|
+
// FIRST_PARTY_FALLBACKS.app + PORT_RESERVATIONS' port-1944 comment) —
|
|
626
|
+
// claims the `parachute-app` manifestName (and the bare `app` short) for
|
|
627
|
+
// real. Keeping these entries would make `dropRetiredModuleRows`
|
|
628
|
+
// unconditionally GC the new module's services.json row on every read,
|
|
629
|
+
// breaking it outright — see the curation-rule exception above. An
|
|
630
|
+
// operator with a genuinely stale PRE-2026-05-27 `app`/`parachute-app` row
|
|
631
|
+
// (from the old, now-defunct @openparachute/app package) is handled the
|
|
632
|
+
// same as any other unrecognized row once un-retired: `parachute status`
|
|
633
|
+
// renders it, and `parachute serve` boots it via installDir's
|
|
634
|
+
// module.json if present and logs-and-skips otherwise — the same
|
|
635
|
+
// graceful fallback the `runner` registry removal (2026-07-01, see
|
|
636
|
+
// KNOWN_MODULES below) relies on. In practice the real parachute-app
|
|
637
|
+
// module's own first boot overwrites any stale row sharing its name.
|
|
556
638
|
};
|
|
557
639
|
|
|
558
640
|
/**
|
|
@@ -632,7 +714,8 @@ export function knownServices(): string[] {
|
|
|
632
714
|
* manifest-declared `focus`; this map is the fallback when `module.json` omits
|
|
633
715
|
* it (and the bootstrap value before a module is installed).
|
|
634
716
|
*
|
|
635
|
-
* - `core` — vault / scribe / hub / surface (the product surface
|
|
717
|
+
* - `core` — vault / scribe / hub / surface / app (the product surface;
|
|
718
|
+
* `app` joined 2026-07-11, hub-parity P5 — the super-surface front door).
|
|
636
719
|
* - `experimental` — agent (legit preview; still OFFERED on a fresh install)
|
|
637
720
|
* + any unlisted third-party short.
|
|
638
721
|
* - `deprecated` — notes (notes-daemon deprecated 2026-05-22; notes-ui moved
|
|
@@ -650,6 +733,7 @@ const FOCUS_DEFAULTS: Record<string, ModuleFocus> = {
|
|
|
650
733
|
scribe: "core",
|
|
651
734
|
hub: "core",
|
|
652
735
|
surface: "core",
|
|
736
|
+
app: "core",
|
|
653
737
|
agent: "experimental",
|
|
654
738
|
notes: "deprecated",
|
|
655
739
|
};
|
|
@@ -738,7 +822,7 @@ export function canonicalPortForManifest(manifestName: string): number | undefin
|
|
|
738
822
|
/**
|
|
739
823
|
* Resolve the runtime spec for a known short name.
|
|
740
824
|
*
|
|
741
|
-
* FIRST_PARTY_FALLBACKS shorts (notes) return a fully-composed
|
|
825
|
+
* FIRST_PARTY_FALLBACKS shorts (notes / app) return a fully-composed
|
|
742
826
|
* spec with embedded manifest + extras — the vendored manifest is the
|
|
743
827
|
* source of truth pre-install and the install path preserves it through.
|
|
744
828
|
*
|
|
@@ -851,7 +935,7 @@ const LEGACY_MANIFEST_ALIASES: Record<string, string> = {
|
|
|
851
935
|
};
|
|
852
936
|
|
|
853
937
|
/** Short name for a given manifest name, e.g. `parachute-vault` → `vault`.
|
|
854
|
-
* Consults both FIRST_PARTY_FALLBACKS (notes) and KNOWN_MODULES
|
|
938
|
+
* Consults both FIRST_PARTY_FALLBACKS (notes / app) and KNOWN_MODULES
|
|
855
939
|
* (vault / scribe / agent / surface — post-FALLBACK-retirement).
|
|
856
940
|
* Returns undefined for unknown manifests. */
|
|
857
941
|
export function shortNameForManifest(manifestName: string): string | undefined {
|
package/src/services-manifest.ts
CHANGED
|
@@ -623,7 +623,8 @@ export function readManifestLenient(
|
|
|
623
623
|
);
|
|
624
624
|
return { services: [] };
|
|
625
625
|
}
|
|
626
|
-
const
|
|
626
|
+
const healed = healStaleUiHostAppRows(raw, path);
|
|
627
|
+
const afterRetired = dropRetiredModuleRows(healed.raw, path);
|
|
627
628
|
const cleaned = dropLegacyShortNameRows(afterRetired.raw, path);
|
|
628
629
|
// `typeof null === "object"` in JS, so the `!cleaned.raw` part of this
|
|
629
630
|
// guard is load-bearing for the null case — not a typo or redundancy.
|
|
@@ -671,21 +672,28 @@ export function readManifest(path: string = SERVICES_MANIFEST_PATH): ServicesMan
|
|
|
671
672
|
`failed to parse ${path}: ${err instanceof Error ? err.message : String(err)}`,
|
|
672
673
|
);
|
|
673
674
|
}
|
|
674
|
-
//
|
|
675
|
+
// Row-heal + retired-module + legacy short-name cleanup all run BEFORE shape
|
|
675
676
|
// validation because the bugs they heal (rows that conflict on port with
|
|
676
677
|
// a current row) would otherwise throw inside `validateManifest`'s
|
|
677
|
-
// duplicate-port gate. Order matters:
|
|
678
|
-
//
|
|
679
|
-
// `
|
|
680
|
-
//
|
|
681
|
-
//
|
|
682
|
-
// the
|
|
683
|
-
//
|
|
684
|
-
|
|
678
|
+
// duplicate-port gate. Order matters:
|
|
679
|
+
// 1. `healStaleUiHostAppRows` FIRST — a pre-rename operator can carry BOTH
|
|
680
|
+
// a `parachute-app` AND an `app` stale row (2026-05-22 double-write
|
|
681
|
+
// reproducer); dropping by OLD-UI-host shape here clears both, whereas
|
|
682
|
+
// `dropLegacyShortNameRows` would keep the `parachute-app` half. Runs
|
|
683
|
+
// ahead of the retired-module pass too (the `app`/`parachute-app`
|
|
684
|
+
// RETIRED_MODULES entries were removed in hub-parity P5 — the names are
|
|
685
|
+
// the NEW app's now — so this shape-gated heal is their replacement).
|
|
686
|
+
// 2. `dropRetiredModuleRows` — drop genuinely-retired rows so their
|
|
687
|
+
// absence can unmask a `parachute-<X>` ↔ `<X>` pair underneath that
|
|
688
|
+
// 3. `dropLegacyShortNameRows` then handles.
|
|
689
|
+
// Each pass mutates the raw JSON object's `services` array; we re-validate
|
|
690
|
+
// against the cleaned shape. See the three helpers for the full discipline.
|
|
691
|
+
const healed = healStaleUiHostAppRows(raw, path);
|
|
692
|
+
const afterRetired = dropRetiredModuleRows(healed.raw, path);
|
|
685
693
|
const cleaned = dropLegacyShortNameRows(afterRetired.raw, path);
|
|
686
694
|
const validated = validateManifest(cleaned.raw, path);
|
|
687
695
|
const migrated = migrateClawToAgent(validated);
|
|
688
|
-
const changed = afterRetired.changed || cleaned.changed || migrated.changed;
|
|
696
|
+
const changed = healed.changed || afterRetired.changed || cleaned.changed || migrated.changed;
|
|
689
697
|
if (changed) writeManifest(migrated.manifest, path);
|
|
690
698
|
return migrated.manifest;
|
|
691
699
|
}
|
|
@@ -745,6 +753,91 @@ function dropRetiredModuleRows(raw: unknown, where: string): { raw: unknown; cha
|
|
|
745
753
|
};
|
|
746
754
|
}
|
|
747
755
|
|
|
756
|
+
/**
|
|
757
|
+
* Heal stale OLD-UI-host `app`/`parachute-app` rows on load (hub-parity P5,
|
|
758
|
+
* 2026-07-11).
|
|
759
|
+
*
|
|
760
|
+
* Context: the name `parachute-app` originally belonged to the UI-host module
|
|
761
|
+
* that renamed to `parachute-surface` on 2026-05-27 (patterns#102). Its
|
|
762
|
+
* retirement used to be handled by a `RETIRED_MODULES` entry that
|
|
763
|
+
* `dropRetiredModuleRows` GC'd unconditionally. Hub-parity P5 REUSES the
|
|
764
|
+
* `parachute-app` manifestName (+ the bare `app` short) for a genuinely NEW,
|
|
765
|
+
* unrelated module — the super-surface front door — so that retirement entry
|
|
766
|
+
* had to be removed (keeping it would GC the new module's own row on every
|
|
767
|
+
* read). This heal is the surgically narrow replacement for the ONE case the
|
|
768
|
+
* removed retirement still needs to cover: an operator upgrading DIRECTLY from
|
|
769
|
+
* a pre-rename release (~0.5.13-stable) who skipped every intermediate boot
|
|
770
|
+
* that would have GC'd their stale row. Without a heal, that stale row would:
|
|
771
|
+
* - crash-loop on boot — `reconcilePortToCanonical` rewrites its port to
|
|
772
|
+
* 1944, then the supervisor spawns the app static-serve shim against a
|
|
773
|
+
* `@openparachute/parachute-app` that isn't installed → exit 1 → a
|
|
774
|
+
* forever-restarting "app" unit the operator never installed;
|
|
775
|
+
* - hijack `/surface/*` if a real `parachute-surface` row is also present
|
|
776
|
+
* (both claim `/surface`);
|
|
777
|
+
* - and, after `parachute install app`, keep its stale `/surface` mount so
|
|
778
|
+
* the app serves under `/surface` while the install's `root_redirect=/app/`
|
|
779
|
+
* points the front page at a 404.
|
|
780
|
+
*
|
|
781
|
+
* Discriminant (structural — pinned against the OLD-UI-host row shape verified
|
|
782
|
+
* in `git show origin/main`'s retired-`app` + legacy-short-name test fixtures:
|
|
783
|
+
* `port: 1946`, `paths: ["/surface"]`, `health: "/surface/..."`):
|
|
784
|
+
* - `name` is exactly `"app"` or `"parachute-app"`, AND
|
|
785
|
+
* - it is NOT the NEW app's own row (the new app mounts at `/app` — never
|
|
786
|
+
* healed regardless of port), AND
|
|
787
|
+
* - it carries the OLD-UI-host signature: `paths[0]` is `/surface`-rooted OR
|
|
788
|
+
* `port === 1946` (the old canonical slot; now `parachute-surface`'s).
|
|
789
|
+
*
|
|
790
|
+
* The NEW app row (`paths: ["/app"]`, port 1944) is structurally disjoint on
|
|
791
|
+
* BOTH clauses, so it is always left intact. Matches `dropRetiredModuleRows`'s
|
|
792
|
+
* behavior: a plain DROP + a stderr warning steering the operator to
|
|
793
|
+
* `parachute install surface` (the same replacement the retirement named).
|
|
794
|
+
*
|
|
795
|
+
* Operates on raw JSON (before `validateManifest`) because a stale row can
|
|
796
|
+
* collide on port 1946 with a real `parachute-surface` row and would otherwise
|
|
797
|
+
* trip the duplicate-port gate before this heal runs. Runs FIRST in the load
|
|
798
|
+
* pipeline (ahead of `dropRetiredModuleRows` / `dropLegacyShortNameRows`): a
|
|
799
|
+
* pre-rename operator can carry BOTH a `parachute-app` and an `app` stale row
|
|
800
|
+
* (the 2026-05-22 double-write reproducer) — dropping by shape here clears
|
|
801
|
+
* both, whereas `dropLegacyShortNameRows` would keep the `parachute-app` half.
|
|
802
|
+
*/
|
|
803
|
+
function healStaleUiHostAppRows(raw: unknown, where: string): { raw: unknown; changed: boolean } {
|
|
804
|
+
if (!raw || typeof raw !== "object") return { raw, changed: false };
|
|
805
|
+
const services = (raw as Record<string, unknown>).services;
|
|
806
|
+
if (!Array.isArray(services)) return { raw, changed: false };
|
|
807
|
+
|
|
808
|
+
const dropped: string[] = [];
|
|
809
|
+
const nextServices = services.filter((row) => {
|
|
810
|
+
if (!row || typeof row !== "object") return true;
|
|
811
|
+
const rec = row as Record<string, unknown>;
|
|
812
|
+
const name = rec.name;
|
|
813
|
+
if (name !== "app" && name !== "parachute-app") return true;
|
|
814
|
+
const paths = rec.paths;
|
|
815
|
+
const first = Array.isArray(paths) && typeof paths[0] === "string" ? paths[0] : undefined;
|
|
816
|
+
// NEW super-surface app mounts at `/app` (port 1944) — never heal it,
|
|
817
|
+
// whatever its port happens to be after a reconcile/collision walk.
|
|
818
|
+
if (first === "/app" || first?.startsWith("/app/")) return true;
|
|
819
|
+
const port = rec.port;
|
|
820
|
+
const oldSurfaceMount = first === "/surface" || first?.startsWith("/surface/") === true;
|
|
821
|
+
const oldCanonicalPort = port === 1946;
|
|
822
|
+
if (!oldSurfaceMount && !oldCanonicalPort) return true;
|
|
823
|
+
dropped.push(String(name));
|
|
824
|
+
return false;
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
if (dropped.length === 0) return { raw, changed: false };
|
|
828
|
+
|
|
829
|
+
for (const name of dropped) {
|
|
830
|
+
console.error(
|
|
831
|
+
`${where}: dropped stale pre-rename '${name}' row (the OLD UI-host module, renamed to parachute-surface 2026-05-27). The name now belongs to the new Parachute app (mount /app, port 1944). If an old daemon is still running, stop it; install the current UI host with \`parachute install surface\`.`,
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
return {
|
|
836
|
+
raw: { ...(raw as Record<string, unknown>), services: nextServices },
|
|
837
|
+
changed: true,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
748
841
|
/**
|
|
749
842
|
* Drop legacy short-name rows when a same-module manifestName row exists
|
|
750
843
|
* on the same port. Handles the duplicate-row class introduced when a
|