@openparachute/hub 0.6.2 → 0.6.3-rc.2

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.
Files changed (58) hide show
  1. package/README.md +87 -35
  2. package/package.json +1 -1
  3. package/src/__tests__/api-hub-upgrade.test.ts +690 -0
  4. package/src/__tests__/api-modules-ops.test.ts +359 -3
  5. package/src/__tests__/api-modules.test.ts +54 -0
  6. package/src/__tests__/expose-cloudflare.test.ts +163 -72
  7. package/src/__tests__/expose-off-auto.test.ts +26 -1
  8. package/src/__tests__/expose.test.ts +260 -240
  9. package/src/__tests__/hub-control.test.ts +1 -242
  10. package/src/__tests__/hub-server.test.ts +64 -0
  11. package/src/__tests__/hub-unit.test.ts +574 -0
  12. package/src/__tests__/init.test.ts +219 -2
  13. package/src/__tests__/lifecycle.test.ts +416 -1448
  14. package/src/__tests__/managed-unit.test.ts +575 -0
  15. package/src/__tests__/migrate-cutover.test.ts +840 -0
  16. package/src/__tests__/migrate-offer.test.ts +240 -0
  17. package/src/__tests__/migrate.test.ts +132 -0
  18. package/src/__tests__/module-ops-client.test.ts +556 -0
  19. package/src/__tests__/port-probe.test.ts +23 -0
  20. package/src/__tests__/setup-wizard.test.ts +130 -0
  21. package/src/__tests__/status-supervisor.test.ts +504 -0
  22. package/src/__tests__/status.test.ts +157 -708
  23. package/src/__tests__/supervisor.test.ts +471 -6
  24. package/src/__tests__/upgrade.test.ts +351 -5
  25. package/src/api-hub-upgrade.ts +384 -0
  26. package/src/api-hub.ts +2 -1
  27. package/src/api-modules-ops.ts +221 -0
  28. package/src/api-modules.ts +18 -2
  29. package/src/cli.ts +97 -12
  30. package/src/cloudflare/connector-service.ts +117 -322
  31. package/src/commands/expose-cloudflare.ts +63 -71
  32. package/src/commands/expose-supervisor.ts +247 -0
  33. package/src/commands/expose.ts +59 -48
  34. package/src/commands/init.ts +225 -12
  35. package/src/commands/lifecycle.ts +455 -816
  36. package/src/commands/migrate-cutover.ts +837 -0
  37. package/src/commands/migrate.ts +71 -2
  38. package/src/commands/serve-boot.ts +71 -25
  39. package/src/commands/status.ts +535 -235
  40. package/src/commands/upgrade.ts +100 -2
  41. package/src/help.ts +128 -68
  42. package/src/hub-control.ts +23 -162
  43. package/src/hub-server.ts +39 -0
  44. package/src/hub-unit.ts +735 -0
  45. package/src/hub-upgrade-helper.ts +306 -0
  46. package/src/hub-upgrade-mode.ts +209 -0
  47. package/src/hub-upgrade-status.ts +150 -0
  48. package/src/managed-unit.ts +692 -0
  49. package/src/migrate-offer.ts +186 -0
  50. package/src/module-ops-client.ts +457 -0
  51. package/src/port-probe.ts +50 -0
  52. package/src/process-state.ts +19 -3
  53. package/src/setup-wizard.ts +80 -1
  54. package/src/supervisor.ts +389 -38
  55. package/web/ui/dist/assets/index-D_6AFvZy.js +61 -0
  56. package/web/ui/dist/assets/{index-BiBlvEaj.css → index-mz8XcVPP.css} +1 -1
  57. package/web/ui/dist/index.html +2 -2
  58. package/web/ui/dist/assets/index-CIN3mnmf.js +0 -61
@@ -2,8 +2,13 @@ import { describe, expect, test } from "bun:test";
2
2
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import type { LifecycleOpts } from "../commands/lifecycle.ts";
6
+ import { restart as lifecycleRestart } from "../commands/lifecycle.ts";
5
7
  import type { UpgradeRunner } from "../commands/upgrade.ts";
6
8
  import { compareVersions, defaultRunner, detectChannel, upgrade } from "../commands/upgrade.ts";
9
+ import type { HubUnitDeps, HubUnitManagerOpResult } from "../hub-unit.ts";
10
+ import { defaultHubUnitDeps } from "../hub-unit.ts";
11
+ import type { MigrateOfferResult } from "../migrate-offer.ts";
7
12
  import { upsertService } from "../services-manifest.ts";
8
13
 
9
14
  interface RunCall {
@@ -84,6 +89,16 @@ function seedVault(manifestPath: string, installDir: string, version = "0.4.0"):
84
89
  );
85
90
  }
86
91
 
92
+ /**
93
+ * Phase 5b: `upgrade hub` always restarts the hub UNIT via the platform manager
94
+ * (`restartHubUnit`) — the detached restart arm is retired. Hub-upgrade tests
95
+ * that aren't asserting the restart mechanism itself inject this benign seam so
96
+ * the manager op succeeds without a real systemd/launchd on the test host.
97
+ */
98
+ const okHubUnitSupervisor = {
99
+ restartHubUnit: (): HubUnitManagerOpResult => ({ outcome: "ok" as const, messages: [] }),
100
+ };
101
+
87
102
  describe("parachute upgrade", () => {
88
103
  test("errors cleanly when targeting a service that's not installed", async () => {
89
104
  const h = makeHarness();
@@ -587,6 +602,7 @@ describe("parachute upgrade", () => {
587
602
  let restartedShort: string | undefined;
588
603
  const logs: string[] = [];
589
604
  const code = await upgrade("hub", {
605
+ supervisor: okHubUnitSupervisor,
590
606
  manifestPath: h.manifestPath,
591
607
  configDir: h.configDir,
592
608
  runner,
@@ -599,7 +615,8 @@ describe("parachute upgrade", () => {
599
615
  log: (l) => logs.push(l),
600
616
  });
601
617
  expect(code).toBe(0);
602
- expect(restartedShort).toBe("hub");
618
+ // Phase 5b: the hub restarts via the platform manager (okHubUnitSupervisor),
619
+ // not the detached restartFn — the unit-restart path has its own test.
603
620
  const addCall = seenCmd.find((c) => c[0] === "bun" && c[1] === "add");
604
621
  expect(addCall).toEqual(["bun", "add", "-g", "@openparachute/hub@latest"]);
605
622
  const joined = logs.join("\n");
@@ -636,6 +653,7 @@ describe("parachute upgrade", () => {
636
653
 
637
654
  let restartedShort: string | undefined;
638
655
  const code = await upgrade("hub", {
656
+ supervisor: okHubUnitSupervisor,
639
657
  manifestPath: h.manifestPath, // file doesn't exist
640
658
  configDir: h.configDir,
641
659
  runner,
@@ -648,7 +666,8 @@ describe("parachute upgrade", () => {
648
666
  log: () => {},
649
667
  });
650
668
  expect(code).toBe(0);
651
- expect(restartedShort).toBe("hub");
669
+ // Phase 5b: the hub restarts via the platform manager (okHubUnitSupervisor),
670
+ // not the detached restartFn — the unit-restart path has its own test.
652
671
  } finally {
653
672
  h.cleanup();
654
673
  }
@@ -683,6 +702,7 @@ describe("parachute upgrade", () => {
683
702
  let restartedShort: string | undefined;
684
703
  const logs: string[] = [];
685
704
  const code = await upgrade("hub", {
705
+ supervisor: okHubUnitSupervisor,
686
706
  manifestPath: h.manifestPath,
687
707
  configDir: h.configDir,
688
708
  runner,
@@ -695,7 +715,8 @@ describe("parachute upgrade", () => {
695
715
  log: (l) => logs.push(l),
696
716
  });
697
717
  expect(code).toBe(0);
698
- expect(restartedShort).toBe("hub");
718
+ // Phase 5b: the hub restarts via the platform manager (okHubUnitSupervisor),
719
+ // not the detached restartFn — the unit-restart path has its own test.
699
720
  expect(logs.join("\n")).toMatch(/hub: bun-linked checkout/);
700
721
  } finally {
701
722
  h.cleanup();
@@ -723,6 +744,7 @@ describe("parachute upgrade", () => {
723
744
  };
724
745
 
725
746
  await upgrade("hub", {
747
+ supervisor: okHubUnitSupervisor,
726
748
  manifestPath: h.manifestPath,
727
749
  configDir: h.configDir,
728
750
  runner,
@@ -771,6 +793,15 @@ describe("parachute upgrade", () => {
771
793
 
772
794
  const restartCalls: string[] = [];
773
795
  const code = await upgrade(undefined, {
796
+ // Phase 5b: the hub restarts via the platform manager (restartHubUnit),
797
+ // modules via restartFn. Record both into one order list so the hub-first
798
+ // invariant is still asserted.
799
+ supervisor: {
800
+ restartHubUnit: (): HubUnitManagerOpResult => {
801
+ restartCalls.push("hub");
802
+ return { outcome: "ok", messages: [] };
803
+ },
804
+ },
774
805
  manifestPath: h.manifestPath,
775
806
  configDir: h.configDir,
776
807
  runner,
@@ -786,7 +817,8 @@ describe("parachute upgrade", () => {
786
817
  log: () => {},
787
818
  });
788
819
  expect(code).toBe(0);
789
- // Hub goes first so its dispatcher upgrade isn't preempted.
820
+ // Hub goes first (manager restart) so its dispatcher upgrade isn't
821
+ // preempted, then the module restarts route through lifecycle.
790
822
  expect(restartCalls).toEqual(["hub", "vault"]);
791
823
  } finally {
792
824
  h.cleanup();
@@ -841,6 +873,7 @@ describe("parachute upgrade", () => {
841
873
  const restartCalls: string[] = [];
842
874
  const logs: string[] = [];
843
875
  const code = await upgrade(undefined, {
876
+ supervisor: okHubUnitSupervisor,
844
877
  manifestPath: h.manifestPath,
845
878
  configDir: h.configDir,
846
879
  runner,
@@ -920,6 +953,7 @@ describe("parachute upgrade", () => {
920
953
  };
921
954
 
922
955
  const code = await upgrade("hub", {
956
+ supervisor: okHubUnitSupervisor,
923
957
  manifestPath: h.manifestPath,
924
958
  configDir: h.configDir,
925
959
  runner,
@@ -961,6 +995,7 @@ describe("parachute upgrade", () => {
961
995
  };
962
996
 
963
997
  await upgrade("hub", {
998
+ supervisor: okHubUnitSupervisor,
964
999
  manifestPath: h.manifestPath,
965
1000
  configDir: h.configDir,
966
1001
  runner,
@@ -1003,6 +1038,7 @@ describe("parachute upgrade", () => {
1003
1038
  };
1004
1039
 
1005
1040
  await upgrade("hub", {
1041
+ supervisor: okHubUnitSupervisor,
1006
1042
  manifestPath: h.manifestPath,
1007
1043
  configDir: h.configDir,
1008
1044
  runner,
@@ -1045,6 +1081,7 @@ describe("parachute upgrade", () => {
1045
1081
  const logs: string[] = [];
1046
1082
  let restartCalled = false;
1047
1083
  const code = await upgrade("hub", {
1084
+ supervisor: okHubUnitSupervisor,
1048
1085
  manifestPath: h.manifestPath,
1049
1086
  configDir: h.configDir,
1050
1087
  runner,
@@ -1096,6 +1133,7 @@ describe("parachute upgrade", () => {
1096
1133
  };
1097
1134
 
1098
1135
  const code = await upgrade("hub", {
1136
+ supervisor: okHubUnitSupervisor,
1099
1137
  manifestPath: h.manifestPath,
1100
1138
  configDir: h.configDir,
1101
1139
  runner,
@@ -1153,6 +1191,7 @@ describe("parachute upgrade", () => {
1153
1191
  const logs: string[] = [];
1154
1192
  let restartedShort: string | undefined;
1155
1193
  const code = await upgrade("hub", {
1194
+ supervisor: okHubUnitSupervisor,
1156
1195
  manifestPath: h.manifestPath,
1157
1196
  configDir: h.configDir,
1158
1197
  runner,
@@ -1168,7 +1207,8 @@ describe("parachute upgrade", () => {
1168
1207
  log: (l) => logs.push(l),
1169
1208
  });
1170
1209
  expect(code).toBe(0);
1171
- expect(restartedShort).toBe("hub");
1210
+ // Phase 5b: the hub restarts via the platform manager (okHubUnitSupervisor),
1211
+ // not the detached restartFn — the unit-restart path has its own test.
1172
1212
  const addCall = seenCmd.find((c) => c[0] === "bun" && c[1] === "add");
1173
1213
  expect(addCall).toEqual(["bun", "add", "-g", "@openparachute/hub@rc"]);
1174
1214
  const joined = logs.join("\n");
@@ -1200,6 +1240,7 @@ describe("parachute upgrade", () => {
1200
1240
  };
1201
1241
 
1202
1242
  await upgrade("hub", {
1243
+ supervisor: okHubUnitSupervisor,
1203
1244
  manifestPath: h.manifestPath,
1204
1245
  configDir: h.configDir,
1205
1246
  runner,
@@ -1218,3 +1259,308 @@ describe("parachute upgrade", () => {
1218
1259
  }
1219
1260
  });
1220
1261
  });
1262
+
1263
+ // ---------------------------------------------------------------------------
1264
+ // Phase 4 dual-dispatch (design §5): when a hub UNIT is installed, `upgrade hub`
1265
+ // rewrites the binary as usual then RESTARTS THE UNIT via the platform manager
1266
+ // (`restartHubUnit`), NOT the detached `restartFn` (stopHub/ensureHubRunning).
1267
+ // The manager tears down the old hub (children die) and starts the new binary,
1268
+ // which re-boots every module. No-unit → the unchanged detached restart.
1269
+ // ---------------------------------------------------------------------------
1270
+
1271
+ describe("Phase 4 upgrade-hub dual-dispatch", () => {
1272
+ test("upgrade hub unit-managed → binary rewrite + restartHubUnit (manager), NOT detached restartFn", async () => {
1273
+ const h = makeHarness();
1274
+ try {
1275
+ const hubInstallDir = join(h.installRoot, "hub");
1276
+ writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.6.3-rc.1" });
1277
+ const seenCmd: string[][] = [];
1278
+ const runner: UpgradeRunner = {
1279
+ async run(cmd) {
1280
+ seenCmd.push([...cmd]);
1281
+ if (cmd[0] === "bun" && cmd[1] === "add" && cmd[2] === "-g") {
1282
+ // Channel auto-detected as @rc from the installed -rc version.
1283
+ writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.6.3-rc.2" });
1284
+ }
1285
+ return 0;
1286
+ },
1287
+ async capture(cmd) {
1288
+ // Not a git checkout → npm-install branch.
1289
+ if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
1290
+ return { code: 128, stdout: "fatal: not a git repository\n" };
1291
+ }
1292
+ return { code: 0, stdout: "" };
1293
+ },
1294
+ };
1295
+
1296
+ let restartFnCalled = false;
1297
+ let restartHubUnitCalls = 0;
1298
+ const logs: string[] = [];
1299
+ const code = await upgrade("hub", {
1300
+ manifestPath: h.manifestPath,
1301
+ configDir: h.configDir,
1302
+ runner,
1303
+ findGlobalInstall: (pkg) =>
1304
+ pkg === "@openparachute/hub" ? join(hubInstallDir, "package.json") : null,
1305
+ // The detached restart path must NOT be taken on the unit arm.
1306
+ restartFn: async () => {
1307
+ restartFnCalled = true;
1308
+ return 0;
1309
+ },
1310
+ // Avoid a real registry round-trip in the downgrade guard.
1311
+ resolveChannelVersion: async () => null,
1312
+ supervisor: {
1313
+ restartHubUnit: (_deps: HubUnitDeps): HubUnitManagerOpResult => {
1314
+ restartHubUnitCalls++;
1315
+ return { outcome: "ok", messages: [] };
1316
+ },
1317
+ },
1318
+ log: (l) => logs.push(l),
1319
+ });
1320
+ expect(code).toBe(0);
1321
+ // The binary was rewritten via bun add -g @rc…
1322
+ const addCall = seenCmd.find((c) => c[0] === "bun" && c[1] === "add");
1323
+ expect(addCall).toEqual(["bun", "add", "-g", "@openparachute/hub@rc"]);
1324
+ // …and the restart went through the platform manager, not the detached PID path.
1325
+ expect(restartHubUnitCalls).toBe(1);
1326
+ expect(restartFnCalled).toBe(false);
1327
+ expect(logs.join("\n")).toMatch(/restarted the hub unit/);
1328
+ } finally {
1329
+ h.cleanup();
1330
+ }
1331
+ });
1332
+
1333
+ test("upgrade hub NO unit → restartHubUnit reports no-unit, surfaced as a failure", async () => {
1334
+ // Phase 5b: the detached restart arm is retired. `upgrade hub` always
1335
+ // restarts the hub UNIT via the platform manager; on a box with no unit the
1336
+ // manager op returns `no-unit` (after the binary rewrite), which the verb
1337
+ // surfaces as a non-zero exit with the manager's message — never a detached
1338
+ // spawn.
1339
+ const h = makeHarness();
1340
+ try {
1341
+ const hubInstallDir = join(h.installRoot, "hub");
1342
+ writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.5.8" });
1343
+ const runner: UpgradeRunner = {
1344
+ async run(cmd) {
1345
+ if (cmd[0] === "bun" && cmd[1] === "add" && cmd[2] === "-g") {
1346
+ writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.5.9" });
1347
+ }
1348
+ return 0;
1349
+ },
1350
+ async capture(cmd) {
1351
+ if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
1352
+ return { code: 128, stdout: "" };
1353
+ }
1354
+ return { code: 0, stdout: "" };
1355
+ },
1356
+ };
1357
+
1358
+ const logs: string[] = [];
1359
+ const code = await upgrade("hub", {
1360
+ manifestPath: h.manifestPath,
1361
+ configDir: h.configDir,
1362
+ runner,
1363
+ findGlobalInstall: (pkg) =>
1364
+ pkg === "@openparachute/hub" ? join(hubInstallDir, "package.json") : null,
1365
+ resolveChannelVersion: async () => null,
1366
+ supervisor: {
1367
+ restartHubUnit: (_deps: HubUnitDeps): HubUnitManagerOpResult => ({
1368
+ outcome: "no-unit",
1369
+ messages: ["no hub unit installed — run `parachute migrate` to install it"],
1370
+ }),
1371
+ },
1372
+ log: (l) => logs.push(l),
1373
+ });
1374
+ expect(code).toBe(1);
1375
+ expect(logs.join("\n")).toMatch(/no hub unit installed/);
1376
+ } finally {
1377
+ h.cleanup();
1378
+ }
1379
+ });
1380
+
1381
+ test("sweep stays hub-first; hub restart uses the manager, module restarts route through lifecycle", async () => {
1382
+ const h = makeHarness();
1383
+ try {
1384
+ // Hub install dir + a vault module in services.json — sweep upgrades both.
1385
+ const hubInstallDir = join(h.installRoot, "hub");
1386
+ writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.6.3-rc.1" });
1387
+ const vaultInstallDir = join(h.installRoot, "vault");
1388
+ writePackageJson(vaultInstallDir, { name: "@openparachute/vault", version: "0.6.3-rc.1" });
1389
+ seedVault(h.manifestPath, vaultInstallDir, "0.6.3-rc.1");
1390
+
1391
+ const runner: UpgradeRunner = {
1392
+ async run(cmd) {
1393
+ if (cmd[0] === "bun" && cmd[1] === "add" && cmd[2] === "-g") {
1394
+ const spec = cmd[3] ?? "";
1395
+ if (spec.startsWith("@openparachute/hub")) {
1396
+ writePackageJson(hubInstallDir, {
1397
+ name: "@openparachute/hub",
1398
+ version: "0.6.3-rc.2",
1399
+ });
1400
+ } else if (spec.startsWith("@openparachute/vault")) {
1401
+ writePackageJson(vaultInstallDir, {
1402
+ name: "@openparachute/vault",
1403
+ version: "0.6.3-rc.2",
1404
+ });
1405
+ }
1406
+ }
1407
+ return 0;
1408
+ },
1409
+ async capture(cmd) {
1410
+ if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
1411
+ return { code: 128, stdout: "" };
1412
+ }
1413
+ return { code: 0, stdout: "" };
1414
+ },
1415
+ };
1416
+
1417
+ const restartOrder: string[] = [];
1418
+ let restartHubUnitCalls = 0;
1419
+ const code = await upgrade(undefined, {
1420
+ manifestPath: h.manifestPath,
1421
+ configDir: h.configDir,
1422
+ runner,
1423
+ findGlobalInstall: (pkg) => {
1424
+ if (pkg === "@openparachute/hub") return join(hubInstallDir, "package.json");
1425
+ if (pkg === "@openparachute/vault") return join(vaultInstallDir, "package.json");
1426
+ return null;
1427
+ },
1428
+ // Module restarts (vault) go through lifecycle's restart with a
1429
+ // supervisor block; the stub records the order so we can assert hub-first.
1430
+ restartFn: async (svc) => {
1431
+ restartOrder.push(svc);
1432
+ return 0;
1433
+ },
1434
+ resolveChannelVersion: async () => null,
1435
+ supervisor: {
1436
+ restartHubUnit: (_deps: HubUnitDeps): HubUnitManagerOpResult => {
1437
+ restartHubUnitCalls++;
1438
+ restartOrder.push("hub-unit");
1439
+ return { outcome: "ok", messages: [] };
1440
+ },
1441
+ },
1442
+ log: () => {},
1443
+ });
1444
+ expect(code).toBe(0);
1445
+ // Hub-first: the hub unit restart precedes the vault module restart.
1446
+ expect(restartOrder[0]).toBe("hub-unit");
1447
+ expect(restartOrder).toContain("vault");
1448
+ expect(restartOrder.indexOf("hub-unit")).toBeLessThan(restartOrder.indexOf("vault"));
1449
+ expect(restartHubUnitCalls).toBe(1);
1450
+ } finally {
1451
+ h.cleanup();
1452
+ }
1453
+ });
1454
+ });
1455
+
1456
+ // ---------------------------------------------------------------------------
1457
+ // Phase 5b nit: the module-target restart leg of `upgrade` must thread the SAME
1458
+ // opts a bare `parachute restart <svc>` does — `supervisor: {}` (real
1459
+ // `isHubUnitInstalled` probe, NOT a forced `unitInstalled: true`) +
1460
+ // `migrateOffer: { enabled: true }`. On a NO-UNIT box that means the module
1461
+ // restart surfaces the actionable "run `parachute migrate --to-supervised`"
1462
+ // error / fires the auto-offer, NOT a bare connection-refused from
1463
+ // `driveModuleOp`. The other upgrade tests stub `restartFn: async () => 0`, so
1464
+ // the supervised arm was never exercised here — this closes that gap by driving
1465
+ // the REAL `lifecycle.restart`.
1466
+ // ---------------------------------------------------------------------------
1467
+
1468
+ describe("Phase 5b: upgrade module-restart on a no-unit box", () => {
1469
+ test("module restart threads supervisor:{} + migrateOffer (no forced unitInstalled), surfaces actionable migrate error — NOT a bare connection-refused", async () => {
1470
+ const h = makeHarness();
1471
+ try {
1472
+ const installDir = join(h.installRoot, "vault");
1473
+ writePackageJson(installDir, { name: "@openparachute/vault", version: "0.4.0" });
1474
+ seedVault(h.manifestPath, installDir, "0.4.0");
1475
+
1476
+ const runner: UpgradeRunner = {
1477
+ async run(cmd) {
1478
+ // `bun add -g` rewrites the package.json with a new version → the
1479
+ // module restart leg (`restartTarget`) is reached.
1480
+ if (cmd[0] === "bun" && cmd[1] === "add" && cmd[2] === "-g") {
1481
+ writePackageJson(installDir, { name: "@openparachute/vault", version: "0.5.0" });
1482
+ }
1483
+ return 0;
1484
+ },
1485
+ async capture(cmd) {
1486
+ // Not a git checkout → npm-install branch.
1487
+ if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
1488
+ return { code: 128, stdout: "fatal: not a git repository\n" };
1489
+ }
1490
+ return { code: 0, stdout: "" };
1491
+ },
1492
+ };
1493
+
1494
+ // A NO-UNIT box: force `isHubUnitInstalled` → false deterministically
1495
+ // (regardless of the test host) by stubbing the unit-file existence probe.
1496
+ const noUnitDeps: HubUnitDeps = { ...defaultHubUnitDeps, exists: () => false };
1497
+
1498
+ // The supervised arm's loopback module-ops call MUST NOT run on a no-unit
1499
+ // box. If the regressed code forced `unitInstalled: true`, the verb would
1500
+ // skip the probe and hit this — surfacing a bare connection-refused.
1501
+ let driveModuleOpCalled = false;
1502
+ const refuse = async (): Promise<never> => {
1503
+ driveModuleOpCalled = true;
1504
+ throw new Error("connect ECONNREFUSED 127.0.0.1:1939");
1505
+ };
1506
+
1507
+ // Capture the opts `restartTarget` hands `lifecycle.restart`, then delegate
1508
+ // to the REAL `lifecycle.restart` so the no-unit handling actually runs.
1509
+ // The offer is overridden to a deterministic `declined` so the outcome
1510
+ // doesn't depend on the host's stdin-TTY / prior-detached state.
1511
+ let seenOpts: LifecycleOpts | undefined;
1512
+ let offerCalls = 0;
1513
+ const restartFn = async (svc: string, opts: LifecycleOpts): Promise<number> => {
1514
+ seenOpts = opts;
1515
+ return await lifecycleRestart(svc, {
1516
+ ...opts,
1517
+ supervisor: {
1518
+ ...opts.supervisor,
1519
+ hubUnitDeps: noUnitDeps,
1520
+ driveModuleOp: refuse,
1521
+ },
1522
+ migrateOffer: {
1523
+ enabled: opts.migrateOffer?.enabled ?? false,
1524
+ offer: async (): Promise<MigrateOfferResult> => {
1525
+ offerCalls++;
1526
+ return { outcome: "declined" };
1527
+ },
1528
+ },
1529
+ });
1530
+ };
1531
+
1532
+ const logs: string[] = [];
1533
+ const code = await upgrade("vault", {
1534
+ manifestPath: h.manifestPath,
1535
+ configDir: h.configDir,
1536
+ runner,
1537
+ findGlobalInstall: () => join(installDir, "package.json"),
1538
+ restartFn,
1539
+ // Upgrade-side supervisor seam: feeds `r.hubUnitDeps`, which
1540
+ // `restartTarget` threads into `lifecycle.restart`'s supervisor block.
1541
+ supervisor: { hubUnitDeps: noUnitDeps },
1542
+ log: (l) => logs.push(l),
1543
+ });
1544
+
1545
+ // The package rewrite + restart attempt happened (non-zero because the box
1546
+ // is un-migrated and the offer was declined — a clean, actionable failure).
1547
+ expect(code).toBe(1);
1548
+
1549
+ // The contract fix: `restartTarget` passes `supervisor` WITHOUT forcing
1550
+ // `unitInstalled: true`, plus an ENABLED migrate offer — exactly what a
1551
+ // bare `parachute restart <svc>` threads.
1552
+ expect(seenOpts).toBeDefined();
1553
+ expect(seenOpts?.supervisor).toBeDefined();
1554
+ expect(seenOpts?.supervisor?.unitInstalled).toBeUndefined();
1555
+ expect(seenOpts?.migrateOffer?.enabled).toBe(true);
1556
+
1557
+ // The no-unit handling ran: the auto-offer fired (real probe → no unit),
1558
+ // and the supervised loopback call was NEVER reached (no connection-refused).
1559
+ expect(offerCalls).toBe(1);
1560
+ expect(driveModuleOpCalled).toBe(false);
1561
+ expect(logs.join("\n")).not.toMatch(/ECONNREFUSED|connection refused/i);
1562
+ } finally {
1563
+ h.cleanup();
1564
+ }
1565
+ });
1566
+ });