@openparachute/hub 0.5.13-rc.14 → 0.5.13-rc.21
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__/api-modules-ops.test.ts +257 -4
- package/src/__tests__/api-modules.test.ts +90 -0
- package/src/__tests__/cli.test.ts +13 -0
- package/src/__tests__/hub-server.test.ts +10 -13
- package/src/__tests__/install.test.ts +259 -24
- package/src/__tests__/lifecycle.test.ts +90 -13
- package/src/__tests__/module-manifest.test.ts +17 -57
- package/src/__tests__/post-install.test.ts +0 -2
- package/src/__tests__/scope-registry.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +456 -43
- package/src/__tests__/setup-wizard.test.ts +228 -0
- package/src/__tests__/status.test.ts +4 -4
- package/src/__tests__/upgrade.test.ts +362 -3
- package/src/api-modules-ops.ts +79 -7
- package/src/api-modules.ts +97 -1
- package/src/cli.ts +50 -4
- package/src/commands/install.ts +108 -6
- package/src/commands/lifecycle.ts +20 -0
- package/src/commands/upgrade.ts +213 -27
- package/src/help.ts +54 -17
- package/src/hub-server.ts +5 -0
- package/src/hub.ts +71 -0
- package/src/module-manifest.ts +4 -34
- package/src/service-spec.ts +44 -67
- package/src/services-manifest.ts +163 -3
- package/src/setup-wizard.ts +205 -12
- package/web/ui/dist/assets/index-5Mj6FqPg.css +1 -0
- package/web/ui/dist/assets/{index-D63mUkVX.js → index-BqjySZ_7.js} +12 -12
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DliViliP.css +0 -1
package/src/service-spec.ts
CHANGED
|
@@ -78,16 +78,6 @@ export function isCanonicalPort(port: number): boolean {
|
|
|
78
78
|
return port >= CANONICAL_PORT_MIN && port <= CANONICAL_PORT_MAX;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
/**
|
|
82
|
-
* Broad shape of a service. Matches the hub's card-kind taxonomy.
|
|
83
|
-
* "frontend" a user-facing UI (notes). Safe to expose by default.
|
|
84
|
-
* "api" a programmatic surface (vault, channel, scribe). Whether
|
|
85
|
-
* it's safe to expose depends on `hasAuth`.
|
|
86
|
-
* "tool" like "api" but specifically MCP-shaped / agent-callable.
|
|
87
|
-
* Treated the same as "api" for exposure defaults.
|
|
88
|
-
*/
|
|
89
|
-
export type ServiceKind = "api" | "tool" | "frontend";
|
|
90
|
-
|
|
91
81
|
/**
|
|
92
82
|
* Imperative behaviors that don't fit the static `module.json` schema.
|
|
93
83
|
*
|
|
@@ -174,14 +164,6 @@ export interface ServiceSpec {
|
|
|
174
164
|
* First service boot overwrites the seed with its own authoritative version.
|
|
175
165
|
*/
|
|
176
166
|
readonly seedEntry?: () => ServiceEntry;
|
|
177
|
-
/**
|
|
178
|
-
* Optional as of hub#327 (Phase A's fold): the validator no longer
|
|
179
|
-
* inspects `kind`, so synthesized + third-party-manifest specs may
|
|
180
|
-
* carry `undefined` here. The single read site
|
|
181
|
-
* (`commands/upgrade.ts: target.spec?.kind === "frontend"`) handles
|
|
182
|
-
* the absent case via the `=== "frontend"` falsy-fallthrough.
|
|
183
|
-
*/
|
|
184
|
-
readonly kind?: ServiceKind;
|
|
185
167
|
readonly hasAuth?: boolean;
|
|
186
168
|
readonly urlForEntry?: (entry: ServiceEntry) => string | undefined;
|
|
187
169
|
readonly postInstallFooter?: () => readonly string[];
|
|
@@ -239,7 +221,6 @@ export function composeServiceSpec(opts: {
|
|
|
239
221
|
package: packageName,
|
|
240
222
|
manifestName: manifest.manifestName,
|
|
241
223
|
seedEntry: () => seedEntryFromManifest(manifest),
|
|
242
|
-
kind: manifest.kind,
|
|
243
224
|
};
|
|
244
225
|
if (extras?.init !== undefined) (spec as { init?: readonly string[] }).init = extras.init;
|
|
245
226
|
if (startCmd !== undefined) {
|
|
@@ -301,7 +282,6 @@ const NOTES_FALLBACK: FirstPartyFallback = {
|
|
|
301
282
|
manifestName: "parachute-notes",
|
|
302
283
|
displayName: "Notes",
|
|
303
284
|
tagline: "Notes PWA — daemon deprecated 2026-05-22; install `app` for the current path.",
|
|
304
|
-
kind: "frontend",
|
|
305
285
|
port: 1942,
|
|
306
286
|
paths: ["/notes"],
|
|
307
287
|
health: "/notes/health",
|
|
@@ -331,7 +311,6 @@ const CHANNEL_FALLBACK: FirstPartyFallback = {
|
|
|
331
311
|
manifestName: "parachute-channel",
|
|
332
312
|
displayName: "Channel",
|
|
333
313
|
tagline: "Notification fan-out across modules.",
|
|
334
|
-
kind: "api",
|
|
335
314
|
port: 1941,
|
|
336
315
|
paths: ["/channel"],
|
|
337
316
|
health: "/channel/health",
|
|
@@ -397,9 +376,6 @@ export interface KnownModule {
|
|
|
397
376
|
/** Pre-install catalog surfaces use these. After install, services.json wins. */
|
|
398
377
|
readonly displayName: string;
|
|
399
378
|
readonly tagline: string;
|
|
400
|
-
/** Module kind — needed for `synthesizeManifest` since KNOWN_MODULES doesn't
|
|
401
|
-
* carry an embedded manifest. All three current entries are api/tool. */
|
|
402
|
-
readonly kind: ModuleKind;
|
|
403
379
|
/** Canonical mount paths — used to synthesize a minimal manifest when
|
|
404
380
|
* module.json is unreadable (legacy install paths, test fixtures). The
|
|
405
381
|
* module's own `module.json` overrides these once it's installed. */
|
|
@@ -412,12 +388,6 @@ export interface KnownModule {
|
|
|
412
388
|
readonly extras?: FirstPartyExtras;
|
|
413
389
|
}
|
|
414
390
|
|
|
415
|
-
// Local import-time alias for ModuleKind so the public KnownModule type can
|
|
416
|
-
// reference it without forcing every consumer to import from
|
|
417
|
-
// module-manifest. The same `ModuleKind` type is exported from there for
|
|
418
|
-
// callers that want it directly.
|
|
419
|
-
type ModuleKind = ModuleManifest["kind"];
|
|
420
|
-
|
|
421
391
|
export const KNOWN_MODULES: Record<string, KnownModule> = {
|
|
422
392
|
vault: {
|
|
423
393
|
short: "vault",
|
|
@@ -426,7 +396,6 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
|
|
|
426
396
|
canonicalPort: 1940,
|
|
427
397
|
displayName: "Vault",
|
|
428
398
|
tagline: "Your owner-authenticated MCP knowledge store.",
|
|
429
|
-
kind: "api",
|
|
430
399
|
canonicalPaths: ["/vault/default"],
|
|
431
400
|
canonicalHealth: "/vault/default/health",
|
|
432
401
|
extras: {
|
|
@@ -446,7 +415,6 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
|
|
|
446
415
|
canonicalPort: 1943,
|
|
447
416
|
displayName: "Scribe",
|
|
448
417
|
tagline: "Local audio transcription for vault recordings.",
|
|
449
|
-
kind: "api",
|
|
450
418
|
canonicalPaths: ["/scribe"],
|
|
451
419
|
canonicalHealth: "/scribe/health",
|
|
452
420
|
canonicalStripPrefix: true,
|
|
@@ -481,7 +449,6 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
|
|
|
481
449
|
displayName: "Runner",
|
|
482
450
|
tagline:
|
|
483
451
|
"Vault-as-job-substrate engine — spawns claude -p against vault job notes on schedule.",
|
|
484
|
-
kind: "tool",
|
|
485
452
|
canonicalPaths: ["/runner", "/.parachute"],
|
|
486
453
|
canonicalHealth: "/runner/healthz",
|
|
487
454
|
canonicalStripPrefix: false,
|
|
@@ -507,10 +474,6 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
|
|
|
507
474
|
// still exists as a back-compat install (CURATED_MODULES still lists
|
|
508
475
|
// `notes`) but `app` is the recommended first install post-vault.
|
|
509
476
|
tagline: "Host module for Parachute UIs — auto-installs Notes on first boot.",
|
|
510
|
-
// Frontend posture: app's primary surface is serving sub-app UIs under
|
|
511
|
-
// `/app/<name>/` mounted under one origin (design doc §12). Hub's
|
|
512
|
-
// exposure-default + supervisor-defaults follow the frontend lane.
|
|
513
|
-
kind: "frontend",
|
|
514
477
|
canonicalPaths: ["/app", "/.parachute"],
|
|
515
478
|
canonicalHealth: "/app/healthz",
|
|
516
479
|
canonicalStripPrefix: false,
|
|
@@ -528,6 +491,35 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
|
|
|
528
491
|
},
|
|
529
492
|
};
|
|
530
493
|
|
|
494
|
+
/**
|
|
495
|
+
* Modules that were once first-party (committed-core or FIRST_PARTY_FALLBACKS)
|
|
496
|
+
* but have since been retired. Services.json rows under these names are
|
|
497
|
+
* GC'd on load with a stderr warning.
|
|
498
|
+
*
|
|
499
|
+
* Adding a name here is a deliberate retirement signal — operators who
|
|
500
|
+
* still have the module's daemon running will see the warning + a hint
|
|
501
|
+
* to stop the daemon. The row reappears if they restart the daemon
|
|
502
|
+
* (which still self-registers under its old name), but the GC ensures
|
|
503
|
+
* routing isn't blocked by a stale row.
|
|
504
|
+
*
|
|
505
|
+
* Curation rules:
|
|
506
|
+
* - Only add an entry when the module is *explicitly* retired (see
|
|
507
|
+
* `parachute-patterns/migrations/` + per-module DEPRECATED.md). Don't
|
|
508
|
+
* speculate on Phase-2-deprecating modules — they're still serving
|
|
509
|
+
* back-compat traffic and adding them here would prematurely break
|
|
510
|
+
* legacy operators. `notes` (the daemon) is the canonical
|
|
511
|
+
* "deprecating-but-not-retired" case as of 2026-05-22: do not add
|
|
512
|
+
* until its Phase 3 retirement lands.
|
|
513
|
+
* - Entries stay forever. Removing an entry would let a stale row
|
|
514
|
+
* reappear silently on legacy installs.
|
|
515
|
+
*/
|
|
516
|
+
export const RETIRED_MODULES: Record<string, { retiredAt: string; replacement?: string }> = {
|
|
517
|
+
agent: {
|
|
518
|
+
retiredAt: "2026-05-20",
|
|
519
|
+
replacement: "parachute-app or parachute-runner (depending on use case)",
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
|
|
531
523
|
/**
|
|
532
524
|
* Synthesize a minimal `ModuleManifest` from a KNOWN_MODULES entry. Used as
|
|
533
525
|
* a fallback when `<installDir>/.parachute/module.json` can't be read
|
|
@@ -547,7 +539,6 @@ export function synthesizeManifestForKnownModule(km: KnownModule): ModuleManifes
|
|
|
547
539
|
manifestName: km.manifestName,
|
|
548
540
|
displayName: km.displayName,
|
|
549
541
|
tagline: km.tagline,
|
|
550
|
-
kind: km.kind,
|
|
551
542
|
port: km.canonicalPort,
|
|
552
543
|
paths: km.canonicalPaths,
|
|
553
544
|
health: km.canonicalHealth,
|
|
@@ -560,10 +551,10 @@ export function synthesizeManifestForKnownModule(km: KnownModule): ModuleManifes
|
|
|
560
551
|
|
|
561
552
|
/**
|
|
562
553
|
* Effective publicExposure for a service, given what's on its services.json
|
|
563
|
-
* entry. Explicit wins. If absent, derive from the spec:
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
* third-party services
|
|
554
|
+
* entry. Explicit wins. If absent, derive from the spec: services with
|
|
555
|
+
* declared auth (extras.hasAuth === true, or no hasAuth set) default to
|
|
556
|
+
* "allowed"; services with extras.hasAuth === false default to
|
|
557
|
+
* "auth-required". Unknown third-party services default to "allowed".
|
|
567
558
|
*
|
|
568
559
|
* Layer behavior (post-#187 layer-aware proxy):
|
|
569
560
|
* "allowed" — reaches all layers (loopback / tailnet / public);
|
|
@@ -582,30 +573,17 @@ export function effectivePublicExposure(
|
|
|
582
573
|
if (entry.publicExposure !== undefined) return entry.publicExposure;
|
|
583
574
|
const short = shortNameForManifest(entry.name);
|
|
584
575
|
if (short === undefined) return "allowed";
|
|
585
|
-
//
|
|
576
|
+
// Post hub#301 Phase C/D (`kind` field retired — hub#330), the
|
|
577
|
+
// exposure-default heuristic collapses to the imperative `extras.hasAuth`
|
|
578
|
+
// signal: an explicit `hasAuth: false` declaration ("no auth gate
|
|
579
|
+
// implemented yet") → require auth before exposing; anything else →
|
|
580
|
+
// allowed. Scribe is the canonical `hasAuth: false` case today.
|
|
586
581
|
const fb = FIRST_PARTY_FALLBACKS[short];
|
|
587
582
|
if (fb) {
|
|
588
|
-
|
|
589
|
-
(fb.manifest.kind === "api" || fb.manifest.kind === "tool") &&
|
|
590
|
-
fb.extras?.hasAuth === false
|
|
591
|
-
) {
|
|
592
|
-
return "auth-required";
|
|
593
|
-
}
|
|
594
|
-
return "allowed";
|
|
583
|
+
return fb.extras?.hasAuth === false ? "auth-required" : "allowed";
|
|
595
584
|
}
|
|
596
|
-
// KNOWN_MODULES path: vault / scribe / runner. The `kind` lives on
|
|
597
|
-
// services.json (operator-authoritative after self-register) — if the row
|
|
598
|
-
// doesn't declare it, fall through to the imperative `extras.hasAuth`
|
|
599
|
-
// signal which says "no auth gate → require auth before exposing." This
|
|
600
|
-
// matches the pre-retirement FALLBACK behavior for scribe.
|
|
601
585
|
const km = KNOWN_MODULES[short];
|
|
602
|
-
if (km && km.extras?.hasAuth === false)
|
|
603
|
-
// Only api/tool services hit the "auth-required default" lane —
|
|
604
|
-
// services.json's `kind` is the authoritative source; absent it,
|
|
605
|
-
// KNOWN_MODULES doesn't carry `kind` so we assume an api/tool posture
|
|
606
|
-
// (the three KNOWN_MODULES entries are all api/tool today).
|
|
607
|
-
return "auth-required";
|
|
608
|
-
}
|
|
586
|
+
if (km && km.extras?.hasAuth === false) return "auth-required";
|
|
609
587
|
return "allowed";
|
|
610
588
|
}
|
|
611
589
|
|
|
@@ -650,7 +628,7 @@ export function canonicalPortForManifest(manifestName: string): number | undefin
|
|
|
650
628
|
*
|
|
651
629
|
* KNOWN_MODULES shorts (vault / scribe / runner — post hub#310 FALLBACK
|
|
652
630
|
* retirement) return a **minimal** spec carrying `package`, `manifestName`,
|
|
653
|
-
*
|
|
631
|
+
* and the imperative `extras` fields
|
|
654
632
|
* (`init`, `hasAuth`, `urlForEntry`, `postInstallFooter`). They do NOT carry
|
|
655
633
|
* `startCmd` or `seedEntry` — those come from `<installDir>/.parachute/module.json`
|
|
656
634
|
* at lifecycle time via {@link getSpecFromInstallDir}, since the module
|
|
@@ -670,14 +648,13 @@ export function getSpec(short: string): ServiceSpec | undefined {
|
|
|
670
648
|
const km = KNOWN_MODULES[short];
|
|
671
649
|
if (!km) return undefined;
|
|
672
650
|
// Use the synthesized manifest from KNOWN_MODULES' canonical fields so
|
|
673
|
-
// downstream consumers (seedEntry,
|
|
674
|
-
//
|
|
675
|
-
//
|
|
651
|
+
// downstream consumers (seedEntry, port assignment) see a coherent spec.
|
|
652
|
+
// Module.json wins at lifecycle time (`composeKnownModuleSpec`); this
|
|
653
|
+
// synth is the bootstrap shape.
|
|
676
654
|
const synthManifest = synthesizeManifestForKnownModule(km);
|
|
677
655
|
const spec: ServiceSpec = {
|
|
678
656
|
package: km.package,
|
|
679
657
|
manifestName: km.manifestName,
|
|
680
|
-
kind: synthManifest.kind,
|
|
681
658
|
seedEntry: () => seedEntryFromManifest(synthManifest),
|
|
682
659
|
};
|
|
683
660
|
if (km.extras?.hasAuth !== undefined) {
|
package/src/services-manifest.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
4
|
+
import { RETIRED_MODULES } from "./service-spec.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Whether the service is safe to mount on public-facing expose layers.
|
|
@@ -17,7 +18,7 @@ import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
|
17
18
|
* auth state over `/.parachute/info`.
|
|
18
19
|
*
|
|
19
20
|
* Absent field: the CLI derives a safe default from the service's ServiceSpec
|
|
20
|
-
* (
|
|
21
|
+
* (services with extras.hasAuth === false → "auth-required"; everything
|
|
21
22
|
* else → "allowed"). Unknown services default to "allowed" for back-compat.
|
|
22
23
|
*/
|
|
23
24
|
export type PublicExposure = "allowed" | "loopback" | "auth-required";
|
|
@@ -376,12 +377,171 @@ export function readManifest(path: string = SERVICES_MANIFEST_PATH): ServicesMan
|
|
|
376
377
|
`failed to parse ${path}: ${err instanceof Error ? err.message : String(err)}`,
|
|
377
378
|
);
|
|
378
379
|
}
|
|
379
|
-
|
|
380
|
+
// Retired-module + legacy short-name row cleanup runs BEFORE shape
|
|
381
|
+
// validation because the bugs they heal (rows that conflict on port with
|
|
382
|
+
// a current row) would otherwise throw inside `validateManifest`'s
|
|
383
|
+
// duplicate-port gate. Order matters: drop retired-module rows FIRST so
|
|
384
|
+
// their absence can unmask a `parachute-<X>` ↔ `<X>` pair underneath that
|
|
385
|
+
// `dropLegacyShortNameRows` then handles. The reverse order would leave
|
|
386
|
+
// a retired row that the short-name pass doesn't recognize. Both passes
|
|
387
|
+
// mutate the raw JSON object's `services` array; we re-validate against
|
|
388
|
+
// the cleaned shape. See `dropRetiredModuleRows` + `dropLegacyShortNameRows`
|
|
389
|
+
// for the full discipline.
|
|
390
|
+
const afterRetired = dropRetiredModuleRows(raw, path);
|
|
391
|
+
const cleaned = dropLegacyShortNameRows(afterRetired.raw, path);
|
|
392
|
+
const validated = validateManifest(cleaned.raw, path);
|
|
380
393
|
const migrated = migrateClawToAgent(validated);
|
|
381
|
-
|
|
394
|
+
const changed = afterRetired.changed || cleaned.changed || migrated.changed;
|
|
395
|
+
if (changed) writeManifest(migrated.manifest, path);
|
|
382
396
|
return migrated.manifest;
|
|
383
397
|
}
|
|
384
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Drop rows whose `name` matches a registered retired module (see
|
|
401
|
+
* `RETIRED_MODULES` in `service-spec.ts`). Retirement is unconditional —
|
|
402
|
+
* unlike `dropLegacyShortNameRows`, which only fires when a same-port
|
|
403
|
+
* manifestName twin is present, this pass drops a retired row whether or
|
|
404
|
+
* not anything else collides with it. The row is stale by definition: the
|
|
405
|
+
* module's npm package and daemon are no longer first-party, and leaving
|
|
406
|
+
* the row in place either (a) routes operators to a no-longer-supported
|
|
407
|
+
* binary or (b) blocks a current module from claiming the port the retired
|
|
408
|
+
* row squats on (Aaron's 2026-05-22 reproducer: stale `agent` row at 1946
|
|
409
|
+
* collided with `parachute-app` self-registering at 1946).
|
|
410
|
+
*
|
|
411
|
+
* Per-row stderr warning is operator-actionable: cites the retirement date,
|
|
412
|
+
* names the replacement module (if any), and includes a one-line shell
|
|
413
|
+
* snippet for stopping the still-running daemon process. The row reappears
|
|
414
|
+
* on the next read if the daemon is still up (its self-register writes
|
|
415
|
+
* back the same name), so the warning steers operators to fully retire
|
|
416
|
+
* the legacy process, not just hand-edit the file.
|
|
417
|
+
*
|
|
418
|
+
* Operates on raw JSON rather than validated entries so a retired row that
|
|
419
|
+
* shares a port with a current row doesn't trip `validateManifest`'s
|
|
420
|
+
* duplicate-port gate before this helper runs. Closes hub#334.
|
|
421
|
+
*/
|
|
422
|
+
function dropRetiredModuleRows(raw: unknown, where: string): { raw: unknown; changed: boolean } {
|
|
423
|
+
if (!raw || typeof raw !== "object") return { raw, changed: false };
|
|
424
|
+
const services = (raw as Record<string, unknown>).services;
|
|
425
|
+
if (!Array.isArray(services)) return { raw, changed: false };
|
|
426
|
+
|
|
427
|
+
type Dropped = { name: string; retiredAt: string; replacement?: string };
|
|
428
|
+
const dropped: Dropped[] = [];
|
|
429
|
+
const nextServices = services.filter((row) => {
|
|
430
|
+
if (!row || typeof row !== "object") return true;
|
|
431
|
+
const name = (row as Record<string, unknown>).name;
|
|
432
|
+
if (typeof name !== "string") return true;
|
|
433
|
+
const retired = RETIRED_MODULES[name];
|
|
434
|
+
if (retired === undefined) return true;
|
|
435
|
+
dropped.push({ name, retiredAt: retired.retiredAt, replacement: retired.replacement });
|
|
436
|
+
return false;
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
if (dropped.length === 0) return { raw, changed: false };
|
|
440
|
+
|
|
441
|
+
for (const d of dropped) {
|
|
442
|
+
const replacementLine = d.replacement ? ` Replacement: ${d.replacement}.` : "";
|
|
443
|
+
console.error(
|
|
444
|
+
`${where}: dropped stale row for retired module '${d.name}' (retired ${d.retiredAt}).${replacementLine} If the ${d.name} daemon is still running, stop it (e.g. \`ps aux | grep ${d.name}\` then \`kill <pid>\`).`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
raw: { ...(raw as Record<string, unknown>), services: nextServices },
|
|
450
|
+
changed: true,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Drop legacy short-name rows when a same-module manifestName row exists
|
|
456
|
+
* on the same port. Handles the duplicate-row class introduced when a
|
|
457
|
+
* module's self-register wrote `name: "<short>"` (e.g. `"app"`) while
|
|
458
|
+
* hub's install path stamped `name: "parachute-<short>"` (the canonical
|
|
459
|
+
* manifestName key). The two rows shared a port and tripped the
|
|
460
|
+
* duplicate-port read gate, leaving operators with an unbootable
|
|
461
|
+
* services.json until they edited by hand. Aaron hit this on 2026-05-22
|
|
462
|
+
* with `parachute-app` + `app` after parachute-app#13 + parachute-runner#4
|
|
463
|
+
* had already fixed the upstream self-register writes; this gate cleans
|
|
464
|
+
* up legacy state on operators who had already booted the bad code path.
|
|
465
|
+
*
|
|
466
|
+
* Detection is structural — no module registry import needed:
|
|
467
|
+
* - two rows share a port (NOT the multi-vault carve-out)
|
|
468
|
+
* - one row's name matches `parachute-<X>`
|
|
469
|
+
* - the other row's name matches `<X>` (same `<X>`)
|
|
470
|
+
*
|
|
471
|
+
* When that shape is present, drop the short-named row + log a warning
|
|
472
|
+
* to stderr so operators see the auto-cleanup the next time they read
|
|
473
|
+
* services.json (via any CLI command that touches the file). The
|
|
474
|
+
* manifestName row wins — it carries the hub-stamped fields (installDir,
|
|
475
|
+
* version after self-register's first overwrite) operators care about.
|
|
476
|
+
*
|
|
477
|
+
* Returns the (possibly mutated) raw object + a `changed` flag so the
|
|
478
|
+
* caller can re-write services.json with the cleaned shape. Operates on
|
|
479
|
+
* raw JSON rather than validated entries because the duplicate-port row
|
|
480
|
+
* pair would otherwise throw during validation before this helper had a
|
|
481
|
+
* chance to run.
|
|
482
|
+
*/
|
|
483
|
+
function dropLegacyShortNameRows(raw: unknown, where: string): { raw: unknown; changed: boolean } {
|
|
484
|
+
if (!raw || typeof raw !== "object") return { raw, changed: false };
|
|
485
|
+
const services = (raw as Record<string, unknown>).services;
|
|
486
|
+
if (!Array.isArray(services)) return { raw, changed: false };
|
|
487
|
+
|
|
488
|
+
// Build a port → [rows] index to spot same-port pairs. We only care
|
|
489
|
+
// about rows that parse to the minimal `{ name, port }` shape — anything
|
|
490
|
+
// else fails downstream validation anyway and isn't part of the bug
|
|
491
|
+
// class.
|
|
492
|
+
type Probe = { name: string; port: number; index: number };
|
|
493
|
+
const byPort = new Map<number, Probe[]>();
|
|
494
|
+
services.forEach((row, index) => {
|
|
495
|
+
if (!row || typeof row !== "object") return;
|
|
496
|
+
const name = (row as Record<string, unknown>).name;
|
|
497
|
+
const port = (row as Record<string, unknown>).port;
|
|
498
|
+
if (typeof name !== "string" || typeof port !== "number") return;
|
|
499
|
+
const bucket = byPort.get(port) ?? [];
|
|
500
|
+
bucket.push({ name, port, index });
|
|
501
|
+
byPort.set(port, bucket);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const dropIndices = new Set<number>();
|
|
505
|
+
for (const bucket of byPort.values()) {
|
|
506
|
+
if (bucket.length < 2) continue;
|
|
507
|
+
for (const a of bucket) {
|
|
508
|
+
for (const b of bucket) {
|
|
509
|
+
if (a.index === b.index) continue;
|
|
510
|
+
// a is the "short" candidate, b is the "manifestName" candidate.
|
|
511
|
+
// The shape rule: b.name === `parachute-${a.name}` and a is not
|
|
512
|
+
// itself a `parachute-…` name. Both halves matter: without the
|
|
513
|
+
// second check, `parachute-vault` paired with `parachute-parachute-vault`
|
|
514
|
+
// would drop the real vault row (no such pair exists today, but
|
|
515
|
+
// the shape rule keeps the heuristic narrow on principle).
|
|
516
|
+
if (a.name.startsWith("parachute-")) continue;
|
|
517
|
+
if (b.name !== `parachute-${a.name}`) continue;
|
|
518
|
+
dropIndices.add(a.index);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (dropIndices.size === 0) return { raw, changed: false };
|
|
524
|
+
|
|
525
|
+
const dropped: string[] = [];
|
|
526
|
+
const nextServices = services.filter((row, index) => {
|
|
527
|
+
if (!dropIndices.has(index)) return true;
|
|
528
|
+
if (row && typeof row === "object") {
|
|
529
|
+
const name = (row as Record<string, unknown>).name;
|
|
530
|
+
if (typeof name === "string") dropped.push(name);
|
|
531
|
+
}
|
|
532
|
+
return false;
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
console.error(
|
|
536
|
+
`${where}: dropped legacy short-name row(s) [${dropped.join(", ")}] in favor of same-port manifestName row(s). This is the parachute-app#13 / parachute-runner#4 self-register fixup. See parachute-patterns/patterns/services-json-row-conventions.md.`,
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
raw: { ...(raw as Record<string, unknown>), services: nextServices },
|
|
541
|
+
changed: true,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
385
545
|
/**
|
|
386
546
|
* Migrate legacy `claw` entries to `agent` in-place. Paraclaw was renamed
|
|
387
547
|
* to parachute-agent across the ecosystem (npm package, mount path, short
|