@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.
@@ -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: known api/tool
557
- * services without declared auth fall back to "auth-required"; everything
558
- * else defaults to "allowed" so vault, notes, channel and unknown
559
- * third-party services continue to be exposed without needing to opt in.
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
- // FALLBACK path: notes / channel still vendor their kind + extras.
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
- if (
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
- * `kind` (best-effort api/tool), and the imperative `extras` fields
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, kind-aware exposure, port assignment)
667
- // see a coherent spec. Module.json wins at lifecycle time
668
- // (`composeKnownModuleSpec`); this synth is the bootstrap shape.
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) {
@@ -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
- * (known api/tool services without declared auth → "auth-required"; everything
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
- const validated = validateManifest(raw, path);
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
- if (migrated.changed) writeManifest(migrated.manifest, path);
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