@openparachute/hub 0.6.1 → 0.6.3-rc.1

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.
@@ -0,0 +1,674 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ /**
6
+ * Platform-agnostic "managed unit" machinery — the reusable launchd/systemd
7
+ * install + remove + render core that powers BOTH the reboot-persistent
8
+ * cloudflared connector (`src/cloudflare/connector-service.ts`) and the hub
9
+ * unit (`buildHubManagedUnit`, the Phase 3+ `parachute serve` keeper).
10
+ *
11
+ * This file was extracted in Phase 2b of the hub-as-supervisor unification
12
+ * (design: `parachute.computer/design/2026-06-01-hub-as-supervisor-unification.md`
13
+ * §4.1, §4.2, §7.1). The cloudflared connector was the prototype: it already
14
+ * had the per-platform install/remove seam, the graceful no-throw contract, and
15
+ * the launchd-bootstrap / systemd-enable command sequences. Phase 2b factors the
16
+ * cloudflared-specifics out into a `ManagedUnit` descriptor so the SAME machinery
17
+ * can emit a hub unit running `<bun> <cli.ts> serve`.
18
+ *
19
+ * THE HARD CONSTRAINT (design §"preserve connector behavior exactly"): the
20
+ * cloudflared connector is LIVE on both production boxes. The rendered systemd
21
+ * unit text, the rendered launchd plist text, and the install/remove command
22
+ * sequences must be BYTE-identical before and after this extraction. The
23
+ * generalization adds three NEW, OPTIONAL capabilities — an env block, a
24
+ * crash-loop ceiling, and an install-without-start mode — each gated so that a
25
+ * descriptor that leaves them unset (the connector) renders exactly as before:
26
+ * - env block: emitted only when `env` is non-empty (connector passes `{}`).
27
+ * - crash-loop ceiling: emitted only when `crashLoop` is set (connector omits).
28
+ * - install-without-start: `start` defaults to `true` (connector's behavior).
29
+ *
30
+ * Platform shapes (carried over verbatim from the connector):
31
+ * - macOS → a launchd LaunchAgent plist (RunAtLoad + KeepAlive), bootstrapped
32
+ * into the per-user GUI domain. No sudo.
33
+ * - Linux (non-root) → a systemd *user* unit, `systemctl --user enable --now`,
34
+ * plus a best-effort `loginctl enable-linger` (hub#494).
35
+ * - Linux (root) → a systemd *system* unit, `systemctl enable --now`.
36
+ *
37
+ * Everything is behind the injectable `ManagedUnitDeps` seam (identical shape to
38
+ * the connector's old `ConnectorServiceDeps`) so tests drive install/remove
39
+ * without touching real launchctl/systemctl or the operator's home directory.
40
+ */
41
+
42
+ /** Synchronous command result from the injected service runner. */
43
+ export interface ServiceCommandResult {
44
+ code: number;
45
+ stdout: string;
46
+ stderr: string;
47
+ }
48
+
49
+ /**
50
+ * Injectable side-effect seam for the managed-unit machinery. Production wires
51
+ * the real fs / os / child-process implementations (`defaultManagedUnitDeps`);
52
+ * tests inject fakes to assert generated file content + the install/remove
53
+ * command sequence without a live launchctl/systemctl.
54
+ */
55
+ export interface ManagedUnitDeps {
56
+ /** `process.platform`. */
57
+ platform: NodeJS.Platform;
58
+ /**
59
+ * Effective uid. Linux uses `0 === root` to pick a system vs user systemd
60
+ * unit. `undefined` (Windows / platforms without getuid) → treated as
61
+ * non-root. macOS ignores this (LaunchAgents are always per-user).
62
+ */
63
+ getuid: () => number | undefined;
64
+ /** `$HOME`. */
65
+ homeDir: () => string;
66
+ /** Username for the linger call + systemd unit `User=` (system unit). */
67
+ userName: () => string;
68
+ /** Resolve a binary to an absolute path (launchd/systemd don't inherit PATH). */
69
+ which: (binary: string) => string | null;
70
+ /** Run launchctl/systemctl/loginctl synchronously. */
71
+ run: (cmd: readonly string[]) => ServiceCommandResult;
72
+ /** Write a service file (creates parent dirs). */
73
+ writeFile: (path: string, content: string) => void;
74
+ /** Remove a service file if present (no-op when absent). */
75
+ removeFile: (path: string) => void;
76
+ /** Read a service file, or undefined when absent. */
77
+ readFile: (path: string) => string | undefined;
78
+ /** True when the path exists. */
79
+ exists: (path: string) => boolean;
80
+ }
81
+
82
+ export const defaultManagedUnitDeps: ManagedUnitDeps = {
83
+ platform: process.platform,
84
+ getuid: () => (typeof process.getuid === "function" ? process.getuid() : undefined),
85
+ homeDir: () => homedir(),
86
+ userName: () => process.env.USER ?? process.env.LOGNAME ?? process.env.USERNAME ?? "",
87
+ which: (binary) => Bun.which(binary),
88
+ run: (cmd) => {
89
+ const proc = Bun.spawnSync([...cmd], { env: process.env });
90
+ return {
91
+ code: proc.exitCode ?? 1,
92
+ stdout: proc.stdout?.toString() ?? "",
93
+ stderr: proc.stderr?.toString() ?? "",
94
+ };
95
+ },
96
+ writeFile: (path, content) => {
97
+ mkdirSync(dirname(path), { recursive: true });
98
+ writeFileSync(path, content);
99
+ },
100
+ removeFile: (path) => {
101
+ if (existsSync(path)) rmSync(path, { force: true });
102
+ },
103
+ readFile: (path) => (existsSync(path) ? readFileSync(path, "utf8") : undefined),
104
+ exists: (path) => existsSync(path),
105
+ };
106
+
107
+ /**
108
+ * Optional crash-loop ceiling for a managed unit. Caps the respawn rate so a
109
+ * wedged process (corrupt DB, held port) becomes a visible `failed` unit rather
110
+ * than an infinite tight loop (design §4.1 / §6.3). The connector leaves this
111
+ * unset (its respawn is unbounded as before); the hub unit sets it.
112
+ */
113
+ export interface CrashLoopCeiling {
114
+ /** systemd `StartLimitIntervalSec` — the rate-limit window, seconds. */
115
+ intervalSec: number;
116
+ /** systemd `StartLimitBurst` — max restarts within the window. */
117
+ burst: number;
118
+ /**
119
+ * launchd `ThrottleInterval` — minimum seconds between respawns. launchd has
120
+ * no burst concept, so this bounds the rate by spacing rather than capping a
121
+ * count (design §4.1 — "the same hub-crash-loop story as systemd's
122
+ * StartLimit").
123
+ */
124
+ throttleIntervalSec: number;
125
+ }
126
+
127
+ /**
128
+ * Platform-agnostic descriptor of a process to keep alive under launchd /
129
+ * systemd. The connector and the hub both reduce to one of these; the renderers
130
+ * + installer below consume only this shape.
131
+ */
132
+ export interface ManagedUnit {
133
+ /**
134
+ * launchd Label (also the plist basename, minus `.plist`), reverse-DNS style,
135
+ * e.g. `computer.parachute.cloudflared.<tunnel>` or `computer.parachute.hub`.
136
+ */
137
+ launchdLabel: string;
138
+ /** systemd unit name including the `.service` suffix, e.g. `parachute-hub.service`. */
139
+ systemdUnitName: string;
140
+ /** First line of every rendered unit file (provenance comment). */
141
+ headerComment: string;
142
+ /** systemd `[Unit] Description=`. */
143
+ systemdDescription: string;
144
+ /**
145
+ * The argv to run. `execStart[0]` MUST be an absolute binary path (launchd
146
+ * does not search `$PATH`, systemd uses no login `$PATH`); callers resolve it
147
+ * via `deps.which` at build time.
148
+ */
149
+ execStart: string[];
150
+ /**
151
+ * Environment to inject into the unit. EMPTY → no env block is emitted at all
152
+ * (the connector's behavior — keeps its output byte-identical). Non-empty →
153
+ * a systemd `Environment=KEY=VAL` line per entry / a launchd
154
+ * `EnvironmentVariables` dict.
155
+ */
156
+ env: Record<string, string>;
157
+ /** Where the process's stdout+stderr are written. */
158
+ logPath: string;
159
+ /**
160
+ * Optional crash-loop ceiling. Unset → no StartLimit / ThrottleInterval lines
161
+ * (connector). Set → emitted (hub unit).
162
+ */
163
+ crashLoop?: CrashLoopCeiling;
164
+ /**
165
+ * When `true`, a systemd *system* unit pins `User=<userName>` so the process
166
+ * drops root. `false` → never pin `User=` even on a system unit. The connector
167
+ * sets this `true` (it has always pinned `User=` on the root/system unit); the
168
+ * hub sets it `true` as well (design §4.1 "User=<operator> on the system
169
+ * unit only"). It is a descriptor field rather than a hard-coded behavior so a
170
+ * future unit that wants to genuinely run as root can opt out.
171
+ */
172
+ runAsInvokingUserOnSystemUnit: boolean;
173
+ }
174
+
175
+ /** XML-escape a string for safe inclusion in a plist `<string>` element. */
176
+ function plistEscape(s: string): string {
177
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
178
+ }
179
+
180
+ /**
181
+ * Render a launchd LaunchAgent plist for a managed unit. `RunAtLoad` starts the
182
+ * process on load (and on login/boot once bootstrapped); `KeepAlive` restarts it
183
+ * if it exits. `ProgramArguments` is the descriptor's `execStart` verbatim
184
+ * (absolute argv[0] — launchd does not search `$PATH`). Logs go to `logPath`.
185
+ *
186
+ * BYTE-IDENTICAL CONSTRAINT: when `unit.env` is empty and `unit.crashLoop` is
187
+ * unset (the connector), this emits exactly the plist the connector emitted
188
+ * before extraction — no `EnvironmentVariables` dict, no `ThrottleInterval`.
189
+ */
190
+ export function renderManagedLaunchdPlist(unit: ManagedUnit): string {
191
+ const argXml = unit.execStart.map((a) => ` <string>${plistEscape(a)}</string>`).join("\n");
192
+ // EnvironmentVariables dict — emitted ONLY when env is non-empty (connector
193
+ // passes {} and gets no dict, preserving its byte-identical output).
194
+ const envKeys = Object.keys(unit.env);
195
+ let envBlock = "";
196
+ if (envKeys.length > 0) {
197
+ const entries = envKeys
198
+ .map(
199
+ (k) =>
200
+ ` <key>${plistEscape(k)}</key>\n <string>${plistEscape(unit.env[k] ?? "")}</string>`,
201
+ )
202
+ .join("\n");
203
+ envBlock = ` <key>EnvironmentVariables</key>\n <dict>\n${entries}\n </dict>\n`;
204
+ }
205
+ // ThrottleInterval — emitted ONLY when a crash-loop ceiling is set.
206
+ const throttle = unit.crashLoop
207
+ ? ` <key>ThrottleInterval</key>\n <integer>${unit.crashLoop.throttleIntervalSec}</integer>\n`
208
+ : "";
209
+ return `<?xml version="1.0" encoding="UTF-8"?>
210
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
211
+ <!-- ${unit.headerComment} -->
212
+ <plist version="1.0">
213
+ <dict>
214
+ <key>Label</key>
215
+ <string>${plistEscape(unit.launchdLabel)}</string>
216
+ <key>ProgramArguments</key>
217
+ <array>
218
+ ${argXml}
219
+ </array>
220
+ ${envBlock} <key>RunAtLoad</key>
221
+ <true/>
222
+ <key>KeepAlive</key>
223
+ <true/>
224
+ ${throttle} <key>StandardOutPath</key>
225
+ <string>${plistEscape(unit.logPath)}</string>
226
+ <key>StandardErrorPath</key>
227
+ <string>${plistEscape(unit.logPath)}</string>
228
+ </dict>
229
+ </plist>
230
+ `;
231
+ }
232
+
233
+ /**
234
+ * Render a systemd unit for a managed unit. `Restart=always` mirrors launchd's
235
+ * KeepAlive; `WantedBy` differs by scope (system → multi-user.target; user →
236
+ * default.target). A system unit pins `User=` (when `runAsInvokingUserOnSystemUnit`
237
+ * + a known user) so the process doesn't run as root unnecessarily. `ExecStart`
238
+ * uses the descriptor's absolute `execStart` joined by spaces.
239
+ *
240
+ * BYTE-IDENTICAL CONSTRAINT: when `unit.env` is empty and `unit.crashLoop` is
241
+ * unset (the connector), this emits exactly the unit the connector emitted
242
+ * before extraction — no `Environment=` lines, no `StartLimit*` lines.
243
+ */
244
+ export function renderManagedSystemdUnit(
245
+ unit: ManagedUnit,
246
+ opts: { root: boolean; userName: string },
247
+ ): string {
248
+ const { root, userName } = opts;
249
+ const execStart = unit.execStart.join(" ");
250
+ const userLine =
251
+ root && unit.runAsInvokingUserOnSystemUnit && userName ? `User=${userName}\n` : "";
252
+ const wantedBy = root ? "multi-user.target" : "default.target";
253
+ // Environment= lines — emitted ONLY when env is non-empty (connector passes {}
254
+ // and gets none, preserving its byte-identical output). One line per entry,
255
+ // insertion order, placed after the optional User= line.
256
+ const envLines = Object.keys(unit.env)
257
+ .map((k) => `Environment=${k}=${unit.env[k] ?? ""}\n`)
258
+ .join("");
259
+ // StartLimit* — emitted ONLY when a crash-loop ceiling is set, in the
260
+ // [Service] section after RestartSec.
261
+ const startLimit = unit.crashLoop
262
+ ? `StartLimitIntervalSec=${unit.crashLoop.intervalSec}\nStartLimitBurst=${unit.crashLoop.burst}\n`
263
+ : "";
264
+ return `# ${unit.headerComment}
265
+ [Unit]
266
+ Description=${unit.systemdDescription}
267
+ After=network-online.target
268
+ Wants=network-online.target
269
+
270
+ [Service]
271
+ Type=simple
272
+ ${userLine}${envLines}ExecStart=${execStart}
273
+ Restart=always
274
+ RestartSec=5
275
+ ${startLimit}
276
+ [Install]
277
+ WantedBy=${wantedBy}
278
+ `;
279
+ }
280
+
281
+ /** launchd plist path under the user's LaunchAgents dir for a label. */
282
+ export function launchdPlistPathForLabel(label: string, home: string): string {
283
+ return join(home, "Library", "LaunchAgents", `${label}.plist`);
284
+ }
285
+
286
+ /** systemd unit path — user-level under $HOME, system-level under /etc. */
287
+ export function systemdUnitPathForName(unitName: string, home: string, root: boolean): string {
288
+ return root
289
+ ? join("/etc/systemd/system", unitName)
290
+ : join(home, ".config", "systemd", "user", unitName);
291
+ }
292
+
293
+ export interface ManagedUnitInstallResult {
294
+ /**
295
+ * "installed" → an OS service now owns the unit (survives reboot).
296
+ * "fallback" → the service tool was unavailable / failed; the caller decides
297
+ * what to do (the connector falls back to a transient `proc.unref()` spawn).
298
+ */
299
+ outcome: "installed" | "fallback";
300
+ /** Which init system installed the service (when outcome === "installed"). */
301
+ kind?: "launchd" | "systemd-user" | "systemd-system" | "unsupported";
302
+ /** Path of the written service file (when outcome === "installed"). */
303
+ servicePath?: string;
304
+ /** Human-readable lines for the CLI to print (warnings, hints). */
305
+ messages: string[];
306
+ }
307
+
308
+ export interface ManagedUnitMessages {
309
+ /** Message when launchctl is missing — caller-specific (connector vs hub wording). */
310
+ launchctlMissing: string;
311
+ /** Message when systemctl is missing. */
312
+ systemctlMissing: string;
313
+ /** Soft warning when `loginctl enable-linger` couldn't run (non-root user unit). */
314
+ lingerWarning: string;
315
+ /** Prefix for the failed-to-write-file fallback message. */
316
+ writeFailedPrefix: string;
317
+ /** Prefix for the launchctl-could-not-load fallback message. */
318
+ launchctlLoadFailedPrefix: string;
319
+ /** Prefix for the systemctl-daemon-reload-failed fallback message. */
320
+ daemonReloadFailedPrefix: string;
321
+ /** Prefix for the systemctl-enable-failed fallback message. */
322
+ enableFailedPrefix: string;
323
+ /** Success message for a launchd install (`{label}` placeholder). */
324
+ launchdInstalled: (label: string, started: boolean) => string;
325
+ /** Success message for a systemd install. */
326
+ systemdInstalled: (unitName: string, root: boolean, started: boolean) => string;
327
+ }
328
+
329
+ export interface InstallManagedUnitOpts {
330
+ unit: ManagedUnit;
331
+ deps: ManagedUnitDeps;
332
+ messages: ManagedUnitMessages;
333
+ /**
334
+ * When `false`, write + register the unit WITHOUT starting it: systemd does
335
+ * `daemon-reload` but NOT `enable --now`; launchd writes the plist but does
336
+ * NOT `bootstrap`/`kickstart`. Used by the Phase 5 migration cutover to avoid
337
+ * a second hub racing port 1939 (design §7.1). Defaults to `true` (full
338
+ * behavior — the connector's path is unchanged).
339
+ */
340
+ start?: boolean;
341
+ }
342
+
343
+ /**
344
+ * Install (or refresh) a managed unit on the current platform. Idempotent:
345
+ * re-installing overwrites the unit file and re-loads it. Graceful: a missing /
346
+ * failing tool returns `{ outcome: "fallback", messages }` WITHOUT throwing.
347
+ *
348
+ * Dispatches by platform; `execStart[0]` is assumed already resolved to an
349
+ * absolute path by the caller (the connector resolves cloudflared, the hub
350
+ * resolves bun — both via `deps.which`).
351
+ */
352
+ export function installManagedUnit(opts: InstallManagedUnitOpts): ManagedUnitInstallResult {
353
+ const { deps } = opts;
354
+ if (deps.platform === "darwin") return installLaunchdUnit(opts);
355
+ if (deps.platform === "linux") return installSystemdUnit(opts);
356
+ return {
357
+ outcome: "fallback",
358
+ messages: [
359
+ `Boot-persistent unit isn't supported on ${deps.platform}; using a transient process.`,
360
+ ],
361
+ };
362
+ }
363
+
364
+ function installLaunchdUnit(opts: InstallManagedUnitOpts): ManagedUnitInstallResult {
365
+ const { unit, deps, messages } = opts;
366
+ const start = opts.start ?? true;
367
+ if (deps.which("launchctl") === null) {
368
+ return { outcome: "fallback", messages: [messages.launchctlMissing] };
369
+ }
370
+ const home = deps.homeDir();
371
+ const plistPath = launchdPlistPathForLabel(unit.launchdLabel, home);
372
+ const label = unit.launchdLabel;
373
+ const uid = deps.getuid() ?? 0;
374
+ const domain = `gui/${uid}`;
375
+
376
+ try {
377
+ deps.writeFile(plistPath, renderManagedLaunchdPlist(unit));
378
+ } catch (err) {
379
+ return {
380
+ outcome: "fallback",
381
+ messages: [
382
+ `${messages.writeFailedPrefix} (${err instanceof Error ? err.message : String(err)}).`,
383
+ ],
384
+ };
385
+ }
386
+
387
+ if (!start) {
388
+ // install-without-start: the plist is on disk + will load on next login/boot
389
+ // (RunAtLoad), but we do NOT bootstrap/kickstart it now, so no process is
390
+ // started in this call (design §7.1 — avoid racing port 1939 during cutover).
391
+ // We also deliberately do NOT bootout any prior service: the CALLER (the
392
+ // Phase 5 migrate cutover) owns stopping the detached/prior process before
393
+ // installing this unit. Do not add a pre-bootout/stop here — it would break
394
+ // the §7.1 ordering the caller relies on to avoid the double-spawn race.
395
+ return {
396
+ outcome: "installed",
397
+ kind: "launchd",
398
+ servicePath: plistPath,
399
+ messages: [messages.launchdInstalled(label, false)],
400
+ };
401
+ }
402
+
403
+ // Re-install must be idempotent: bootout any prior load (ignore failure when
404
+ // nothing's loaded), then bootstrap the freshly-written plist. `bootstrap`
405
+ // both loads AND starts (RunAtLoad), so no separate `kickstart` is needed on
406
+ // the happy path; we add a `kickstart -k` to force a restart when the label
407
+ // was already bootstrapped (bootstrap is a no-op then).
408
+ deps.run(["launchctl", "bootout", `${domain}/${label}`]);
409
+ const boot = deps.run(["launchctl", "bootstrap", domain, plistPath]);
410
+ if (boot.code !== 0) {
411
+ // Older macOS (or a sandboxed context) may not accept `bootstrap`; fall back
412
+ // to the legacy `load -w`, then to a fallback outcome.
413
+ const legacy = deps.run(["launchctl", "load", "-w", plistPath]);
414
+ if (legacy.code !== 0) {
415
+ deps.removeFile(plistPath);
416
+ return {
417
+ outcome: "fallback",
418
+ messages: [
419
+ `${messages.launchctlLoadFailedPrefix} (${boot.stderr.trim() || legacy.stderr.trim() || "unknown error"}).`,
420
+ ],
421
+ };
422
+ }
423
+ } else {
424
+ deps.run(["launchctl", "kickstart", "-k", `${domain}/${label}`]);
425
+ }
426
+
427
+ return {
428
+ outcome: "installed",
429
+ kind: "launchd",
430
+ servicePath: plistPath,
431
+ messages: [messages.launchdInstalled(label, true)],
432
+ };
433
+ }
434
+
435
+ function installSystemdUnit(opts: InstallManagedUnitOpts): ManagedUnitInstallResult {
436
+ const { unit, deps, messages } = opts;
437
+ const start = opts.start ?? true;
438
+ if (deps.which("systemctl") === null) {
439
+ return { outcome: "fallback", messages: [messages.systemctlMissing] };
440
+ }
441
+ const root = (deps.getuid() ?? 1000) === 0;
442
+ const home = deps.homeDir();
443
+ const unitName = unit.systemdUnitName;
444
+ const unitPath = systemdUnitPathForName(unitName, home, root);
445
+ const userName = deps.userName();
446
+
447
+ try {
448
+ deps.writeFile(unitPath, renderManagedSystemdUnit(unit, { root, userName }));
449
+ } catch (err) {
450
+ return {
451
+ outcome: "fallback",
452
+ messages: [
453
+ `${messages.writeFailedPrefix} (${err instanceof Error ? err.message : String(err)}).`,
454
+ ],
455
+ };
456
+ }
457
+
458
+ const scope = root ? [] : ["--user"];
459
+ const outMessages: string[] = [];
460
+
461
+ // Non-root: enable linger so the user unit runs without an active login (i.e.
462
+ // after a reboot before the operator logs back in). Strictly best-effort —
463
+ // linger may be unavailable: `loginctl` absent entirely (a container with
464
+ // systemd but no logind), or present-but-failing. Either way we keep the
465
+ // install and warn. The probe + try/catch matter because production
466
+ // `Bun.spawnSync` THROWS on ENOENT — without the guard a box that has
467
+ // systemctl but not loginctl would propagate the spawn error out and hard-fail
468
+ // the calling command. (Run on both start + install-without-start: linger is a
469
+ // boot-survival nicety independent of whether we start the unit now.)
470
+ if (!root && userName) {
471
+ if (deps.which("loginctl") === null) {
472
+ outMessages.push(messages.lingerWarning);
473
+ } else {
474
+ try {
475
+ const linger = deps.run(["loginctl", "enable-linger", userName]);
476
+ if (linger.code !== 0) outMessages.push(messages.lingerWarning);
477
+ } catch {
478
+ // loginctl vanished between probe and run, or threw (ENOENT/EACCES) —
479
+ // never fatal; linger is a best-effort nicety.
480
+ outMessages.push(messages.lingerWarning);
481
+ }
482
+ }
483
+ }
484
+
485
+ const reload = deps.run(["systemctl", ...scope, "daemon-reload"]);
486
+ if (reload.code !== 0) {
487
+ deps.removeFile(unitPath);
488
+ return {
489
+ outcome: "fallback",
490
+ messages: [
491
+ `${messages.daemonReloadFailedPrefix} (${reload.stderr.trim() || "unknown error"}).`,
492
+ ],
493
+ };
494
+ }
495
+
496
+ if (!start) {
497
+ // install-without-start: the unit file is on disk + daemon-reloaded, but we
498
+ // do NOT `enable --now` it, so no process is started in this call and the
499
+ // unit is not yet enabled for boot (design §7.1 — avoid racing port 1939).
500
+ // We also deliberately do NOT stop any prior service: the CALLER (the Phase 5
501
+ // migrate cutover) owns stopping the detached/prior process before installing
502
+ // this unit. Do not add a pre-stop here — it would break the §7.1 ordering the
503
+ // caller relies on to avoid the double-spawn race.
504
+ outMessages.unshift(messages.systemdInstalled(unitName, root, false));
505
+ return {
506
+ outcome: "installed",
507
+ kind: root ? "systemd-system" : "systemd-user",
508
+ servicePath: unitPath,
509
+ messages: outMessages,
510
+ };
511
+ }
512
+
513
+ const enable = deps.run(["systemctl", ...scope, "enable", "--now", unitName]);
514
+ if (enable.code !== 0) {
515
+ deps.removeFile(unitPath);
516
+ deps.run(["systemctl", ...scope, "daemon-reload"]);
517
+ return {
518
+ outcome: "fallback",
519
+ messages: [`${messages.enableFailedPrefix} (${enable.stderr.trim() || "unknown error"}).`],
520
+ };
521
+ }
522
+
523
+ outMessages.unshift(messages.systemdInstalled(unitName, root, true));
524
+ return {
525
+ outcome: "installed",
526
+ kind: root ? "systemd-system" : "systemd-user",
527
+ servicePath: unitPath,
528
+ messages: outMessages,
529
+ };
530
+ }
531
+
532
+ export interface ManagedUnitRemoveResult {
533
+ /** True when a service file was found + removed (best-effort tool teardown ran). */
534
+ removed: boolean;
535
+ messages: string[];
536
+ }
537
+
538
+ export interface RemoveManagedUnitOpts {
539
+ launchdLabel: string;
540
+ systemdUnitName: string;
541
+ deps: ManagedUnitDeps;
542
+ /** Success message for a launchd removal (`{label}` placeholder). */
543
+ removedLaunchdMessage: (label: string) => string;
544
+ /** Success message for a systemd removal. */
545
+ removedSystemdMessage: (unitName: string) => string;
546
+ }
547
+
548
+ /**
549
+ * Stop + remove a managed unit on the current platform. Idempotent +
550
+ * best-effort: a missing service file is a no-op; tool failures never throw (the
551
+ * teardown path must always succeed at clearing state even if the OS service
552
+ * tool hiccups).
553
+ */
554
+ export function removeManagedUnit(opts: RemoveManagedUnitOpts): ManagedUnitRemoveResult {
555
+ const { deps, launchdLabel, systemdUnitName } = opts;
556
+
557
+ if (deps.platform === "darwin") {
558
+ const home = deps.homeDir();
559
+ const plistPath = launchdPlistPathForLabel(launchdLabel, home);
560
+ if (!deps.exists(plistPath)) return { removed: false, messages: [] };
561
+ const uid = deps.getuid() ?? 0;
562
+ // bootout unloads + stops; ignore its exit (nothing-loaded is fine).
563
+ deps.run(["launchctl", "bootout", `gui/${uid}/${launchdLabel}`]);
564
+ deps.removeFile(plistPath);
565
+ return { removed: true, messages: [opts.removedLaunchdMessage(launchdLabel)] };
566
+ }
567
+
568
+ if (deps.platform === "linux") {
569
+ const root = (deps.getuid() ?? 1000) === 0;
570
+ const home = deps.homeDir();
571
+ const unitPath = systemdUnitPathForName(systemdUnitName, home, root);
572
+ if (!deps.exists(unitPath)) return { removed: false, messages: [] };
573
+ const scope = root ? [] : ["--user"];
574
+ // disable --now stops + removes the enable symlink; ignore exit (best-effort).
575
+ deps.run(["systemctl", ...scope, "disable", "--now", systemdUnitName]);
576
+ deps.removeFile(unitPath);
577
+ deps.run(["systemctl", ...scope, "daemon-reload"]);
578
+ return { removed: true, messages: [opts.removedSystemdMessage(systemdUnitName)] };
579
+ }
580
+
581
+ return { removed: false, messages: [] };
582
+ }
583
+
584
+ // ---------------------------------------------------------------------------
585
+ // Hub unit builder (design §4.1 + §4.2)
586
+ //
587
+ // NOT wired into any command in this PR — Phase 3 wires `init` to install it.
588
+ // Phase 2b only provides + tests the builder.
589
+ // ---------------------------------------------------------------------------
590
+
591
+ /** Reverse-DNS launchd label for the hub unit. */
592
+ export const HUB_LAUNCHD_LABEL = "computer.parachute.hub";
593
+ /** systemd unit name for the hub unit. */
594
+ export const HUB_SYSTEMD_UNIT_NAME = "parachute-hub.service";
595
+
596
+ /** Crash-loop ceiling for the hub unit (design §4.1 / §6.3). */
597
+ const HUB_CRASH_LOOP: CrashLoopCeiling = {
598
+ intervalSec: 300,
599
+ burst: 5,
600
+ throttleIntervalSec: 10,
601
+ };
602
+
603
+ export interface BuildHubManagedUnitOpts {
604
+ /**
605
+ * The operator's CURRENT `PARACHUTE_HOME`, captured at install time and baked
606
+ * into the unit env — NOT the hard-coded default (design §4.2). Phase 3 passes
607
+ * the real captured home.
608
+ */
609
+ parachuteHome: string;
610
+ /** Hub port (default 1939, the canonical pin). */
611
+ port?: number;
612
+ /** Path to the operator's bun install dir (`$BUN_INSTALL`), e.g. `/home/op/.bun`. */
613
+ bunInstall: string;
614
+ /** PATH the unit should run with (must include bun's global bin). */
615
+ path: string;
616
+ /**
617
+ * Absolute path to the `parachute-hub` `src/cli.ts` entry the unit's
618
+ * `ExecStart`/`ProgramArguments` runs `serve` against (the bun-linked checkout
619
+ * or the installed bin). Caller supplies it — Phase 3 derives it.
620
+ */
621
+ cliPath: string;
622
+ /** Log file the hub's stdout+stderr is written to. */
623
+ logPath: string;
624
+ /** Injectable deps for `which` resolution (defaults to production). */
625
+ deps?: ManagedUnitDeps;
626
+ }
627
+
628
+ /**
629
+ * Build the `ManagedUnit` descriptor for the hub itself (design §4.1).
630
+ *
631
+ * Resolves the absolute `bun` path via the `which` seam (launchd/systemd don't
632
+ * search `$PATH` — mirrors how the connector resolves cloudflared). The env
633
+ * carries `PARACHUTE_HOME` / `PORT` / `PATH` / `BUN_INSTALL` — and INTENTIONALLY
634
+ * OMITS `PARACHUTE_HUB_ORIGIN`: baking a stale origin here would re-create the
635
+ * iss-mismatch class; `resolveStartupIssuer` derives it and start-hub self-heals
636
+ * the operator token + vault `.env` to the current origin (design §4.1 comment).
637
+ *
638
+ * NOT called by any command in this PR (additive — Phase 3 wires it into `init`).
639
+ */
640
+ export function buildHubManagedUnit(opts: BuildHubManagedUnitOpts): ManagedUnit {
641
+ const deps = opts.deps ?? defaultManagedUnitDeps;
642
+ const port = opts.port ?? 1939;
643
+ // launchd/systemd do not search $PATH; resolve bun to an absolute path at build
644
+ // time. Fail loud if it can't be resolved — falling back to the literal "bun"
645
+ // would bake a non-functional ExecStart/ProgramArguments[0] into the unit
646
+ // (cryptic start-time failure), so refuse to build a broken unit. (cliPath is
647
+ // caller-supplied as an absolute path, not which-resolved, so it needs no guard.)
648
+ const bunPath = deps.which("bun");
649
+ if (bunPath === null) {
650
+ throw new Error(
651
+ "cannot build hub unit: 'bun' not found on PATH — install bun or ensure it is resolvable",
652
+ );
653
+ }
654
+ return {
655
+ launchdLabel: HUB_LAUNCHD_LABEL,
656
+ systemdUnitName: HUB_SYSTEMD_UNIT_NAME,
657
+ headerComment: "Generated by parachute — do not edit by hand.",
658
+ systemdDescription: "Parachute hub (serve + supervisor)",
659
+ execStart: [bunPath, opts.cliPath, "serve"],
660
+ env: {
661
+ // PARACHUTE_HOME captured at install time (design §4.2) — NOT the default.
662
+ PARACHUTE_HOME: opts.parachuteHome,
663
+ PORT: String(port),
664
+ // PATH + BUN_INSTALL are load-bearing for supervised children that resolve
665
+ // a bun-linked binary on cold boot under a linger-started user unit
666
+ // (design §4.1 / R20). PARACHUTE_HUB_ORIGIN is intentionally OMITTED.
667
+ PATH: opts.path,
668
+ BUN_INSTALL: opts.bunInstall,
669
+ },
670
+ logPath: opts.logPath,
671
+ crashLoop: HUB_CRASH_LOOP,
672
+ runAsInvokingUserOnSystemUnit: true,
673
+ };
674
+ }