@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.
@@ -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: parachute-agent ships `name: "agent",
1422
- // manifestName: "parachute-agent"`. Install used to seed services.json
1423
- // under `parachute-agent` (the npm label) while lifecycle looks up by
1424
- // `agent` (the canonical short) → "unknown service". Fix: services.json
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-agent", {
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: "agent",
1443
- manifestName: "parachute-agent",
1444
- kind: "api",
1678
+ name: "someapp",
1679
+ manifestName: "parachute-someapp",
1445
1680
  port: 1945,
1446
- paths: ["/agent"],
1447
- health: "/agent/health",
1681
+ paths: ["/someapp"],
1682
+ health: "/someapp/health",
1448
1683
  startCmd: ["bun", "server.ts"],
1449
1684
  }),
1450
- findGlobalInstall: () => "/fake/prefix/parachute-agent/package.json",
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("agent", path)?.name).toBe("agent");
1455
- expect(findService("parachute-agent", path)).toBeUndefined();
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(["agent"]);
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 agent/);
1463
- expect(joined).toMatch(/agent registered on port \d+/);
1464
- expect(joined).not.toMatch(/Seeded services\.json entry for parachute-agent/);
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-agent");
502
- seedThirdParty(h.manifestPath, h.configDir, "agent", {
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("agent", {
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("agent", h.configDir)).toBe(8080);
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-agent");
584
- seedThirdParty(h.manifestPath, h.configDir, "agent", {
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-agent");
968
- seedThirdParty(h.manifestPath, h.configDir, "agent", {
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("agent", h.configDir);
973
- writeFileSync(p, "agent line 1\nagent line 2\n");
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("agent", {
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(["agent line 1", "agent line 2"]);
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
- // "agent") differs from the npm package name on disk ("nanoagent" for
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: "agent",
153
+ name: "someapp",
154
154
  port: 1944,
155
- paths: ["/agent"],
155
+ paths: ["/someapp"],
156
156
  health: "/api/health",
157
157
  version: "0.0.0-linked",
158
- installDir: "/Users/test/ParachuteComputer/parachute-agent",
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 === "agent" ? ["agent:read", "agent:write", "agent:admin"] : null;
168
+ return pkg === "someapp" ? ["someapp:read", "someapp:write", "someapp:admin"] : null;
169
169
  },
170
170
  });
171
171
  expect(calls).toEqual([
172
- { pkg: "agent", installDir: "/Users/test/ParachuteComputer/parachute-agent" },
172
+ { pkg: "someapp", installDir: "/Users/test/ParachuteComputer/parachute-someapp" },
173
173
  ]);
174
- expect(declared.has("agent:read")).toBe(true);
175
- expect(declared.has("agent:write")).toBe(true);
176
- expect(declared.has("agent:admin")).toBe(true);
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
  }