@openparachute/hub 0.6.1-rc.4 → 0.6.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.
@@ -310,8 +310,11 @@ function renderVaultCard(opts: VaultCardOpts): string {
310
310
  <p class="vault-notes-cta">
311
311
  <a class="btn btn-primary" href="https://notes.parachute.computer/add?url=${vaultUrlForAdd}"
312
312
  target="_blank" rel="noopener" data-testid="open-notes-cta">Open Notes ↗</a>
313
+ <a class="btn btn-secondary" href="https://notes.parachute.computer/import?url=${vaultUrlForAdd}"
314
+ target="_blank" rel="noopener" data-testid="import-notes-cta">Import notes ↗</a>
313
315
  <span class="vault-notes-cta-sub">Prefer a browser UI? Open Notes to browse +
314
- capture in this vault.</span>
316
+ capture in this vault — or jump straight to bulk-importing Markdown/Obsidian
317
+ notes into it.</span>
315
318
  </p>
316
319
  ${tokenMintBlock}
317
320
  </div>`;
package/src/cli.ts CHANGED
@@ -159,7 +159,8 @@ function extractNamedFlag(
159
159
  * fall through to today's Tailscale
160
160
  * default (CI escape hatch, #29)
161
161
  * --domain=<host> hostname for the Cloudflare path
162
- * --tunnel-name=<name> named tunnel override (#32)
162
+ * --tunnel-name=<name> named tunnel override (#32); defaults to a
163
+ * per-hostname dedicated name (#491)
163
164
  *
164
165
  * Returns the stripped argv so the layer/action parser sees `[layer, action?]`
165
166
  * regardless of flag placement. `--tailnet` + `--cloudflare` together is
@@ -2,18 +2,84 @@ import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { CONFIG_DIR } from "../config.ts";
4
4
 
5
+ /**
6
+ * The legacy shared tunnel name. Pre-#491, every machine defaulted its
7
+ * Cloudflare tunnel to this single constant — but Cloudflare tunnels are
8
+ * account-wide, so a second machine exposing a *different* hostname found and
9
+ * reused the SAME tunnel, both connectors registered on one UUID, and the edge
10
+ * load-balanced requests across them → a request for host B could land on host
11
+ * A's connector (whose config.yml only routes host A) → ~50% cross-host 404s.
12
+ *
13
+ * The default is now a per-hostname derived name (`deriveTunnelName`). This
14
+ * constant's role narrows to "the legacy shared name we migrate away from":
15
+ * - the up-path legacy-sweep kills a stale `"parachute"` connector on the box
16
+ * so running deploys self-heal on the next expose, and
17
+ * - the off-path reuse-hint compares against it (records no longer equal it,
18
+ * so the hint always includes `--tunnel-name`, which is now correct).
19
+ */
5
20
  export const DEFAULT_TUNNEL_NAME = "parachute";
6
21
 
22
+ /**
23
+ * Derive a dedicated, per-hostname tunnel name from a hostname. Cloudflare
24
+ * tunnels are account-wide, so each machine/hostname needs its OWN tunnel —
25
+ * sharing one name across boxes collides their connectors (#491). The name is
26
+ * deterministic (same hostname → same name) so re-exposing the same hostname
27
+ * is idempotent: it finds and reuses the tunnel it created last time.
28
+ *
29
+ * Sanitization: lowercase, dots → hyphens, drop anything outside `[a-z0-9_-]`,
30
+ * then prefix `parachute-`. Examples:
31
+ * `our.parachute.computer` → `parachute-our-parachute-computer`
32
+ * `vault.example.com` → `parachute-vault-example-com`
33
+ *
34
+ * Length: tunnel names must satisfy `isValidTunnelName` (≤64 chars). When the
35
+ * derived name would exceed 64, truncate the sanitized body and append a short
36
+ * stable suffix (`-<8-hex>`) computed deterministically from the FULL hostname
37
+ * so two long hostnames sharing a 64-char prefix can't collide on the same
38
+ * tunnel. The hash is a non-crypto FNV-1a-style fold — deterministic, no
39
+ * Math.random / Date dependency (those would break idempotent re-expose).
40
+ */
41
+ const TUNNEL_NAME_PREFIX = "parachute-";
42
+ const MAX_TUNNEL_NAME = 64;
43
+
44
+ function shortStableHash(input: string): string {
45
+ // FNV-1a 32-bit. Deterministic, dependency-free, good enough to disambiguate
46
+ // two hostnames that sanitize to the same truncated prefix. >>> 0 keeps it
47
+ // unsigned so the hex is stable across runtimes.
48
+ let h = 0x811c9dc5;
49
+ for (let i = 0; i < input.length; i++) {
50
+ h ^= input.charCodeAt(i);
51
+ h = Math.imul(h, 0x01000193);
52
+ }
53
+ return (h >>> 0).toString(16).padStart(8, "0");
54
+ }
55
+
56
+ export function deriveTunnelName(hostname: string): string {
57
+ const body = hostname
58
+ .toLowerCase()
59
+ .replace(/\./g, "-")
60
+ .replace(/[^a-z0-9_-]/g, "");
61
+ const full = `${TUNNEL_NAME_PREFIX}${body}`;
62
+ if (full.length <= MAX_TUNNEL_NAME) return full;
63
+ // Too long — truncate the body and append a stable 8-hex suffix derived from
64
+ // the full hostname. Reserve room for the prefix + "-" + 8 hex chars.
65
+ const suffix = `-${shortStableHash(hostname)}`;
66
+ const room = MAX_TUNNEL_NAME - TUNNEL_NAME_PREFIX.length - suffix.length;
67
+ // Strip any trailing hyphen the truncation left behind (e.g. a slice that
68
+ // lands on a dot-turned-hyphen) so the body doesn't abut the suffix as `--`.
69
+ const truncated = body.slice(0, room).replace(/-+$/, "");
70
+ return `${TUNNEL_NAME_PREFIX}${truncated}${suffix}`;
71
+ }
72
+
7
73
  /**
8
74
  * Per-tunnel config + log file paths. Each tunnel gets its own subdirectory
9
75
  * under `~/.parachute/cloudflared/<tunnelName>/` so multiple tunnels on one
10
76
  * box don't trample each other's config.yml or interleave log lines.
11
77
  *
12
- * The default tunnel ("parachute") lives at
13
- * `~/.parachute/cloudflared/parachute/{config.yml,cloudflared.log}` a
14
- * location change from pre-#32 (`~/.parachute/cloudflared/config.yml`).
78
+ * The per-hostname tunnel (`deriveTunnelName(host)`, e.g.
79
+ * `parachute-our-parachute-computer`) lives at
80
+ * `~/.parachute/cloudflared/<tunnelName>/{config.yml,cloudflared.log}`.
15
81
  * Re-running `parachute expose public --cloudflare` regenerates the file
16
- * at the new path; the legacy file is left in place but unused.
82
+ * at that path; any legacy `parachute/` file is left in place but unused.
17
83
  *
18
84
  * `configDir` overrides the base (`~/.parachute` by default). Tests pass a
19
85
  * tmp dir so per-tunnel-derived paths never resolve against the operator's
@@ -0,0 +1,478 @@
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
+ * Reboot-persistent cloudflared connector.
7
+ *
8
+ * Pre-0.6.2 `parachute expose public --cloudflare` spawned the connector as a
9
+ * bare detached background process (`Bun.spawn(...).unref()`), which dies on
10
+ * reboot — the operator had to re-run the expose command every time the box
11
+ * restarted. This module installs a per-tunnel OS service that runs the same
12
+ * `cloudflared tunnel --config <path> run` command on boot, so the connector
13
+ * survives reboots.
14
+ *
15
+ * Platform shapes:
16
+ * - macOS → a launchd LaunchAgent plist at
17
+ * `~/Library/LaunchAgents/computer.parachute.cloudflared.<tunnelName>.plist`
18
+ * (RunAtLoad + KeepAlive), bootstrapped into the per-user GUI domain. No
19
+ * sudo: a LaunchAgent runs as the logged-in user.
20
+ * - Linux (non-root) → a systemd *user* unit at
21
+ * `~/.config/systemd/user/parachute-cloudflared-<tunnelName>.service`,
22
+ * `systemctl --user enable --now`, plus a best-effort
23
+ * `loginctl enable-linger $USER` so the unit runs without an active login.
24
+ * - Linux (root) → a systemd *system* unit at
25
+ * `/etc/systemd/system/parachute-cloudflared-<tunnelName>.service`,
26
+ * `systemctl enable --now`. No linger needed — system units run on boot.
27
+ *
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.
32
+ *
33
+ * Service name keyed by the same per-host tunnel name the 0.6.1 work derives
34
+ * (`deriveTunnelName`), so install / remove always target the connector for
35
+ * exactly one tunnel and the expose off / legacy-sweep paths can tear it down.
36
+ */
37
+
38
+ /** Synchronous command result from the injected service runner. */
39
+ export interface ServiceCommandResult {
40
+ code: number;
41
+ stdout: string;
42
+ stderr: string;
43
+ }
44
+
45
+ /**
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.
50
+ */
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
+ }
77
+
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
+ };
102
+
103
+ /** Reverse-DNS prefix for the launchd label + plist filename. */
104
+ const LAUNCHD_LABEL_PREFIX = "computer.parachute.cloudflared";
105
+ /** systemd unit name prefix. */
106
+ const SYSTEMD_UNIT_PREFIX = "parachute-cloudflared-";
107
+
108
+ /** launchd label for a tunnel (also the plist basename, minus `.plist`). */
109
+ export function launchdLabel(tunnelName: string): string {
110
+ return `${LAUNCHD_LABEL_PREFIX}.${tunnelName}`;
111
+ }
112
+
113
+ /** launchd plist path under the user's LaunchAgents dir. */
114
+ export function launchdPlistPath(tunnelName: string, home: string): string {
115
+ return join(home, "Library", "LaunchAgents", `${launchdLabel(tunnelName)}.plist`);
116
+ }
117
+
118
+ /** systemd unit name (with `.service` suffix). */
119
+ export function systemdUnitName(tunnelName: string): string {
120
+ return `${SYSTEMD_UNIT_PREFIX}${tunnelName}.service`;
121
+ }
122
+
123
+ /** systemd unit path — user-level under $HOME, system-level under /etc. */
124
+ 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));
128
+ }
129
+
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;");
133
+ }
134
+
135
+ /**
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.
142
+ */
143
+ export function renderLaunchdPlist(opts: {
144
+ tunnelName: string;
145
+ cloudflaredPath: string;
146
+ configPath: string;
147
+ logPath: string;
148
+ }): 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
+ `;
174
+ }
175
+
176
+ /**
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`).
182
+ */
183
+ export function renderSystemdUnit(opts: {
184
+ tunnelName: string;
185
+ cloudflaredPath: string;
186
+ configPath: string;
187
+ logPath: string;
188
+ root: boolean;
189
+ userName: string;
190
+ }): 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
+ `;
210
+ }
211
+
212
+ export interface InstallResult {
213
+ /**
214
+ * "installed" → an OS service now owns the connector (survives reboot).
215
+ * "fallback" → the service tool was unavailable / failed; the caller should
216
+ * fall back to the transient `proc.unref()` spawn (does NOT survive reboot).
217
+ */
218
+ outcome: "installed" | "fallback";
219
+ /** Which init system installed the service (when outcome === "installed"). */
220
+ kind?: "launchd" | "systemd-user" | "systemd-system" | "unsupported";
221
+ /** Path of the written service file (when outcome === "installed"). */
222
+ servicePath?: string;
223
+ /** Human-readable lines for the CLI to print (warnings, hints). */
224
+ messages: string[];
225
+ }
226
+
227
+ export interface ConnectorServiceOpts {
228
+ tunnelName: string;
229
+ configPath: string;
230
+ logPath: string;
231
+ deps?: ConnectorServiceDeps;
232
+ }
233
+
234
+ /**
235
+ * Install (or refresh) the reboot-persistent connector service for one tunnel
236
+ * and start it. Idempotent: re-installing overwrites the service file and
237
+ * re-loads it, so re-`expose` of the same hostname converges on exactly one
238
+ * managed connector.
239
+ *
240
+ * Graceful fallback: if the platform's service tool is missing or any step
241
+ * fails, returns `{ outcome: "fallback", messages }` WITHOUT throwing — the
242
+ * caller then spawns the transient connector and warns it won't survive a
243
+ * reboot. We never hard-fail the expose because the service install didn't take.
244
+ */
245
+ export function installConnectorService(opts: ConnectorServiceOpts): InstallResult {
246
+ const deps = opts.deps ?? defaultServiceDeps;
247
+ const cloudflaredPath = deps.which("cloudflared");
248
+ if (!cloudflaredPath) {
249
+ return {
250
+ outcome: "fallback",
251
+ messages: ["Could not resolve the cloudflared binary path; skipping boot-service install."],
252
+ };
253
+ }
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) {
353
+ return {
354
+ outcome: "fallback",
355
+ messages: [
356
+ `Failed to write systemd unit (${err instanceof Error ? err.message : String(err)}); using a transient connector (won't survive a reboot).`,
357
+ ],
358
+ };
359
+ }
360
+
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 ENOENT — without 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
+ };
420
+ }
421
+
422
+ export interface RemoveResult {
423
+ /** True when a service file was found + removed (best-effort tool teardown ran). */
424
+ removed: boolean;
425
+ messages: string[];
426
+ }
427
+
428
+ /**
429
+ * Stop + remove the reboot-persistent connector service for one tunnel.
430
+ * Idempotent + best-effort: a missing service file is a no-op; tool failures
431
+ * never throw (the expose-off path must always succeed at clearing state even
432
+ * if the OS service tool hiccups). Mirrors `installConnectorService`'s seam.
433
+ *
434
+ * Called by `exposeCloudflareOff` (and the legacy-tunnel sweep) so tearing down
435
+ * a tunnel also tears down its boot service — otherwise the service would
436
+ * resurrect a dead connector on the next reboot.
437
+ */
438
+ export function removeConnectorService(opts: {
439
+ tunnelName: string;
440
+ deps?: ConnectorServiceDeps;
441
+ }): RemoveResult {
442
+ 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: [] };
478
+ }
@@ -25,6 +25,14 @@ export interface CloudflaredTunnelRecord {
25
25
  startedAt: string;
26
26
  /** Absolute path to the cloudflared config.yml driving this tunnel. */
27
27
  configPath: string;
28
+ /**
29
+ * True when a reboot-persistent OS service (launchd/systemd) owns this
30
+ * connector (0.6.2). Drives the off-path to remove the service (not just
31
+ * SIGTERM the pid — a still-enabled service would otherwise restart the
32
+ * connector it just killed). Optional + defaults false so pre-0.6.2 state
33
+ * files (and the transient-fallback path) validate + read as unmanaged.
34
+ */
35
+ serviceManaged?: boolean;
28
36
  }
29
37
 
30
38
  /**
@@ -64,7 +72,7 @@ function validateRecord(raw: unknown, path: string): CloudflaredTunnelRecord {
64
72
  throw new CloudflaredStateError(`${path}: tunnel record must be an object`);
65
73
  }
66
74
  const r = raw as Record<string, unknown>;
67
- return {
75
+ const record: CloudflaredTunnelRecord = {
68
76
  pid: requirePositiveInt(r, "pid", path),
69
77
  tunnelUuid: requireString(r, "tunnelUuid", path),
70
78
  tunnelName: requireString(r, "tunnelName", path),
@@ -72,6 +80,10 @@ function validateRecord(raw: unknown, path: string): CloudflaredTunnelRecord {
72
80
  startedAt: requireString(r, "startedAt", path),
73
81
  configPath: requireString(r, "configPath", path),
74
82
  };
83
+ // Optional — present from 0.6.2 onward. A non-boolean (or absent) value
84
+ // reads as unmanaged so legacy state files keep validating.
85
+ if (r.serviceManaged === true) record.serviceManaged = true;
86
+ return record;
75
87
  }
76
88
 
77
89
  function validate(raw: unknown, path: string): CloudflaredState {