@openparachute/hub 0.5.13-rc.13 → 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 +19 -3
- 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 +22 -17
- package/src/service-spec.ts +44 -60
- 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,7 +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
|
-
readonly kind: ServiceKind;
|
|
178
167
|
readonly hasAuth?: boolean;
|
|
179
168
|
readonly urlForEntry?: (entry: ServiceEntry) => string | undefined;
|
|
180
169
|
readonly postInstallFooter?: () => readonly string[];
|
|
@@ -232,7 +221,6 @@ export function composeServiceSpec(opts: {
|
|
|
232
221
|
package: packageName,
|
|
233
222
|
manifestName: manifest.manifestName,
|
|
234
223
|
seedEntry: () => seedEntryFromManifest(manifest),
|
|
235
|
-
kind: manifest.kind,
|
|
236
224
|
};
|
|
237
225
|
if (extras?.init !== undefined) (spec as { init?: readonly string[] }).init = extras.init;
|
|
238
226
|
if (startCmd !== undefined) {
|
|
@@ -294,7 +282,6 @@ const NOTES_FALLBACK: FirstPartyFallback = {
|
|
|
294
282
|
manifestName: "parachute-notes",
|
|
295
283
|
displayName: "Notes",
|
|
296
284
|
tagline: "Notes PWA — daemon deprecated 2026-05-22; install `app` for the current path.",
|
|
297
|
-
kind: "frontend",
|
|
298
285
|
port: 1942,
|
|
299
286
|
paths: ["/notes"],
|
|
300
287
|
health: "/notes/health",
|
|
@@ -324,7 +311,6 @@ const CHANNEL_FALLBACK: FirstPartyFallback = {
|
|
|
324
311
|
manifestName: "parachute-channel",
|
|
325
312
|
displayName: "Channel",
|
|
326
313
|
tagline: "Notification fan-out across modules.",
|
|
327
|
-
kind: "api",
|
|
328
314
|
port: 1941,
|
|
329
315
|
paths: ["/channel"],
|
|
330
316
|
health: "/channel/health",
|
|
@@ -390,9 +376,6 @@ export interface KnownModule {
|
|
|
390
376
|
/** Pre-install catalog surfaces use these. After install, services.json wins. */
|
|
391
377
|
readonly displayName: string;
|
|
392
378
|
readonly tagline: string;
|
|
393
|
-
/** Module kind — needed for `synthesizeManifest` since KNOWN_MODULES doesn't
|
|
394
|
-
* carry an embedded manifest. All three current entries are api/tool. */
|
|
395
|
-
readonly kind: ModuleKind;
|
|
396
379
|
/** Canonical mount paths — used to synthesize a minimal manifest when
|
|
397
380
|
* module.json is unreadable (legacy install paths, test fixtures). The
|
|
398
381
|
* module's own `module.json` overrides these once it's installed. */
|
|
@@ -405,12 +388,6 @@ export interface KnownModule {
|
|
|
405
388
|
readonly extras?: FirstPartyExtras;
|
|
406
389
|
}
|
|
407
390
|
|
|
408
|
-
// Local import-time alias for ModuleKind so the public KnownModule type can
|
|
409
|
-
// reference it without forcing every consumer to import from
|
|
410
|
-
// module-manifest. The same `ModuleKind` type is exported from there for
|
|
411
|
-
// callers that want it directly.
|
|
412
|
-
type ModuleKind = ModuleManifest["kind"];
|
|
413
|
-
|
|
414
391
|
export const KNOWN_MODULES: Record<string, KnownModule> = {
|
|
415
392
|
vault: {
|
|
416
393
|
short: "vault",
|
|
@@ -419,7 +396,6 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
|
|
|
419
396
|
canonicalPort: 1940,
|
|
420
397
|
displayName: "Vault",
|
|
421
398
|
tagline: "Your owner-authenticated MCP knowledge store.",
|
|
422
|
-
kind: "api",
|
|
423
399
|
canonicalPaths: ["/vault/default"],
|
|
424
400
|
canonicalHealth: "/vault/default/health",
|
|
425
401
|
extras: {
|
|
@@ -439,7 +415,6 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
|
|
|
439
415
|
canonicalPort: 1943,
|
|
440
416
|
displayName: "Scribe",
|
|
441
417
|
tagline: "Local audio transcription for vault recordings.",
|
|
442
|
-
kind: "api",
|
|
443
418
|
canonicalPaths: ["/scribe"],
|
|
444
419
|
canonicalHealth: "/scribe/health",
|
|
445
420
|
canonicalStripPrefix: true,
|
|
@@ -474,7 +449,6 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
|
|
|
474
449
|
displayName: "Runner",
|
|
475
450
|
tagline:
|
|
476
451
|
"Vault-as-job-substrate engine — spawns claude -p against vault job notes on schedule.",
|
|
477
|
-
kind: "tool",
|
|
478
452
|
canonicalPaths: ["/runner", "/.parachute"],
|
|
479
453
|
canonicalHealth: "/runner/healthz",
|
|
480
454
|
canonicalStripPrefix: false,
|
|
@@ -500,10 +474,6 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
|
|
|
500
474
|
// still exists as a back-compat install (CURATED_MODULES still lists
|
|
501
475
|
// `notes`) but `app` is the recommended first install post-vault.
|
|
502
476
|
tagline: "Host module for Parachute UIs — auto-installs Notes on first boot.",
|
|
503
|
-
// Frontend posture: app's primary surface is serving sub-app UIs under
|
|
504
|
-
// `/app/<name>/` mounted under one origin (design doc §12). Hub's
|
|
505
|
-
// exposure-default + supervisor-defaults follow the frontend lane.
|
|
506
|
-
kind: "frontend",
|
|
507
477
|
canonicalPaths: ["/app", "/.parachute"],
|
|
508
478
|
canonicalHealth: "/app/healthz",
|
|
509
479
|
canonicalStripPrefix: false,
|
|
@@ -521,6 +491,35 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
|
|
|
521
491
|
},
|
|
522
492
|
};
|
|
523
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
|
+
|
|
524
523
|
/**
|
|
525
524
|
* Synthesize a minimal `ModuleManifest` from a KNOWN_MODULES entry. Used as
|
|
526
525
|
* a fallback when `<installDir>/.parachute/module.json` can't be read
|
|
@@ -540,7 +539,6 @@ export function synthesizeManifestForKnownModule(km: KnownModule): ModuleManifes
|
|
|
540
539
|
manifestName: km.manifestName,
|
|
541
540
|
displayName: km.displayName,
|
|
542
541
|
tagline: km.tagline,
|
|
543
|
-
kind: km.kind,
|
|
544
542
|
port: km.canonicalPort,
|
|
545
543
|
paths: km.canonicalPaths,
|
|
546
544
|
health: km.canonicalHealth,
|
|
@@ -553,10 +551,10 @@ export function synthesizeManifestForKnownModule(km: KnownModule): ModuleManifes
|
|
|
553
551
|
|
|
554
552
|
/**
|
|
555
553
|
* Effective publicExposure for a service, given what's on its services.json
|
|
556
|
-
* entry. Explicit wins. If absent, derive from the spec:
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
* 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".
|
|
560
558
|
*
|
|
561
559
|
* Layer behavior (post-#187 layer-aware proxy):
|
|
562
560
|
* "allowed" — reaches all layers (loopback / tailnet / public);
|
|
@@ -575,30 +573,17 @@ export function effectivePublicExposure(
|
|
|
575
573
|
if (entry.publicExposure !== undefined) return entry.publicExposure;
|
|
576
574
|
const short = shortNameForManifest(entry.name);
|
|
577
575
|
if (short === undefined) return "allowed";
|
|
578
|
-
//
|
|
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.
|
|
579
581
|
const fb = FIRST_PARTY_FALLBACKS[short];
|
|
580
582
|
if (fb) {
|
|
581
|
-
|
|
582
|
-
(fb.manifest.kind === "api" || fb.manifest.kind === "tool") &&
|
|
583
|
-
fb.extras?.hasAuth === false
|
|
584
|
-
) {
|
|
585
|
-
return "auth-required";
|
|
586
|
-
}
|
|
587
|
-
return "allowed";
|
|
583
|
+
return fb.extras?.hasAuth === false ? "auth-required" : "allowed";
|
|
588
584
|
}
|
|
589
|
-
// KNOWN_MODULES path: vault / scribe / runner. The `kind` lives on
|
|
590
|
-
// services.json (operator-authoritative after self-register) — if the row
|
|
591
|
-
// doesn't declare it, fall through to the imperative `extras.hasAuth`
|
|
592
|
-
// signal which says "no auth gate → require auth before exposing." This
|
|
593
|
-
// matches the pre-retirement FALLBACK behavior for scribe.
|
|
594
585
|
const km = KNOWN_MODULES[short];
|
|
595
|
-
if (km && km.extras?.hasAuth === false)
|
|
596
|
-
// Only api/tool services hit the "auth-required default" lane —
|
|
597
|
-
// services.json's `kind` is the authoritative source; absent it,
|
|
598
|
-
// KNOWN_MODULES doesn't carry `kind` so we assume an api/tool posture
|
|
599
|
-
// (the three KNOWN_MODULES entries are all api/tool today).
|
|
600
|
-
return "auth-required";
|
|
601
|
-
}
|
|
586
|
+
if (km && km.extras?.hasAuth === false) return "auth-required";
|
|
602
587
|
return "allowed";
|
|
603
588
|
}
|
|
604
589
|
|
|
@@ -643,7 +628,7 @@ export function canonicalPortForManifest(manifestName: string): number | undefin
|
|
|
643
628
|
*
|
|
644
629
|
* KNOWN_MODULES shorts (vault / scribe / runner — post hub#310 FALLBACK
|
|
645
630
|
* retirement) return a **minimal** spec carrying `package`, `manifestName`,
|
|
646
|
-
*
|
|
631
|
+
* and the imperative `extras` fields
|
|
647
632
|
* (`init`, `hasAuth`, `urlForEntry`, `postInstallFooter`). They do NOT carry
|
|
648
633
|
* `startCmd` or `seedEntry` — those come from `<installDir>/.parachute/module.json`
|
|
649
634
|
* at lifecycle time via {@link getSpecFromInstallDir}, since the module
|
|
@@ -663,14 +648,13 @@ export function getSpec(short: string): ServiceSpec | undefined {
|
|
|
663
648
|
const km = KNOWN_MODULES[short];
|
|
664
649
|
if (!km) return undefined;
|
|
665
650
|
// Use the synthesized manifest from KNOWN_MODULES' canonical fields so
|
|
666
|
-
// downstream consumers (seedEntry,
|
|
667
|
-
//
|
|
668
|
-
//
|
|
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.
|
|
669
654
|
const synthManifest = synthesizeManifestForKnownModule(km);
|
|
670
655
|
const spec: ServiceSpec = {
|
|
671
656
|
package: km.package,
|
|
672
657
|
manifestName: km.manifestName,
|
|
673
|
-
kind: synthManifest.kind,
|
|
674
658
|
seedEntry: () => seedEntryFromManifest(synthManifest),
|
|
675
659
|
};
|
|
676
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
|