@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.
- package/README.md +87 -35
- package/package.json +1 -1
- package/src/__tests__/api-hub-upgrade.test.ts +690 -0
- package/src/__tests__/api-modules-ops.test.ts +359 -3
- package/src/__tests__/api-modules.test.ts +54 -0
- package/src/__tests__/expose-cloudflare.test.ts +163 -72
- package/src/__tests__/expose-off-auto.test.ts +26 -1
- package/src/__tests__/expose.test.ts +260 -240
- package/src/__tests__/hub-control.test.ts +1 -242
- package/src/__tests__/hub-server.test.ts +64 -0
- package/src/__tests__/hub-unit.test.ts +574 -0
- package/src/__tests__/init.test.ts +219 -2
- package/src/__tests__/lifecycle.test.ts +416 -1448
- package/src/__tests__/managed-unit.test.ts +575 -0
- package/src/__tests__/migrate-cutover.test.ts +840 -0
- package/src/__tests__/migrate-offer.test.ts +240 -0
- package/src/__tests__/migrate.test.ts +132 -0
- package/src/__tests__/module-ops-client.test.ts +556 -0
- package/src/__tests__/port-probe.test.ts +23 -0
- package/src/__tests__/setup-wizard.test.ts +130 -0
- package/src/__tests__/status-supervisor.test.ts +504 -0
- package/src/__tests__/status.test.ts +157 -708
- package/src/__tests__/supervisor.test.ts +471 -6
- package/src/__tests__/upgrade.test.ts +351 -5
- package/src/api-hub-upgrade.ts +384 -0
- package/src/api-hub.ts +2 -1
- package/src/api-modules-ops.ts +221 -0
- package/src/api-modules.ts +18 -2
- package/src/cli.ts +97 -12
- package/src/cloudflare/connector-service.ts +117 -322
- package/src/commands/expose-cloudflare.ts +63 -71
- package/src/commands/expose-supervisor.ts +247 -0
- package/src/commands/expose.ts +59 -48
- package/src/commands/init.ts +225 -12
- package/src/commands/lifecycle.ts +455 -816
- package/src/commands/migrate-cutover.ts +837 -0
- package/src/commands/migrate.ts +71 -2
- package/src/commands/serve-boot.ts +71 -25
- package/src/commands/status.ts +535 -235
- package/src/commands/upgrade.ts +100 -2
- package/src/help.ts +128 -68
- package/src/hub-control.ts +23 -162
- package/src/hub-server.ts +39 -0
- package/src/hub-unit.ts +735 -0
- package/src/hub-upgrade-helper.ts +306 -0
- package/src/hub-upgrade-mode.ts +209 -0
- package/src/hub-upgrade-status.ts +150 -0
- package/src/managed-unit.ts +692 -0
- package/src/migrate-offer.ts +186 -0
- package/src/module-ops-client.ts +457 -0
- package/src/port-probe.ts +50 -0
- package/src/process-state.ts +19 -3
- package/src/setup-wizard.ts +80 -1
- package/src/supervisor.ts +389 -38
- package/web/ui/dist/assets/index-D_6AFvZy.js +61 -0
- package/web/ui/dist/assets/{index-BiBlvEaj.css → index-mz8XcVPP.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-CIN3mnmf.js +0 -61
|
@@ -0,0 +1,692 @@
|
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
/**
|
|
301
|
+
* On a `fallback`, WHY we fell back — so callers can give an accurate message
|
|
302
|
+
* instead of conflating the two causes:
|
|
303
|
+
* - "no-manager" → no service manager is available here (launchctl/systemctl
|
|
304
|
+
* missing, or an unsupported platform). Boot-persistence is impossible.
|
|
305
|
+
* - "write-failed" → a manager exists but the install itself failed (couldn't
|
|
306
|
+
* write the unit file, daemon-reload / enable / bootstrap returned non-zero).
|
|
307
|
+
* Undefined when `outcome === "installed"`. The connector ignores this (it only
|
|
308
|
+
* branches on `outcome`); the Phase 5 cutover reads it to pick `no-manager` vs
|
|
309
|
+
* `write-failed`.
|
|
310
|
+
*/
|
|
311
|
+
reason?: "no-manager" | "write-failed";
|
|
312
|
+
/** Which init system installed the service (when outcome === "installed"). */
|
|
313
|
+
kind?: "launchd" | "systemd-user" | "systemd-system" | "unsupported";
|
|
314
|
+
/** Path of the written service file (when outcome === "installed"). */
|
|
315
|
+
servicePath?: string;
|
|
316
|
+
/** Human-readable lines for the CLI to print (warnings, hints). */
|
|
317
|
+
messages: string[];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export interface ManagedUnitMessages {
|
|
321
|
+
/** Message when launchctl is missing — caller-specific (connector vs hub wording). */
|
|
322
|
+
launchctlMissing: string;
|
|
323
|
+
/** Message when systemctl is missing. */
|
|
324
|
+
systemctlMissing: string;
|
|
325
|
+
/** Soft warning when `loginctl enable-linger` couldn't run (non-root user unit). */
|
|
326
|
+
lingerWarning: string;
|
|
327
|
+
/** Prefix for the failed-to-write-file fallback message. */
|
|
328
|
+
writeFailedPrefix: string;
|
|
329
|
+
/** Prefix for the launchctl-could-not-load fallback message. */
|
|
330
|
+
launchctlLoadFailedPrefix: string;
|
|
331
|
+
/** Prefix for the systemctl-daemon-reload-failed fallback message. */
|
|
332
|
+
daemonReloadFailedPrefix: string;
|
|
333
|
+
/** Prefix for the systemctl-enable-failed fallback message. */
|
|
334
|
+
enableFailedPrefix: string;
|
|
335
|
+
/** Success message for a launchd install (`{label}` placeholder). */
|
|
336
|
+
launchdInstalled: (label: string, started: boolean) => string;
|
|
337
|
+
/** Success message for a systemd install. */
|
|
338
|
+
systemdInstalled: (unitName: string, root: boolean, started: boolean) => string;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export interface InstallManagedUnitOpts {
|
|
342
|
+
unit: ManagedUnit;
|
|
343
|
+
deps: ManagedUnitDeps;
|
|
344
|
+
messages: ManagedUnitMessages;
|
|
345
|
+
/**
|
|
346
|
+
* When `false`, write + register the unit WITHOUT starting it: systemd does
|
|
347
|
+
* `daemon-reload` but NOT `enable --now`; launchd writes the plist but does
|
|
348
|
+
* NOT `bootstrap`/`kickstart`. Used by the Phase 5 migration cutover to avoid
|
|
349
|
+
* a second hub racing port 1939 (design §7.1). Defaults to `true` (full
|
|
350
|
+
* behavior — the connector's path is unchanged).
|
|
351
|
+
*/
|
|
352
|
+
start?: boolean;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Install (or refresh) a managed unit on the current platform. Idempotent:
|
|
357
|
+
* re-installing overwrites the unit file and re-loads it. Graceful: a missing /
|
|
358
|
+
* failing tool returns `{ outcome: "fallback", messages }` WITHOUT throwing.
|
|
359
|
+
*
|
|
360
|
+
* Dispatches by platform; `execStart[0]` is assumed already resolved to an
|
|
361
|
+
* absolute path by the caller (the connector resolves cloudflared, the hub
|
|
362
|
+
* resolves bun — both via `deps.which`).
|
|
363
|
+
*/
|
|
364
|
+
export function installManagedUnit(opts: InstallManagedUnitOpts): ManagedUnitInstallResult {
|
|
365
|
+
const { deps } = opts;
|
|
366
|
+
if (deps.platform === "darwin") return installLaunchdUnit(opts);
|
|
367
|
+
if (deps.platform === "linux") return installSystemdUnit(opts);
|
|
368
|
+
return {
|
|
369
|
+
outcome: "fallback",
|
|
370
|
+
reason: "no-manager",
|
|
371
|
+
messages: [
|
|
372
|
+
`Boot-persistent unit isn't supported on ${deps.platform}; using a transient process.`,
|
|
373
|
+
],
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function installLaunchdUnit(opts: InstallManagedUnitOpts): ManagedUnitInstallResult {
|
|
378
|
+
const { unit, deps, messages } = opts;
|
|
379
|
+
const start = opts.start ?? true;
|
|
380
|
+
if (deps.which("launchctl") === null) {
|
|
381
|
+
return { outcome: "fallback", reason: "no-manager", messages: [messages.launchctlMissing] };
|
|
382
|
+
}
|
|
383
|
+
const home = deps.homeDir();
|
|
384
|
+
const plistPath = launchdPlistPathForLabel(unit.launchdLabel, home);
|
|
385
|
+
const label = unit.launchdLabel;
|
|
386
|
+
const uid = deps.getuid() ?? 0;
|
|
387
|
+
const domain = `gui/${uid}`;
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
deps.writeFile(plistPath, renderManagedLaunchdPlist(unit));
|
|
391
|
+
} catch (err) {
|
|
392
|
+
return {
|
|
393
|
+
outcome: "fallback",
|
|
394
|
+
reason: "write-failed",
|
|
395
|
+
messages: [
|
|
396
|
+
`${messages.writeFailedPrefix} (${err instanceof Error ? err.message : String(err)}).`,
|
|
397
|
+
],
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (!start) {
|
|
402
|
+
// install-without-start: the plist is on disk + will load on next login/boot
|
|
403
|
+
// (RunAtLoad), but we do NOT bootstrap/kickstart it now, so no process is
|
|
404
|
+
// started in this call (design §7.1 — avoid racing port 1939 during cutover).
|
|
405
|
+
// We also deliberately do NOT bootout any prior service: the CALLER (the
|
|
406
|
+
// Phase 5 migrate cutover) owns stopping the detached/prior process before
|
|
407
|
+
// installing this unit. Do not add a pre-bootout/stop here — it would break
|
|
408
|
+
// the §7.1 ordering the caller relies on to avoid the double-spawn race.
|
|
409
|
+
return {
|
|
410
|
+
outcome: "installed",
|
|
411
|
+
kind: "launchd",
|
|
412
|
+
servicePath: plistPath,
|
|
413
|
+
messages: [messages.launchdInstalled(label, false)],
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Re-install must be idempotent: bootout any prior load (ignore failure when
|
|
418
|
+
// nothing's loaded), then bootstrap the freshly-written plist. `bootstrap`
|
|
419
|
+
// both loads AND starts (RunAtLoad), so no separate `kickstart` is needed on
|
|
420
|
+
// the happy path; we add a `kickstart -k` to force a restart when the label
|
|
421
|
+
// was already bootstrapped (bootstrap is a no-op then).
|
|
422
|
+
deps.run(["launchctl", "bootout", `${domain}/${label}`]);
|
|
423
|
+
const boot = deps.run(["launchctl", "bootstrap", domain, plistPath]);
|
|
424
|
+
if (boot.code !== 0) {
|
|
425
|
+
// Older macOS (or a sandboxed context) may not accept `bootstrap`; fall back
|
|
426
|
+
// to the legacy `load -w`, then to a fallback outcome.
|
|
427
|
+
const legacy = deps.run(["launchctl", "load", "-w", plistPath]);
|
|
428
|
+
if (legacy.code !== 0) {
|
|
429
|
+
deps.removeFile(plistPath);
|
|
430
|
+
return {
|
|
431
|
+
outcome: "fallback",
|
|
432
|
+
reason: "write-failed",
|
|
433
|
+
messages: [
|
|
434
|
+
`${messages.launchctlLoadFailedPrefix} (${boot.stderr.trim() || legacy.stderr.trim() || "unknown error"}).`,
|
|
435
|
+
],
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
} else {
|
|
439
|
+
deps.run(["launchctl", "kickstart", "-k", `${domain}/${label}`]);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
outcome: "installed",
|
|
444
|
+
kind: "launchd",
|
|
445
|
+
servicePath: plistPath,
|
|
446
|
+
messages: [messages.launchdInstalled(label, true)],
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function installSystemdUnit(opts: InstallManagedUnitOpts): ManagedUnitInstallResult {
|
|
451
|
+
const { unit, deps, messages } = opts;
|
|
452
|
+
const start = opts.start ?? true;
|
|
453
|
+
if (deps.which("systemctl") === null) {
|
|
454
|
+
return { outcome: "fallback", reason: "no-manager", messages: [messages.systemctlMissing] };
|
|
455
|
+
}
|
|
456
|
+
const root = (deps.getuid() ?? 1000) === 0;
|
|
457
|
+
const home = deps.homeDir();
|
|
458
|
+
const unitName = unit.systemdUnitName;
|
|
459
|
+
const unitPath = systemdUnitPathForName(unitName, home, root);
|
|
460
|
+
const userName = deps.userName();
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
deps.writeFile(unitPath, renderManagedSystemdUnit(unit, { root, userName }));
|
|
464
|
+
} catch (err) {
|
|
465
|
+
return {
|
|
466
|
+
outcome: "fallback",
|
|
467
|
+
reason: "write-failed",
|
|
468
|
+
messages: [
|
|
469
|
+
`${messages.writeFailedPrefix} (${err instanceof Error ? err.message : String(err)}).`,
|
|
470
|
+
],
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const scope = root ? [] : ["--user"];
|
|
475
|
+
const outMessages: string[] = [];
|
|
476
|
+
|
|
477
|
+
// Non-root: enable linger so the user unit runs without an active login (i.e.
|
|
478
|
+
// after a reboot before the operator logs back in). Strictly best-effort —
|
|
479
|
+
// linger may be unavailable: `loginctl` absent entirely (a container with
|
|
480
|
+
// systemd but no logind), or present-but-failing. Either way we keep the
|
|
481
|
+
// install and warn. The probe + try/catch matter because production
|
|
482
|
+
// `Bun.spawnSync` THROWS on ENOENT — without the guard a box that has
|
|
483
|
+
// systemctl but not loginctl would propagate the spawn error out and hard-fail
|
|
484
|
+
// the calling command. (Run on both start + install-without-start: linger is a
|
|
485
|
+
// boot-survival nicety independent of whether we start the unit now.)
|
|
486
|
+
if (!root && userName) {
|
|
487
|
+
if (deps.which("loginctl") === null) {
|
|
488
|
+
outMessages.push(messages.lingerWarning);
|
|
489
|
+
} else {
|
|
490
|
+
try {
|
|
491
|
+
const linger = deps.run(["loginctl", "enable-linger", userName]);
|
|
492
|
+
if (linger.code !== 0) outMessages.push(messages.lingerWarning);
|
|
493
|
+
} catch {
|
|
494
|
+
// loginctl vanished between probe and run, or threw (ENOENT/EACCES) —
|
|
495
|
+
// never fatal; linger is a best-effort nicety.
|
|
496
|
+
outMessages.push(messages.lingerWarning);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const reload = deps.run(["systemctl", ...scope, "daemon-reload"]);
|
|
502
|
+
if (reload.code !== 0) {
|
|
503
|
+
deps.removeFile(unitPath);
|
|
504
|
+
return {
|
|
505
|
+
outcome: "fallback",
|
|
506
|
+
reason: "write-failed",
|
|
507
|
+
messages: [
|
|
508
|
+
`${messages.daemonReloadFailedPrefix} (${reload.stderr.trim() || "unknown error"}).`,
|
|
509
|
+
],
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (!start) {
|
|
514
|
+
// install-without-start: the unit file is on disk + daemon-reloaded, but we
|
|
515
|
+
// do NOT `enable --now` it, so no process is started in this call and the
|
|
516
|
+
// unit is not yet enabled for boot (design §7.1 — avoid racing port 1939).
|
|
517
|
+
// We also deliberately do NOT stop any prior service: the CALLER (the Phase 5
|
|
518
|
+
// migrate cutover) owns stopping the detached/prior process before installing
|
|
519
|
+
// this unit. Do not add a pre-stop here — it would break the §7.1 ordering the
|
|
520
|
+
// caller relies on to avoid the double-spawn race.
|
|
521
|
+
outMessages.unshift(messages.systemdInstalled(unitName, root, false));
|
|
522
|
+
return {
|
|
523
|
+
outcome: "installed",
|
|
524
|
+
kind: root ? "systemd-system" : "systemd-user",
|
|
525
|
+
servicePath: unitPath,
|
|
526
|
+
messages: outMessages,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const enable = deps.run(["systemctl", ...scope, "enable", "--now", unitName]);
|
|
531
|
+
if (enable.code !== 0) {
|
|
532
|
+
deps.removeFile(unitPath);
|
|
533
|
+
deps.run(["systemctl", ...scope, "daemon-reload"]);
|
|
534
|
+
return {
|
|
535
|
+
outcome: "fallback",
|
|
536
|
+
reason: "write-failed",
|
|
537
|
+
messages: [`${messages.enableFailedPrefix} (${enable.stderr.trim() || "unknown error"}).`],
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
outMessages.unshift(messages.systemdInstalled(unitName, root, true));
|
|
542
|
+
return {
|
|
543
|
+
outcome: "installed",
|
|
544
|
+
kind: root ? "systemd-system" : "systemd-user",
|
|
545
|
+
servicePath: unitPath,
|
|
546
|
+
messages: outMessages,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export interface ManagedUnitRemoveResult {
|
|
551
|
+
/** True when a service file was found + removed (best-effort tool teardown ran). */
|
|
552
|
+
removed: boolean;
|
|
553
|
+
messages: string[];
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export interface RemoveManagedUnitOpts {
|
|
557
|
+
launchdLabel: string;
|
|
558
|
+
systemdUnitName: string;
|
|
559
|
+
deps: ManagedUnitDeps;
|
|
560
|
+
/** Success message for a launchd removal (`{label}` placeholder). */
|
|
561
|
+
removedLaunchdMessage: (label: string) => string;
|
|
562
|
+
/** Success message for a systemd removal. */
|
|
563
|
+
removedSystemdMessage: (unitName: string) => string;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Stop + remove a managed unit on the current platform. Idempotent +
|
|
568
|
+
* best-effort: a missing service file is a no-op; tool failures never throw (the
|
|
569
|
+
* teardown path must always succeed at clearing state even if the OS service
|
|
570
|
+
* tool hiccups).
|
|
571
|
+
*/
|
|
572
|
+
export function removeManagedUnit(opts: RemoveManagedUnitOpts): ManagedUnitRemoveResult {
|
|
573
|
+
const { deps, launchdLabel, systemdUnitName } = opts;
|
|
574
|
+
|
|
575
|
+
if (deps.platform === "darwin") {
|
|
576
|
+
const home = deps.homeDir();
|
|
577
|
+
const plistPath = launchdPlistPathForLabel(launchdLabel, home);
|
|
578
|
+
if (!deps.exists(plistPath)) return { removed: false, messages: [] };
|
|
579
|
+
const uid = deps.getuid() ?? 0;
|
|
580
|
+
// bootout unloads + stops; ignore its exit (nothing-loaded is fine).
|
|
581
|
+
deps.run(["launchctl", "bootout", `gui/${uid}/${launchdLabel}`]);
|
|
582
|
+
deps.removeFile(plistPath);
|
|
583
|
+
return { removed: true, messages: [opts.removedLaunchdMessage(launchdLabel)] };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (deps.platform === "linux") {
|
|
587
|
+
const root = (deps.getuid() ?? 1000) === 0;
|
|
588
|
+
const home = deps.homeDir();
|
|
589
|
+
const unitPath = systemdUnitPathForName(systemdUnitName, home, root);
|
|
590
|
+
if (!deps.exists(unitPath)) return { removed: false, messages: [] };
|
|
591
|
+
const scope = root ? [] : ["--user"];
|
|
592
|
+
// disable --now stops + removes the enable symlink; ignore exit (best-effort).
|
|
593
|
+
deps.run(["systemctl", ...scope, "disable", "--now", systemdUnitName]);
|
|
594
|
+
deps.removeFile(unitPath);
|
|
595
|
+
deps.run(["systemctl", ...scope, "daemon-reload"]);
|
|
596
|
+
return { removed: true, messages: [opts.removedSystemdMessage(systemdUnitName)] };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return { removed: false, messages: [] };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ---------------------------------------------------------------------------
|
|
603
|
+
// Hub unit builder (design §4.1 + §4.2)
|
|
604
|
+
//
|
|
605
|
+
// NOT wired into any command in this PR — Phase 3 wires `init` to install it.
|
|
606
|
+
// Phase 2b only provides + tests the builder.
|
|
607
|
+
// ---------------------------------------------------------------------------
|
|
608
|
+
|
|
609
|
+
/** Reverse-DNS launchd label for the hub unit. */
|
|
610
|
+
export const HUB_LAUNCHD_LABEL = "computer.parachute.hub";
|
|
611
|
+
/** systemd unit name for the hub unit. */
|
|
612
|
+
export const HUB_SYSTEMD_UNIT_NAME = "parachute-hub.service";
|
|
613
|
+
|
|
614
|
+
/** Crash-loop ceiling for the hub unit (design §4.1 / §6.3). */
|
|
615
|
+
const HUB_CRASH_LOOP: CrashLoopCeiling = {
|
|
616
|
+
intervalSec: 300,
|
|
617
|
+
burst: 5,
|
|
618
|
+
throttleIntervalSec: 10,
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
export interface BuildHubManagedUnitOpts {
|
|
622
|
+
/**
|
|
623
|
+
* The operator's CURRENT `PARACHUTE_HOME`, captured at install time and baked
|
|
624
|
+
* into the unit env — NOT the hard-coded default (design §4.2). Phase 3 passes
|
|
625
|
+
* the real captured home.
|
|
626
|
+
*/
|
|
627
|
+
parachuteHome: string;
|
|
628
|
+
/** Hub port (default 1939, the canonical pin). */
|
|
629
|
+
port?: number;
|
|
630
|
+
/** Path to the operator's bun install dir (`$BUN_INSTALL`), e.g. `/home/op/.bun`. */
|
|
631
|
+
bunInstall: string;
|
|
632
|
+
/** PATH the unit should run with (must include bun's global bin). */
|
|
633
|
+
path: string;
|
|
634
|
+
/**
|
|
635
|
+
* Absolute path to the `parachute-hub` `src/cli.ts` entry the unit's
|
|
636
|
+
* `ExecStart`/`ProgramArguments` runs `serve` against (the bun-linked checkout
|
|
637
|
+
* or the installed bin). Caller supplies it — Phase 3 derives it.
|
|
638
|
+
*/
|
|
639
|
+
cliPath: string;
|
|
640
|
+
/** Log file the hub's stdout+stderr is written to. */
|
|
641
|
+
logPath: string;
|
|
642
|
+
/** Injectable deps for `which` resolution (defaults to production). */
|
|
643
|
+
deps?: ManagedUnitDeps;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Build the `ManagedUnit` descriptor for the hub itself (design §4.1).
|
|
648
|
+
*
|
|
649
|
+
* Resolves the absolute `bun` path via the `which` seam (launchd/systemd don't
|
|
650
|
+
* search `$PATH` — mirrors how the connector resolves cloudflared). The env
|
|
651
|
+
* carries `PARACHUTE_HOME` / `PORT` / `PATH` / `BUN_INSTALL` — and INTENTIONALLY
|
|
652
|
+
* OMITS `PARACHUTE_HUB_ORIGIN`: baking a stale origin here would re-create the
|
|
653
|
+
* iss-mismatch class; `resolveStartupIssuer` derives it and start-hub self-heals
|
|
654
|
+
* the operator token + vault `.env` to the current origin (design §4.1 comment).
|
|
655
|
+
*
|
|
656
|
+
* NOT called by any command in this PR (additive — Phase 3 wires it into `init`).
|
|
657
|
+
*/
|
|
658
|
+
export function buildHubManagedUnit(opts: BuildHubManagedUnitOpts): ManagedUnit {
|
|
659
|
+
const deps = opts.deps ?? defaultManagedUnitDeps;
|
|
660
|
+
const port = opts.port ?? 1939;
|
|
661
|
+
// launchd/systemd do not search $PATH; resolve bun to an absolute path at build
|
|
662
|
+
// time. Fail loud if it can't be resolved — falling back to the literal "bun"
|
|
663
|
+
// would bake a non-functional ExecStart/ProgramArguments[0] into the unit
|
|
664
|
+
// (cryptic start-time failure), so refuse to build a broken unit. (cliPath is
|
|
665
|
+
// caller-supplied as an absolute path, not which-resolved, so it needs no guard.)
|
|
666
|
+
const bunPath = deps.which("bun");
|
|
667
|
+
if (bunPath === null) {
|
|
668
|
+
throw new Error(
|
|
669
|
+
"cannot build hub unit: 'bun' not found on PATH — install bun or ensure it is resolvable",
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
return {
|
|
673
|
+
launchdLabel: HUB_LAUNCHD_LABEL,
|
|
674
|
+
systemdUnitName: HUB_SYSTEMD_UNIT_NAME,
|
|
675
|
+
headerComment: "Generated by parachute — do not edit by hand.",
|
|
676
|
+
systemdDescription: "Parachute hub (serve + supervisor)",
|
|
677
|
+
execStart: [bunPath, opts.cliPath, "serve"],
|
|
678
|
+
env: {
|
|
679
|
+
// PARACHUTE_HOME captured at install time (design §4.2) — NOT the default.
|
|
680
|
+
PARACHUTE_HOME: opts.parachuteHome,
|
|
681
|
+
PORT: String(port),
|
|
682
|
+
// PATH + BUN_INSTALL are load-bearing for supervised children that resolve
|
|
683
|
+
// a bun-linked binary on cold boot under a linger-started user unit
|
|
684
|
+
// (design §4.1 / R20). PARACHUTE_HUB_ORIGIN is intentionally OMITTED.
|
|
685
|
+
PATH: opts.path,
|
|
686
|
+
BUN_INSTALL: opts.bunInstall,
|
|
687
|
+
},
|
|
688
|
+
logPath: opts.logPath,
|
|
689
|
+
crashLoop: HUB_CRASH_LOOP,
|
|
690
|
+
runAsInvokingUserOnSystemUnit: true,
|
|
691
|
+
};
|
|
692
|
+
}
|