@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
|
@@ -556,6 +556,246 @@ describe("install", () => {
|
|
|
556
556
|
}
|
|
557
557
|
});
|
|
558
558
|
|
|
559
|
+
// hub#337 — channel resolution (--channel flag + PARACHUTE_INSTALL_CHANNEL env var).
|
|
560
|
+
// Precedence: --tag > --channel > env > "latest" default.
|
|
561
|
+
|
|
562
|
+
test("default channel: bare bun-add (no @latest suffix) when nothing requested (back-compat)", async () => {
|
|
563
|
+
// Back-compat: pre-hub#337 `parachute install vault` ran `bun add -g
|
|
564
|
+
// @openparachute/vault` with no suffix. The env-default plumbing keeps
|
|
565
|
+
// emitting the bare spec when nothing's explicitly chosen — bun
|
|
566
|
+
// resolves bare names to @latest anyway, so this is a no-op for npm,
|
|
567
|
+
// but keeps logs byte-identical with the pre-#337 shape.
|
|
568
|
+
const { path, cleanup } = makeTempPath();
|
|
569
|
+
try {
|
|
570
|
+
const calls: string[][] = [];
|
|
571
|
+
const code = await install("vault", {
|
|
572
|
+
runner: async (cmd) => {
|
|
573
|
+
calls.push([...cmd]);
|
|
574
|
+
return 0;
|
|
575
|
+
},
|
|
576
|
+
manifestPath: path,
|
|
577
|
+
startService: async () => 0,
|
|
578
|
+
isLinked: () => false,
|
|
579
|
+
portProbe: async () => false,
|
|
580
|
+
log: () => {},
|
|
581
|
+
// No env override → defaults to process.env. We explicitly pass
|
|
582
|
+
// a stub object so a real-shell `PARACHUTE_INSTALL_CHANNEL=…` can't
|
|
583
|
+
// leak into the test.
|
|
584
|
+
envOverride: {},
|
|
585
|
+
});
|
|
586
|
+
expect(code).toBe(0);
|
|
587
|
+
expect(calls[0]).toEqual(["bun", "add", "-g", "@openparachute/vault"]);
|
|
588
|
+
} finally {
|
|
589
|
+
cleanup();
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("PARACHUTE_INSTALL_CHANNEL=rc env composes <pkg>@rc for bun add", async () => {
|
|
594
|
+
// Cluster-wide cascade: when the platform sets the env var, every
|
|
595
|
+
// `parachute install <svc>` lands on the rc dist-tag without a flag.
|
|
596
|
+
// The Render-deploy shape — hub container on rc → all modules
|
|
597
|
+
// installed via /admin/modules also on rc.
|
|
598
|
+
const { path, cleanup } = makeTempPath();
|
|
599
|
+
try {
|
|
600
|
+
const calls: string[][] = [];
|
|
601
|
+
const logs: string[] = [];
|
|
602
|
+
const code = await install("vault", {
|
|
603
|
+
runner: async (cmd) => {
|
|
604
|
+
calls.push([...cmd]);
|
|
605
|
+
return 0;
|
|
606
|
+
},
|
|
607
|
+
manifestPath: path,
|
|
608
|
+
startService: async () => 0,
|
|
609
|
+
isLinked: () => false,
|
|
610
|
+
portProbe: async () => false,
|
|
611
|
+
log: (l) => logs.push(l),
|
|
612
|
+
envOverride: { PARACHUTE_INSTALL_CHANNEL: "rc" },
|
|
613
|
+
});
|
|
614
|
+
expect(code).toBe(0);
|
|
615
|
+
expect(calls[0]).toEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
|
|
616
|
+
expect(logs.join("\n")).toMatch(/Installing @openparachute\/vault@rc/);
|
|
617
|
+
} finally {
|
|
618
|
+
cleanup();
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test("--channel rc flag wins over PARACHUTE_INSTALL_CHANNEL=latest env", async () => {
|
|
623
|
+
// Per-call flag is the operator's deliberate intent; it beats the
|
|
624
|
+
// platform default.
|
|
625
|
+
const { path, cleanup } = makeTempPath();
|
|
626
|
+
try {
|
|
627
|
+
const calls: string[][] = [];
|
|
628
|
+
const code = await install("vault", {
|
|
629
|
+
runner: async (cmd) => {
|
|
630
|
+
calls.push([...cmd]);
|
|
631
|
+
return 0;
|
|
632
|
+
},
|
|
633
|
+
manifestPath: path,
|
|
634
|
+
startService: async () => 0,
|
|
635
|
+
isLinked: () => false,
|
|
636
|
+
portProbe: async () => false,
|
|
637
|
+
log: () => {},
|
|
638
|
+
channel: "rc",
|
|
639
|
+
envOverride: { PARACHUTE_INSTALL_CHANNEL: "latest" },
|
|
640
|
+
});
|
|
641
|
+
expect(code).toBe(0);
|
|
642
|
+
expect(calls[0]).toEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
|
|
643
|
+
} finally {
|
|
644
|
+
cleanup();
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test("--channel latest flag wins over PARACHUTE_INSTALL_CHANNEL=rc env", async () => {
|
|
649
|
+
const { path, cleanup } = makeTempPath();
|
|
650
|
+
try {
|
|
651
|
+
const calls: string[][] = [];
|
|
652
|
+
const code = await install("vault", {
|
|
653
|
+
runner: async (cmd) => {
|
|
654
|
+
calls.push([...cmd]);
|
|
655
|
+
return 0;
|
|
656
|
+
},
|
|
657
|
+
manifestPath: path,
|
|
658
|
+
startService: async () => 0,
|
|
659
|
+
isLinked: () => false,
|
|
660
|
+
portProbe: async () => false,
|
|
661
|
+
log: () => {},
|
|
662
|
+
channel: "latest",
|
|
663
|
+
envOverride: { PARACHUTE_INSTALL_CHANNEL: "rc" },
|
|
664
|
+
});
|
|
665
|
+
expect(code).toBe(0);
|
|
666
|
+
expect(calls[0]).toEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
|
|
667
|
+
} finally {
|
|
668
|
+
cleanup();
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("--tag wins over both --channel and the env var (programmatic pin)", async () => {
|
|
673
|
+
// `--tag` is the exact-pin escape hatch (e.g. `--tag 0.3.0-rc.1`),
|
|
674
|
+
// wins over both operator-facing flag and env default. Mirrors the
|
|
675
|
+
// upgrade-command shape (hub#338) where tag is the programmatic
|
|
676
|
+
// override above all channel resolution.
|
|
677
|
+
const { path, cleanup } = makeTempPath();
|
|
678
|
+
try {
|
|
679
|
+
const calls: string[][] = [];
|
|
680
|
+
const code = await install("vault", {
|
|
681
|
+
runner: async (cmd) => {
|
|
682
|
+
calls.push([...cmd]);
|
|
683
|
+
return 0;
|
|
684
|
+
},
|
|
685
|
+
manifestPath: path,
|
|
686
|
+
startService: async () => 0,
|
|
687
|
+
isLinked: () => false,
|
|
688
|
+
portProbe: async () => false,
|
|
689
|
+
log: () => {},
|
|
690
|
+
tag: "0.3.0-rc.1",
|
|
691
|
+
channel: "latest",
|
|
692
|
+
envOverride: { PARACHUTE_INSTALL_CHANNEL: "rc" },
|
|
693
|
+
});
|
|
694
|
+
expect(code).toBe(0);
|
|
695
|
+
expect(calls[0]).toEqual(["bun", "add", "-g", "@openparachute/vault@0.3.0-rc.1"]);
|
|
696
|
+
} finally {
|
|
697
|
+
cleanup();
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
test("garbage PARACHUTE_INSTALL_CHANNEL value falls back to latest with a warning (no crash)", async () => {
|
|
702
|
+
// Operator typo (`PARACHUTE_INSTALL_CHANNEL=banana`) shouldn't crash
|
|
703
|
+
// a Render container at boot. Warn loudly, then fall back to the
|
|
704
|
+
// safe default so installs keep working.
|
|
705
|
+
const { path, cleanup } = makeTempPath();
|
|
706
|
+
try {
|
|
707
|
+
const calls: string[][] = [];
|
|
708
|
+
const logs: string[] = [];
|
|
709
|
+
const code = await install("vault", {
|
|
710
|
+
runner: async (cmd) => {
|
|
711
|
+
calls.push([...cmd]);
|
|
712
|
+
return 0;
|
|
713
|
+
},
|
|
714
|
+
manifestPath: path,
|
|
715
|
+
startService: async () => 0,
|
|
716
|
+
isLinked: () => false,
|
|
717
|
+
portProbe: async () => false,
|
|
718
|
+
log: (l) => logs.push(l),
|
|
719
|
+
envOverride: { PARACHUTE_INSTALL_CHANNEL: "banana" },
|
|
720
|
+
});
|
|
721
|
+
expect(code).toBe(0);
|
|
722
|
+
// Falls back to "@latest" — explicit because the env var WAS set (just
|
|
723
|
+
// to a bad value), so we make the resolution visible in the spec.
|
|
724
|
+
expect(calls[0]).toEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
|
|
725
|
+
const joined = logs.join("\n");
|
|
726
|
+
expect(joined).toMatch(/PARACHUTE_INSTALL_CHANNEL="banana" is not a valid channel/);
|
|
727
|
+
expect(joined).toMatch(/Falling back to "latest"/);
|
|
728
|
+
} finally {
|
|
729
|
+
cleanup();
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
test("--channel is moot when the package is already bun-linked (link short-circuit wins)", async () => {
|
|
734
|
+
// Same shape as the --tag short-circuit test: local-link beats remote
|
|
735
|
+
// dist-tag resolution. No `bun add` invocation at all.
|
|
736
|
+
const { path, cleanup } = makeTempPath();
|
|
737
|
+
try {
|
|
738
|
+
const calls: string[][] = [];
|
|
739
|
+
const logs: string[] = [];
|
|
740
|
+
const code = await install("scribe", {
|
|
741
|
+
runner: async (cmd) => {
|
|
742
|
+
calls.push([...cmd]);
|
|
743
|
+
return 0;
|
|
744
|
+
},
|
|
745
|
+
manifestPath: path,
|
|
746
|
+
startService: async () => 0,
|
|
747
|
+
isLinked: () => true,
|
|
748
|
+
portProbe: async () => false,
|
|
749
|
+
log: (l) => logs.push(l),
|
|
750
|
+
channel: "rc",
|
|
751
|
+
envOverride: { PARACHUTE_INSTALL_CHANNEL: "rc" },
|
|
752
|
+
});
|
|
753
|
+
expect(code).toBe(0);
|
|
754
|
+
expect(calls).toHaveLength(0);
|
|
755
|
+
expect(logs.join("\n")).toMatch(/already linked globally/);
|
|
756
|
+
} finally {
|
|
757
|
+
cleanup();
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
test("local-path install ignores --channel + env (filesystem source, not npm)", async () => {
|
|
762
|
+
// The local-path branch installs from the operator's checkout via
|
|
763
|
+
// `bun add -g <abspath>`; there's no npm dist-tag to resolve. Channel
|
|
764
|
+
// resolution is bypassed entirely.
|
|
765
|
+
const { path, cleanup } = makeTempPath();
|
|
766
|
+
const pkgDir = mkdtempSync(join(tmpdir(), "pcli-localpkg-"));
|
|
767
|
+
try {
|
|
768
|
+
const calls: string[][] = [];
|
|
769
|
+
const code = await install(pkgDir, {
|
|
770
|
+
runner: async (cmd) => {
|
|
771
|
+
calls.push([...cmd]);
|
|
772
|
+
return 0;
|
|
773
|
+
},
|
|
774
|
+
manifestPath: path,
|
|
775
|
+
startService: async () => 0,
|
|
776
|
+
isLinked: () => false,
|
|
777
|
+
portProbe: async () => false,
|
|
778
|
+
log: () => {},
|
|
779
|
+
readManifest: async () => ({
|
|
780
|
+
name: "demo",
|
|
781
|
+
manifestName: "@local/demo",
|
|
782
|
+
port: 1951,
|
|
783
|
+
paths: ["/demo"],
|
|
784
|
+
health: "/healthz",
|
|
785
|
+
}),
|
|
786
|
+
readPackageName: () => "@local/demo",
|
|
787
|
+
channel: "rc",
|
|
788
|
+
envOverride: { PARACHUTE_INSTALL_CHANNEL: "rc" },
|
|
789
|
+
});
|
|
790
|
+
expect(code).toBe(0);
|
|
791
|
+
// No `@rc` suffix — the absolute path passes through verbatim.
|
|
792
|
+
expect(calls[0]).toEqual(["bun", "add", "-g", pkgDir]);
|
|
793
|
+
} finally {
|
|
794
|
+
cleanup();
|
|
795
|
+
rmSync(pkgDir, { recursive: true, force: true });
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
|
|
559
799
|
test("linked vault still runs init and defers to init's manifest write", async () => {
|
|
560
800
|
const { path, cleanup } = makeTempPath();
|
|
561
801
|
try {
|
|
@@ -1180,7 +1420,6 @@ describe("install", () => {
|
|
|
1180
1420
|
readManifest: async () => ({
|
|
1181
1421
|
name: "widget",
|
|
1182
1422
|
manifestName: "@acme/widget",
|
|
1183
|
-
kind: "api",
|
|
1184
1423
|
port: 1950,
|
|
1185
1424
|
paths: ["/widget"],
|
|
1186
1425
|
health: "/healthz",
|
|
@@ -1238,7 +1477,6 @@ describe("install", () => {
|
|
|
1238
1477
|
readManifest: async () => ({
|
|
1239
1478
|
name: "vault",
|
|
1240
1479
|
manifestName: "@evil/squatter",
|
|
1241
|
-
kind: "api",
|
|
1242
1480
|
port: 1950,
|
|
1243
1481
|
paths: ["/vault"],
|
|
1244
1482
|
health: "/healthz",
|
|
@@ -1271,7 +1509,6 @@ describe("install", () => {
|
|
|
1271
1509
|
readManifest: async () => ({
|
|
1272
1510
|
name: "demo",
|
|
1273
1511
|
manifestName: "@local/demo",
|
|
1274
|
-
kind: "api",
|
|
1275
1512
|
port: 1951,
|
|
1276
1513
|
paths: ["/demo"],
|
|
1277
1514
|
health: "/healthz",
|
|
@@ -1323,7 +1560,6 @@ describe("install", () => {
|
|
|
1323
1560
|
readManifest: async () => ({
|
|
1324
1561
|
name: "demo",
|
|
1325
1562
|
manifestName: "@local/demo",
|
|
1326
|
-
kind: "api",
|
|
1327
1563
|
port: 1951,
|
|
1328
1564
|
paths: ["/demo"],
|
|
1329
1565
|
health: "/healthz",
|
|
@@ -1367,7 +1603,6 @@ describe("install", () => {
|
|
|
1367
1603
|
readManifest: async () => ({
|
|
1368
1604
|
name: "demo",
|
|
1369
1605
|
manifestName: "@local/demo",
|
|
1370
|
-
kind: "api",
|
|
1371
1606
|
port: 1951,
|
|
1372
1607
|
paths: ["/demo"],
|
|
1373
1608
|
health: "/healthz",
|
|
@@ -1400,7 +1635,6 @@ describe("install", () => {
|
|
|
1400
1635
|
readManifest: async () => ({
|
|
1401
1636
|
name: "widget",
|
|
1402
1637
|
manifestName: "@vendor/widget",
|
|
1403
|
-
kind: "api",
|
|
1404
1638
|
port: 1952,
|
|
1405
1639
|
paths: ["/widget"],
|
|
1406
1640
|
health: "/widget/health",
|
|
@@ -1418,16 +1652,18 @@ describe("install", () => {
|
|
|
1418
1652
|
});
|
|
1419
1653
|
|
|
1420
1654
|
test("third-party with diverging name/manifestName keys services.json by name (hub#85)", async () => {
|
|
1421
|
-
// Repro for parachute-hub#85:
|
|
1422
|
-
// manifestName: "parachute-
|
|
1423
|
-
// under `parachute-
|
|
1424
|
-
// `
|
|
1425
|
-
// key is always `manifest.name` for third-party.
|
|
1655
|
+
// Repro for parachute-hub#85: a module ships `name: "someapp",
|
|
1656
|
+
// manifestName: "parachute-someapp"`. Install used to seed services.json
|
|
1657
|
+
// under `parachute-someapp` (the npm label) while lifecycle looks up by
|
|
1658
|
+
// `someapp` (the canonical short) → "unknown service". Fix: services.json
|
|
1659
|
+
// key is always `manifest.name` for third-party. Original repro was
|
|
1660
|
+
// against parachute-agent before it was retired (2026-05-20); the same
|
|
1661
|
+
// shape applies to any third-party module with diverging name/manifestName.
|
|
1426
1662
|
const { path, configDir, cleanup } = makeTempPath();
|
|
1427
1663
|
try {
|
|
1428
1664
|
const startCalls: string[] = [];
|
|
1429
1665
|
const logs: string[] = [];
|
|
1430
|
-
const code = await install("parachute-
|
|
1666
|
+
const code = await install("parachute-someapp", {
|
|
1431
1667
|
runner: async () => 0,
|
|
1432
1668
|
manifestPath: path,
|
|
1433
1669
|
configDir,
|
|
@@ -1439,29 +1675,28 @@ describe("install", () => {
|
|
|
1439
1675
|
portProbe: async () => false,
|
|
1440
1676
|
log: (l) => logs.push(l),
|
|
1441
1677
|
readManifest: async () => ({
|
|
1442
|
-
name: "
|
|
1443
|
-
manifestName: "parachute-
|
|
1444
|
-
kind: "api",
|
|
1678
|
+
name: "someapp",
|
|
1679
|
+
manifestName: "parachute-someapp",
|
|
1445
1680
|
port: 1945,
|
|
1446
|
-
paths: ["/
|
|
1447
|
-
health: "/
|
|
1681
|
+
paths: ["/someapp"],
|
|
1682
|
+
health: "/someapp/health",
|
|
1448
1683
|
startCmd: ["bun", "server.ts"],
|
|
1449
1684
|
}),
|
|
1450
|
-
findGlobalInstall: () => "/fake/prefix/parachute-
|
|
1685
|
+
findGlobalInstall: () => "/fake/prefix/parachute-someapp/package.json",
|
|
1451
1686
|
});
|
|
1452
1687
|
expect(code).toBe(0);
|
|
1453
1688
|
// services.json is keyed by `name`, not `manifestName`.
|
|
1454
|
-
expect(findService("
|
|
1455
|
-
expect(findService("parachute-
|
|
1689
|
+
expect(findService("someapp", path)?.name).toBe("someapp");
|
|
1690
|
+
expect(findService("parachute-someapp", path)).toBeUndefined();
|
|
1456
1691
|
// Auto-start receives the canonical short name (= manifest.name).
|
|
1457
|
-
expect(startCalls).toEqual(["
|
|
1692
|
+
expect(startCalls).toEqual(["someapp"]);
|
|
1458
1693
|
// Log lines speak in the canonical short name too. Port comes from
|
|
1459
1694
|
// assignServicePort (third-party gets the first unassigned canonical
|
|
1460
1695
|
// slot, currently 1944), not the manifest's port hint.
|
|
1461
1696
|
const joined = logs.join("\n");
|
|
1462
|
-
expect(joined).toMatch(/Seeded services\.json entry for
|
|
1463
|
-
expect(joined).toMatch(/
|
|
1464
|
-
expect(joined).not.toMatch(/Seeded services\.json entry for parachute-
|
|
1697
|
+
expect(joined).toMatch(/Seeded services\.json entry for someapp/);
|
|
1698
|
+
expect(joined).toMatch(/someapp registered on port \d+/);
|
|
1699
|
+
expect(joined).not.toMatch(/Seeded services\.json entry for parachute-someapp/);
|
|
1465
1700
|
} finally {
|
|
1466
1701
|
cleanup();
|
|
1467
1702
|
}
|
|
@@ -80,7 +80,6 @@ function seedThirdParty(
|
|
|
80
80
|
const manifest = {
|
|
81
81
|
name,
|
|
82
82
|
manifestName: opts.manifestName ?? name,
|
|
83
|
-
kind: "api" as const,
|
|
84
83
|
port: opts.port ?? 1944,
|
|
85
84
|
paths: [`/${name}`],
|
|
86
85
|
health: `/${name}/health`,
|
|
@@ -498,14 +497,14 @@ describe("parachute start", () => {
|
|
|
498
497
|
// gets cwd=installDir so manifest-declared relative paths work.
|
|
499
498
|
const h = makeHarness();
|
|
500
499
|
try {
|
|
501
|
-
const installDir = join(h.configDir, "_pkg-
|
|
502
|
-
seedThirdParty(h.manifestPath, h.configDir, "
|
|
500
|
+
const installDir = join(h.configDir, "_pkg-someapp");
|
|
501
|
+
seedThirdParty(h.manifestPath, h.configDir, "someapp", {
|
|
503
502
|
installDir,
|
|
504
503
|
startCmd: ["bun", "web/server/src/server.ts"],
|
|
505
504
|
port: 1944,
|
|
506
505
|
});
|
|
507
506
|
const spawner = makeSpawner([8080]);
|
|
508
|
-
const code = await start("
|
|
507
|
+
const code = await start("someapp", {
|
|
509
508
|
configDir: h.configDir,
|
|
510
509
|
manifestPath: h.manifestPath,
|
|
511
510
|
spawner,
|
|
@@ -515,7 +514,7 @@ describe("parachute start", () => {
|
|
|
515
514
|
expect(spawner.calls).toHaveLength(1);
|
|
516
515
|
expect(spawner.calls[0]?.cmd).toEqual(["bun", "web/server/src/server.ts"]);
|
|
517
516
|
expect(spawner.calls[0]?.cwd).toBe(installDir);
|
|
518
|
-
expect(readPid("
|
|
517
|
+
expect(readPid("someapp", h.configDir)).toBe(8080);
|
|
519
518
|
} finally {
|
|
520
519
|
h.cleanup();
|
|
521
520
|
}
|
|
@@ -580,8 +579,8 @@ describe("parachute start", () => {
|
|
|
580
579
|
const h = makeHarness();
|
|
581
580
|
try {
|
|
582
581
|
seedVault(h.manifestPath);
|
|
583
|
-
const installDir = join(h.configDir, "_pkg-
|
|
584
|
-
seedThirdParty(h.manifestPath, h.configDir, "
|
|
582
|
+
const installDir = join(h.configDir, "_pkg-someapp");
|
|
583
|
+
seedThirdParty(h.manifestPath, h.configDir, "someapp", {
|
|
585
584
|
installDir,
|
|
586
585
|
startCmd: ["bun", "server.ts"],
|
|
587
586
|
port: 1944,
|
|
@@ -964,21 +963,21 @@ describe("parachute logs", () => {
|
|
|
964
963
|
test("third-party module name with installDir is recognised", async () => {
|
|
965
964
|
const h = makeHarness();
|
|
966
965
|
try {
|
|
967
|
-
const installDir = join(h.configDir, "_pkg-
|
|
968
|
-
seedThirdParty(h.manifestPath, h.configDir, "
|
|
966
|
+
const installDir = join(h.configDir, "_pkg-someapp");
|
|
967
|
+
seedThirdParty(h.manifestPath, h.configDir, "someapp", {
|
|
969
968
|
installDir,
|
|
970
969
|
startCmd: ["bun", "server.ts"],
|
|
971
970
|
});
|
|
972
|
-
const p = ensureLogPath("
|
|
973
|
-
writeFileSync(p, "
|
|
971
|
+
const p = ensureLogPath("someapp", h.configDir);
|
|
972
|
+
writeFileSync(p, "someapp line 1\nsomeapp line 2\n");
|
|
974
973
|
const lines: string[] = [];
|
|
975
|
-
const code = await logs("
|
|
974
|
+
const code = await logs("someapp", {
|
|
976
975
|
configDir: h.configDir,
|
|
977
976
|
manifestPath: h.manifestPath,
|
|
978
977
|
log: (l) => lines.push(l),
|
|
979
978
|
});
|
|
980
979
|
expect(code).toBe(0);
|
|
981
|
-
expect(lines).toEqual(["
|
|
980
|
+
expect(lines).toEqual(["someapp line 1", "someapp line 2"]);
|
|
982
981
|
} finally {
|
|
983
982
|
h.cleanup();
|
|
984
983
|
}
|
|
@@ -1014,6 +1013,84 @@ describe("parachute logs", () => {
|
|
|
1014
1013
|
h.cleanup();
|
|
1015
1014
|
}
|
|
1016
1015
|
});
|
|
1016
|
+
|
|
1017
|
+
test("running daemon + missing log file: surfaces alive-but-no-log shape (hub#335)", async () => {
|
|
1018
|
+
// Aaron's #335 reproducer shape: parachute-app daemon was running
|
|
1019
|
+
// (curl proxied 200s, pidfile alive) but `parachute logs app` printed
|
|
1020
|
+
// `parachute start app to begin` — telling the operator to start a
|
|
1021
|
+
// service that was already up. The fix: when the log file is missing
|
|
1022
|
+
// but a live pidfile exists, surface the running pid + the path we
|
|
1023
|
+
// expected instead of the misleading start-hint.
|
|
1024
|
+
const h = makeHarness();
|
|
1025
|
+
try {
|
|
1026
|
+
seedVault(h.manifestPath);
|
|
1027
|
+
writePid("vault", 9999, h.configDir);
|
|
1028
|
+
const lines: string[] = [];
|
|
1029
|
+
const code = await logs("vault", {
|
|
1030
|
+
configDir: h.configDir,
|
|
1031
|
+
manifestPath: h.manifestPath,
|
|
1032
|
+
// pid 9999 is "alive" — simulates the running daemon case.
|
|
1033
|
+
alive: () => true,
|
|
1034
|
+
log: (l) => lines.push(l),
|
|
1035
|
+
});
|
|
1036
|
+
expect(code).toBe(0);
|
|
1037
|
+
const out = lines.join("\n");
|
|
1038
|
+
expect(out).toMatch(/vault is running \(pid 9999\)/);
|
|
1039
|
+
expect(out).toMatch(/no log file/);
|
|
1040
|
+
expect(out).not.toMatch(/parachute start vault/);
|
|
1041
|
+
} finally {
|
|
1042
|
+
h.cleanup();
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
test("stale pidfile + missing log file: falls through to start hint", async () => {
|
|
1047
|
+
// The other half of the disambiguation: pidfile exists but the process
|
|
1048
|
+
// is gone (stale pidfile, or cleanly shut down). That's effectively
|
|
1049
|
+
// "not running," so the original `parachute start` hint is still the
|
|
1050
|
+
// right message.
|
|
1051
|
+
const h = makeHarness();
|
|
1052
|
+
try {
|
|
1053
|
+
seedVault(h.manifestPath);
|
|
1054
|
+
writePid("vault", 9999, h.configDir);
|
|
1055
|
+
const lines: string[] = [];
|
|
1056
|
+
const code = await logs("vault", {
|
|
1057
|
+
configDir: h.configDir,
|
|
1058
|
+
manifestPath: h.manifestPath,
|
|
1059
|
+
// pid 9999 is "dead" — `processState` returns `stopped`.
|
|
1060
|
+
alive: () => false,
|
|
1061
|
+
log: (l) => lines.push(l),
|
|
1062
|
+
});
|
|
1063
|
+
expect(code).toBe(0);
|
|
1064
|
+
expect(lines.join("\n")).toMatch(/no logs yet for vault/);
|
|
1065
|
+
} finally {
|
|
1066
|
+
h.cleanup();
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
test("log file exists: prints tail regardless of pidfile state (hub#335)", async () => {
|
|
1071
|
+
// The happy path Aaron's title calls out: when the log file exists,
|
|
1072
|
+
// we tail it — independent of whether the pidfile is present. A
|
|
1073
|
+
// running daemon's logs are useful; a stopped daemon's prior logs are
|
|
1074
|
+
// useful too (post-mortem). Pidfile state only changes the message
|
|
1075
|
+
// when the file is missing.
|
|
1076
|
+
const h = makeHarness();
|
|
1077
|
+
try {
|
|
1078
|
+
const p = ensureLogPath("vault", h.configDir);
|
|
1079
|
+
writeFileSync(p, "vault line a\nvault line b\n");
|
|
1080
|
+
// No pidfile written — verify we still print the tail.
|
|
1081
|
+
const lines: string[] = [];
|
|
1082
|
+
const code = await logs("vault", {
|
|
1083
|
+
configDir: h.configDir,
|
|
1084
|
+
manifestPath: h.manifestPath,
|
|
1085
|
+
alive: () => false,
|
|
1086
|
+
log: (l) => lines.push(l),
|
|
1087
|
+
});
|
|
1088
|
+
expect(code).toBe(0);
|
|
1089
|
+
expect(lines).toEqual(["vault line a", "vault line b"]);
|
|
1090
|
+
} finally {
|
|
1091
|
+
h.cleanup();
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1017
1094
|
});
|
|
1018
1095
|
|
|
1019
1096
|
describe("process-group lifecycle (hub#88)", () => {
|
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
const VALID = {
|
|
13
13
|
name: "demo",
|
|
14
14
|
manifestName: "@example/demo",
|
|
15
|
-
kind: "api",
|
|
16
15
|
port: 1950,
|
|
17
16
|
paths: ["/demo"],
|
|
18
17
|
health: "/healthz",
|
|
@@ -22,7 +21,6 @@ describe("validateModuleManifest", () => {
|
|
|
22
21
|
test("accepts a minimal valid manifest", () => {
|
|
23
22
|
const m = validateModuleManifest(VALID, "test");
|
|
24
23
|
expect(m.name).toBe("demo");
|
|
25
|
-
expect(m.kind).toBe("api");
|
|
26
24
|
expect(m.port).toBe(1950);
|
|
27
25
|
expect(m.paths).toEqual(["/demo"]);
|
|
28
26
|
expect(m.health).toBe("/healthz");
|
|
@@ -35,7 +33,6 @@ describe("validateModuleManifest", () => {
|
|
|
35
33
|
|
|
36
34
|
test("rejects missing required fields", () => {
|
|
37
35
|
expect(() => validateModuleManifest({ ...VALID, name: undefined }, "x")).toThrow(/name/);
|
|
38
|
-
expect(() => validateModuleManifest({ ...VALID, kind: "weird" }, "x")).toThrow(/kind/);
|
|
39
36
|
expect(() => validateModuleManifest({ ...VALID, port: -1 }, "x")).toThrow(/port/);
|
|
40
37
|
expect(() => validateModuleManifest({ ...VALID, port: 99999 }, "x")).toThrow(/port/);
|
|
41
38
|
expect(() => validateModuleManifest({ ...VALID, paths: "not-array" }, "x")).toThrow(/paths/);
|
|
@@ -44,6 +41,25 @@ describe("validateModuleManifest", () => {
|
|
|
44
41
|
);
|
|
45
42
|
});
|
|
46
43
|
|
|
44
|
+
// hub#301 Phase C/D (#330): the `kind` field is fully retired. Hub doesn't
|
|
45
|
+
// read it anymore; module.json values are silently ignored. Validation just
|
|
46
|
+
// doesn't inspect the field — present, absent, valid string, typo, wrong
|
|
47
|
+
// type — none of it errors and none of it surfaces on the parsed manifest.
|
|
48
|
+
test("kind values in module.json are silently ignored (hub#330)", () => {
|
|
49
|
+
// No matter what the author wrote, the validator passes through without
|
|
50
|
+
// throwing and the parsed manifest exposes no `kind` field.
|
|
51
|
+
expect(() => validateModuleManifest({ ...VALID, kind: "frontend" }, "x")).not.toThrow();
|
|
52
|
+
expect(() => validateModuleManifest({ ...VALID, kind: "api" }, "x")).not.toThrow();
|
|
53
|
+
expect(() => validateModuleManifest({ ...VALID, kind: "tool" }, "x")).not.toThrow();
|
|
54
|
+
expect(() => validateModuleManifest({ ...VALID, kind: "static" }, "x")).not.toThrow();
|
|
55
|
+
expect(() => validateModuleManifest({ ...VALID, kind: 42 }, "x")).not.toThrow();
|
|
56
|
+
expect(() => validateModuleManifest({ ...VALID, kind: null }, "x")).not.toThrow();
|
|
57
|
+
// The parsed manifest type has no `kind` field at all — confirm the
|
|
58
|
+
// validator doesn't smuggle it through as a hidden property.
|
|
59
|
+
const m = validateModuleManifest({ ...VALID, kind: "frontend" }, "x");
|
|
60
|
+
expect((m as { kind?: unknown }).kind).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
47
63
|
test("rejects invalid name shape", () => {
|
|
48
64
|
expect(() => validateModuleManifest({ ...VALID, name: "Demo" }, "x")).toThrow(/name/);
|
|
49
65
|
expect(() => validateModuleManifest({ ...VALID, name: "1demo" }, "x")).toThrow(/name/);
|
|
@@ -176,7 +176,6 @@ describe("refreshWellKnown", () => {
|
|
|
176
176
|
? {
|
|
177
177
|
name: "notes",
|
|
178
178
|
manifestName: "parachute-notes",
|
|
179
|
-
kind: "frontend",
|
|
180
179
|
port: 5173,
|
|
181
180
|
paths: ["/notes"],
|
|
182
181
|
health: "/notes/health",
|
|
@@ -260,7 +259,6 @@ describe("finalizeModuleInstall", () => {
|
|
|
260
259
|
const manifest = {
|
|
261
260
|
name: "vault",
|
|
262
261
|
manifestName: "parachute-vault",
|
|
263
|
-
kind: "api" as const,
|
|
264
262
|
port: 1940,
|
|
265
263
|
paths: ["/vault/default"],
|
|
266
264
|
health: "/vault/default/health",
|
|
@@ -140,7 +140,7 @@ describe("loadDeclaredScopes", () => {
|
|
|
140
140
|
test("readModuleScopes receives installDir from services.json (closes #85 follow-up)", () => {
|
|
141
141
|
// Regression: scope-registry was looking up by services.json `name` in
|
|
142
142
|
// bun-globals. For third-party modules where name (canonical short like
|
|
143
|
-
// "
|
|
143
|
+
// "someapp") differs from the npm package name on disk ("nanoapp" for
|
|
144
144
|
// forks), that lookup fails and the module's scopes are never declared.
|
|
145
145
|
// installDir from hub#84 is the correct path source.
|
|
146
146
|
const { manifestPath, cleanup } = tmp();
|
|
@@ -150,12 +150,12 @@ describe("loadDeclaredScopes", () => {
|
|
|
150
150
|
JSON.stringify({
|
|
151
151
|
services: [
|
|
152
152
|
{
|
|
153
|
-
name: "
|
|
153
|
+
name: "someapp",
|
|
154
154
|
port: 1944,
|
|
155
|
-
paths: ["/
|
|
155
|
+
paths: ["/someapp"],
|
|
156
156
|
health: "/api/health",
|
|
157
157
|
version: "0.0.0-linked",
|
|
158
|
-
installDir: "/Users/test/ParachuteComputer/parachute-
|
|
158
|
+
installDir: "/Users/test/ParachuteComputer/parachute-someapp",
|
|
159
159
|
},
|
|
160
160
|
],
|
|
161
161
|
}),
|
|
@@ -165,15 +165,15 @@ describe("loadDeclaredScopes", () => {
|
|
|
165
165
|
manifestPath,
|
|
166
166
|
readModuleScopes: (pkg, installDir) => {
|
|
167
167
|
calls.push({ pkg, installDir });
|
|
168
|
-
return pkg === "
|
|
168
|
+
return pkg === "someapp" ? ["someapp:read", "someapp:write", "someapp:admin"] : null;
|
|
169
169
|
},
|
|
170
170
|
});
|
|
171
171
|
expect(calls).toEqual([
|
|
172
|
-
{ pkg: "
|
|
172
|
+
{ pkg: "someapp", installDir: "/Users/test/ParachuteComputer/parachute-someapp" },
|
|
173
173
|
]);
|
|
174
|
-
expect(declared.has("
|
|
175
|
-
expect(declared.has("
|
|
176
|
-
expect(declared.has("
|
|
174
|
+
expect(declared.has("someapp:read")).toBe(true);
|
|
175
|
+
expect(declared.has("someapp:write")).toBe(true);
|
|
176
|
+
expect(declared.has("someapp:admin")).toBe(true);
|
|
177
177
|
} finally {
|
|
178
178
|
cleanup();
|
|
179
179
|
}
|