@hellcoder/companion 0.124.0 → 0.124.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.
package/bin/cli.ts CHANGED
@@ -110,9 +110,17 @@ switch (command) {
110
110
  } else if (result.running) {
111
111
  console.log(`The Companion is running (PID: ${result.pid})`);
112
112
  console.log(` URL: http://localhost:${result.port}`);
113
+ if (result.scope === "system") {
114
+ console.log(" Managed by a system-wide systemd unit (use sudo systemctl).");
115
+ }
113
116
  } else {
114
117
  console.log("The Companion is installed but not running.");
115
- console.log("Check logs at ~/.companion/logs/");
118
+ if (result.scope === "system") {
119
+ console.log("It is managed by a system-wide systemd unit:");
120
+ console.log(" sudo systemctl status the-companion.service");
121
+ } else {
122
+ console.log("Check logs at ~/.companion/logs/");
123
+ }
116
124
  }
117
125
  }
118
126
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hellcoder/companion",
3
- "version": "0.124.0",
3
+ "version": "0.124.1",
4
4
  "type": "module",
5
5
  "description": "Web UI for launching and interacting with Claude Code agents — Moritz Edition (fork of the-companion)",
6
6
  "license": "MIT",
package/server/index.ts CHANGED
@@ -45,7 +45,7 @@ import { getSettings, updateSettings } from "./settings-manager.js";
45
45
  import { setCliIngressServer } from "./routes/system-routes.js";
46
46
  import { imagePullManager } from "./image-pull-manager.js";
47
47
  import { restoreIfNeeded as restoreTailscaleFunnel, cleanup as cleanupTailscaleFunnel } from "./tailscale-manager.js";
48
- import { isRunningAsService } from "./service.js";
48
+ import { getDetectedServiceKind, isRunningAsService } from "./service.js";
49
49
  import { getToken, verifyToken } from "./auth-manager.js";
50
50
  import { getCookie } from "hono/cookie";
51
51
  import type { SocketData } from "./ws-bridge.js";
@@ -421,7 +421,7 @@ if (getSettings().claudeBridgeMode === "patched") {
421
421
  }
422
422
  if (isRunningAsService()) {
423
423
  setServiceMode(true);
424
- console.log("[server] Running as background service (auto-update available)");
424
+ console.log(`[server] Running as background service (${getDetectedServiceKind()}, auto-update available)`);
425
425
  }
426
426
 
427
427
  // ── Runtime diagnostics ──────────────────────────────────────────────────────
@@ -28,6 +28,15 @@ vi.mock("../update-checker.js", () => ({
28
28
  // ─── Mock service ──────────────────────────────────────────────────────────
29
29
  vi.mock("../service.js", () => ({
30
30
  refreshServiceDefinition: vi.fn(),
31
+ getDetectedServiceKind: vi.fn(() => "systemd-user"),
32
+ getUpdateRestartPlan: vi.fn((kind: string | null) =>
33
+ kind === "systemd-system" || kind === "external"
34
+ ? { exitCode: 75 }
35
+ : {
36
+ command: ["systemctl", "--user", "restart", "the-companion.service"],
37
+ env: { XDG_RUNTIME_DIR: "/run/user/1000" },
38
+ exitCode: 0,
39
+ }),
31
40
  }));
32
41
 
33
42
  // ─── Mock claude-compat checker ────────────────────────────────────────────
@@ -88,6 +97,7 @@ import {
88
97
  setUpdateInProgress,
89
98
  } from "../update-checker.js";
90
99
  import { registerSystemRoutes } from "./system-routes.js";
100
+ import { getDetectedServiceKind } from "../service.js";
91
101
  import { checkCompat, getCompatState } from "../claude-compat-checker.js";
92
102
  import { pinToVersion, patchBinary, unpatch } from "../claude-patcher.js";
93
103
  import { getSettings, updateSettings } from "../settings-manager.js";
@@ -496,6 +506,53 @@ describe("POST /api/update", () => {
496
506
  // Advance past the 500ms exit timeout
497
507
  await vi.advanceTimersByTimeAsync(600);
498
508
 
509
+ // The user unit is restarted through systemctl --user, then we exit 0.
510
+ expect(mockSpawn).toHaveBeenCalledWith(
511
+ ["systemctl", "--user", "restart", "the-companion.service"],
512
+ expect.objectContaining({ env: expect.objectContaining({ XDG_RUNTIME_DIR: "/run/user/1000" }) }),
513
+ );
514
+ expect(exitSpy).toHaveBeenCalledWith(0);
515
+
516
+ vi.useRealTimers();
517
+ exitSpy.mockRestore();
518
+ // @ts-expect-error -- cleanup Bun global mock
519
+ delete globalThis.Bun;
520
+ });
521
+
522
+ // Under a system-wide unit (issue #139) the server can't restart itself via
523
+ // systemctl; it must exit non-zero so Restart=on-failure respawns it.
524
+ it("exits with the restart code and spawns nothing under a system unit", async () => {
525
+ vi.useFakeTimers();
526
+ vi.mocked(getDetectedServiceKind).mockReturnValueOnce("systemd-system");
527
+ vi.mocked(getUpdateState).mockReturnValue({
528
+ currentVersion: "1.0.0",
529
+ latestVersion: "2.0.0",
530
+ lastChecked: Date.now(),
531
+ isServiceMode: true,
532
+ checking: false,
533
+ updateInProgress: false,
534
+ channel: "stable",
535
+ });
536
+ vi.mocked(isUpdateAvailable).mockReturnValue(true);
537
+
538
+ const mockSpawn = vi.fn().mockReturnValueOnce({
539
+ exited: Promise.resolve(0),
540
+ stdout: new ReadableStream(),
541
+ stderr: new ReadableStream(),
542
+ });
543
+ // @ts-expect-error -- Bun global mock
544
+ globalThis.Bun = { spawn: mockSpawn };
545
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
546
+
547
+ const res = await app.request("/api/update", { method: "POST" });
548
+ expect(res.status).toBe(200);
549
+ await vi.advanceTimersByTimeAsync(150);
550
+ await vi.advanceTimersByTimeAsync(600);
551
+
552
+ // Only the install was spawned — no systemctl --user restart.
553
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
554
+ expect(exitSpy).toHaveBeenCalledWith(75);
555
+
499
556
  vi.useRealTimers();
500
557
  exitSpy.mockRestore();
501
558
  // @ts-expect-error -- cleanup Bun global mock
@@ -11,7 +11,11 @@ import {
11
11
  isUpdateAvailable,
12
12
  setUpdateInProgress,
13
13
  } from "../update-checker.js";
14
- import { refreshServiceDefinition } from "../service.js";
14
+ import {
15
+ getDetectedServiceKind,
16
+ getUpdateRestartPlan,
17
+ refreshServiceDefinition,
18
+ } from "../service.js";
15
19
  import { getSettings, updateSettings } from "../settings-manager.js";
16
20
  import { imagePullManager } from "../image-pull-manager.js";
17
21
  import { isSandboxEnabled } from "../feature-flags.js";
@@ -187,29 +191,21 @@ export function registerSystemRoutes(
187
191
 
188
192
  console.log("[update] Update successful, restarting service...");
189
193
 
190
- const isLinux = process.platform === "linux";
191
- const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
192
- const restartCmd = isLinux
193
- ? ["systemctl", "--user", "restart", "the-companion.service"]
194
- : uid !== undefined
195
- ? ["launchctl", "kickstart", "-k", `gui/${uid}/sh.thecompanion.app`]
196
- : ["launchctl", "kickstart", "-k", "sh.thecompanion.app"];
197
-
198
- Bun.spawn(restartCmd, {
199
- stdout: "ignore",
200
- stderr: "ignore",
201
- stdin: "ignore",
202
- env: isLinux
203
- ? {
204
- ...process.env,
205
- XDG_RUNTIME_DIR:
206
- process.env.XDG_RUNTIME_DIR ||
207
- `/run/user/${uid ?? 1000}`,
208
- }
209
- : undefined,
210
- });
211
-
212
- setTimeout(() => process.exit(0), 500);
194
+ const plan = getUpdateRestartPlan(getDetectedServiceKind());
195
+ if (plan.command) {
196
+ Bun.spawn(plan.command, {
197
+ stdout: "ignore",
198
+ stderr: "ignore",
199
+ stdin: "ignore",
200
+ env: plan.env ? { ...process.env, ...plan.env } : undefined,
201
+ });
202
+ } else {
203
+ console.log(
204
+ `[update] Exiting with code ${plan.exitCode} so the supervisor restarts the server.`,
205
+ );
206
+ }
207
+
208
+ setTimeout(() => process.exit(plan.exitCode), 500);
213
209
  } catch (err) {
214
210
  console.error("[update] Update failed:", err);
215
211
  setUpdateInProgress(false);
@@ -50,6 +50,38 @@ vi.mock("node:child_process", async (importOriginal) => {
50
50
  };
51
51
  });
52
52
 
53
+ // Mock node:net so isPortInUse() never binds a real port (the dev box may
54
+ // well be running a Companion on the default port).
55
+ const mockPortInUse = vi.hoisted(() => {
56
+ let inUse = false;
57
+ return { get: () => inUse, set: (v: boolean) => { inUse = v; } };
58
+ });
59
+
60
+ vi.mock("node:net", async (importOriginal) => {
61
+ const actual = await importOriginal<typeof import("node:net")>();
62
+ const { EventEmitter } = await import("node:events");
63
+ return {
64
+ ...actual,
65
+ createServer: () => {
66
+ const server = new EventEmitter() as InstanceType<typeof EventEmitter> & {
67
+ listen: (port: number) => void;
68
+ close: (cb?: () => void) => void;
69
+ };
70
+ server.listen = () => {
71
+ queueMicrotask(() => {
72
+ if (mockPortInUse.get()) {
73
+ server.emit("error", Object.assign(new Error("in use"), { code: "EADDRINUSE" }));
74
+ } else {
75
+ server.emit("listening");
76
+ }
77
+ });
78
+ };
79
+ server.close = (cb) => cb?.();
80
+ return server;
81
+ },
82
+ };
83
+ });
84
+
53
85
  // Mock path-resolver to return a deterministic enriched PATH
54
86
  const MOCK_SERVICE_PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/mock/.bun/bin:/mock/.local/bin";
55
87
  vi.mock("./path-resolver.js", () => ({
@@ -74,6 +106,9 @@ beforeEach(async () => {
74
106
  mockHomedir.set(tempDir);
75
107
  mockPlatform.set("darwin");
76
108
  mockExecSync.mockReset();
109
+ mockPortInUse.set(false);
110
+ delete process.env.COMPANION_SERVICE_MODE;
111
+ delete process.env.INVOCATION_ID;
77
112
 
78
113
  // Set process.platform AFTER resetting mockPlatform to "darwin"
79
114
  Object.defineProperty(process, "platform", {
@@ -1417,3 +1452,200 @@ describe("platform check", () => {
1417
1452
  expect(existsSync(unitPath())).toBe(true);
1418
1453
  });
1419
1454
  });
1455
+
1456
+ // ===========================================================================
1457
+ // System-wide systemd unit support (issue #139)
1458
+ // ===========================================================================
1459
+
1460
+ const SYSTEM_UNIT_SHOW = [
1461
+ "LoadState=loaded",
1462
+ "ActiveState=active",
1463
+ "MainPID=4321",
1464
+ "InvocationID=0123456789abcdef0123456789abcdef",
1465
+ "FragmentPath=/etc/systemd/system/the-companion.service",
1466
+ "Environment=NODE_ENV=production PORT=3999 HOST=0.0.0.0",
1467
+ ].join("\n");
1468
+
1469
+ /** True for system-scope `systemctl show the-companion.service` queries. */
1470
+ function isSystemShow(cmd: string): boolean {
1471
+ return cmd.startsWith("systemctl show the-companion.service");
1472
+ }
1473
+
1474
+ describe("system-wide systemd unit (linux)", () => {
1475
+ beforeEach(async () => {
1476
+ mockPlatform.set("linux");
1477
+ Object.defineProperty(process, "platform", { value: "linux" });
1478
+ vi.resetModules();
1479
+ service = await import("./service.js");
1480
+ });
1481
+
1482
+ describe("getSystemUnitInfo", () => {
1483
+ it("parses state, pid, invocation id, path and port", () => {
1484
+ mockExecSync.mockImplementation((cmd: string) => (isSystemShow(cmd) ? SYSTEM_UNIT_SHOW : ""));
1485
+ expect(service.getSystemUnitInfo()).toEqual({
1486
+ activeState: "active",
1487
+ mainPid: 4321,
1488
+ invocationId: "0123456789abcdef0123456789abcdef",
1489
+ fragmentPath: "/etc/systemd/system/the-companion.service",
1490
+ port: 3999,
1491
+ });
1492
+ });
1493
+
1494
+ it("returns undefined for LoadState=not-found", () => {
1495
+ mockExecSync.mockImplementation(() => "LoadState=not-found\nActiveState=inactive\n");
1496
+ expect(service.getSystemUnitInfo()).toBeUndefined();
1497
+ });
1498
+
1499
+ it("returns undefined when systemctl fails", () => {
1500
+ mockExecSync.mockImplementation(() => { throw new Error("no systemd"); });
1501
+ expect(service.getSystemUnitInfo()).toBeUndefined();
1502
+ });
1503
+ });
1504
+
1505
+ describe("detectServiceKind / isRunningAsService", () => {
1506
+ it("detects the system unit when INVOCATION_ID matches", () => {
1507
+ process.env.INVOCATION_ID = "0123456789abcdef0123456789abcdef";
1508
+ mockExecSync.mockImplementation((cmd: string) => (isSystemShow(cmd) ? SYSTEM_UNIT_SHOW : ""));
1509
+ expect(service.isRunningAsService()).toBe(true);
1510
+ expect(service.getDetectedServiceKind()).toBe("systemd-system");
1511
+ });
1512
+
1513
+ it("ignores the system unit when this process is not its invocation", () => {
1514
+ process.env.INVOCATION_ID = "ffffffffffffffffffffffffffffffff";
1515
+ mockExecSync.mockImplementation((cmd: string) => (isSystemShow(cmd) ? SYSTEM_UNIT_SHOW : ""));
1516
+ expect(service.detectServiceKind()).toBeNull();
1517
+ });
1518
+
1519
+ it("ignores the system unit when not started by systemd (no INVOCATION_ID)", () => {
1520
+ mockExecSync.mockImplementation((cmd: string) => (isSystemShow(cmd) ? SYSTEM_UNIT_SHOW : ""));
1521
+ expect(service.detectServiceKind()).toBeNull();
1522
+ });
1523
+
1524
+ it("ignores an inactive system unit", () => {
1525
+ process.env.INVOCATION_ID = "0123456789abcdef0123456789abcdef";
1526
+ mockExecSync.mockImplementation((cmd: string) =>
1527
+ isSystemShow(cmd) ? SYSTEM_UNIT_SHOW.replace("ActiveState=active", "ActiveState=failed") : "");
1528
+ expect(service.detectServiceKind()).toBeNull();
1529
+ });
1530
+
1531
+ it("COMPANION_SERVICE_MODE=1 opts in without any unit", () => {
1532
+ process.env.COMPANION_SERVICE_MODE = "1";
1533
+ expect(service.isRunningAsService()).toBe(true);
1534
+ expect(service.getDetectedServiceKind()).toBe("external");
1535
+ });
1536
+
1537
+ it("COMPANION_SERVICE_MODE=0 forces service mode off", () => {
1538
+ process.env.COMPANION_SERVICE_MODE = "0";
1539
+ process.env.INVOCATION_ID = "0123456789abcdef0123456789abcdef";
1540
+ mockExecSync.mockImplementation((cmd: string) => (isSystemShow(cmd) ? SYSTEM_UNIT_SHOW : ""));
1541
+ expect(service.isRunningAsService()).toBe(false);
1542
+ expect(service.getDetectedServiceKind()).toBeNull();
1543
+ });
1544
+
1545
+ it("still reports systemd-user for an active per-user unit", async () => {
1546
+ mockExecSync.mockImplementation((cmd: string) =>
1547
+ cmd.startsWith("which") ? "/usr/local/bin/the-companion\n" : "");
1548
+ await service.install();
1549
+ mockExecSync.mockReset();
1550
+ mockExecSync.mockImplementation((cmd: string) => (cmd.includes("is-active") ? "active\n" : ""));
1551
+ expect(service.detectServiceKind()).toBe("systemd-user");
1552
+ });
1553
+ });
1554
+
1555
+ describe("install", () => {
1556
+ it("refuses when a system-wide unit exists", async () => {
1557
+ mockExecSync.mockImplementation((cmd: string) => {
1558
+ if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
1559
+ if (isSystemShow(cmd)) return SYSTEM_UNIT_SHOW;
1560
+ return "";
1561
+ });
1562
+ await expect(service.install()).rejects.toThrow("process.exit(1)");
1563
+ expect(existsSync(unitPath())).toBe(false);
1564
+ expect(mockExecSync).not.toHaveBeenCalledWith(
1565
+ expect.stringContaining("enable --now"), expect.anything());
1566
+ });
1567
+
1568
+ it("refuses when the port is already in use", async () => {
1569
+ mockPortInUse.set(true);
1570
+ mockExecSync.mockImplementation((cmd: string) =>
1571
+ cmd.startsWith("which") ? "/usr/local/bin/the-companion\n" : "");
1572
+ await expect(service.install()).rejects.toThrow("process.exit(1)");
1573
+ expect(existsSync(unitPath())).toBe(false);
1574
+ });
1575
+ });
1576
+
1577
+ describe("start/stop/restart/status/uninstall with only a system unit", () => {
1578
+ beforeEach(() => {
1579
+ mockExecSync.mockImplementation((cmd: string) => (isSystemShow(cmd) ? SYSTEM_UNIT_SHOW : ""));
1580
+ });
1581
+
1582
+ it("start does not install a user unit and prints the sudo hint when unprivileged", async () => {
1583
+ vi.spyOn(process, "getuid").mockReturnValue(1000);
1584
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
1585
+ await expect(service.start()).rejects.toThrow("process.exit(1)");
1586
+ expect(existsSync(unitPath())).toBe(false);
1587
+ expect(log.mock.calls.flat().join("\n")).toContain("sudo systemctl start the-companion.service");
1588
+ });
1589
+
1590
+ it("restart drives the system unit directly as root", async () => {
1591
+ vi.spyOn(process, "getuid").mockReturnValue(0);
1592
+ vi.spyOn(console, "log").mockImplementation(() => {});
1593
+ await service.restart();
1594
+ expect(mockExecSync).toHaveBeenCalledWith(
1595
+ "systemctl restart the-companion.service", expect.anything());
1596
+ expect(mockExecSync).not.toHaveBeenCalledWith(
1597
+ expect.stringContaining("--user restart"), expect.anything());
1598
+ });
1599
+
1600
+ it("stop prints the sudo hint when unprivileged", async () => {
1601
+ vi.spyOn(process, "getuid").mockReturnValue(1000);
1602
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
1603
+ await expect(service.stop()).rejects.toThrow("process.exit(1)");
1604
+ expect(log.mock.calls.flat().join("\n")).toContain("sudo systemctl stop the-companion.service");
1605
+ });
1606
+
1607
+ it("status reports the system unit", async () => {
1608
+ expect(await service.status()).toEqual({
1609
+ installed: true, running: true, pid: 4321, port: 3999, scope: "system",
1610
+ });
1611
+ });
1612
+
1613
+ it("uninstall leaves the system unit alone", async () => {
1614
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
1615
+ await service.uninstall();
1616
+ expect(mockExecSync).not.toHaveBeenCalledWith(
1617
+ expect.stringContaining("disable"), expect.anything());
1618
+ expect(log.mock.calls.flat().join("\n")).toContain("sudo systemctl disable --now");
1619
+ });
1620
+ });
1621
+ });
1622
+
1623
+ describe("getUpdateRestartPlan", () => {
1624
+ it("exits non-zero without spawning for a system unit", () => {
1625
+ expect(service.getUpdateRestartPlan("systemd-system")).toEqual({
1626
+ exitCode: service.UPDATE_RESTART_EXIT_CODE,
1627
+ });
1628
+ expect(service.UPDATE_RESTART_EXIT_CODE).not.toBe(0);
1629
+ // 42 is SuccessExitStatus in our own template; a "please restart" code must differ.
1630
+ expect(service.UPDATE_RESTART_EXIT_CODE).not.toBe(42);
1631
+ });
1632
+
1633
+ it("exits non-zero without spawning for COMPANION_SERVICE_MODE=1", () => {
1634
+ expect(service.getUpdateRestartPlan("external").command).toBeUndefined();
1635
+ expect(service.getUpdateRestartPlan("external").exitCode).toBe(service.UPDATE_RESTART_EXIT_CODE);
1636
+ });
1637
+
1638
+ it("uses systemctl --user restart for the user unit", () => {
1639
+ const plan = service.getUpdateRestartPlan("systemd-user");
1640
+ expect(plan.command).toEqual(["systemctl", "--user", "restart", "the-companion.service"]);
1641
+ expect(plan.env?.XDG_RUNTIME_DIR).toBeTruthy();
1642
+ expect(plan.exitCode).toBe(0);
1643
+ });
1644
+
1645
+ it("uses launchctl kickstart for launchd", () => {
1646
+ const plan = service.getUpdateRestartPlan("launchd");
1647
+ expect(plan.command?.slice(0, 3)).toEqual(["launchctl", "kickstart", "-k"]);
1648
+ expect(plan.command?.[3]).toMatch(/sh\.thecompanion\.app$/);
1649
+ expect(plan.exitCode).toBe(0);
1650
+ });
1651
+ });
package/server/service.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  import { join } from "node:path";
9
9
  import { homedir } from "node:os";
10
10
  import { execSync } from "node:child_process";
11
+ import { createServer } from "node:net";
11
12
  import { DEFAULT_PORT_PROD } from "./constants.js";
12
13
  import { getServicePath } from "./path-resolver.js";
13
14
  import { COMPANION_HOME } from "./paths.js";
@@ -32,6 +33,16 @@ const SYSTEMD_DIR = join(homedir(), ".config", "systemd", "user");
32
33
  const UNIT_NAME = "the-companion.service";
33
34
  const UNIT_PATH = join(SYSTEMD_DIR, UNIT_NAME);
34
35
 
36
+ /**
37
+ * Exit code the server uses to ask an external supervisor (a system-wide
38
+ * systemd unit, or anything configured via COMPANION_SERVICE_MODE=1) to
39
+ * restart it after an in-app update. It must be non-zero so that units with
40
+ * `Restart=on-failure` respawn the process, and it deliberately differs from
41
+ * 42, which our own user-unit template lists in `SuccessExitStatus=`.
42
+ * 75 is EX_TEMPFAIL from sysexits.h ("temporary failure, try again").
43
+ */
44
+ export const UPDATE_RESTART_EXIT_CODE = 75;
45
+
35
46
  // ─── Platform check ─────────────────────────────────────────────────────────────
36
47
 
37
48
  function ensureSupportedPlatform(): void {
@@ -225,6 +236,114 @@ function systemctlUser(cmd: string): string {
225
236
  });
226
237
  }
227
238
 
239
+ /**
240
+ * Run a command against the system-wide systemd instance (no `--user`).
241
+ * Read-only queries (show / is-active) work unprivileged; mutating commands
242
+ * need root or a polkit rule.
243
+ */
244
+ function systemctlSystem(cmd: string): string {
245
+ return execSync(`systemctl ${cmd}`, {
246
+ encoding: "utf-8",
247
+ stdio: ["pipe", "pipe", "pipe"],
248
+ });
249
+ }
250
+
251
+ export interface SystemUnitInfo {
252
+ activeState: string;
253
+ mainPid?: number;
254
+ invocationId?: string;
255
+ fragmentPath?: string;
256
+ port?: number;
257
+ }
258
+
259
+ /**
260
+ * Look up a system-wide `the-companion.service` (e.g. one an admin placed in
261
+ * /etc/systemd/system with `User=...`). Returns undefined when no such unit
262
+ * is loaded or systemctl is unavailable. Never throws.
263
+ */
264
+ export function getSystemUnitInfo(): SystemUnitInfo | undefined {
265
+ if (!isLinux()) return undefined;
266
+ let output: string;
267
+ try {
268
+ output = systemctlSystem(
269
+ `show ${UNIT_NAME} --property=LoadState,ActiveState,MainPID,InvocationID,FragmentPath,Environment --no-pager`,
270
+ );
271
+ } catch {
272
+ return undefined;
273
+ }
274
+ if (typeof output !== "string") return undefined;
275
+ const prop = (name: string): string | undefined => {
276
+ const m = output.match(new RegExp(`^${name}=(.*)$`, "m"));
277
+ return m ? m[1].trim() : undefined;
278
+ };
279
+ // LoadState=not-found is what systemd reports for units that don't exist.
280
+ if (prop("LoadState") !== "loaded") return undefined;
281
+
282
+ const pid = Number(prop("MainPID"));
283
+ const portMatch = prop("Environment")?.match(/(?:^|\s)PORT=(\d+)/);
284
+ return {
285
+ activeState: prop("ActiveState") || "unknown",
286
+ mainPid: Number.isFinite(pid) && pid > 0 ? pid : undefined,
287
+ invocationId: prop("InvocationID") || undefined,
288
+ fragmentPath: prop("FragmentPath") || undefined,
289
+ port: portMatch ? Number(portMatch[1]) : undefined,
290
+ };
291
+ }
292
+
293
+ /** Explains how to manage a system-wide unit, which we can't do for the user. */
294
+ function printSystemUnitHint(action: string, info: SystemUnitInfo): void {
295
+ console.log("The Companion is managed by a system-wide systemd unit:");
296
+ console.log(` Unit: ${info.fragmentPath ?? UNIT_NAME}`);
297
+ console.log(` State: ${info.activeState}`);
298
+ console.log("");
299
+ console.log(`Use: sudo systemctl ${action} ${UNIT_NAME}`);
300
+ }
301
+
302
+ function isRoot(): boolean {
303
+ return typeof process.getuid === "function" && process.getuid() === 0;
304
+ }
305
+
306
+ /**
307
+ * Handle start/stop/restart when only a system-wide unit exists. As root we
308
+ * can drive it directly; otherwise print the sudo command and fail, rather
309
+ * than falling through to `systemctl --user` (misleading) or, for `start`,
310
+ * installing a second, conflicting user unit.
311
+ */
312
+ function controlSystemUnit(action: "start" | "stop" | "restart", info: SystemUnitInfo): void {
313
+ if (isRoot()) {
314
+ try {
315
+ systemctlSystem(`${action} ${UNIT_NAME}`);
316
+ } catch (err: unknown) {
317
+ console.error(`Failed to ${action} the system service with systemctl:`);
318
+ console.error(err instanceof Error ? err.message : String(err));
319
+ process.exit(1);
320
+ }
321
+ const past = action === "stop" ? "stopped" : action === "start" ? "started" : "restarted";
322
+ console.log(`The Companion system service has been ${past}.`);
323
+ return;
324
+ }
325
+ printSystemUnitHint(action, info);
326
+ process.exit(1);
327
+ }
328
+
329
+ /**
330
+ * Resolve true when something is already listening on `port`. Binding with
331
+ * no host uses the dual-stack wildcard, which conflicts with a listener on
332
+ * any address, so this also catches servers bound to 0.0.0.0 or 127.0.0.1.
333
+ */
334
+ export function isPortInUse(port: number): Promise<boolean> {
335
+ return new Promise((resolve) => {
336
+ const server = createServer();
337
+ server.once("error", (err: NodeJS.ErrnoException) => {
338
+ resolve(err.code === "EADDRINUSE");
339
+ });
340
+ server.once("listening", () => {
341
+ server.close(() => resolve(false));
342
+ });
343
+ server.listen(port);
344
+ });
345
+ }
346
+
228
347
  // ─── Install ────────────────────────────────────────────────────────────────────
229
348
 
230
349
  export async function install(opts?: { port?: number }): Promise<void> {
@@ -285,9 +404,31 @@ async function installLinux(opts?: { port?: number }): Promise<void> {
285
404
  process.exit(1);
286
405
  }
287
406
 
407
+ // A system-wide unit already supervises the Companion on this host. A
408
+ // second (user) unit would fight it for the port and crash-loop, or, if it
409
+ // ever won, start with the wrong environment and an empty session store.
410
+ const systemUnit = getSystemUnitInfo();
411
+ if (systemUnit) {
412
+ console.error("The Companion is already installed as a system-wide systemd unit:");
413
+ console.error(` ${systemUnit.fragmentPath ?? UNIT_NAME} (${systemUnit.activeState})`);
414
+ console.error("");
415
+ console.error("Refusing to install a second, per-user unit.");
416
+ console.error(`Manage it with: sudo systemctl <start|stop|restart|status> ${UNIT_NAME}`);
417
+ console.error("In-app updates work there too (see COMPANION_SERVICE_MODE in the docs).");
418
+ process.exit(1);
419
+ }
420
+
288
421
  const binPath = resolveBinPath();
289
422
  const port = opts?.port ?? DEFAULT_PORT_PROD;
290
423
 
424
+ if (await isPortInUse(port)) {
425
+ console.error(`Port ${port} is already in use.`);
426
+ console.error("Another Companion (or something else) is listening there, so the");
427
+ console.error("service would crash-loop. Stop it first, or install on another port:");
428
+ console.error(" the-companion install --port <port>");
429
+ process.exit(1);
430
+ }
431
+
291
432
  // Create log directory
292
433
  mkdirSync(LOG_DIR, { recursive: true });
293
434
 
@@ -356,6 +497,16 @@ async function uninstallDarwin(): Promise<void> {
356
497
 
357
498
  async function uninstallLinux(): Promise<void> {
358
499
  if (!isSystemdUnitInstalled()) {
500
+ const systemUnit = getSystemUnitInfo();
501
+ if (systemUnit) {
502
+ // Never remove an admin-managed unit on the user's behalf.
503
+ console.log("The Companion is managed by a system-wide systemd unit, which");
504
+ console.log("'the-companion uninstall' does not remove. To remove it:");
505
+ console.log(` sudo systemctl disable --now ${UNIT_NAME}`);
506
+ console.log(` sudo rm ${systemUnit.fragmentPath ?? `/etc/systemd/system/${UNIT_NAME}`}`);
507
+ console.log(" sudo systemctl daemon-reload");
508
+ return;
509
+ }
359
510
  console.log("The Companion is not installed as a service.");
360
511
  return;
361
512
  }
@@ -428,6 +579,8 @@ async function startDarwin(): Promise<void> {
428
579
 
429
580
  async function startLinux(): Promise<void> {
430
581
  if (!isSystemdUnitInstalled()) {
582
+ const systemUnit = getSystemUnitInfo();
583
+ if (systemUnit) return controlSystemUnit("start", systemUnit);
431
584
  console.log("Service not yet installed. Installing now...");
432
585
  await installLinux();
433
586
  return; // installLinux uses enable --now which starts the service
@@ -484,6 +637,8 @@ async function stopDarwin(): Promise<void> {
484
637
 
485
638
  async function stopLinux(): Promise<void> {
486
639
  if (!isSystemdUnitInstalled()) {
640
+ const systemUnit = getSystemUnitInfo();
641
+ if (systemUnit) return controlSystemUnit("stop", systemUnit);
487
642
  console.log("The Companion is not installed as a service.");
488
643
  return;
489
644
  }
@@ -540,6 +695,8 @@ async function restartDarwin(): Promise<void> {
540
695
 
541
696
  async function restartLinux(): Promise<void> {
542
697
  if (!isSystemdUnitInstalled()) {
698
+ const systemUnit = getSystemUnitInfo();
699
+ if (systemUnit) return controlSystemUnit("restart", systemUnit);
543
700
  console.log("The Companion is not installed as a service.");
544
701
  return;
545
702
  }
@@ -565,38 +722,135 @@ export interface ServiceStatus {
565
722
  running: boolean;
566
723
  pid?: number;
567
724
  port?: number;
725
+ /** Linux only: "system" when managed by a system-wide unit. */
726
+ scope?: "user" | "system";
568
727
  }
569
728
 
570
729
  /**
571
- * Safe check for whether the current process is running as a managed service.
572
- * Unlike status(), this never calls process.exit() and works on all platforms.
730
+ * How the current process is supervised, which decides how an in-app update
731
+ * restarts it:
732
+ * - "launchd" / "systemd-user": our own `the-companion install` units; the
733
+ * updater asks the service manager to restart us.
734
+ * - "systemd-system": a system-wide `the-companion.service` (typically with
735
+ * `User=...`), which an unprivileged process can't restart, so the updater
736
+ * exits with UPDATE_RESTART_EXIT_CODE and lets `Restart=` respawn it.
737
+ * - "external": COMPANION_SERVICE_MODE=1, i.e. the operator promises some
738
+ * supervisor restarts the process on a non-zero exit. Same exit strategy.
573
739
  */
574
- export function isRunningAsService(): boolean {
740
+ export type ServiceKind = "launchd" | "systemd-user" | "systemd-system" | "external";
741
+
742
+ function serviceModeEnv(): "on" | "off" | undefined {
743
+ const raw = process.env.COMPANION_SERVICE_MODE?.trim().toLowerCase();
744
+ if (!raw) return undefined;
745
+ if (["1", "true", "yes", "on"].includes(raw)) return "on";
746
+ if (["0", "false", "no", "off"].includes(raw)) return "off";
747
+ return undefined;
748
+ }
749
+
750
+ /**
751
+ * Detect how (and whether) the current process runs as a managed service.
752
+ * Never calls process.exit() and never throws.
753
+ *
754
+ * COMPANION_SERVICE_MODE=0 forces service mode off; =1 forces it on when no
755
+ * known service manager is detected.
756
+ */
757
+ export function detectServiceKind(): ServiceKind | null {
758
+ const envMode = serviceModeEnv();
759
+ if (envMode === "off") return null;
760
+
575
761
  if (isDarwin()) {
576
762
  const installedService = getInstalledLaunchdService();
577
- if (!installedService) return false;
578
- try {
579
- const output = execSync(`launchctl list "${installedService.label}"`, {
580
- encoding: "utf-8",
581
- stdio: ["pipe", "pipe", "pipe"],
582
- });
583
- return /"PID"\s*=\s*\d+/.test(output);
584
- } catch {
585
- return false;
763
+ if (installedService) {
764
+ try {
765
+ const output = execSync(`launchctl list "${installedService.label}"`, {
766
+ encoding: "utf-8",
767
+ stdio: ["pipe", "pipe", "pipe"],
768
+ });
769
+ if (/"PID"\s*=\s*\d+/.test(output)) return "launchd";
770
+ } catch { /* not loaded */ }
586
771
  }
587
- }
588
-
589
- if (isLinux()) {
590
- if (!isSystemdUnitInstalled()) return false;
591
- try {
592
- const output = systemctlUser(`is-active ${UNIT_NAME}`);
593
- return output.trim() === "active";
594
- } catch {
595
- return false;
772
+ } else if (isLinux()) {
773
+ if (isSystemdUnitInstalled()) {
774
+ try {
775
+ if (systemctlUser(`is-active ${UNIT_NAME}`).trim() === "active") {
776
+ return "systemd-user";
777
+ }
778
+ } catch { /* inactive or no user bus */ }
596
779
  }
780
+ if (envMode !== "on" && isUnderSystemUnit()) return "systemd-system";
597
781
  }
598
782
 
599
- return false;
783
+ return envMode === "on" ? "external" : null;
784
+ }
785
+
786
+ /**
787
+ * True when this process was started by systemd as the system-wide
788
+ * the-companion.service. systemd exports INVOCATION_ID to every service it
789
+ * starts; matching it against the unit's InvocationID proves we are that
790
+ * unit's process rather than, say, a foreground run on a host that also has
791
+ * the unit installed.
792
+ */
793
+ function isUnderSystemUnit(): boolean {
794
+ const invocationId = process.env.INVOCATION_ID?.trim();
795
+ if (!invocationId) return false;
796
+ const info = getSystemUnitInfo();
797
+ if (!info) return false;
798
+ if (info.activeState !== "active" && info.activeState !== "reloading") return false;
799
+ // Very old systemd versions don't expose InvocationID; fall back to the
800
+ // active state alone there.
801
+ return !info.invocationId || info.invocationId === invocationId.replace(/-/g, "");
802
+ }
803
+
804
+ let detectedServiceKind: ServiceKind | null = null;
805
+
806
+ /**
807
+ * Safe check for whether the current process is running as a managed service.
808
+ * Unlike status(), this never calls process.exit() and works on all platforms.
809
+ * The detected kind is remembered for getDetectedServiceKind().
810
+ */
811
+ export function isRunningAsService(): boolean {
812
+ detectedServiceKind = detectServiceKind();
813
+ return detectedServiceKind !== null;
814
+ }
815
+
816
+ /** The kind found by the last isRunningAsService() call (null if none). */
817
+ export function getDetectedServiceKind(): ServiceKind | null {
818
+ return detectedServiceKind;
819
+ }
820
+
821
+ export interface UpdateRestartPlan {
822
+ /** Command to spawn (detached) before exiting, if any. */
823
+ command?: string[];
824
+ /** Extra environment for the command. */
825
+ env?: Record<string, string>;
826
+ /** Exit code for the current process once the command was spawned. */
827
+ exitCode: number;
828
+ }
829
+
830
+ /**
831
+ * How to get the new version running after `bun install -g` succeeded.
832
+ * A null/undefined kind keeps the historical platform-based behaviour.
833
+ */
834
+ export function getUpdateRestartPlan(kind: ServiceKind | null | undefined): UpdateRestartPlan {
835
+ if (kind === "systemd-system" || kind === "external") {
836
+ // Nothing to spawn: an unprivileged process can't restart a system unit,
837
+ // and the non-zero exit makes Restart=on-failure (or always) respawn us.
838
+ return { exitCode: UPDATE_RESTART_EXIT_CODE };
839
+ }
840
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
841
+ if (kind === "systemd-user" || (!kind && process.platform === "linux")) {
842
+ return {
843
+ command: ["systemctl", "--user", "restart", UNIT_NAME],
844
+ env: { XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR || `/run/user/${uid ?? 1000}` },
845
+ exitCode: 0,
846
+ };
847
+ }
848
+ return {
849
+ command: uid !== undefined
850
+ ? ["launchctl", "kickstart", "-k", `gui/${uid}/${LABEL}`]
851
+ : ["launchctl", "kickstart", "-k", LABEL],
852
+ exitCode: 0,
853
+ };
600
854
  }
601
855
 
602
856
  /**
@@ -687,7 +941,16 @@ async function statusDarwin(): Promise<ServiceStatus> {
687
941
 
688
942
  async function statusLinux(): Promise<ServiceStatus> {
689
943
  if (!isSystemdUnitInstalled()) {
690
- return { installed: false, running: false };
944
+ const systemUnit = getSystemUnitInfo();
945
+ if (!systemUnit) return { installed: false, running: false };
946
+ const running = systemUnit.activeState === "active" && !!systemUnit.mainPid;
947
+ return {
948
+ installed: true,
949
+ running,
950
+ pid: running ? systemUnit.mainPid : undefined,
951
+ port: systemUnit.port ?? DEFAULT_PORT_PROD,
952
+ scope: "system",
953
+ };
691
954
  }
692
955
 
693
956
  // Read port from the unit file