@openparachute/hub 0.7.4-rc.17 → 0.7.4-rc.19

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.4-rc.17",
3
+ "version": "0.7.4-rc.19",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -6,8 +6,10 @@ import {
6
6
  API_MODULES_CHANNEL_REQUIRED_SCOPE,
7
7
  API_MODULES_REQUIRED_SCOPE,
8
8
  _clearLatestVersionCacheForTests,
9
+ defaultReadInstalledVersion,
9
10
  handleApiModules,
10
11
  handleApiModulesChannel,
12
+ isUpgradeAvailable,
11
13
  } from "../api-modules.ts";
12
14
  import { hubDbPath, openHubDb } from "../hub-db.ts";
13
15
  import { getSetting, setModuleInstallChannel } from "../hub-settings.ts";
@@ -491,6 +493,147 @@ describe("GET /api/modules", () => {
491
493
  expect(scribe?.installed_version).toBeNull();
492
494
  });
493
495
 
496
+ // ── hub#243: upgrade-offer must be semver-aware + installed-version must be live ──
497
+
498
+ type UpgradeWire = {
499
+ short: string;
500
+ installed_version: string | null;
501
+ latest_version: string | null;
502
+ upgrade_available: boolean;
503
+ };
504
+
505
+ async function modulesWith(opts: {
506
+ installedVersion: string;
507
+ latest: string | null;
508
+ readInstalledVersion?: (installDir: string) => string | null;
509
+ }): Promise<UpgradeWire[]> {
510
+ writeManifest(h.manifestPath, [
511
+ {
512
+ name: "parachute-vault",
513
+ port: 1940,
514
+ paths: ["/vault/default"],
515
+ health: "/vault/default/health",
516
+ version: opts.installedVersion,
517
+ installDir: "/install/dir/vault",
518
+ },
519
+ ]);
520
+ const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
521
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
522
+ db: h.db,
523
+ issuer: ISSUER,
524
+ manifestPath: h.manifestPath,
525
+ fetchLatestVersion: async () => opts.latest,
526
+ // Default: no live read (synthetic install dir has no package.json), so
527
+ // the services.json cache is used — matching the prior behavior.
528
+ readInstalledVersion: opts.readInstalledVersion ?? (() => null),
529
+ });
530
+ const body = (await res.json()) as { modules: UpgradeWire[] };
531
+ return body.modules;
532
+ }
533
+
534
+ test("does NOT offer an upgrade when the channel target is OLDER than installed (the live downgrade bug)", async () => {
535
+ // The exact live shape: rc operator installed 0.6.4-rc.15; channel resolved
536
+ // latest_version to the OLDER @latest 0.6.3. Strings differ, but it's a
537
+ // downgrade — upgrade_available MUST be false.
538
+ const mods = await modulesWith({ installedVersion: "0.6.4-rc.15", latest: "0.6.3" });
539
+ const vault = mods.find((m) => m.short === "vault");
540
+ expect(vault?.installed_version).toBe("0.6.4-rc.15");
541
+ expect(vault?.latest_version).toBe("0.6.3");
542
+ expect(vault?.upgrade_available).toBe(false);
543
+ });
544
+
545
+ test("offers an upgrade for a real rc → newer-rc move", async () => {
546
+ const mods = await modulesWith({ installedVersion: "0.6.4-rc.15", latest: "0.6.4-rc.16" });
547
+ const vault = mods.find((m) => m.short === "vault");
548
+ expect(vault?.upgrade_available).toBe(true);
549
+ });
550
+
551
+ test("offers an upgrade for rc → its own stable (stable > its rc per semver)", async () => {
552
+ const mods = await modulesWith({ installedVersion: "0.6.4-rc.15", latest: "0.6.4" });
553
+ const vault = mods.find((m) => m.short === "vault");
554
+ expect(vault?.upgrade_available).toBe(true);
555
+ });
556
+
557
+ test("offers an upgrade for a plain stable → newer stable", async () => {
558
+ const mods = await modulesWith({ installedVersion: "0.4.5", latest: "0.5.0" });
559
+ const vault = mods.find((m) => m.short === "vault");
560
+ expect(vault?.upgrade_available).toBe(true);
561
+ });
562
+
563
+ test("no upgrade when installed === latest", async () => {
564
+ const mods = await modulesWith({ installedVersion: "0.5.0", latest: "0.5.0" });
565
+ const vault = mods.find((m) => m.short === "vault");
566
+ expect(vault?.upgrade_available).toBe(false);
567
+ });
568
+
569
+ test("no upgrade when the npm probe failed (latest_version null)", async () => {
570
+ const mods = await modulesWith({ installedVersion: "0.5.0", latest: null });
571
+ const vault = mods.find((m) => m.short === "vault");
572
+ expect(vault?.latest_version).toBeNull();
573
+ expect(vault?.upgrade_available).toBe(false);
574
+ });
575
+
576
+ test("installed_version reflects the LIVE on-disk version, not a stale services.json cache (hub#243)", async () => {
577
+ // services.json cache lags the bun-linked checkout: cache says 0.5.4-rc.15
578
+ // (the live symptom) while package.json on disk is already 0.6.4-rc.15.
579
+ // The admin view must show what's actually installed.
580
+ const mods = await modulesWith({
581
+ installedVersion: "0.5.4-rc.15",
582
+ latest: "0.6.3",
583
+ readInstalledVersion: (dir) => (dir === "/install/dir/vault" ? "0.6.4-rc.15" : null),
584
+ });
585
+ const vault = mods.find((m) => m.short === "vault");
586
+ expect(vault?.installed_version).toBe("0.6.4-rc.15");
587
+ // And with the corrected current, @latest 0.6.3 is still a downgrade → no offer.
588
+ expect(vault?.upgrade_available).toBe(false);
589
+ });
590
+
591
+ test("falls back to the services.json version when the live read returns null", async () => {
592
+ const mods = await modulesWith({
593
+ installedVersion: "0.6.4-rc.15",
594
+ latest: "0.6.4-rc.16",
595
+ readInstalledVersion: () => null,
596
+ });
597
+ const vault = mods.find((m) => m.short === "vault");
598
+ expect(vault?.installed_version).toBe("0.6.4-rc.15");
599
+ expect(vault?.upgrade_available).toBe(true);
600
+ });
601
+
602
+ test("isUpgradeAvailable: semver-aware, fail-closed on unparseable + nulls", () => {
603
+ // strictly-newer → true
604
+ expect(isUpgradeAvailable("0.4.5", "0.5.0")).toBe(true);
605
+ expect(isUpgradeAvailable("0.6.4-rc.15", "0.6.4-rc.16")).toBe(true);
606
+ expect(isUpgradeAvailable("0.6.4-rc.15", "0.6.4")).toBe(true); // stable > its rc
607
+ // same / older → false
608
+ expect(isUpgradeAvailable("0.5.0", "0.5.0")).toBe(false);
609
+ expect(isUpgradeAvailable("0.6.4-rc.15", "0.6.3")).toBe(false); // the live downgrade
610
+ expect(isUpgradeAvailable("0.6.4", "0.6.4-rc.15")).toBe(false); // stable → its rc
611
+ // nulls → false (not installed / probe failed)
612
+ expect(isUpgradeAvailable(null, "0.5.0")).toBe(false);
613
+ expect(isUpgradeAvailable("0.5.0", null)).toBe(false);
614
+ // unparseable → false (fail-closed: never offer a move we can't verify)
615
+ expect(isUpgradeAvailable("not-a-version", "0.5.0")).toBe(false);
616
+ expect(isUpgradeAvailable("0.5.0", "garbage")).toBe(false);
617
+ });
618
+
619
+ test("defaultReadInstalledVersion reads package.json version + tolerates missing/bad files", () => {
620
+ const tmp = mkdtempSync(join(tmpdir(), "phub-live-ver-"));
621
+ try {
622
+ writeFileSync(join(tmp, "package.json"), JSON.stringify({ version: "0.6.4-rc.15" }));
623
+ expect(defaultReadInstalledVersion(tmp)).toBe("0.6.4-rc.15");
624
+ // Missing dir / no package.json → null.
625
+ expect(defaultReadInstalledVersion(join(tmp, "does-not-exist"))).toBeNull();
626
+ // Malformed JSON → null (no throw).
627
+ writeFileSync(join(tmp, "package.json"), "{ not json");
628
+ expect(defaultReadInstalledVersion(tmp)).toBeNull();
629
+ // No version field → null.
630
+ writeFileSync(join(tmp, "package.json"), JSON.stringify({ name: "x" }));
631
+ expect(defaultReadInstalledVersion(tmp)).toBeNull();
632
+ } finally {
633
+ rmSync(tmp, { recursive: true, force: true });
634
+ }
635
+ });
636
+
494
637
  test("includes supervisor status + pid when a supervisor is injected", async () => {
495
638
  writeManifest(h.manifestPath, [
496
639
  {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { type CheckResult, type DoctorDeps, doctor } from "../commands/doctor.ts";
@@ -462,3 +462,294 @@ describe("doctor — version drift (cosmetic; never FAIL)", () => {
462
462
  }
463
463
  });
464
464
  });
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // Canonical-port-drift detection + `doctor --fix` repair (#267 doctor sub-item)
468
+ // ---------------------------------------------------------------------------
469
+
470
+ /** Write a services.json with the given rows (verbatim — for drift fixtures). */
471
+ function writeManifestRows(manifestPath: string, services: unknown[]): void {
472
+ writeFileSync(manifestPath, JSON.stringify({ services }, null, 2));
473
+ }
474
+
475
+ /** Read services.json back as parsed rows for post-fix assertions. */
476
+ function readRows(manifestPath: string): Record<string, unknown>[] {
477
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf8")) as {
478
+ services: Record<string, unknown>[];
479
+ };
480
+ return parsed.services;
481
+ }
482
+
483
+ /** Run `doctor --fix`, capturing printed lines + exit code. */
484
+ async function runFix(
485
+ h: Harness,
486
+ over: Partial<DoctorDeps> = {},
487
+ flags: { yes?: boolean } = {},
488
+ ): Promise<{ code: number; lines: string[] }> {
489
+ const lines: string[] = [];
490
+ const code = await doctor({
491
+ configDir: h.configDir,
492
+ manifestPath: h.manifestPath,
493
+ print: (l) => lines.push(l),
494
+ fix: true,
495
+ yes: flags.yes ?? false,
496
+ deps: healthyDeps(over),
497
+ });
498
+ return { code, lines };
499
+ }
500
+
501
+ describe("doctor — canonical-port-drift detection (read-only)", () => {
502
+ test("a non-canonical port + a duplicate-port pair → port-drift WARNs naming the services", async () => {
503
+ const h = makeHarness();
504
+ try {
505
+ // scribe drifted off 1943 onto 1944; agent also squats 1944 (a collision).
506
+ writeManifestRows(h.manifestPath, [
507
+ {
508
+ name: "parachute-vault",
509
+ port: 1940,
510
+ paths: ["/vault/default"],
511
+ health: "/h",
512
+ version: "1",
513
+ },
514
+ { name: "parachute-scribe", port: 1944, paths: ["/scribe"], health: "/h", version: "1" },
515
+ { name: "parachute-agent", port: 1944, paths: ["/agent"], health: "/h", version: "1" },
516
+ ]);
517
+ seedOperatorToken(h.configDir);
518
+ const { code, checks } = await runDoctor(h, healthyDeps());
519
+ const pd = byName(checks, "port-drift");
520
+ expect(pd?.status).toBe("warn");
521
+ // Names the drifted service AND the colliding pair.
522
+ expect(pd?.detail).toContain("scribe");
523
+ expect(pd?.detail).toContain("1944");
524
+ expect(pd?.detail).toContain("parachute-scribe + parachute-agent");
525
+ expect(pd?.fix).toBe("parachute doctor --fix");
526
+ // Drift is advisory — exit stays 0 (a WARN, not a FAIL). The duplicate
527
+ // rows also trip modules-alive (both can't bind 1944) but that's expected
528
+ // for this fixture; we only assert on port-drift here.
529
+ expect([0, 1]).toContain(code);
530
+ } finally {
531
+ h.cleanup();
532
+ }
533
+ });
534
+
535
+ test("a clean file → port-drift PASSES with no drift", async () => {
536
+ const h = makeHarness();
537
+ try {
538
+ seedCurrentManifest(h.manifestPath);
539
+ seedOperatorToken(h.configDir);
540
+ const { code, checks } = await runDoctor(h, healthyDeps());
541
+ const pd = byName(checks, "port-drift");
542
+ expect(pd?.status).toBe("pass");
543
+ expect(pd?.detail).toContain("canonical");
544
+ expect(code).toBe(0);
545
+ expectNoUnexpectedNonPass(checks, []);
546
+ } finally {
547
+ h.cleanup();
548
+ }
549
+ });
550
+
551
+ test("a third-party service with NO canonical port is not flagged", async () => {
552
+ const h = makeHarness();
553
+ try {
554
+ // An unknown module on a non-1939–1949 port — no canonical to drift from.
555
+ writeManifestRows(h.manifestPath, [
556
+ {
557
+ name: "parachute-vault",
558
+ port: 1940,
559
+ paths: ["/vault/default"],
560
+ health: "/h",
561
+ version: "1",
562
+ },
563
+ { name: "acme-thing", port: 5000, paths: ["/acme"], health: "/h", version: "1" },
564
+ ]);
565
+ seedOperatorToken(h.configDir);
566
+ const { code, checks } = await runDoctor(h, healthyDeps());
567
+ const pd = byName(checks, "port-drift");
568
+ expect(pd?.status).toBe("pass");
569
+ expect(code).toBe(0);
570
+ } finally {
571
+ h.cleanup();
572
+ }
573
+ });
574
+
575
+ test("a named multi-vault row sharing 1940 is NOT flagged (drift or duplicate)", async () => {
576
+ const h = makeHarness();
577
+ try {
578
+ // A legit multi-vault setup: the canonical vault row plus a second named
579
+ // vault instance, both on 1940 (the documented carve-out). Neither should
580
+ // be flagged as drifted (named vault rows have no canonical port) nor as a
581
+ // duplicate-port collision (all-vault-on-1940 is by design).
582
+ writeManifestRows(h.manifestPath, [
583
+ {
584
+ name: "parachute-vault",
585
+ port: 1940,
586
+ paths: ["/vault/default"],
587
+ health: "/h",
588
+ version: "1",
589
+ },
590
+ {
591
+ name: "parachute-vault-work",
592
+ port: 1940,
593
+ paths: ["/vault/work"],
594
+ health: "/h",
595
+ version: "1",
596
+ },
597
+ ]);
598
+ seedOperatorToken(h.configDir);
599
+ const { code, checks } = await runDoctor(h, healthyDeps());
600
+ const pd = byName(checks, "port-drift");
601
+ // PASS (clean) — neither flagged as drifted nor as a duplicate collision.
602
+ expect(pd?.status).toBe("pass");
603
+ expect(pd?.detail).toContain("canonical");
604
+ expect(code).toBe(0);
605
+ } finally {
606
+ h.cleanup();
607
+ }
608
+ });
609
+ });
610
+
611
+ describe("doctor --fix — canonical-port repair (confirm-gated, idempotent, non-tty-safe)", () => {
612
+ test("--fix on a clean file → 'no drift', exit 0, file unchanged (idempotent)", async () => {
613
+ const h = makeHarness();
614
+ try {
615
+ seedCurrentManifest(h.manifestPath);
616
+ const before = readFileSync(h.manifestPath, "utf8");
617
+ const { code, lines } = await runFix(h, { isInteractive: () => true }, { yes: true });
618
+ expect(code).toBe(0);
619
+ expect(lines.join("\n").toLowerCase()).toContain("nothing to fix");
620
+ expect(readFileSync(h.manifestPath, "utf8")).toBe(before);
621
+ } finally {
622
+ h.cleanup();
623
+ }
624
+ });
625
+
626
+ test("--fix on an absent services.json (fresh install) → 'nothing to fix', exit 0", async () => {
627
+ const h = makeHarness();
628
+ try {
629
+ // No services.json at all — the truly-fresh case. --fix must NOT report a
630
+ // corrupt-file error; it's the idempotent no-op path.
631
+ const { code, lines } = await runFix(h, { isInteractive: () => false }, { yes: false });
632
+ expect(code).toBe(0);
633
+ expect(lines.join("\n").toLowerCase()).toContain("nothing to fix");
634
+ expect(lines.join("\n").toLowerCase()).not.toContain("can't read");
635
+ } finally {
636
+ h.cleanup();
637
+ }
638
+ });
639
+
640
+ test("--fix --yes rewrites the drifted port to canonical + preserves other fields", async () => {
641
+ const h = makeHarness();
642
+ try {
643
+ // scribe drifted onto 1944; carries an optional displayName/tagline that
644
+ // must survive the rewrite.
645
+ writeManifestRows(h.manifestPath, [
646
+ {
647
+ name: "parachute-scribe",
648
+ port: 1944,
649
+ paths: ["/scribe"],
650
+ health: "/scribe/health",
651
+ version: "0.7.4",
652
+ displayName: "Scribe",
653
+ tagline: "Local audio transcription.",
654
+ stripPrefix: true,
655
+ },
656
+ ]);
657
+ const { code, lines } = await runFix(h, {}, { yes: true });
658
+ expect(code).toBe(0);
659
+ expect(lines.join("\n")).toContain("→ :1943");
660
+ const rows = readRows(h.manifestPath);
661
+ const scribe = rows.find((r) => r.name === "parachute-scribe");
662
+ expect(scribe?.port).toBe(1943);
663
+ // Optional + unknown fields preserved verbatim.
664
+ expect(scribe?.displayName).toBe("Scribe");
665
+ expect(scribe?.tagline).toBe("Local audio transcription.");
666
+ expect(scribe?.stripPrefix).toBe(true);
667
+
668
+ // Re-run → idempotent: now clean, exit 0, nothing to fix.
669
+ const again = await runFix(h, {}, { yes: true });
670
+ expect(again.code).toBe(0);
671
+ expect(again.lines.join("\n").toLowerCase()).toContain("nothing to fix");
672
+ } finally {
673
+ h.cleanup();
674
+ }
675
+ });
676
+
677
+ test("--fix in a TTY without --yes prompts; 'y' applies the rewrite", async () => {
678
+ const h = makeHarness();
679
+ try {
680
+ writeManifestRows(h.manifestPath, [
681
+ { name: "parachute-scribe", port: 1944, paths: ["/scribe"], health: "/h", version: "1" },
682
+ ]);
683
+ const { code } = await runFix(
684
+ h,
685
+ { isInteractive: () => true, readLine: async () => "y" },
686
+ { yes: false },
687
+ );
688
+ expect(code).toBe(0);
689
+ expect(readRows(h.manifestPath).find((r) => r.name === "parachute-scribe")?.port).toBe(1943);
690
+ } finally {
691
+ h.cleanup();
692
+ }
693
+ });
694
+
695
+ test("--fix in a TTY answered 'n' → aborts, exit non-zero, file UNCHANGED", async () => {
696
+ const h = makeHarness();
697
+ try {
698
+ writeManifestRows(h.manifestPath, [
699
+ { name: "parachute-scribe", port: 1944, paths: ["/scribe"], health: "/h", version: "1" },
700
+ ]);
701
+ const before = readFileSync(h.manifestPath, "utf8");
702
+ const { code, lines } = await runFix(
703
+ h,
704
+ { isInteractive: () => true, readLine: async () => "n" },
705
+ { yes: false },
706
+ );
707
+ expect(code).not.toBe(0);
708
+ expect(lines.join("\n").toLowerCase()).toContain("unchanged");
709
+ expect(readFileSync(h.manifestPath, "utf8")).toBe(before);
710
+ } finally {
711
+ h.cleanup();
712
+ }
713
+ });
714
+
715
+ test("--fix in a NON-TTY without --yes → bails, exit non-zero, file UNCHANGED", async () => {
716
+ const h = makeHarness();
717
+ try {
718
+ writeManifestRows(h.manifestPath, [
719
+ { name: "parachute-scribe", port: 1944, paths: ["/scribe"], health: "/h", version: "1" },
720
+ ]);
721
+ const before = readFileSync(h.manifestPath, "utf8");
722
+ const { code, lines } = await runFix(h, { isInteractive: () => false }, { yes: false });
723
+ expect(code).not.toBe(0);
724
+ expect(lines.join("\n")).toContain("--yes");
725
+ // The load-bearing guarantee: no write happened.
726
+ expect(readFileSync(h.manifestPath, "utf8")).toBe(before);
727
+ } finally {
728
+ h.cleanup();
729
+ }
730
+ });
731
+
732
+ test("--fix reports a duplicate-port collision but does not auto-resolve it", async () => {
733
+ const h = makeHarness();
734
+ try {
735
+ // Two services collide on 1944; neither is on its canonical slot. The
736
+ // diff fixes the canonical drift; the collision is reported, not guessed.
737
+ writeManifestRows(h.manifestPath, [
738
+ { name: "parachute-scribe", port: 1944, paths: ["/scribe"], health: "/h", version: "1" },
739
+ { name: "parachute-agent", port: 1944, paths: ["/agent"], health: "/h", version: "1" },
740
+ ]);
741
+ const { code, lines } = await runFix(h, {}, { yes: true });
742
+ const text = lines.join("\n");
743
+ expect(text.toLowerCase()).toContain("shared by");
744
+ expect(text).toContain("parachute-scribe + parachute-agent");
745
+ // scribe → 1943 and agent → 1941 are both off 1944, so after the rewrite
746
+ // they no longer collide; fix applied, exit 0.
747
+ expect(code).toBe(0);
748
+ const rows = readRows(h.manifestPath);
749
+ expect(rows.find((r) => r.name === "parachute-scribe")?.port).toBe(1943);
750
+ expect(rows.find((r) => r.name === "parachute-agent")?.port).toBe(1941);
751
+ } finally {
752
+ h.cleanup();
753
+ }
754
+ });
755
+ });
@@ -42,6 +42,9 @@
42
42
  */
43
43
 
44
44
  import type { Database } from "bun:sqlite";
45
+ import { readFileSync } from "node:fs";
46
+ import { join } from "node:path";
47
+ import { compareVersions } from "./commands/upgrade.ts";
45
48
  import { validateHostAdminToken } from "./host-admin-token-validation.ts";
46
49
  import {
47
50
  type ModuleInstallChannel,
@@ -178,6 +181,40 @@ export interface ApiModulesDeps {
178
181
  * (hub#342 — drives the admin SPA Modules page's "Open" button).
179
182
  */
180
183
  readModuleManifest?: (installDir: string) => Promise<ModuleManifest | null>;
184
+ /**
185
+ * Read the LIVE installed version of a module from its install dir's
186
+ * `package.json` `version` (hub#243). The services.json `version` field is a
187
+ * CACHE the module writes on its own boot — on the bun-linked dev path it
188
+ * goes stale the moment the checkout is rebuilt to a newer version without a
189
+ * restart that re-stamps services.json. The admin Modules view reads
190
+ * `installed_version` straight from that cache, so it can show a stale version
191
+ * (the live symptom: services.json said 0.5.4-rc.15 while the linked checkout
192
+ * was already 0.6.4-rc.15). When this resolves a version, it WINS over the
193
+ * services.json cache for the `installed_version` field so the operator sees
194
+ * what's actually on disk. Returns null on any failure (no install dir,
195
+ * missing/unreadable package.json, no version) → fall back to the
196
+ * services.json cache, which is the only source for a not-yet-booted module.
197
+ *
198
+ * Production reads `<installDir>/package.json`; tests inject a fake.
199
+ */
200
+ readInstalledVersion?: (installDir: string) => string | null;
201
+ }
202
+
203
+ /**
204
+ * Default `readInstalledVersion`. Reads `<installDir>/package.json`'s `version`
205
+ * — the authoritative live version of a module's code on disk, which the
206
+ * bun-linked dev path keeps current while the services.json cache can lag
207
+ * (hub#243). Returns null on any failure so the caller falls back to the
208
+ * services.json `version`.
209
+ */
210
+ export function defaultReadInstalledVersion(installDir: string): string | null {
211
+ try {
212
+ const raw = readFileSync(join(installDir, "package.json"), "utf8");
213
+ const parsed = JSON.parse(raw) as { version?: unknown };
214
+ return typeof parsed.version === "string" && parsed.version.length > 0 ? parsed.version : null;
215
+ } catch {
216
+ return null;
217
+ }
181
218
  }
182
219
 
183
220
  /**
@@ -239,6 +276,24 @@ interface ModuleWireShape {
239
276
  installed: boolean;
240
277
  installed_version: string | null;
241
278
  latest_version: string | null;
279
+ /**
280
+ * Whether the channel-resolved `latest_version` is a REAL upgrade — i.e.
281
+ * STRICTLY NEWER than `installed_version` under semver ordering (hub#243).
282
+ * Computed server-side via `compareVersions` so the rc/prerelease ordering is
283
+ * correct and there's ONE tested source of truth (the SPA used to derive this
284
+ * client-side with a string `!==`, which framed a *downgrade* as an upgrade:
285
+ * an rc operator on `0.6.4-rc.15` was offered an "upgrade" to the older
286
+ * `@latest` `0.6.3` purely because the two strings differed).
287
+ *
288
+ * `false` when: not installed · no `latest_version` (probe failed) · target ≤
289
+ * installed (same or older — incl. the rc→older-stable downgrade) · either
290
+ * version is unparseable (`compareVersions` returns null → fail-closed: don't
291
+ * offer a move we can't verify is forward). `true` ONLY when the target
292
+ * parses AND sorts strictly above installed — so `0.6.4-rc.15` → stable
293
+ * `0.6.4` IS an upgrade (stable > its own rc per semver §11.4.3), but
294
+ * `0.6.4-rc.15` → `0.6.3` is NOT.
295
+ */
296
+ upgrade_available: boolean;
242
297
  supervisor_status: ModuleState["status"] | null;
243
298
  pid: number | null;
244
299
  /**
@@ -451,6 +506,8 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
451
506
  // Lenient read so a single bad row written by a buggy module install
452
507
  // (e.g. app@0.2.0-rc.4) doesn't take down /api/modules — see hub#406.
453
508
  const manifest = readManifestLenient(deps.manifestPath);
509
+ // Live on-disk version reader (hub#243) — see `defaultReadInstalledVersion`.
510
+ const readInstalledVersion = deps.readInstalledVersion ?? defaultReadInstalledVersion;
454
511
  const installedByShort = new Map<
455
512
  string,
456
513
  {
@@ -476,6 +533,18 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
476
533
  uis?: Record<string, UiSubUnit>;
477
534
  mountPath?: string;
478
535
  } = { version: entry.version };
536
+ // Prefer the LIVE on-disk version over the services.json cache when we can
537
+ // read it (hub#243). `entry.version` is a cache the module stamps on its own
538
+ // boot; on the bun-linked dev path it lags after a rebuild-without-restart,
539
+ // so the admin view shows a stale "current" (the live symptom: cache said
540
+ // 0.5.4-rc.15 while the linked checkout was already 0.6.4-rc.15). The
541
+ // module's `<installDir>/package.json` `version` is authoritative for the
542
+ // code actually on disk. Falls back to the cache when there's no installDir
543
+ // or the package.json can't be read (e.g. a not-yet-booted seed row).
544
+ if (entry.installDir !== undefined) {
545
+ const live = readInstalledVersion(entry.installDir);
546
+ if (live !== null) value.version = live;
547
+ }
479
548
  if (entry.installDir !== undefined) value.installDir = entry.installDir;
480
549
  if (entry.uis !== undefined) value.uis = entry.uis;
481
550
  // First non-`.parachute` path is the module's user-facing mount
@@ -622,6 +691,10 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
622
691
  installed: installed !== undefined,
623
692
  installed_version: installed?.version ?? null,
624
693
  latest_version: latestByShort.get(short) ?? null,
694
+ upgrade_available: isUpgradeAvailable(
695
+ installed?.version ?? null,
696
+ latestByShort.get(short) ?? null,
697
+ ),
625
698
  supervisor_status: state?.status ?? null,
626
699
  pid: state?.pid ?? null,
627
700
  supervisor_start_error: state?.startError ?? null,
@@ -644,6 +717,9 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
644
717
 
645
718
  // Every supervised module's run-state — curated AND non-curated (hub#539).
646
719
  // Built from the same supervisor.list() snapshot already in `stateByShort`.
720
+ // `installed_version` here inherits the live-on-disk override already applied
721
+ // to `installedByShort[].version` in the manifest loop above (hub#243), so the
722
+ // `supervised` array reports the same corrected version as the `modules` rows.
647
723
  const supervised: SupervisedSnapshotWire[] = Array.from(stateByShort.values()).map((s) => ({
648
724
  short: s.short,
649
725
  installed: installedByShort.has(s.short),
@@ -842,6 +918,35 @@ function toUisWireShape(uis: Record<string, UiSubUnit> | undefined): UiSubUnitWi
842
918
  }));
843
919
  }
844
920
 
921
+ /**
922
+ * Whether `latest` is a REAL upgrade over `installed` — strictly newer under
923
+ * semver ordering (hub#243). The single tested source of truth for the
924
+ * "upgrade available?" decision, shared by the wire shape's `upgrade_available`
925
+ * field and (transitively) the admin SPA's Upgrade button.
926
+ *
927
+ * Returns `false` unless BOTH versions parse AND `latest` sorts strictly above
928
+ * `installed`:
929
+ * - no installed version (not installed) → false.
930
+ * - no latest version (probe failed / unknown package) → false.
931
+ * - `compareVersions` can't parse either side → false (FAIL-CLOSED: never
932
+ * offer a move we can't prove is forward).
933
+ * - target ≤ installed → false. This is the load-bearing downgrade guard:
934
+ * an rc operator on `0.6.4-rc.15` whose channel resolves `latest_version`
935
+ * to the OLDER `@latest` `0.6.3` is NOT offered an "upgrade" (the live bug
936
+ * — a downgrade framed as an upgrade by the old string `!==`).
937
+ *
938
+ * Reuses `commands/upgrade.ts:compareVersions`, the same comparator the CLI
939
+ * `parachute upgrade` downgrade guard uses, so the SPA offer and the CLI guard
940
+ * can't disagree about rc/prerelease ordering. Note `0.6.4-rc.15` → stable
941
+ * `0.6.4` IS strictly-newer (stable > its own rc per semver §11.4.3), so a real
942
+ * rc→its-stable promotion still surfaces as an upgrade.
943
+ */
944
+ export function isUpgradeAvailable(installed: string | null, latest: string | null): boolean {
945
+ if (installed === null || latest === null) return false;
946
+ const cmp = compareVersions(latest, installed);
947
+ return cmp !== null && cmp > 0;
948
+ }
949
+
845
950
  /**
846
951
  * Reset the in-memory `latest_version` cache. Tests call this between
847
952
  * runs to prevent state leakage across test cases; production never
package/src/cli.ts CHANGED
@@ -581,15 +581,26 @@ async function main(argv: string[]): Promise<number> {
581
581
  return 0;
582
582
  }
583
583
  const json = rest.includes("--json");
584
- const unknown = rest.find((a) => a !== "--json");
584
+ const fix = rest.includes("--fix");
585
+ const yes = rest.includes("--yes") || rest.includes("-y");
586
+ const known = new Set(["--json", "--fix", "--yes", "-y"]);
587
+ const unknown = rest.find((a) => !known.has(a));
585
588
  if (unknown !== undefined) {
586
589
  console.error(`parachute doctor: unknown argument "${unknown}"`);
587
- console.error("usage: parachute doctor [--json]");
590
+ console.error("usage: parachute doctor [--json] [--fix [--yes]]");
591
+ return 1;
592
+ }
593
+ if (json && fix) {
594
+ console.error("parachute doctor: --json and --fix are mutually exclusive");
595
+ return 1;
596
+ }
597
+ if (yes && !fix) {
598
+ console.error("parachute doctor: --yes has no effect without --fix");
588
599
  return 1;
589
600
  }
590
601
  const mod = await loadCommand("doctor", () => import("./commands/doctor.ts"));
591
602
  if (!mod) return 1;
592
- return await mod.doctor({ json });
603
+ return await mod.doctor({ json, fix, yes });
593
604
  }
594
605
 
595
606
  case "expose": {