@openparachute/hub 0.7.7-rc.8 → 0.7.7
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/README.md +7 -7
- package/package.json +4 -12
- package/src/__tests__/account-api.test.ts +29 -2
- package/src/__tests__/account-session.test.ts +9 -1
- package/src/__tests__/account-token.test.ts +26 -2
- package/src/__tests__/admin-connections-credentials.test.ts +41 -0
- package/src/__tests__/admin-lock.test.ts +3 -14
- package/src/__tests__/admin-module-token.test.ts +10 -30
- package/src/__tests__/admin-surfaces.test.ts +21 -0
- package/src/__tests__/api-hub-upgrade.test.ts +11 -0
- package/src/__tests__/api-mint-token.test.ts +25 -0
- package/src/__tests__/api-modules-ops.test.ts +34 -29
- package/src/__tests__/api-modules.test.ts +50 -58
- package/src/__tests__/api-revoke-token.test.ts +23 -0
- package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
- package/src/__tests__/api-tokens.test.ts +44 -0
- package/src/__tests__/audience-gate.test.ts +24 -0
- package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
- package/src/__tests__/chrome-strip.test.ts +18 -1
- package/src/__tests__/doctor.test.ts +10 -17
- package/src/__tests__/hub-command.test.ts +70 -2
- package/src/__tests__/hub-server.test.ts +322 -1
- package/src/__tests__/hub-settings.test.ts +110 -6
- package/src/__tests__/hub.test.ts +29 -0
- package/src/__tests__/install.test.ts +359 -5
- package/src/__tests__/migrate.test.ts +3 -1
- package/src/__tests__/notes-serve.test.ts +216 -0
- package/src/__tests__/oauth-handlers.test.ts +19 -8
- package/src/__tests__/operator-token.test.ts +1 -2
- package/src/__tests__/port-assign.test.ts +37 -17
- package/src/__tests__/root-serve.test.ts +139 -0
- package/src/__tests__/scope-explanations.test.ts +0 -2
- package/src/__tests__/serve-boot.test.ts +25 -36
- package/src/__tests__/service-spec-discovery.test.ts +30 -35
- package/src/__tests__/services-manifest.test.ts +372 -132
- package/src/__tests__/setup-wizard.test.ts +301 -22
- package/src/__tests__/setup.test.ts +13 -14
- package/src/__tests__/status.test.ts +0 -5
- package/src/__tests__/surface-notes-alias.test.ts +296 -0
- package/src/__tests__/wizard-transcription.test.ts +35 -0
- package/src/__tests__/wizard.test.ts +79 -0
- package/src/account-api.ts +6 -0
- package/src/admin-connections.ts +4 -2
- package/src/admin-lock.ts +1 -2
- package/src/admin-module-token.ts +13 -8
- package/src/admin-surfaces.ts +2 -1
- package/src/api-hub-upgrade.ts +2 -1
- package/src/api-mint-token.ts +2 -1
- package/src/api-modules-ops.ts +2 -1
- package/src/api-modules.ts +10 -8
- package/src/api-revoke-token.ts +2 -1
- package/src/api-settings-hub-origin.ts +2 -1
- package/src/api-settings-root-redirect.ts +97 -20
- package/src/api-tokens.ts +2 -1
- package/src/audience-gate.ts +2 -1
- package/src/chrome-strip.ts +16 -4
- package/src/commands/hub.ts +104 -0
- package/src/commands/install.ts +97 -8
- package/src/commands/migrate.ts +5 -1
- package/src/commands/setup.ts +9 -6
- package/src/commands/wizard-transcription.ts +24 -0
- package/src/commands/wizard.ts +34 -1
- package/src/help.ts +6 -6
- package/src/hub-server.ts +111 -54
- package/src/hub-settings.ts +147 -0
- package/src/hub.ts +64 -31
- package/src/module-ops-client.ts +2 -1
- package/src/notes-serve.ts +73 -31
- package/src/oauth-handlers.ts +1 -11
- package/src/operator-token.ts +0 -1
- package/src/origin-check.ts +2 -2
- package/src/root-serve.ts +156 -0
- package/src/scope-explanations.ts +2 -5
- package/src/service-spec.ts +129 -74
- package/src/services-manifest.ts +112 -52
- package/src/setup-wizard.ts +144 -31
- package/src/surface-notes-alias.ts +126 -0
- package/src/__tests__/admin-agent-token.test.ts +0 -173
- package/src/admin-agent-token.ts +0 -147
package/src/service-spec.ts
CHANGED
|
@@ -8,10 +8,16 @@ import type { ServiceEntry } from "./services-manifest.ts";
|
|
|
8
8
|
*
|
|
9
9
|
* 1939 parachute-hub internal static + proxy, CLI-managed
|
|
10
10
|
* 1940 parachute-vault committed core
|
|
11
|
-
* 1941
|
|
11
|
+
* 1941 unassigned
|
|
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
|
|
@@ -64,15 +70,27 @@ export interface PortReservation {
|
|
|
64
70
|
export const PORT_RESERVATIONS: readonly PortReservation[] = [
|
|
65
71
|
{ port: 1939, name: "parachute-hub", status: "assigned" },
|
|
66
72
|
{ port: 1940, name: "parachute-vault", status: "assigned" },
|
|
67
|
-
{ port: 1941, name: "
|
|
73
|
+
{ port: 1941, name: "unassigned", status: "reserved" },
|
|
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,59 @@ 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 serves the app directly too, unless you've",
|
|
386
|
+
"already customized the root (`parachute hub set-root-mode` / `set-root-redirect`",
|
|
387
|
+
"or the admin SPA).",
|
|
388
|
+
],
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
|
|
320
392
|
/**
|
|
321
393
|
* Vendored manifests + extras for first-party modules that still need them.
|
|
322
394
|
* Indexed by short name (the `parachute install <X>` token).
|
|
323
395
|
*
|
|
324
|
-
*
|
|
396
|
+
* notes + app remain — see the block comment above for the rationale
|
|
325
397
|
* (vault/scribe/agent now self-register and ship their own
|
|
326
398
|
* module.json). Other code paths consult both this table AND `KNOWN_MODULES`
|
|
327
399
|
* (which carries the post-self-register-retirement entries) via the helpers
|
|
@@ -329,6 +401,7 @@ const NOTES_FALLBACK: FirstPartyFallback = {
|
|
|
329
401
|
*/
|
|
330
402
|
export const FIRST_PARTY_FALLBACKS: Record<string, FirstPartyFallback> = {
|
|
331
403
|
notes: NOTES_FALLBACK,
|
|
404
|
+
app: APP_FALLBACK,
|
|
332
405
|
};
|
|
333
406
|
|
|
334
407
|
/**
|
|
@@ -438,8 +511,8 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
|
|
|
438
511
|
},
|
|
439
512
|
},
|
|
440
513
|
// NOTE (2026-07-01): `runner` was REMOVED from this registry (decision:
|
|
441
|
-
// Aaron 2026-07-01 — the module set of record is vault / hub /
|
|
442
|
-
//
|
|
514
|
+
// Aaron 2026-07-01 — the module set of record is vault / hub / scribe /
|
|
515
|
+
// surface). Runner is no longer offered, installable, or
|
|
443
516
|
// lifecycle-addressable by short name from the hub's bootstrap registries.
|
|
444
517
|
// Existing installs stay GRACEFUL: a legacy `parachute-runner` services.json
|
|
445
518
|
// row is handled exactly like any unknown/third-party row — `parachute
|
|
@@ -448,29 +521,7 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
|
|
|
448
521
|
// stamped and logs-and-skips otherwise. Deliberately NOT added to
|
|
449
522
|
// RETIRED_MODULES: that registry GC-drops rows on load, which would break
|
|
450
523
|
// routing for operators still running the runner daemon.
|
|
451
|
-
|
|
452
|
-
short: "agent",
|
|
453
|
-
package: "@openparachute/agent",
|
|
454
|
-
manifestName: "parachute-agent",
|
|
455
|
-
canonicalPort: 1941,
|
|
456
|
-
displayName: "Agent",
|
|
457
|
-
// Mirrors agent's own module.json (the canonical fields below do too —
|
|
458
|
-
// keep in sync if agent's declaration changes). Renamed from `channel`
|
|
459
|
-
// 2026-06-17 (parachute-channel → parachute-agent); the module is the
|
|
460
|
-
// same webhook-fan-out + MCP bridge daemon, on the same port 1941.
|
|
461
|
-
tagline: "Chat with your Claude Code sessions — a channel per session.",
|
|
462
|
-
canonicalPaths: ["/agent"],
|
|
463
|
-
canonicalHealth: "/health",
|
|
464
|
-
canonicalStripPrefix: true,
|
|
465
|
-
extras: {
|
|
466
|
-
// Backward-compat startCmd for rows without installDir — same rationale
|
|
467
|
-
// as scribe / vault. The bare binary IS the daemon (agent's
|
|
468
|
-
// package.json bin maps `parachute-agent` → src/daemon.ts).
|
|
469
|
-
startCmd: () => ["parachute-agent"],
|
|
470
|
-
// Agent gates its endpoints behind hub-issued JWTs (agent:* scopes).
|
|
471
|
-
hasAuth: true,
|
|
472
|
-
},
|
|
473
|
-
},
|
|
524
|
+
|
|
474
525
|
surface: {
|
|
475
526
|
short: "surface",
|
|
476
527
|
package: "@openparachute/surface",
|
|
@@ -520,39 +571,43 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
|
|
|
520
571
|
* legacy operators. `notes` (the daemon) is the canonical
|
|
521
572
|
* "deprecating-but-not-retired" case as of 2026-05-22: do not add
|
|
522
573
|
* until its Phase 3 retirement lands.
|
|
523
|
-
* - Entries stay forever
|
|
524
|
-
*
|
|
574
|
+
* - Entries stay forever — UNLESS the retired name is later reclaimed by a
|
|
575
|
+
* genuinely new, unrelated module that needs to self-register under it
|
|
576
|
+
* for real (see the `app` / `parachute-app` un-retirement note below).
|
|
577
|
+
* That's the one case where removal is correct: keeping a retirement
|
|
578
|
+
* entry alongside a live FIRST_PARTY_FALLBACKS/KNOWN_MODULES entry of
|
|
579
|
+
* the SAME name would make `dropRetiredModuleRows` GC the live module's
|
|
580
|
+
* own services.json row on every read — an outright regression, not a
|
|
581
|
+
* conservative default. Ordinary "this module is genuinely gone"
|
|
582
|
+
* entries (no successor reusing the name) still stay forever.
|
|
525
583
|
*/
|
|
526
584
|
export const RETIRED_MODULES: Record<string, { retiredAt: string; replacement?: string }> = {
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
retiredAt: "2026-05-27",
|
|
554
|
-
replacement: "parachute-surface (renamed from parachute-app — `parachute install surface`)",
|
|
555
|
-
},
|
|
585
|
+
agent: { retiredAt: "2026-07-15", replacement: "Vault and Surface" },
|
|
586
|
+
channel: { retiredAt: "2026-07-15", replacement: "Vault and Surface" },
|
|
587
|
+
"parachute-agent": { retiredAt: "2026-07-15", replacement: "Vault and Surface" },
|
|
588
|
+
"parachute-channel": { retiredAt: "2026-07-15", replacement: "Vault and Surface" },
|
|
589
|
+
// Agent was removed from Hub on 2026-07-15. All historical service-row
|
|
590
|
+
// identities are retired so normal manifest reads stop routing or supervising
|
|
591
|
+
// the deprecated daemon. Generic AI-agent authorization grants/tokens are a
|
|
592
|
+
// separate Hub capability and remain supported.
|
|
593
|
+
// NOTE (2026-07-11, hub-parity P5 — `app` / `parachute-app` UN-RETIRED):
|
|
594
|
+
// this table carried `app` + `parachute-app` (retired 2026-05-27, the
|
|
595
|
+
// app → surface rename, patterns#102) from hub#219 through hub-parity P4.
|
|
596
|
+
// They are REMOVED as of this note: a NEW, unrelated module — the real
|
|
597
|
+
// `@openparachute/parachute-app` super-surface front door (see
|
|
598
|
+
// FIRST_PARTY_FALLBACKS.app + PORT_RESERVATIONS' port-1944 comment) —
|
|
599
|
+
// claims the `parachute-app` manifestName (and the bare `app` short) for
|
|
600
|
+
// real. Keeping these entries would make `dropRetiredModuleRows`
|
|
601
|
+
// unconditionally GC the new module's services.json row on every read,
|
|
602
|
+
// breaking it outright — see the curation-rule exception above. An
|
|
603
|
+
// operator with a genuinely stale PRE-2026-05-27 `app`/`parachute-app` row
|
|
604
|
+
// (from the old, now-defunct @openparachute/app package) is handled the
|
|
605
|
+
// same as any other unrecognized row once un-retired: `parachute status`
|
|
606
|
+
// renders it, and `parachute serve` boots it via installDir's
|
|
607
|
+
// module.json if present and logs-and-skips otherwise — the same
|
|
608
|
+
// graceful fallback the `runner` registry removal (2026-07-01, see
|
|
609
|
+
// KNOWN_MODULES below) relies on. In practice the real parachute-app
|
|
610
|
+
// module's own first boot overwrites any stale row sharing its name.
|
|
556
611
|
};
|
|
557
612
|
|
|
558
613
|
/**
|
|
@@ -632,9 +687,9 @@ export function knownServices(): string[] {
|
|
|
632
687
|
* manifest-declared `focus`; this map is the fallback when `module.json` omits
|
|
633
688
|
* it (and the bootstrap value before a module is installed).
|
|
634
689
|
*
|
|
635
|
-
* - `core` — vault / scribe / hub / surface (the product surface
|
|
636
|
-
*
|
|
637
|
-
*
|
|
690
|
+
* - `core` — vault / scribe / hub / surface / app (the product surface;
|
|
691
|
+
* `app` joined 2026-07-11, hub-parity P5 — the super-surface front door).
|
|
692
|
+
* - `experimental` — any unlisted third-party short.
|
|
638
693
|
* - `deprecated` — notes (notes-daemon deprecated 2026-05-22; notes-ui moved
|
|
639
694
|
* into parachute-surface). Still RESOLVABLE (discoverableShorts unchanged)
|
|
640
695
|
* and SHOWN-IF-INSTALLED so an existing operator can manage/uninstall, but
|
|
@@ -650,7 +705,8 @@ const FOCUS_DEFAULTS: Record<string, ModuleFocus> = {
|
|
|
650
705
|
scribe: "core",
|
|
651
706
|
hub: "core",
|
|
652
707
|
surface: "core",
|
|
653
|
-
|
|
708
|
+
app: "core",
|
|
709
|
+
|
|
654
710
|
notes: "deprecated",
|
|
655
711
|
};
|
|
656
712
|
|
|
@@ -738,7 +794,7 @@ export function canonicalPortForManifest(manifestName: string): number | undefin
|
|
|
738
794
|
/**
|
|
739
795
|
* Resolve the runtime spec for a known short name.
|
|
740
796
|
*
|
|
741
|
-
* FIRST_PARTY_FALLBACKS shorts (notes) return a fully-composed
|
|
797
|
+
* FIRST_PARTY_FALLBACKS shorts (notes / app) return a fully-composed
|
|
742
798
|
* spec with embedded manifest + extras — the vendored manifest is the
|
|
743
799
|
* source of truth pre-install and the install path preserves it through.
|
|
744
800
|
*
|
|
@@ -847,11 +903,10 @@ export async function getSpecFromInstallDir(
|
|
|
847
903
|
*/
|
|
848
904
|
const LEGACY_MANIFEST_ALIASES: Record<string, string> = {
|
|
849
905
|
"parachute-lens": "notes",
|
|
850
|
-
"parachute-channel": "agent",
|
|
851
906
|
};
|
|
852
907
|
|
|
853
908
|
/** Short name for a given manifest name, e.g. `parachute-vault` → `vault`.
|
|
854
|
-
* Consults both FIRST_PARTY_FALLBACKS (notes) and KNOWN_MODULES
|
|
909
|
+
* Consults both FIRST_PARTY_FALLBACKS (notes / app) and KNOWN_MODULES
|
|
855
910
|
* (vault / scribe / agent / surface — post-FALLBACK-retirement).
|
|
856
911
|
* Returns undefined for unknown manifests. */
|
|
857
912
|
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,23 +672,29 @@ 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
|
-
const
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
return migrated.manifest;
|
|
695
|
+
const changed = healed.changed || afterRetired.changed || cleaned.changed;
|
|
696
|
+
if (changed) writeManifest(validated, path);
|
|
697
|
+
return validated;
|
|
691
698
|
}
|
|
692
699
|
|
|
693
700
|
/**
|
|
@@ -724,7 +731,12 @@ function dropRetiredModuleRows(raw: unknown, where: string): { raw: unknown; cha
|
|
|
724
731
|
if (!row || typeof row !== "object") return true;
|
|
725
732
|
const name = (row as Record<string, unknown>).name;
|
|
726
733
|
if (typeof name !== "string") return true;
|
|
727
|
-
|
|
734
|
+
// `claw` is too generic to reserve globally, but the historical Agent row
|
|
735
|
+
// had a structural identity: name=claw mounted at /claw. Retire only that
|
|
736
|
+
// shape; an unrelated third-party `claw` on another mount remains valid.
|
|
737
|
+
const paths = (row as Record<string, unknown>).paths;
|
|
738
|
+
const retiredClawShape = name === "claw" && Array.isArray(paths) && paths[0] === "/claw";
|
|
739
|
+
const retired = RETIRED_MODULES[name] ?? (retiredClawShape ? RETIRED_MODULES.agent : undefined);
|
|
728
740
|
if (retired === undefined) return true;
|
|
729
741
|
dropped.push({ name, retiredAt: retired.retiredAt, replacement: retired.replacement });
|
|
730
742
|
return false;
|
|
@@ -745,6 +757,91 @@ function dropRetiredModuleRows(raw: unknown, where: string): { raw: unknown; cha
|
|
|
745
757
|
};
|
|
746
758
|
}
|
|
747
759
|
|
|
760
|
+
/**
|
|
761
|
+
* Heal stale OLD-UI-host `app`/`parachute-app` rows on load (hub-parity P5,
|
|
762
|
+
* 2026-07-11).
|
|
763
|
+
*
|
|
764
|
+
* Context: the name `parachute-app` originally belonged to the UI-host module
|
|
765
|
+
* that renamed to `parachute-surface` on 2026-05-27 (patterns#102). Its
|
|
766
|
+
* retirement used to be handled by a `RETIRED_MODULES` entry that
|
|
767
|
+
* `dropRetiredModuleRows` GC'd unconditionally. Hub-parity P5 REUSES the
|
|
768
|
+
* `parachute-app` manifestName (+ the bare `app` short) for a genuinely NEW,
|
|
769
|
+
* unrelated module — the super-surface front door — so that retirement entry
|
|
770
|
+
* had to be removed (keeping it would GC the new module's own row on every
|
|
771
|
+
* read). This heal is the surgically narrow replacement for the ONE case the
|
|
772
|
+
* removed retirement still needs to cover: an operator upgrading DIRECTLY from
|
|
773
|
+
* a pre-rename release (~0.5.13-stable) who skipped every intermediate boot
|
|
774
|
+
* that would have GC'd their stale row. Without a heal, that stale row would:
|
|
775
|
+
* - crash-loop on boot — `reconcilePortToCanonical` rewrites its port to
|
|
776
|
+
* 1944, then the supervisor spawns the app static-serve shim against a
|
|
777
|
+
* `@openparachute/parachute-app` that isn't installed → exit 1 → a
|
|
778
|
+
* forever-restarting "app" unit the operator never installed;
|
|
779
|
+
* - hijack `/surface/*` if a real `parachute-surface` row is also present
|
|
780
|
+
* (both claim `/surface`);
|
|
781
|
+
* - and, after `parachute install app`, keep its stale `/surface` mount so
|
|
782
|
+
* the app serves under `/surface` while the install's `root_redirect=/app/`
|
|
783
|
+
* points the front page at a 404.
|
|
784
|
+
*
|
|
785
|
+
* Discriminant (structural — pinned against the OLD-UI-host row shape verified
|
|
786
|
+
* in `git show origin/main`'s retired-`app` + legacy-short-name test fixtures:
|
|
787
|
+
* `port: 1946`, `paths: ["/surface"]`, `health: "/surface/..."`):
|
|
788
|
+
* - `name` is exactly `"app"` or `"parachute-app"`, AND
|
|
789
|
+
* - it is NOT the NEW app's own row (the new app mounts at `/app` — never
|
|
790
|
+
* healed regardless of port), AND
|
|
791
|
+
* - it carries the OLD-UI-host signature: `paths[0]` is `/surface`-rooted OR
|
|
792
|
+
* `port === 1946` (the old canonical slot; now `parachute-surface`'s).
|
|
793
|
+
*
|
|
794
|
+
* The NEW app row (`paths: ["/app"]`, port 1944) is structurally disjoint on
|
|
795
|
+
* BOTH clauses, so it is always left intact. Matches `dropRetiredModuleRows`'s
|
|
796
|
+
* behavior: a plain DROP + a stderr warning steering the operator to
|
|
797
|
+
* `parachute install surface` (the same replacement the retirement named).
|
|
798
|
+
*
|
|
799
|
+
* Operates on raw JSON (before `validateManifest`) because a stale row can
|
|
800
|
+
* collide on port 1946 with a real `parachute-surface` row and would otherwise
|
|
801
|
+
* trip the duplicate-port gate before this heal runs. Runs FIRST in the load
|
|
802
|
+
* pipeline (ahead of `dropRetiredModuleRows` / `dropLegacyShortNameRows`): a
|
|
803
|
+
* pre-rename operator can carry BOTH a `parachute-app` and an `app` stale row
|
|
804
|
+
* (the 2026-05-22 double-write reproducer) — dropping by shape here clears
|
|
805
|
+
* both, whereas `dropLegacyShortNameRows` would keep the `parachute-app` half.
|
|
806
|
+
*/
|
|
807
|
+
function healStaleUiHostAppRows(raw: unknown, where: string): { raw: unknown; changed: boolean } {
|
|
808
|
+
if (!raw || typeof raw !== "object") return { raw, changed: false };
|
|
809
|
+
const services = (raw as Record<string, unknown>).services;
|
|
810
|
+
if (!Array.isArray(services)) return { raw, changed: false };
|
|
811
|
+
|
|
812
|
+
const dropped: string[] = [];
|
|
813
|
+
const nextServices = services.filter((row) => {
|
|
814
|
+
if (!row || typeof row !== "object") return true;
|
|
815
|
+
const rec = row as Record<string, unknown>;
|
|
816
|
+
const name = rec.name;
|
|
817
|
+
if (name !== "app" && name !== "parachute-app") return true;
|
|
818
|
+
const paths = rec.paths;
|
|
819
|
+
const first = Array.isArray(paths) && typeof paths[0] === "string" ? paths[0] : undefined;
|
|
820
|
+
// NEW super-surface app mounts at `/app` (port 1944) — never heal it,
|
|
821
|
+
// whatever its port happens to be after a reconcile/collision walk.
|
|
822
|
+
if (first === "/app" || first?.startsWith("/app/")) return true;
|
|
823
|
+
const port = rec.port;
|
|
824
|
+
const oldSurfaceMount = first === "/surface" || first?.startsWith("/surface/") === true;
|
|
825
|
+
const oldCanonicalPort = port === 1946;
|
|
826
|
+
if (!oldSurfaceMount && !oldCanonicalPort) return true;
|
|
827
|
+
dropped.push(String(name));
|
|
828
|
+
return false;
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
if (dropped.length === 0) return { raw, changed: false };
|
|
832
|
+
|
|
833
|
+
for (const name of dropped) {
|
|
834
|
+
console.error(
|
|
835
|
+
`${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\`.`,
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
return {
|
|
840
|
+
raw: { ...(raw as Record<string, unknown>), services: nextServices },
|
|
841
|
+
changed: true,
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
|
|
748
845
|
/**
|
|
749
846
|
* Drop legacy short-name rows when a same-module manifestName row exists
|
|
750
847
|
* on the same port. Handles the duplicate-row class introduced when a
|
|
@@ -836,43 +933,6 @@ function dropLegacyShortNameRows(raw: unknown, where: string): { raw: unknown; c
|
|
|
836
933
|
};
|
|
837
934
|
}
|
|
838
935
|
|
|
839
|
-
/**
|
|
840
|
-
* Migrate legacy `claw` entries to `agent` in-place. Paraclaw was renamed
|
|
841
|
-
* to parachute-agent across the ecosystem (npm package, mount path, short
|
|
842
|
-
* name); operators who upgraded hub but still have the old paraclaw row
|
|
843
|
-
* in services.json would otherwise see a tile labelled "Claw" and a hub
|
|
844
|
-
* route at `/claw` while their newly-upgraded daemon listens on `/agent`.
|
|
845
|
-
*
|
|
846
|
-
* Idempotent. Only rewrites when both `name === "claw"` AND the first path
|
|
847
|
-
* is `/claw` — narrow enough that a deliberately-named third-party module
|
|
848
|
-
* (e.g. `name: "claw"` on a different mount) is left alone. Health and any
|
|
849
|
-
* `/claw`-rooted paths are rewritten in lockstep.
|
|
850
|
-
*/
|
|
851
|
-
function migrateClawToAgent(manifest: ServicesManifest): {
|
|
852
|
-
manifest: ServicesManifest;
|
|
853
|
-
changed: boolean;
|
|
854
|
-
} {
|
|
855
|
-
let changed = false;
|
|
856
|
-
const services = manifest.services.map((entry) => {
|
|
857
|
-
if (entry.name !== "claw" || entry.paths[0] !== "/claw") return entry;
|
|
858
|
-
changed = true;
|
|
859
|
-
const next: ServiceEntry = {
|
|
860
|
-
...entry,
|
|
861
|
-
name: "agent",
|
|
862
|
-
paths: entry.paths.map((p) => rewriteClawPath(p)),
|
|
863
|
-
health: rewriteClawPath(entry.health),
|
|
864
|
-
};
|
|
865
|
-
return next;
|
|
866
|
-
});
|
|
867
|
-
return { manifest: { services }, changed };
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
function rewriteClawPath(p: string): string {
|
|
871
|
-
if (p === "/claw") return "/agent";
|
|
872
|
-
if (p.startsWith("/claw/")) return `/agent${p.slice("/claw".length)}`;
|
|
873
|
-
return p;
|
|
874
|
-
}
|
|
875
|
-
|
|
876
936
|
export function writeManifest(
|
|
877
937
|
manifest: ServicesManifest,
|
|
878
938
|
path: string = SERVICES_MANIFEST_PATH,
|