@openparachute/hub 0.6.3 → 0.6.4-rc.10

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 (97) hide show
  1. package/package.json +1 -2
  2. package/src/__tests__/account-home-ui.test.ts +344 -110
  3. package/src/__tests__/account-mirror.test.ts +156 -0
  4. package/src/__tests__/account-setup.test.ts +880 -0
  5. package/src/__tests__/account-usage.test.ts +137 -0
  6. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  7. package/src/__tests__/account-vault-token.test.ts +53 -1
  8. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  9. package/src/__tests__/admin-vaults.test.ts +20 -0
  10. package/src/__tests__/api-account.test.ts +236 -4
  11. package/src/__tests__/api-invites.test.ts +217 -0
  12. package/src/__tests__/api-mint-token.test.ts +259 -10
  13. package/src/__tests__/api-modules-ops.test.ts +195 -3
  14. package/src/__tests__/api-modules.test.ts +40 -4
  15. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  16. package/src/__tests__/auto-wire.test.ts +101 -1
  17. package/src/__tests__/cli.test.ts +188 -2
  18. package/src/__tests__/cloudflare-state.test.ts +104 -0
  19. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  20. package/src/__tests__/expose-cloudflare.test.ts +135 -9
  21. package/src/__tests__/expose-interactive.test.ts +234 -7
  22. package/src/__tests__/expose-supervisor-version.test.ts +104 -0
  23. package/src/__tests__/expose.test.ts +10 -5
  24. package/src/__tests__/grants.test.ts +197 -8
  25. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  26. package/src/__tests__/hub-server.test.ts +761 -13
  27. package/src/__tests__/hub-unit.test.ts +185 -0
  28. package/src/__tests__/init.test.ts +579 -3
  29. package/src/__tests__/install.test.ts +448 -2
  30. package/src/__tests__/invites.test.ts +220 -0
  31. package/src/__tests__/launchctl-guard.test.ts +185 -0
  32. package/src/__tests__/migrate-cutover.test.ts +33 -0
  33. package/src/__tests__/module-ops-client.test.ts +68 -0
  34. package/src/__tests__/scope-explanations.test.ts +16 -0
  35. package/src/__tests__/serve-boot.test.ts +74 -1
  36. package/src/__tests__/serve.test.ts +121 -7
  37. package/src/__tests__/setup-wizard.test.ts +110 -0
  38. package/src/__tests__/spawn-path.test.ts +191 -0
  39. package/src/__tests__/status.test.ts +64 -0
  40. package/src/__tests__/supervisor.test.ts +374 -0
  41. package/src/__tests__/users.test.ts +66 -0
  42. package/src/__tests__/well-known.test.ts +25 -0
  43. package/src/__tests__/wizard.test.ts +72 -1
  44. package/src/account-home-ui.ts +481 -235
  45. package/src/account-mirror.ts +126 -0
  46. package/src/account-setup.ts +381 -0
  47. package/src/account-usage.ts +118 -0
  48. package/src/account-vault-admin-token.ts +242 -0
  49. package/src/account-vault-token.ts +36 -2
  50. package/src/admin-login-ui.ts +121 -0
  51. package/src/admin-vault-admin-token.ts +8 -2
  52. package/src/admin-vaults.ts +137 -29
  53. package/src/api-account.ts +118 -1
  54. package/src/api-invites.ts +345 -0
  55. package/src/api-mint-token.ts +81 -0
  56. package/src/api-modules-ops.ts +168 -53
  57. package/src/api-modules.ts +36 -0
  58. package/src/auto-wire.ts +87 -0
  59. package/src/cli.ts +128 -34
  60. package/src/cloudflare/detect.ts +1 -1
  61. package/src/cloudflare/state.ts +104 -8
  62. package/src/commands/expose-2fa-warning.ts +17 -13
  63. package/src/commands/expose-cloudflare.ts +103 -36
  64. package/src/commands/expose-interactive.ts +163 -17
  65. package/src/commands/expose-supervisor.ts +45 -0
  66. package/src/commands/init.ts +183 -4
  67. package/src/commands/install.ts +321 -3
  68. package/src/commands/migrate-cutover.ts +12 -5
  69. package/src/commands/serve-boot.ts +33 -3
  70. package/src/commands/serve.ts +158 -37
  71. package/src/commands/status.ts +9 -1
  72. package/src/commands/wizard.ts +36 -2
  73. package/src/grants.ts +113 -0
  74. package/src/help.ts +18 -5
  75. package/src/hub-db.ts +70 -2
  76. package/src/hub-server.ts +438 -41
  77. package/src/hub-settings.ts +3 -3
  78. package/src/hub-unit.ts +259 -9
  79. package/src/invites.ts +291 -0
  80. package/src/launchctl-guard.ts +131 -0
  81. package/src/managed-unit.ts +13 -3
  82. package/src/migrate-offer.ts +15 -6
  83. package/src/module-ops-client.ts +47 -22
  84. package/src/scope-attenuation.ts +19 -0
  85. package/src/scope-explanations.ts +9 -1
  86. package/src/service-spec.ts +17 -4
  87. package/src/setup-wizard.ts +34 -2
  88. package/src/spawn-path.ts +148 -0
  89. package/src/supervisor.ts +232 -7
  90. package/src/users.ts +54 -8
  91. package/src/vault-hub-origin-env.ts +28 -0
  92. package/src/vault-name.ts +13 -1
  93. package/src/well-known.ts +13 -0
  94. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  95. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  96. package/web/ui/dist/index.html +2 -2
  97. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { install } from "../commands/install.ts";
5
+ import { defaultStartLifecycleOpts, install } from "../commands/install.ts";
6
6
  import { findService, upsertService } from "../services-manifest.ts";
7
7
 
8
8
  function makeTempPath(): { path: string; configDir: string; cleanup: () => void } {
@@ -40,6 +40,9 @@ describe("install", () => {
40
40
  const calls: string[][] = [];
41
41
  const logs: string[] = [];
42
42
  const code = await install("vault", {
43
+ // --interactive: opt back into vault's full `init` (#579). The light
44
+ // default skips it; this test exercises the interactive path.
45
+ interactive: true,
43
46
  runner: async (cmd) => {
44
47
  calls.push([...cmd]);
45
48
  return 0;
@@ -67,6 +70,8 @@ describe("install", () => {
67
70
  try {
68
71
  const logs: string[] = [];
69
72
  const code = await install("vault", {
73
+ // --interactive: this test asserts init wrote the authoritative entry.
74
+ interactive: true,
70
75
  runner: async (cmd) => {
71
76
  if (cmd[0] === "parachute-vault") {
72
77
  upsertService(
@@ -133,6 +138,8 @@ describe("install", () => {
133
138
  const calls: string[][] = [];
134
139
  const logs: string[] = [];
135
140
  const code = await install("vault", {
141
+ // --interactive: this test asserts init still ran after the bun quirk.
142
+ interactive: true,
136
143
  runner: async (cmd) => {
137
144
  calls.push([...cmd]);
138
145
  // `bun add -g` exits 1; `parachute-vault init` succeeds.
@@ -334,6 +341,96 @@ describe("install", () => {
334
341
  }
335
342
  });
336
343
 
344
+ test("names the squatter holding the canonical port when the walk assigns a fallback (#590)", async () => {
345
+ // Field bug #590 item 2: a stale pre-supervisor vault zombie squats 1940;
346
+ // the install-time port walk silently routed to a fallback. Now it names the
347
+ // holder (pid + command line) + hints it may be a stale daemon. Detection
348
+ // only — never kills. Reuses the #581 pidOnPort / ownerOfPid seams.
349
+ const { path, configDir, cleanup } = makeTempPath();
350
+ try {
351
+ const logs: string[] = [];
352
+ const code = await install("vault", {
353
+ runner: async () => 0,
354
+ manifestPath: path,
355
+ configDir,
356
+ startService: async () => 0,
357
+ isLinked: () => false,
358
+ // Only vault's canonical 1940 is held → the walk picks a fallback in-range.
359
+ portProbe: async (p) => p === 1940,
360
+ // Inject the #581 seams: a foreign pid squats 1940.
361
+ pidOnPort: (p) => (p === 1940 ? 1234 : undefined),
362
+ ownerOfPid: (pid) => (pid === 1234 ? "bun /opt/vault/src/server.ts" : undefined),
363
+ log: (l) => logs.push(l),
364
+ });
365
+ expect(code).toBe(0);
366
+ const joined = logs.join("\n");
367
+ // The fallback warning still fires…
368
+ expect(joined).toMatch(/canonical port 1940 is in use; assigned/);
369
+ // …and now it NAMES the squatter + hints at a stale daemon.
370
+ expect(joined).toContain("pid 1234 (bun /opt/vault/src/server.ts)");
371
+ expect(joined).toMatch(/stale pre-supervisor daemon/);
372
+ expect(joined).toContain("kill 1234");
373
+ const entry = findService("parachute-vault", path);
374
+ expect(entry?.port).not.toBe(1940);
375
+ } finally {
376
+ cleanup();
377
+ }
378
+ });
379
+
380
+ test("squatter pid present but command line unreadable → names the pid alone (#590)", async () => {
381
+ const { path, configDir, cleanup } = makeTempPath();
382
+ try {
383
+ const logs: string[] = [];
384
+ const code = await install("vault", {
385
+ runner: async () => 0,
386
+ manifestPath: path,
387
+ configDir,
388
+ startService: async () => 0,
389
+ isLinked: () => false,
390
+ portProbe: async (p) => p === 1940,
391
+ pidOnPort: (p) => (p === 1940 ? 4321 : undefined),
392
+ ownerOfPid: () => undefined, // ps failed / pid gone
393
+ log: (l) => logs.push(l),
394
+ });
395
+ expect(code).toBe(0);
396
+ const joined = logs.join("\n");
397
+ expect(joined).toContain("held by pid 4321.");
398
+ expect(joined).not.toContain("(undefined)");
399
+ } finally {
400
+ cleanup();
401
+ }
402
+ });
403
+
404
+ test("no squatter naming when the canonical port is free (#590 — no false positive)", async () => {
405
+ const { path, configDir, cleanup } = makeTempPath();
406
+ try {
407
+ const logs: string[] = [];
408
+ let pidProbed = false;
409
+ const code = await install("vault", {
410
+ runner: async () => 0,
411
+ manifestPath: path,
412
+ configDir,
413
+ startService: async () => 0,
414
+ isLinked: () => false,
415
+ portProbe: async () => false, // canonical 1940 is free
416
+ pidOnPort: () => {
417
+ pidProbed = true;
418
+ return 9999;
419
+ },
420
+ ownerOfPid: () => "should-not-appear",
421
+ log: (l) => logs.push(l),
422
+ });
423
+ expect(code).toBe(0);
424
+ const joined = logs.join("\n");
425
+ // Canonical assigned → no fallback warning, no squatter probe at all.
426
+ expect(joined).not.toMatch(/is in use; assigned/);
427
+ expect(joined).not.toContain("should-not-appear");
428
+ expect(pidProbed).toBe(false);
429
+ } finally {
430
+ cleanup();
431
+ }
432
+ });
433
+
337
434
  test("`install lens` aliases to notes with a rename notice", async () => {
338
435
  // Transition alias for the brief Notes→Lens rename (Apr 19) that was
339
436
  // reverted on launch eve (Apr 22). Accepted for one release cycle so
@@ -796,12 +893,14 @@ describe("install", () => {
796
893
  }
797
894
  });
798
895
 
799
- test("linked vault still runs init and defers to init's manifest write", async () => {
896
+ test("linked vault still runs init and defers to init's manifest write (--interactive)", async () => {
800
897
  const { path, cleanup } = makeTempPath();
801
898
  try {
802
899
  const calls: string[][] = [];
803
900
  const logs: string[] = [];
804
901
  const code = await install("vault", {
902
+ // --interactive: this test asserts vault's own init wrote the entry.
903
+ interactive: true,
805
904
  runner: async (cmd) => {
806
905
  calls.push([...cmd]);
807
906
  if (cmd[0] === "parachute-vault") {
@@ -1721,3 +1820,350 @@ describe("install", () => {
1721
1820
  }
1722
1821
  });
1723
1822
  });
1823
+
1824
+ describe("#579 / #580 item 1 — light manual install + guidance", () => {
1825
+ test("default vault install skips the interactive init (no parachute-vault init runs)", async () => {
1826
+ const { path, cleanup } = makeTempPath();
1827
+ try {
1828
+ const calls: string[][] = [];
1829
+ const logs: string[] = [];
1830
+ const code = await install("vault", {
1831
+ // No `interactive` flag → the light default path.
1832
+ runner: async (cmd) => {
1833
+ calls.push([...cmd]);
1834
+ return 0;
1835
+ },
1836
+ manifestPath: path,
1837
+ startService: async () => 0,
1838
+ isLinked: () => false,
1839
+ portProbe: async () => false,
1840
+ log: (l) => logs.push(l),
1841
+ });
1842
+ expect(code).toBe(0);
1843
+ // bun add ran; vault's interactive init did NOT.
1844
+ expect(calls).toEqual([["bun", "add", "-g", "@openparachute/vault"]]);
1845
+ expect(calls).not.toContainEqual(["parachute-vault", "init"]);
1846
+ // The skip is announced + points at the admin UI / --interactive.
1847
+ expect(logs.join("\n")).toMatch(/skipping parachute-vault init/);
1848
+ } finally {
1849
+ cleanup();
1850
+ }
1851
+ });
1852
+
1853
+ test("default vault install still starts the module under the supervisor", async () => {
1854
+ const { path, cleanup } = makeTempPath();
1855
+ try {
1856
+ const startCalls: string[] = [];
1857
+ const code = await install("vault", {
1858
+ runner: async () => 0,
1859
+ manifestPath: path,
1860
+ startService: async (short) => {
1861
+ startCalls.push(short);
1862
+ return 0;
1863
+ },
1864
+ isLinked: () => false,
1865
+ portProbe: async () => false,
1866
+ log: () => {},
1867
+ });
1868
+ expect(code).toBe(0);
1869
+ // Light ≠ no-start: the supervisor owns the lifecycle; vault is started.
1870
+ expect(startCalls).toEqual(["vault"]);
1871
+ } finally {
1872
+ cleanup();
1873
+ }
1874
+ });
1875
+
1876
+ test("guidance block prints the admin URL + extras on a supervised box (loopback)", async () => {
1877
+ const { path, cleanup } = makeTempPath();
1878
+ try {
1879
+ const logs: string[] = [];
1880
+ const code = await install("vault", {
1881
+ runner: async () => 0,
1882
+ manifestPath: path,
1883
+ startService: async () => 0,
1884
+ isLinked: () => false,
1885
+ portProbe: async () => false,
1886
+ log: (l) => logs.push(l),
1887
+ // Deterministic supervised-box context: hub unit installed, not exposed.
1888
+ guidanceCtx: { hubUnitInstalled: true, exposeState: undefined, hubPort: 1939 },
1889
+ });
1890
+ expect(code).toBe(0);
1891
+ const joined = logs.join("\n");
1892
+ expect(joined).toMatch(/Manage \+ create vaults in the admin UI/);
1893
+ expect(joined).toMatch(/http:\/\/127\.0\.0\.1:1939\/admin\//);
1894
+ expect(joined).toMatch(/parachute-vault mcp-install/);
1895
+ expect(joined).toMatch(/--interactive/);
1896
+ // It does NOT mint a token or wire MCP — just points there.
1897
+ expect(joined).toMatch(/Mint an API token.*admin UI/);
1898
+ } finally {
1899
+ cleanup();
1900
+ }
1901
+ });
1902
+
1903
+ test("guidance uses the exposed public FQDN when the hub is exposed", async () => {
1904
+ const { path, cleanup } = makeTempPath();
1905
+ try {
1906
+ const logs: string[] = [];
1907
+ await install("vault", {
1908
+ runner: async () => 0,
1909
+ manifestPath: path,
1910
+ startService: async () => 0,
1911
+ isLinked: () => false,
1912
+ portProbe: async () => false,
1913
+ log: (l) => logs.push(l),
1914
+ guidanceCtx: {
1915
+ hubUnitInstalled: true,
1916
+ exposeState: {
1917
+ version: 1,
1918
+ layer: "public",
1919
+ mode: "path",
1920
+ canonicalFqdn: "friends.parachute.computer",
1921
+ port: 1939,
1922
+ funnel: false,
1923
+ entries: [],
1924
+ },
1925
+ hubPort: 1939,
1926
+ },
1927
+ });
1928
+ const joined = logs.join("\n");
1929
+ expect(joined).toMatch(/https:\/\/friends\.parachute\.computer\/admin\//);
1930
+ expect(joined).not.toMatch(/127\.0\.0\.1/);
1931
+ } finally {
1932
+ cleanup();
1933
+ }
1934
+ });
1935
+
1936
+ test("no guidance block on a non-supervised box (no hub unit)", async () => {
1937
+ const { path, cleanup } = makeTempPath();
1938
+ try {
1939
+ const logs: string[] = [];
1940
+ await install("vault", {
1941
+ runner: async () => 0,
1942
+ manifestPath: path,
1943
+ startService: async () => 0,
1944
+ isLinked: () => false,
1945
+ portProbe: async () => false,
1946
+ log: (l) => logs.push(l),
1947
+ guidanceCtx: { hubUnitInstalled: false, exposeState: undefined, hubPort: 1939 },
1948
+ });
1949
+ const joined = logs.join("\n");
1950
+ expect(joined).not.toMatch(/Manage \+ create vaults in the admin UI/);
1951
+ } finally {
1952
+ cleanup();
1953
+ }
1954
+ });
1955
+
1956
+ test("--interactive runs vault init and suppresses the guidance block", async () => {
1957
+ const { path, cleanup } = makeTempPath();
1958
+ try {
1959
+ const calls: string[][] = [];
1960
+ const logs: string[] = [];
1961
+ const code = await install("vault", {
1962
+ interactive: true,
1963
+ runner: async (cmd) => {
1964
+ calls.push([...cmd]);
1965
+ return 0;
1966
+ },
1967
+ manifestPath: path,
1968
+ startService: async () => 0,
1969
+ isLinked: () => false,
1970
+ portProbe: async () => false,
1971
+ log: (l) => logs.push(l),
1972
+ // Even on a supervised box, --interactive means the service's own
1973
+ // init owns the next-steps surface — no light guidance block.
1974
+ guidanceCtx: { hubUnitInstalled: true, exposeState: undefined, hubPort: 1939 },
1975
+ });
1976
+ expect(code).toBe(0);
1977
+ expect(calls).toContainEqual(["parachute-vault", "init"]);
1978
+ expect(logs.join("\n")).not.toMatch(/Manage \+ create vaults in the admin UI/);
1979
+ } finally {
1980
+ cleanup();
1981
+ }
1982
+ });
1983
+
1984
+ test("scribe (no interactive init) is unaffected — no skip log, no vault guidance", async () => {
1985
+ const { path, cleanup } = makeTempPath();
1986
+ const configDir = join(path, "..");
1987
+ try {
1988
+ const logs: string[] = [];
1989
+ const code = await install("scribe", {
1990
+ runner: async () => 0,
1991
+ manifestPath: path,
1992
+ configDir,
1993
+ startService: async () => 0,
1994
+ isLinked: () => false,
1995
+ portProbe: async () => false,
1996
+ scribeAvailability: { kind: "not-tty" },
1997
+ log: (l) => logs.push(l),
1998
+ guidanceCtx: { hubUnitInstalled: true, exposeState: undefined, hubPort: 1939 },
1999
+ });
2000
+ expect(code).toBe(0);
2001
+ const joined = logs.join("\n");
2002
+ expect(joined).not.toMatch(/skipping/);
2003
+ expect(joined).not.toMatch(/Manage \+ create vaults in the admin UI/);
2004
+ } finally {
2005
+ cleanup();
2006
+ }
2007
+ });
2008
+ });
2009
+
2010
+ describe("#580 item 3 — install-time stale-unit sweep", () => {
2011
+ test("sweeps stale per-module units before starting on a supervised box", async () => {
2012
+ const { path, cleanup } = makeTempPath();
2013
+ try {
2014
+ const logs: string[] = [];
2015
+ let sweepCalls = 0;
2016
+ const code = await install("vault", {
2017
+ runner: async () => 0,
2018
+ manifestPath: path,
2019
+ startService: async () => 0,
2020
+ isLinked: () => false,
2021
+ portProbe: async () => false,
2022
+ log: (l) => logs.push(l),
2023
+ guidanceCtx: { hubUnitInstalled: true, exposeState: undefined, hubPort: 1939 },
2024
+ disableStaleModuleUnits: () => {
2025
+ sweepCalls += 1;
2026
+ return {
2027
+ actions: [
2028
+ {
2029
+ short: "vault",
2030
+ kind: "launchd",
2031
+ unit: "computer.parachute.vault",
2032
+ result: "disabled",
2033
+ messages: [" ✓ Disabled stale computer.parachute.vault"],
2034
+ },
2035
+ ],
2036
+ };
2037
+ },
2038
+ });
2039
+ expect(code).toBe(0);
2040
+ expect(sweepCalls).toBe(1);
2041
+ expect(logs.join("\n")).toMatch(
2042
+ /Swept 1 stale per-module autostart unit\(s\).*computer\.parachute\.vault/,
2043
+ );
2044
+ } finally {
2045
+ cleanup();
2046
+ }
2047
+ });
2048
+
2049
+ test("does NOT sweep on a non-supervised box (no hub unit)", async () => {
2050
+ const { path, cleanup } = makeTempPath();
2051
+ try {
2052
+ let sweepCalls = 0;
2053
+ await install("vault", {
2054
+ runner: async () => 0,
2055
+ manifestPath: path,
2056
+ startService: async () => 0,
2057
+ isLinked: () => false,
2058
+ portProbe: async () => false,
2059
+ log: () => {},
2060
+ guidanceCtx: { hubUnitInstalled: false, exposeState: undefined, hubPort: 1939 },
2061
+ disableStaleModuleUnits: () => {
2062
+ sweepCalls += 1;
2063
+ return { actions: [] };
2064
+ },
2065
+ });
2066
+ // No supervised hub → the per-module unit is the legitimate lifecycle;
2067
+ // the sweep must not run.
2068
+ expect(sweepCalls).toBe(0);
2069
+ } finally {
2070
+ cleanup();
2071
+ }
2072
+ });
2073
+
2074
+ test("does NOT sweep under --no-start (caller owns the process model)", async () => {
2075
+ const { path, cleanup } = makeTempPath();
2076
+ try {
2077
+ let sweepCalls = 0;
2078
+ await install("vault", {
2079
+ runner: async () => 0,
2080
+ manifestPath: path,
2081
+ startService: async () => 0,
2082
+ isLinked: () => false,
2083
+ portProbe: async () => false,
2084
+ noStart: true,
2085
+ log: () => {},
2086
+ guidanceCtx: { hubUnitInstalled: true, exposeState: undefined, hubPort: 1939 },
2087
+ disableStaleModuleUnits: () => {
2088
+ sweepCalls += 1;
2089
+ return { actions: [] };
2090
+ },
2091
+ });
2092
+ expect(sweepCalls).toBe(0);
2093
+ } finally {
2094
+ cleanup();
2095
+ }
2096
+ });
2097
+
2098
+ test("does NOT sweep under --no-create (wizard defers the start; N2)", async () => {
2099
+ // Parallel to the --no-start guard above. `noCreate` (the wizard's
2100
+ // install path) also suppresses the start — and the sweep touches real
2101
+ // launchctl/systemctl on a live box, so it must NOT fire when we're not
2102
+ // about to start the module. A silent regression here would have the
2103
+ // wizard disabling operator units mid-init.
2104
+ const { path, cleanup } = makeTempPath();
2105
+ try {
2106
+ let sweepCalls = 0;
2107
+ await install("vault", {
2108
+ runner: async () => 0,
2109
+ manifestPath: path,
2110
+ startService: async () => 0,
2111
+ isLinked: () => false,
2112
+ portProbe: async () => false,
2113
+ noCreate: true,
2114
+ log: () => {},
2115
+ guidanceCtx: { hubUnitInstalled: true, exposeState: undefined, hubPort: 1939 },
2116
+ disableStaleModuleUnits: () => {
2117
+ sweepCalls += 1;
2118
+ return { actions: [] };
2119
+ },
2120
+ });
2121
+ expect(sweepCalls).toBe(0);
2122
+ } finally {
2123
+ cleanup();
2124
+ }
2125
+ });
2126
+
2127
+ test("a clean no-op sweep (nothing stale) logs nothing extra", async () => {
2128
+ const { path, cleanup } = makeTempPath();
2129
+ try {
2130
+ const logs: string[] = [];
2131
+ await install("vault", {
2132
+ runner: async () => 0,
2133
+ manifestPath: path,
2134
+ startService: async () => 0,
2135
+ isLinked: () => false,
2136
+ portProbe: async () => false,
2137
+ log: (l) => logs.push(l),
2138
+ guidanceCtx: { hubUnitInstalled: true, exposeState: undefined, hubPort: 1939 },
2139
+ disableStaleModuleUnits: () => ({ actions: [] }),
2140
+ });
2141
+ expect(logs.join("\n")).not.toMatch(/Swept .* stale per-module/);
2142
+ } finally {
2143
+ cleanup();
2144
+ }
2145
+ });
2146
+ });
2147
+
2148
+ describe("hub#573 — install auto-start converges on supervised detection", () => {
2149
+ test("the default start opts opt into real supervisor detection + the migrate offer", () => {
2150
+ const log = () => {};
2151
+ const opts = defaultStartLifecycleOpts({
2152
+ manifestPath: "/tmp/services.json",
2153
+ configDir: "/tmp/cfg",
2154
+ log,
2155
+ });
2156
+ // `supervisor: {}` (present, even if empty) → lifecycle resolves
2157
+ // `unitInstalled` via the real `isHubUnitInstalled` probe instead of the
2158
+ // omitted-supervisor default of `false`. Pre-fix this block was absent, so
2159
+ // the auto-start ALWAYS concluded "no unit" and printed the spurious
2160
+ // "No supervised hub unit is installed" + "didn't start cleanly".
2161
+ expect(opts.supervisor).toEqual({});
2162
+ // The cutover offer is armed, matching `parachute start <svc>` (cli.ts).
2163
+ expect(opts.migrateOffer).toEqual({ enabled: true });
2164
+ // Plumbing preserved.
2165
+ expect(opts.manifestPath).toBe("/tmp/services.json");
2166
+ expect(opts.configDir).toBe("/tmp/cfg");
2167
+ expect(opts.log).toBe(log);
2168
+ });
2169
+ });