@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 +1 -1
- package/src/__tests__/api-modules.test.ts +143 -0
- package/src/__tests__/doctor.test.ts +292 -1
- package/src/api-modules.ts +105 -0
- package/src/cli.ts +14 -3
- package/src/commands/doctor.ts +358 -2
- package/src/help.ts +17 -2
- package/web/ui/dist/assets/{index-CkKBaPaO.js → index-CVqK1cV5.js} +1 -1
- package/web/ui/dist/index.html +1 -1
package/package.json
CHANGED
|
@@ -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
|
+
});
|
package/src/api-modules.ts
CHANGED
|
@@ -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
|
|
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": {
|