@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
@@ -1,6 +1,18 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
1
+ import {
2
+ type ManagedUnit,
3
+ type ManagedUnitDeps,
4
+ type ManagedUnitInstallResult,
5
+ type ManagedUnitMessages,
6
+ type ManagedUnitRemoveResult,
7
+ type ServiceCommandResult,
8
+ defaultManagedUnitDeps,
9
+ installManagedUnit,
10
+ launchdPlistPathForLabel,
11
+ removeManagedUnit,
12
+ renderManagedLaunchdPlist,
13
+ renderManagedSystemdUnit,
14
+ systemdUnitPathForName,
15
+ } from "../managed-unit.ts";
4
16
 
5
17
  /**
6
18
  * Reboot-persistent cloudflared connector.
@@ -25,10 +37,20 @@ import { dirname, join } from "node:path";
25
37
  * `/etc/systemd/system/parachute-cloudflared-<tunnelName>.service`,
26
38
  * `systemctl enable --now`. No linger needed — system units run on boot.
27
39
  *
28
- * Everything is behind an injectable `ConnectorServiceDeps` seam (mirrors the
29
- * `Runner`/`CloudflaredSpawner`/`KillFn` injection in expose-cloudflare.ts) so
30
- * tests drive the install/remove without touching real launchctl/systemctl or
31
- * the operator's home directory.
40
+ * As of Phase 2b of the hub-as-supervisor unification, the per-platform
41
+ * install/remove/render machinery lives in `src/managed-unit.ts` as a reusable
42
+ * `ManagedUnit` abstraction (so the hub unit can reuse it — design §4.1). This
43
+ * module is now a thin connector-specific layer over that machinery: it builds
44
+ * the connector's `ManagedUnit` descriptor (empty env, no crash-loop ceiling —
45
+ * which keeps its rendered output BYTE-IDENTICAL to the pre-extraction code, the
46
+ * hard constraint since the connector is live on production) and supplies the
47
+ * connector-flavored install/remove messages. The public exports below keep
48
+ * their original signatures so `expose-cloudflare.ts` and the connector tests
49
+ * need no behavioral change.
50
+ *
51
+ * Everything is behind an injectable deps seam (re-exported `ConnectorServiceDeps`
52
+ * = the generalized `ManagedUnitDeps`) so tests drive the install/remove without
53
+ * touching real launchctl/systemctl or the operator's home directory.
32
54
  *
33
55
  * Service name keyed by the same per-host tunnel name the 0.6.1 work derives
34
56
  * (`deriveTunnelName`), so install / remove always target the connector for
@@ -36,74 +58,23 @@ import { dirname, join } from "node:path";
36
58
  */
37
59
 
38
60
  /** Synchronous command result from the injected service runner. */
39
- export interface ServiceCommandResult {
40
- code: number;
41
- stdout: string;
42
- stderr: string;
43
- }
61
+ export type { ServiceCommandResult };
44
62
 
45
63
  /**
46
- * Injectable side-effect seam for the connector-service module. Production
47
- * wires the real fs / os / child-process implementations (`defaultServiceDeps`);
48
- * tests inject fakes to assert generated file content + the install/remove
49
- * command sequence without a live launchctl/systemctl.
64
+ * Injectable side-effect seam for the connector-service module. This is the
65
+ * generalized `ManagedUnitDeps` re-exported under the connector's historical
66
+ * name so `expose-cloudflare.ts` + the connector tests are unchanged.
50
67
  */
51
- export interface ConnectorServiceDeps {
52
- /** `process.platform`. */
53
- platform: NodeJS.Platform;
54
- /**
55
- * Effective uid. Linux uses `0 === root` to pick a system vs user systemd
56
- * unit. `undefined` (Windows / platforms without getuid) → treated as
57
- * non-root. macOS ignores this (LaunchAgents are always per-user).
58
- */
59
- getuid: () => number | undefined;
60
- /** `$HOME`. */
61
- homeDir: () => string;
62
- /** Username for the linger call + systemd unit `User=` (system unit). */
63
- userName: () => string;
64
- /** Resolve a binary to an absolute path (launchd/systemd don't inherit PATH). */
65
- which: (binary: string) => string | null;
66
- /** Run launchctl/systemctl/loginctl synchronously. */
67
- run: (cmd: readonly string[]) => ServiceCommandResult;
68
- /** Write a service file (creates parent dirs). */
69
- writeFile: (path: string, content: string) => void;
70
- /** Remove a service file if present (no-op when absent). */
71
- removeFile: (path: string) => void;
72
- /** Read a service file, or undefined when absent. */
73
- readFile: (path: string) => string | undefined;
74
- /** True when the path exists. */
75
- exists: (path: string) => boolean;
76
- }
68
+ export type ConnectorServiceDeps = ManagedUnitDeps;
77
69
 
78
- export const defaultServiceDeps: ConnectorServiceDeps = {
79
- platform: process.platform,
80
- getuid: () => (typeof process.getuid === "function" ? process.getuid() : undefined),
81
- homeDir: () => homedir(),
82
- userName: () => process.env.USER ?? process.env.LOGNAME ?? process.env.USERNAME ?? "",
83
- which: (binary) => Bun.which(binary),
84
- run: (cmd) => {
85
- const proc = Bun.spawnSync([...cmd], { env: process.env });
86
- return {
87
- code: proc.exitCode ?? 1,
88
- stdout: proc.stdout?.toString() ?? "",
89
- stderr: proc.stderr?.toString() ?? "",
90
- };
91
- },
92
- writeFile: (path, content) => {
93
- mkdirSync(dirname(path), { recursive: true });
94
- writeFileSync(path, content);
95
- },
96
- removeFile: (path) => {
97
- if (existsSync(path)) rmSync(path, { force: true });
98
- },
99
- readFile: (path) => (existsSync(path) ? readFileSync(path, "utf8") : undefined),
100
- exists: (path) => existsSync(path),
101
- };
70
+ export const defaultServiceDeps: ConnectorServiceDeps = defaultManagedUnitDeps;
102
71
 
103
72
  /** Reverse-DNS prefix for the launchd label + plist filename. */
104
73
  const LAUNCHD_LABEL_PREFIX = "computer.parachute.cloudflared";
105
74
  /** systemd unit name prefix. */
106
75
  const SYSTEMD_UNIT_PREFIX = "parachute-cloudflared-";
76
+ /** Provenance comment baked into every rendered connector unit file. */
77
+ const CONNECTOR_HEADER = "Generated by parachute expose public --cloudflare — do not edit by hand.";
107
78
 
108
79
  /** launchd label for a tunnel (also the plist basename, minus `.plist`). */
109
80
  export function launchdLabel(tunnelName: string): string {
@@ -112,7 +83,7 @@ export function launchdLabel(tunnelName: string): string {
112
83
 
113
84
  /** launchd plist path under the user's LaunchAgents dir. */
114
85
  export function launchdPlistPath(tunnelName: string, home: string): string {
115
- return join(home, "Library", "LaunchAgents", `${launchdLabel(tunnelName)}.plist`);
86
+ return launchdPlistPathForLabel(launchdLabel(tunnelName), home);
116
87
  }
117
88
 
118
89
  /** systemd unit name (with `.service` suffix). */
@@ -122,23 +93,37 @@ export function systemdUnitName(tunnelName: string): string {
122
93
 
123
94
  /** systemd unit path — user-level under $HOME, system-level under /etc. */
124
95
  export function systemdUnitPath(tunnelName: string, home: string, root: boolean): string {
125
- return root
126
- ? join("/etc/systemd/system", systemdUnitName(tunnelName))
127
- : join(home, ".config", "systemd", "user", systemdUnitName(tunnelName));
96
+ return systemdUnitPathForName(systemdUnitName(tunnelName), home, root);
128
97
  }
129
98
 
130
- /** XML-escape a string for safe inclusion in a plist `<string>` element. */
131
- function plistEscape(s: string): string {
132
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
99
+ /**
100
+ * Build the connector's `ManagedUnit` descriptor. Empty `env` + no `crashLoop`
101
+ * are what keep the rendered output byte-identical to the pre-extraction code.
102
+ * `runAsInvokingUserOnSystemUnit: true` reproduces the connector's historical
103
+ * `User=` pin on the root/system unit.
104
+ */
105
+ function connectorUnit(opts: {
106
+ tunnelName: string;
107
+ cloudflaredPath: string;
108
+ configPath: string;
109
+ logPath: string;
110
+ }): ManagedUnit {
111
+ return {
112
+ launchdLabel: launchdLabel(opts.tunnelName),
113
+ systemdUnitName: systemdUnitName(opts.tunnelName),
114
+ headerComment: CONNECTOR_HEADER,
115
+ systemdDescription: `Parachute Cloudflare connector (${opts.tunnelName})`,
116
+ execStart: [opts.cloudflaredPath, "tunnel", "--config", opts.configPath, "run"],
117
+ env: {},
118
+ logPath: opts.logPath,
119
+ runAsInvokingUserOnSystemUnit: true,
120
+ };
133
121
  }
134
122
 
135
123
  /**
136
- * Render the launchd LaunchAgent plist. `RunAtLoad` starts the connector on
137
- * load (and on login/boot once bootstrapped); `KeepAlive` restarts it if it
138
- * exits. We pass `cloudflaredPath` (the resolved absolute binary) as argv[0]
139
- * because launchd does not search `$PATH`. Logs go to the same per-tunnel log
140
- * file the transient spawn used, so `parachute status`/the operator find them
141
- * in one place.
124
+ * Render the launchd LaunchAgent plist. Thin wrapper over the generalized
125
+ * renderer with the connector's descriptor preserved signature so existing
126
+ * callers/tests are unchanged.
142
127
  */
143
128
  export function renderLaunchdPlist(opts: {
144
129
  tunnelName: string;
@@ -146,39 +131,13 @@ export function renderLaunchdPlist(opts: {
146
131
  configPath: string;
147
132
  logPath: string;
148
133
  }): string {
149
- const { tunnelName, cloudflaredPath, configPath, logPath } = opts;
150
- const args = [cloudflaredPath, "tunnel", "--config", configPath, "run"];
151
- const argXml = args.map((a) => ` <string>${plistEscape(a)}</string>`).join("\n");
152
- return `<?xml version="1.0" encoding="UTF-8"?>
153
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
154
- <!-- Generated by parachute expose public --cloudflare — do not edit by hand. -->
155
- <plist version="1.0">
156
- <dict>
157
- <key>Label</key>
158
- <string>${plistEscape(launchdLabel(tunnelName))}</string>
159
- <key>ProgramArguments</key>
160
- <array>
161
- ${argXml}
162
- </array>
163
- <key>RunAtLoad</key>
164
- <true/>
165
- <key>KeepAlive</key>
166
- <true/>
167
- <key>StandardOutPath</key>
168
- <string>${plistEscape(logPath)}</string>
169
- <key>StandardErrorPath</key>
170
- <string>${plistEscape(logPath)}</string>
171
- </dict>
172
- </plist>
173
- `;
134
+ return renderManagedLaunchdPlist(connectorUnit(opts));
174
135
  }
175
136
 
176
137
  /**
177
- * Render a systemd unit. `Restart=always` mirrors launchd's KeepAlive;
178
- * `WantedBy` differs by scope (system multi-user.target; user
179
- * default.target). The system unit pins `User=` so the connector doesn't run
180
- * as root unnecessarily when we know the invoking user. `ExecStart` uses the
181
- * resolved absolute `cloudflaredPath` (systemd doesn't search a login `$PATH`).
138
+ * Render a systemd unit. Thin wrapper over the generalized renderer — preserved
139
+ * signature (`{ root, userName }` threaded through) so existing callers/tests
140
+ * are unchanged.
182
141
  */
183
142
  export function renderSystemdUnit(opts: {
184
143
  tunnelName: string;
@@ -188,25 +147,8 @@ export function renderSystemdUnit(opts: {
188
147
  root: boolean;
189
148
  userName: string;
190
149
  }): string {
191
- const { tunnelName, cloudflaredPath, configPath, root, userName } = opts;
192
- const execStart = `${cloudflaredPath} tunnel --config ${configPath} run`;
193
- const userLine = root && userName ? `User=${userName}\n` : "";
194
- const wantedBy = root ? "multi-user.target" : "default.target";
195
- return `# Generated by parachute expose public --cloudflare — do not edit by hand.
196
- [Unit]
197
- Description=Parachute Cloudflare connector (${tunnelName})
198
- After=network-online.target
199
- Wants=network-online.target
200
-
201
- [Service]
202
- Type=simple
203
- ${userLine}ExecStart=${execStart}
204
- Restart=always
205
- RestartSec=5
206
-
207
- [Install]
208
- WantedBy=${wantedBy}
209
- `;
150
+ const { root, userName } = opts;
151
+ return renderManagedSystemdUnit(connectorUnit(opts), { root, userName });
210
152
  }
211
153
 
212
154
  export interface InstallResult {
@@ -231,6 +173,31 @@ export interface ConnectorServiceOpts {
231
173
  deps?: ConnectorServiceDeps;
232
174
  }
233
175
 
176
+ /** Connector-flavored install/remove messages, supplied to the generalized installer. */
177
+ function connectorMessages(): ManagedUnitMessages {
178
+ const lingerWarning =
179
+ "Note: could not enable lingering (loginctl enable-linger) — the connector will run while you're logged in but may not start on a cold boot before login. To run on cold boot without an active login, re-run this command as root (installs a system unit that needs no linger).";
180
+ return {
181
+ launchctlMissing: "launchctl not found; using a transient connector (won't survive a reboot).",
182
+ systemctlMissing: "systemctl not found; using a transient connector (won't survive a reboot).",
183
+ lingerWarning,
184
+ writeFailedPrefix:
185
+ "Failed to write service file; using a transient connector (won't survive a reboot)",
186
+ launchctlLoadFailedPrefix:
187
+ "launchctl could not load the connector service; using a transient connector (won't survive a reboot)",
188
+ daemonReloadFailedPrefix:
189
+ "systemctl daemon-reload failed; using a transient connector (won't survive a reboot)",
190
+ enableFailedPrefix:
191
+ "systemctl enable --now failed; using a transient connector (won't survive a reboot)",
192
+ // The connector always installs with start:true, so the `started` param these
193
+ // callbacks receive is unused here — the message is always the "starts now" variant.
194
+ launchdInstalled: (label, _started) =>
195
+ `Installed launchd LaunchAgent ${label} — the connector now starts on login/boot.`,
196
+ systemdInstalled: (unitName, root, _started) =>
197
+ `Installed systemd ${root ? "system" : "user"} unit ${unitName} — the connector now starts on boot.`,
198
+ };
199
+ }
200
+
234
201
  /**
235
202
  * Install (or refresh) the reboot-persistent connector service for one tunnel
236
203
  * and start it. Idempotent: re-installing overwrites the service file and
@@ -251,172 +218,27 @@ export function installConnectorService(opts: ConnectorServiceOpts): InstallResu
251
218
  messages: ["Could not resolve the cloudflared binary path; skipping boot-service install."],
252
219
  };
253
220
  }
254
-
255
- if (deps.platform === "darwin") {
256
- return installLaunchd({ ...opts, deps, cloudflaredPath });
257
- }
258
- if (deps.platform === "linux") {
259
- return installSystemd({ ...opts, deps, cloudflaredPath });
260
- }
261
- return {
262
- outcome: "fallback",
263
- messages: [
264
- `Boot-persistent connector isn't supported on ${deps.platform}; using a transient connector.`,
265
- ],
266
- };
267
- }
268
-
269
- function installLaunchd(
270
- opts: ConnectorServiceOpts & { deps: ConnectorServiceDeps; cloudflaredPath: string },
271
- ): InstallResult {
272
- const { deps, tunnelName, configPath, logPath, cloudflaredPath } = opts;
273
- if (deps.which("launchctl") === null) {
274
- return {
275
- outcome: "fallback",
276
- messages: ["launchctl not found; using a transient connector (won't survive a reboot)."],
277
- };
278
- }
279
- const home = deps.homeDir();
280
- const plistPath = launchdPlistPath(tunnelName, home);
281
- const label = launchdLabel(tunnelName);
282
- const uid = deps.getuid() ?? 0;
283
- const domain = `gui/${uid}`;
284
-
285
- try {
286
- deps.writeFile(
287
- plistPath,
288
- renderLaunchdPlist({ tunnelName, cloudflaredPath, configPath, logPath }),
289
- );
290
- } catch (err) {
291
- return {
292
- outcome: "fallback",
293
- messages: [
294
- `Failed to write LaunchAgent (${err instanceof Error ? err.message : String(err)}); using a transient connector (won't survive a reboot).`,
295
- ],
296
- };
297
- }
298
-
299
- // Re-install must be idempotent: bootout any prior load (ignore failure when
300
- // nothing's loaded), then bootstrap the freshly-written plist. `bootstrap`
301
- // both loads AND starts (RunAtLoad), so no separate `kickstart` is needed on
302
- // the happy path; we add a `kickstart -k` to force a restart when the label
303
- // was already bootstrapped (bootstrap is a no-op then).
304
- deps.run(["launchctl", "bootout", `${domain}/${label}`]);
305
- const boot = deps.run(["launchctl", "bootstrap", domain, plistPath]);
306
- if (boot.code !== 0) {
307
- // Older macOS (or a sandboxed context) may not accept `bootstrap`; fall back
308
- // to the legacy `load -w`, then to a transient connector.
309
- const legacy = deps.run(["launchctl", "load", "-w", plistPath]);
310
- if (legacy.code !== 0) {
311
- deps.removeFile(plistPath);
312
- return {
313
- outcome: "fallback",
314
- messages: [
315
- `launchctl could not load the connector service (${boot.stderr.trim() || legacy.stderr.trim() || "unknown error"}); using a transient connector (won't survive a reboot).`,
316
- ],
317
- };
318
- }
319
- } else {
320
- deps.run(["launchctl", "kickstart", "-k", `${domain}/${label}`]);
321
- }
322
-
323
- return {
324
- outcome: "installed",
325
- kind: "launchd",
326
- servicePath: plistPath,
327
- messages: [`Installed launchd LaunchAgent ${label} — the connector now starts on login/boot.`],
328
- };
329
- }
330
-
331
- function installSystemd(
332
- opts: ConnectorServiceOpts & { deps: ConnectorServiceDeps; cloudflaredPath: string },
333
- ): InstallResult {
334
- const { deps, tunnelName, configPath, logPath, cloudflaredPath } = opts;
335
- if (deps.which("systemctl") === null) {
336
- return {
337
- outcome: "fallback",
338
- messages: ["systemctl not found; using a transient connector (won't survive a reboot)."],
339
- };
340
- }
341
- const root = (deps.getuid() ?? 1000) === 0;
342
- const home = deps.homeDir();
343
- const unitName = systemdUnitName(tunnelName);
344
- const unitPath = systemdUnitPath(tunnelName, home, root);
345
- const userName = deps.userName();
346
-
347
- try {
348
- deps.writeFile(
349
- unitPath,
350
- renderSystemdUnit({ tunnelName, cloudflaredPath, configPath, logPath, root, userName }),
351
- );
352
- } catch (err) {
221
+ if (deps.platform !== "darwin" && deps.platform !== "linux") {
353
222
  return {
354
223
  outcome: "fallback",
355
224
  messages: [
356
- `Failed to write systemd unit (${err instanceof Error ? err.message : String(err)}); using a transient connector (won't survive a reboot).`,
225
+ `Boot-persistent connector isn't supported on ${deps.platform}; using a transient connector.`,
357
226
  ],
358
227
  };
359
228
  }
360
229
 
361
- const scope = root ? [] : ["--user"];
362
- const messages: string[] = [];
363
-
364
- // Non-root: enable linger so the user unit runs without an active login
365
- // (i.e. after a reboot before the operator logs back in). Strictly
366
- // best-effort — linger may be unavailable: `loginctl` absent entirely (a
367
- // container with systemd but no logind), or present-but-failing. Either way
368
- // we keep the install (a user unit is still better than a transient spawn)
369
- // and warn. The probe + try/catch matter because production `Bun.spawnSync`
370
- // THROWS on ENOENTwithout the guard a box that has systemctl but not
371
- // loginctl would propagate the spawn error out and hard-fail the expose.
372
- if (!root && userName) {
373
- const lingerWarning =
374
- "Note: could not enable lingering (loginctl enable-linger) — the connector will run while you're logged in but may not start on a cold boot before login. To run on cold boot without an active login, re-run this command as root (installs a system unit that needs no linger).";
375
- if (deps.which("loginctl") === null) {
376
- messages.push(lingerWarning);
377
- } else {
378
- try {
379
- const linger = deps.run(["loginctl", "enable-linger", userName]);
380
- if (linger.code !== 0) messages.push(lingerWarning);
381
- } catch {
382
- // loginctl vanished between probe and run, or threw (ENOENT/EACCES) —
383
- // never fatal; linger is a best-effort nicety.
384
- messages.push(lingerWarning);
385
- }
386
- }
387
- }
388
-
389
- const reload = deps.run(["systemctl", ...scope, "daemon-reload"]);
390
- if (reload.code !== 0) {
391
- deps.removeFile(unitPath);
392
- return {
393
- outcome: "fallback",
394
- messages: [
395
- `systemctl daemon-reload failed (${reload.stderr.trim() || "unknown error"}); using a transient connector (won't survive a reboot).`,
396
- ],
397
- };
398
- }
399
- const enable = deps.run(["systemctl", ...scope, "enable", "--now", unitName]);
400
- if (enable.code !== 0) {
401
- deps.removeFile(unitPath);
402
- deps.run(["systemctl", ...scope, "daemon-reload"]);
403
- return {
404
- outcome: "fallback",
405
- messages: [
406
- `systemctl enable --now failed (${enable.stderr.trim() || "unknown error"}); using a transient connector (won't survive a reboot).`,
407
- ],
408
- };
409
- }
410
-
411
- messages.unshift(
412
- `Installed systemd ${root ? "system" : "user"} unit ${unitName} — the connector now starts on boot.`,
413
- );
414
- return {
415
- outcome: "installed",
416
- kind: root ? "systemd-system" : "systemd-user",
417
- servicePath: unitPath,
418
- messages,
419
- };
230
+ const result: ManagedUnitInstallResult = installManagedUnit({
231
+ unit: connectorUnit({
232
+ tunnelName: opts.tunnelName,
233
+ cloudflaredPath,
234
+ configPath: opts.configPath,
235
+ logPath: opts.logPath,
236
+ }),
237
+ deps,
238
+ messages: connectorMessages(),
239
+ // start: true (default) — the connector's behavior is unchanged.
240
+ });
241
+ return result;
420
242
  }
421
243
 
422
244
  export interface RemoveResult {
@@ -440,39 +262,12 @@ export function removeConnectorService(opts: {
440
262
  deps?: ConnectorServiceDeps;
441
263
  }): RemoveResult {
442
264
  const deps = opts.deps ?? defaultServiceDeps;
443
- const { tunnelName } = opts;
444
-
445
- if (deps.platform === "darwin") {
446
- const home = deps.homeDir();
447
- const plistPath = launchdPlistPath(tunnelName, home);
448
- if (!deps.exists(plistPath)) return { removed: false, messages: [] };
449
- const uid = deps.getuid() ?? 0;
450
- const label = launchdLabel(tunnelName);
451
- // bootout unloads + stops; ignore its exit (nothing-loaded is fine).
452
- deps.run(["launchctl", "bootout", `gui/${uid}/${label}`]);
453
- deps.removeFile(plistPath);
454
- return {
455
- removed: true,
456
- messages: [`Removed launchd LaunchAgent ${label}.`],
457
- };
458
- }
459
-
460
- if (deps.platform === "linux") {
461
- const root = (deps.getuid() ?? 1000) === 0;
462
- const home = deps.homeDir();
463
- const unitName = systemdUnitName(tunnelName);
464
- const unitPath = systemdUnitPath(tunnelName, home, root);
465
- if (!deps.exists(unitPath)) return { removed: false, messages: [] };
466
- const scope = root ? [] : ["--user"];
467
- // disable --now stops + removes the enable symlink; ignore exit (best-effort).
468
- deps.run(["systemctl", ...scope, "disable", "--now", unitName]);
469
- deps.removeFile(unitPath);
470
- deps.run(["systemctl", ...scope, "daemon-reload"]);
471
- return {
472
- removed: true,
473
- messages: [`Removed systemd unit ${unitName}.`],
474
- };
475
- }
476
-
477
- return { removed: false, messages: [] };
265
+ const result: ManagedUnitRemoveResult = removeManagedUnit({
266
+ launchdLabel: launchdLabel(opts.tunnelName),
267
+ systemdUnitName: systemdUnitName(opts.tunnelName),
268
+ deps,
269
+ removedLaunchdMessage: (label) => `Removed launchd LaunchAgent ${label}.`,
270
+ removedSystemdMessage: (unitName) => `Removed systemd unit ${unitName}.`,
271
+ });
272
+ return result;
478
273
  }