@openparachute/hub 0.6.3-rc.3 → 0.6.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.6.3-rc.3",
3
+ "version": "0.6.3",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -175,6 +175,103 @@ describe("renderAccountHome", () => {
175
175
  expect(html).not.toContain('data-testid="mcp-connect"');
176
176
  });
177
177
 
178
+ test("get-started card — links to the two onboarding prompts, placed before the vault card", () => {
179
+ const html = renderAccountHome({
180
+ username: "alice",
181
+ assignedVaults: ["alice"],
182
+ passwordChanged: true,
183
+ hubOrigin: HUB_ORIGIN,
184
+ isFirstAdmin: false,
185
+ csrfToken: CSRF,
186
+ twoFactorEnabled: false,
187
+ });
188
+ // The card renders with both onboarding-prompt links (mirrors the
189
+ // operator setup-wizard's starter-prompts section).
190
+ expect(html).toContain('data-testid="get-started-card"');
191
+ expect(html).toContain("Get started with your AI");
192
+ expect(html).toContain("https://parachute.computer/onboarding/vault-setup/");
193
+ expect(html).toContain("https://parachute.computer/onboarding/surface-build/");
194
+ expect(html).toContain('data-testid="starter-vault-setup"');
195
+ expect(html).toContain('data-testid="starter-surface-build"');
196
+ // External links open safely.
197
+ expect(html).toContain('rel="noopener"');
198
+ // Placed prominently — before the vault card in document order.
199
+ expect(html.indexOf('data-testid="get-started-card"')).toBeLessThan(
200
+ html.indexOf('data-testid="vault-card"'),
201
+ );
202
+ });
203
+
204
+ test("get-started card — present on the admin branch, hidden on the no-vault branch", () => {
205
+ // The on-ramp belongs on branches that have a vault to act against (admin +
206
+ // assigned-vault). It's suppressed on the no-vault branch, where the page
207
+ // says "You don't have a vault yet" — a do-the-thing card there would
208
+ // contradict the you-lack-the-prerequisite message.
209
+ const admin = renderAccountHome({
210
+ username: "admin",
211
+ assignedVaults: [],
212
+ passwordChanged: true,
213
+ hubOrigin: HUB_ORIGIN,
214
+ isFirstAdmin: true,
215
+ csrfToken: CSRF,
216
+ twoFactorEnabled: false,
217
+ });
218
+ expect(admin).toContain('data-testid="get-started-card"');
219
+
220
+ const noVault = renderAccountHome({
221
+ username: "ghost",
222
+ assignedVaults: [],
223
+ passwordChanged: true,
224
+ hubOrigin: HUB_ORIGIN,
225
+ isFirstAdmin: false,
226
+ csrfToken: CSRF,
227
+ twoFactorEnabled: false,
228
+ });
229
+ // No-vault branch: card suppressed, and the no-vault message stands alone.
230
+ expect(noVault).not.toContain('data-testid="get-started-card"');
231
+ expect(noVault).toContain('data-testid="no-vault-card"');
232
+ });
233
+
234
+ test("connect-any-client hint bridges MCP ↔ ChatGPT 'connector' terminology", () => {
235
+ const html = renderAccountHome({
236
+ username: "alice",
237
+ assignedVaults: ["alice"],
238
+ passwordChanged: true,
239
+ hubOrigin: HUB_ORIGIN,
240
+ isFirstAdmin: false,
241
+ csrfToken: CSRF,
242
+ twoFactorEnabled: false,
243
+ });
244
+ // The "any other client" hint now names the ChatGPT "connector" term so
245
+ // a friend who only knows that word can find the right place to paste.
246
+ // Assert the NEW hint string specifically — a bare toContain("connector")
247
+ // was already satisfied pre-PR by the Claude.ai "Connectors" block, so it
248
+ // wouldn't catch a regression that drops this bridging copy.
249
+ expect(html).toContain('data-testid="connect-any-client-hint"');
250
+ expect(html).toContain('call these "connectors."');
251
+ });
252
+
253
+ test("account card — security actions collapse into a secondary <details>", () => {
254
+ const html = renderAccountHome({
255
+ username: "alice",
256
+ assignedVaults: ["alice"],
257
+ passwordChanged: true,
258
+ hubOrigin: HUB_ORIGIN,
259
+ isFirstAdmin: false,
260
+ csrfToken: CSRF,
261
+ twoFactorEnabled: false,
262
+ });
263
+ // Username + sign-out stay prominent; change-password + 2FA tuck into a
264
+ // collapsed "Security & password" details so the card reads calmer.
265
+ expect(html).toContain('data-testid="account-security"');
266
+ expect(html).toContain("Security &amp; password");
267
+ // The security actions live inside the details block (after its summary).
268
+ const securityIdx = html.indexOf('data-testid="account-security"');
269
+ expect(securityIdx).toBeGreaterThan(-1);
270
+ expect(html.indexOf('data-testid="change-password-link"')).toBeGreaterThan(securityIdx);
271
+ // Sign-out form comes BEFORE the security details — it stays prominent.
272
+ expect(html.indexOf('data-testid="signout-form"')).toBeLessThan(securityIdx);
273
+ });
274
+
178
275
  test("account card — change-password link and sign-out form are present", () => {
179
276
  const html = renderAccountHome({
180
277
  username: "alice",
@@ -429,10 +429,13 @@ describe("buildHubManagedUnit — §4.1 hub-unit shape", () => {
429
429
  expect(() => hubUnit(f.deps)).toThrow(/'bun' not found on PATH/);
430
430
  });
431
431
 
432
- test("env carries the 4 vars and INTENTIONALLY OMITS PARACHUTE_HUB_ORIGIN", () => {
432
+ test("env carries the 5 vars and INTENTIONALLY OMITS PARACHUTE_HUB_ORIGIN", () => {
433
433
  const f = fakeDeps({ platform: "linux" });
434
434
  const unit = hubUnit(f.deps);
435
435
  expect(unit.env).toEqual({
436
+ // Forced loopback (security): a self-hosted supervised hub must NOT inherit
437
+ // serve.ts's container-first 0.0.0.0 default and bare-serve all-interfaces.
438
+ PARACHUTE_BIND_HOST: "127.0.0.1",
436
439
  PARACHUTE_HOME: "/home/op/.parachute",
437
440
  PORT: "1939",
438
441
  PATH: "/home/op/.bun/bin:/usr/local/bin:/usr/bin:/bin",
@@ -441,6 +444,17 @@ describe("buildHubManagedUnit — §4.1 hub-unit shape", () => {
441
444
  expect(unit.env.PARACHUTE_HUB_ORIGIN).toBeUndefined();
442
445
  });
443
446
 
447
+ test("env forces PARACHUTE_BIND_HOST=127.0.0.1 (loopback trust model — never 0.0.0.0)", () => {
448
+ const f = fakeDeps({ platform: "linux" });
449
+ const unit = hubUnit(f.deps);
450
+ // The whole point of the fix: the supervised hub unit binds loopback, NOT
451
+ // the serve.ts container-first 0.0.0.0 default. Covers both init + migrate
452
+ // (both route through buildHubManagedUnit) and both platforms (the env is
453
+ // platform-agnostic; systemd/launchd render shapes are asserted below).
454
+ expect(unit.env.PARACHUTE_BIND_HOST).toBe("127.0.0.1");
455
+ expect(unit.env.PARACHUTE_BIND_HOST).not.toBe("0.0.0.0");
456
+ });
457
+
444
458
  test("PARACHUTE_HOME is the captured param, NOT the default (§4.2)", () => {
445
459
  const f = fakeDeps({ platform: "linux" });
446
460
  const unit = buildHubManagedUnit({
@@ -468,11 +482,14 @@ describe("buildHubManagedUnit — §4.1 hub-unit shape", () => {
468
482
  expect(unit.env.PORT).toBe("2939");
469
483
  });
470
484
 
471
- test("rendered systemd SYSTEM unit: 4 Environment= vars, User= present, StartLimit present", () => {
485
+ test("rendered systemd SYSTEM unit: 5 Environment= vars, User= present, StartLimit present", () => {
472
486
  const f = fakeDeps({ platform: "linux", getuid: () => 0, userName: () => "op" });
473
487
  const unit = renderManagedSystemdUnit(hubUnit(f.deps), { root: true, userName: "op" });
474
488
  expect(unit).toContain("Description=Parachute hub (serve + supervisor)");
475
489
  expect(unit).toContain("User=op");
490
+ // Forced loopback bind (security): the supervised hub must bind 127.0.0.1.
491
+ expect(unit).toContain("Environment=PARACHUTE_BIND_HOST=127.0.0.1");
492
+ expect(unit).not.toContain("PARACHUTE_BIND_HOST=0.0.0.0");
476
493
  expect(unit).toContain("Environment=PARACHUTE_HOME=/home/op/.parachute");
477
494
  expect(unit).toContain("Environment=PORT=1939");
478
495
  expect(unit).toContain("Environment=PATH=/home/op/.bun/bin:/usr/local/bin:/usr/bin:/bin");
@@ -494,7 +511,7 @@ describe("buildHubManagedUnit — §4.1 hub-unit shape", () => {
494
511
  expect(unit).toContain("WantedBy=default.target");
495
512
  });
496
513
 
497
- test("rendered launchd plist: EnvironmentVariables dict (4 vars) + ThrottleInterval + abs ProgramArguments", () => {
514
+ test("rendered launchd plist: EnvironmentVariables dict (5 vars) + ThrottleInterval + abs ProgramArguments", () => {
498
515
  const f = fakeDeps({ platform: "darwin" });
499
516
  const plist = renderManagedLaunchdPlist(hubUnit(f.deps));
500
517
  expect(plist).toContain("<key>Label</key>\n <string>computer.parachute.hub</string>");
@@ -502,6 +519,9 @@ describe("buildHubManagedUnit — §4.1 hub-unit shape", () => {
502
519
  expect(plist).toContain("<string>/home/op/parachute-hub/src/cli.ts</string>");
503
520
  expect(plist).toContain("<string>serve</string>");
504
521
  expect(plist).toContain("<key>EnvironmentVariables</key>");
522
+ // Forced loopback bind (security): the supervised hub must bind 127.0.0.1.
523
+ expect(plist).toContain("<key>PARACHUTE_BIND_HOST</key>\n <string>127.0.0.1</string>");
524
+ expect(plist).not.toContain("<string>0.0.0.0</string>");
505
525
  expect(plist).toContain("<key>PARACHUTE_HOME</key>\n <string>/home/op/.parachute</string>");
506
526
  expect(plist).toContain("<key>PORT</key>\n <string>1939</string>");
507
527
  expect(plist).toContain("<key>BUN_INSTALL</key>\n <string>/home/op/.bun</string>");
@@ -122,6 +122,12 @@ function makeFakeCutover(over: Partial<CutoverDeps> = {}): FakeCutover {
122
122
  messages: ["started unit"],
123
123
  };
124
124
  },
125
+ // Hermetic default: the stale-unit disable is a no-op (no real
126
+ // systemctl/launchctl). Tests that exercise #522 override this to trace + act.
127
+ disableStaleModuleUnits: () => {
128
+ trace.push("disableStaleUnits");
129
+ return { actions: [] };
130
+ },
125
131
  ...over,
126
132
  };
127
133
  // Expose the world via closure for tests that want to manipulate it.
@@ -184,6 +190,43 @@ describe("cutoverToSupervised — happy path (§7.1)", () => {
184
190
  }
185
191
  });
186
192
 
193
+ test("#522: stale-unit disable runs in the STOP phase — after detached stop, before unit start", async () => {
194
+ const h = makeHarness();
195
+ try {
196
+ seedManifest(h.manifestPath, [{ name: "vault", port: 1940 }]);
197
+ const fc = makeFakeCutover();
198
+ const w = getWorld(fc.deps);
199
+ w.listening.add(1939);
200
+ w.listening.add(1940);
201
+ w.alivePids.add(5555);
202
+ writePid("vault", 5555, h.configDir);
203
+ const baseKill = fc.deps.kill;
204
+ fc.deps.kill = (pid, signal) => {
205
+ baseKill?.(pid, signal);
206
+ if (pid === 5555) getWorld(fc.deps).listening.delete(1940);
207
+ };
208
+ const result = await cutoverToSupervised({
209
+ configDir: h.configDir,
210
+ manifestPath: h.manifestPath,
211
+ deps: fc.deps,
212
+ log: () => {},
213
+ pollMs: 0,
214
+ });
215
+ expect(result.outcome).toBe("migrated");
216
+ const stopIdx = fc.trace.indexOf("stopHub");
217
+ const disableIdx = fc.trace.indexOf("disableStaleUnits");
218
+ const startIdx = fc.trace.indexOf("startUnit");
219
+ // The disable runs AFTER the detached stop and BEFORE the unit start, so a
220
+ // KeepAlive/Restart=always unit can't re-grab the port between freeing it
221
+ // and the supervised module binding it.
222
+ expect(disableIdx).toBeGreaterThanOrEqual(0);
223
+ expect(stopIdx).toBeLessThan(disableIdx);
224
+ expect(disableIdx).toBeLessThan(startIdx);
225
+ } finally {
226
+ h.cleanup();
227
+ }
228
+ });
229
+
187
230
  test("verify-ports-free runs before start (start never races a held port)", async () => {
188
231
  const h = makeHarness();
189
232
  try {
@@ -443,6 +486,7 @@ describe("cutoverToSupervised — fail-safe recovery states", () => {
443
486
  describe("teardownHubUnit (§7.4)", () => {
444
487
  test("removes the hub unit (idempotent success path)", () => {
445
488
  let removeArgs: { launchdLabel: string; systemdUnitName: string } | undefined;
489
+ let staleCalled = false;
446
490
  const log: string[] = [];
447
491
  const res = teardownHubUnit({
448
492
  log: (l) => log.push(l),
@@ -450,21 +494,36 @@ describe("teardownHubUnit (§7.4)", () => {
450
494
  removeArgs = { launchdLabel: opts.launchdLabel, systemdUnitName: opts.systemdUnitName };
451
495
  return { removed: true, messages: [opts.removedSystemdMessage(opts.systemdUnitName)] };
452
496
  },
497
+ // Hermetic stub — no real systemctl/launchctl.
498
+ disableStaleModuleUnits: () => {
499
+ staleCalled = true;
500
+ return { actions: [] };
501
+ },
453
502
  });
454
503
  expect(res.removed).toBe(true);
455
504
  expect(removeArgs?.launchdLabel).toBe("computer.parachute.hub");
456
505
  expect(removeArgs?.systemdUnitName).toBe("parachute-hub.service");
506
+ // #522: teardown also runs the stale-per-module-autostart disable.
507
+ expect(staleCalled).toBe(true);
457
508
  // Surfaces the fallback hint.
458
509
  expect(log.join("\n")).toContain("parachute serve");
459
510
  });
460
511
 
461
- test("no unit installed → no-op, friendly message", () => {
512
+ test("no unit installed → no-op, friendly message (still runs the stale-unit disable)", () => {
513
+ let staleCalled = false;
462
514
  const log: string[] = [];
463
515
  const res = teardownHubUnit({
464
516
  log: (l) => log.push(l),
465
517
  remove: (): ManagedUnitRemoveResult => ({ removed: false, messages: [] }),
518
+ disableStaleModuleUnits: () => {
519
+ staleCalled = true;
520
+ return { actions: [] };
521
+ },
466
522
  });
467
523
  expect(res.removed).toBe(false);
524
+ // #522: a leftover module autostart must be cleaned even when the hub unit was
525
+ // never installed (a partial / never-migrated box rolling back).
526
+ expect(staleCalled).toBe(true);
468
527
  expect(log.join("\n")).toContain("nothing to tear down");
469
528
  });
470
529
  });
@@ -0,0 +1,286 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ManagedUnitDeps, ServiceCommandResult } from "../managed-unit.ts";
3
+ import {
4
+ disableStaleModuleUnits,
5
+ moduleLaunchdLabel,
6
+ moduleSystemdUnitName,
7
+ targetModuleShorts,
8
+ } from "../stale-module-units.ts";
9
+
10
+ /**
11
+ * #522 — migrate/teardown must DETECT + DISABLE any stale per-module autostart
12
+ * unit (a leftover `parachute-<short>.service` systemd KeepAlive / a
13
+ * `computer.parachute.<short>` launchd KeepAlive) so it stops respawning an
14
+ * unsupervised module that fights the supervised hub for the module's port.
15
+ *
16
+ * ALL tests run against a stubbed `ManagedUnitDeps.run` — NO real
17
+ * systemctl/launchctl. Each fake records the commands it received so we can
18
+ * assert exactly which units were disabled, which were skipped, and that a
19
+ * disable failure / system-level unit is non-fatal.
20
+ */
21
+
22
+ type RunResponder = (cmd: readonly string[]) => ServiceCommandResult;
23
+
24
+ function ok(stdout = ""): ServiceCommandResult {
25
+ return { code: 0, stdout, stderr: "" };
26
+ }
27
+ function fail(stderr = "boom", code = 1): ServiceCommandResult {
28
+ return { code, stdout: "", stderr };
29
+ }
30
+
31
+ function makeDeps(platform: NodeJS.Platform, respond: RunResponder) {
32
+ const calls: string[][] = [];
33
+ const deps: ManagedUnitDeps = {
34
+ platform,
35
+ getuid: () => 501,
36
+ homeDir: () => "/home/op",
37
+ userName: () => "op",
38
+ which: (b) => (b === "systemctl" || b === "launchctl" ? `/usr/bin/${b}` : null),
39
+ run: (cmd) => {
40
+ calls.push([...cmd]);
41
+ return respond(cmd);
42
+ },
43
+ writeFile: () => {},
44
+ removeFile: () => {},
45
+ readFile: () => undefined,
46
+ exists: () => false,
47
+ };
48
+ return { deps, calls };
49
+ }
50
+
51
+ function joined(calls: string[][]): string[] {
52
+ return calls.map((c) => c.join(" "));
53
+ }
54
+
55
+ describe("targetModuleShorts() — known module shorts, never hub/cloudflared", () => {
56
+ test("includes the canonical module shorts and excludes hub", () => {
57
+ const shorts = targetModuleShorts();
58
+ // The canonical knownServices() set: vault / scribe / runner / surface / notes / channel.
59
+ expect(shorts).toContain("vault");
60
+ expect(shorts).toContain("scribe");
61
+ expect(shorts).toContain("surface");
62
+ expect(shorts).toContain("notes");
63
+ // hub is the supervised model itself — never a target.
64
+ expect(shorts).not.toContain("hub");
65
+ expect(shorts).not.toContain("cloudflared");
66
+ });
67
+ });
68
+
69
+ describe("disableStaleModuleUnits — systemd (Linux)", () => {
70
+ test("a stale ENABLED user unit parachute-vault.service is disabled --now + reported", () => {
71
+ const { deps, calls } = makeDeps("linux", (cmd) => {
72
+ const line = cmd.join(" ");
73
+ // Only vault's USER unit reads enabled; everything else is disabled.
74
+ if (line === "systemctl --user is-enabled parachute-vault.service") return ok("enabled\n");
75
+ if (line.startsWith("systemctl --user is-enabled")) return fail("disabled", 1);
76
+ if (line.startsWith("systemctl is-enabled")) return fail("disabled", 1);
77
+ if (line === "systemctl --user disable --now parachute-vault.service") return ok();
78
+ return ok();
79
+ });
80
+ const log: string[] = [];
81
+ const res = disableStaleModuleUnits({ deps, log: (l) => log.push(l) });
82
+
83
+ // Exactly one unit acted on: vault, disabled at user scope.
84
+ expect(res.actions).toHaveLength(1);
85
+ expect(res.actions[0]).toMatchObject({
86
+ short: "vault",
87
+ kind: "systemd-user",
88
+ unit: "parachute-vault.service",
89
+ result: "disabled",
90
+ });
91
+ // The disable --now command was actually invoked.
92
+ expect(joined(calls)).toContain("systemctl --user disable --now parachute-vault.service");
93
+ // The action is reported so the operator sees what changed.
94
+ expect(log.join("\n")).toContain("Disabled stale parachute-vault.service");
95
+ expect(log.join("\n")).toContain("vault's port");
96
+ });
97
+
98
+ test("SKIPS the hub unit + cloudflared units — never queried, never disabled", () => {
99
+ const { deps, calls } = makeDeps("linux", () => fail("disabled", 1));
100
+ disableStaleModuleUnits({ deps });
101
+ const lines = joined(calls);
102
+ // The hub unit is never probed or disabled by this sweep.
103
+ expect(lines.some((l) => l.includes("parachute-hub.service"))).toBe(false);
104
+ // No cloudflared connector unit is touched.
105
+ expect(lines.some((l) => l.includes("parachute-cloudflared"))).toBe(false);
106
+ // And no disable command runs at all (everything reads disabled).
107
+ expect(lines.some((l) => l.includes("disable"))).toBe(false);
108
+ });
109
+
110
+ test("a non-matching / arbitrary unit is never touched (only parachute-<known-short> is queried)", () => {
111
+ const { deps, calls } = makeDeps("linux", () => fail("disabled", 1));
112
+ disableStaleModuleUnits({ deps });
113
+ const lines = joined(calls);
114
+ // Every is-enabled probe targets a parachute-<known-short>.service and nothing else.
115
+ const probes = lines.filter((l) => l.includes("is-enabled"));
116
+ expect(probes.length).toBeGreaterThan(0);
117
+ for (const probe of probes) {
118
+ const m = probe.match(/is-enabled (parachute-[a-z]+\.service)$/);
119
+ expect(m).not.toBeNull();
120
+ const unit = m?.[1] ?? "";
121
+ // The probed unit must be a known module short, and never the hub/cloudflared.
122
+ expect(targetModuleShorts().map(moduleSystemdUnitName)).toContain(unit);
123
+ expect(unit).not.toBe("parachute-hub.service");
124
+ }
125
+ });
126
+
127
+ test("idempotent: every unit already disabled → clean no-op (no disable, no actions)", () => {
128
+ const { deps, calls } = makeDeps("linux", () => fail("disabled", 1));
129
+ const res = disableStaleModuleUnits({ deps });
130
+ expect(res.actions).toHaveLength(0);
131
+ expect(joined(calls).some((l) => l.includes("disable"))).toBe(false);
132
+ });
133
+
134
+ test("system-level unit (no --user enabled, system enabled) → WARNS with manual sudo command, doesn't abort, doesn't sudo", () => {
135
+ const { deps, calls } = makeDeps("linux", (cmd) => {
136
+ const line = cmd.join(" ");
137
+ // vault's USER unit is NOT enabled, but the SYSTEM unit IS.
138
+ if (line === "systemctl --user is-enabled parachute-vault.service")
139
+ return fail("disabled", 1);
140
+ if (line === "systemctl is-enabled parachute-vault.service") return ok("enabled\n");
141
+ if (line.startsWith("systemctl --user is-enabled")) return fail("disabled", 1);
142
+ if (line.startsWith("systemctl is-enabled")) return fail("disabled", 1);
143
+ return ok();
144
+ });
145
+ const log: string[] = [];
146
+ const res = disableStaleModuleUnits({ deps, log: (l) => log.push(l) });
147
+
148
+ const vaultAction = res.actions.find((a) => a.short === "vault");
149
+ expect(vaultAction).toMatchObject({ kind: "systemd-system", result: "warn-system" });
150
+ const out = log.join("\n");
151
+ // The exact manual command is surfaced.
152
+ expect(out).toContain("sudo systemctl disable --now parachute-vault.service");
153
+ // It NEVER attempted a sudo / system disable itself.
154
+ expect(joined(calls)).not.toContain("systemctl disable --now parachute-vault.service");
155
+ expect(joined(calls).some((l) => l.startsWith("sudo"))).toBe(false);
156
+ });
157
+
158
+ test("non-fatal: a disable command that fails → warn + continue (the other units are still swept)", () => {
159
+ const { deps, calls } = makeDeps("linux", (cmd) => {
160
+ const line = cmd.join(" ");
161
+ // Both vault + scribe user units read enabled; vault's disable FAILS.
162
+ if (line === "systemctl --user is-enabled parachute-vault.service") return ok("enabled\n");
163
+ if (line === "systemctl --user is-enabled parachute-scribe.service") return ok("enabled\n");
164
+ if (line.startsWith("systemctl --user is-enabled")) return fail("disabled", 1);
165
+ if (line.startsWith("systemctl is-enabled")) return fail("disabled", 1);
166
+ if (line === "systemctl --user disable --now parachute-vault.service")
167
+ return fail("permission denied");
168
+ if (line === "systemctl --user disable --now parachute-scribe.service") return ok();
169
+ return ok();
170
+ });
171
+ const log: string[] = [];
172
+ const res = disableStaleModuleUnits({ deps, log: (l) => log.push(l) });
173
+
174
+ const vault = res.actions.find((a) => a.short === "vault");
175
+ const scribe = res.actions.find((a) => a.short === "scribe");
176
+ // vault's disable failed → warned, not fatal; scribe was still disabled.
177
+ expect(vault?.result).toBe("failed");
178
+ expect(scribe?.result).toBe("disabled");
179
+ // Both disable attempts ran — the failure didn't abort the sweep.
180
+ expect(joined(calls)).toContain("systemctl --user disable --now parachute-vault.service");
181
+ expect(joined(calls)).toContain("systemctl --user disable --now parachute-scribe.service");
182
+ expect(log.join("\n")).toContain("Could not disable");
183
+ });
184
+
185
+ test("no systemctl on the box → clean no-op", () => {
186
+ const calls: string[][] = [];
187
+ const deps: ManagedUnitDeps = {
188
+ platform: "linux",
189
+ getuid: () => 501,
190
+ homeDir: () => "/home/op",
191
+ userName: () => "op",
192
+ which: () => null, // no systemctl
193
+ run: (cmd) => {
194
+ calls.push([...cmd]);
195
+ return ok();
196
+ },
197
+ writeFile: () => {},
198
+ removeFile: () => {},
199
+ readFile: () => undefined,
200
+ exists: () => false,
201
+ };
202
+ const res = disableStaleModuleUnits({ deps });
203
+ expect(res.actions).toHaveLength(0);
204
+ expect(calls).toHaveLength(0);
205
+ });
206
+ });
207
+
208
+ describe("disableStaleModuleUnits — launchd (Mac)", () => {
209
+ test("a loaded computer.parachute.vault LaunchAgent is booted out + reported", () => {
210
+ const { deps, calls } = makeDeps("darwin", (cmd) => {
211
+ const line = cmd.join(" ");
212
+ // Only vault's label is loaded (print succeeds with content); others print empty.
213
+ if (line === "launchctl print gui/501/computer.parachute.vault")
214
+ return ok("com.apple...\nstate = running\n");
215
+ if (line.startsWith("launchctl print")) return ok(""); // not loaded
216
+ if (line === "launchctl bootout gui/501/computer.parachute.vault") return ok();
217
+ return ok();
218
+ });
219
+ const log: string[] = [];
220
+ const res = disableStaleModuleUnits({ deps, log: (l) => log.push(l) });
221
+
222
+ expect(res.actions).toHaveLength(1);
223
+ expect(res.actions[0]).toMatchObject({
224
+ short: "vault",
225
+ kind: "launchd",
226
+ unit: "computer.parachute.vault",
227
+ result: "disabled",
228
+ });
229
+ expect(joined(calls)).toContain("launchctl bootout gui/501/computer.parachute.vault");
230
+ expect(log.join("\n")).toContain("Disabled stale computer.parachute.vault");
231
+ });
232
+
233
+ test("SKIPS the hub label + cloudflared labels — never printed, never booted out", () => {
234
+ const { deps, calls } = makeDeps("darwin", () => ok("")); // nothing loaded
235
+ disableStaleModuleUnits({ deps });
236
+ const lines = joined(calls);
237
+ expect(lines.some((l) => l.includes("computer.parachute.hub"))).toBe(false);
238
+ expect(lines.some((l) => l.includes("computer.parachute.cloudflared"))).toBe(false);
239
+ // Every print targets a parachute.<known-short> label.
240
+ const prints = lines.filter((l) => l.startsWith("launchctl print"));
241
+ for (const p of prints) {
242
+ const m = p.match(/computer\.parachute\.([a-z]+)$/);
243
+ expect(m).not.toBeNull();
244
+ expect(targetModuleShorts()).toContain(m?.[1] ?? "");
245
+ }
246
+ });
247
+
248
+ test("idempotent: nothing loaded → clean no-op (no bootout, no actions)", () => {
249
+ const { deps, calls } = makeDeps("darwin", () => ok("")); // print returns empty for all
250
+ const res = disableStaleModuleUnits({ deps });
251
+ expect(res.actions).toHaveLength(0);
252
+ expect(joined(calls).some((l) => l.includes("bootout"))).toBe(false);
253
+ });
254
+
255
+ test("non-fatal: a bootout that fails → warn + continue", () => {
256
+ const { deps } = makeDeps("darwin", (cmd) => {
257
+ const line = cmd.join(" ");
258
+ if (line === "launchctl print gui/501/computer.parachute.vault")
259
+ return ok("state = running\n");
260
+ if (line.startsWith("launchctl print")) return ok("");
261
+ if (line === "launchctl bootout gui/501/computer.parachute.vault")
262
+ return fail("Operation not permitted");
263
+ return ok();
264
+ });
265
+ const log: string[] = [];
266
+ const res = disableStaleModuleUnits({ deps, log: (l) => log.push(l) });
267
+ expect(res.actions.find((a) => a.short === "vault")?.result).toBe("failed");
268
+ expect(log.join("\n")).toContain("Could not disable the stale LaunchAgent");
269
+ });
270
+ });
271
+
272
+ describe("disableStaleModuleUnits — unsupported platform", () => {
273
+ test("no per-platform manager (e.g. win32) → clean no-op", () => {
274
+ const { deps, calls } = makeDeps("win32", () => ok());
275
+ const res = disableStaleModuleUnits({ deps });
276
+ expect(res.actions).toHaveLength(0);
277
+ expect(calls).toHaveLength(0);
278
+ });
279
+ });
280
+
281
+ describe("unit-name helpers", () => {
282
+ test("moduleSystemdUnitName / moduleLaunchdLabel build the exact per-module names", () => {
283
+ expect(moduleSystemdUnitName("vault")).toBe("parachute-vault.service");
284
+ expect(moduleLaunchdLabel("vault")).toBe("computer.parachute.vault");
285
+ });
286
+ });