@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 +9 -1
- package/package.json +1 -1
- package/server/index.ts +2 -2
- package/server/routes/system-routes.test.ts +57 -0
- package/server/routes/system-routes.ts +20 -24
- package/server/service.test.ts +232 -0
- package/server/service.ts +286 -23
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
|
-
|
|
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
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(
|
|
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 {
|
|
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
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
:
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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);
|
package/server/service.test.ts
CHANGED
|
@@ -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
|
-
*
|
|
572
|
-
*
|
|
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
|
|
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 (
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
|
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
|
-
|
|
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
|