@hellcoder/companion 0.123.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/dist/assets/{AgentsPage-D_ZGmkrU.js → AgentsPage-Byq8wvdn.js} +1 -1
- package/dist/assets/{CronManager-u5KuicVz.js → CronManager-Bq2lOMbZ.js} +1 -1
- package/dist/assets/{DashboardPage-Dab8XyGT.js → DashboardPage-V77HIt8S.js} +1 -1
- package/dist/assets/{IntegrationsPage-Bc7SgfqX.js → IntegrationsPage-C3aW9cDI.js} +1 -1
- package/dist/assets/{LinearOAuthSettingsPage-CxyO36qY.js → LinearOAuthSettingsPage-_eAbciJN.js} +1 -1
- package/dist/assets/{LinearSettingsPage-BWfwYF05.js → LinearSettingsPage-DDHFHt7j.js} +1 -1
- package/dist/assets/{MagicUIDashboard-CfD7iyXm.js → MagicUIDashboard-Cl18AKjV.js} +1 -1
- package/dist/assets/{MagicUIView-CO6pZB0M.js → MagicUIView-C0aiMTet.js} +1 -1
- package/dist/assets/{Playground-BiB_VDIl.js → Playground-CvoHRMBd.js} +1 -1
- package/dist/assets/{PromptsPage-BIE70OT1.js → PromptsPage-CzTs1_Se.js} +1 -1
- package/dist/assets/{RunsPage-BAblQKAW.js → RunsPage-B7xru-Xy.js} +1 -1
- package/dist/assets/{SandboxManager-DsR3hQz1.js → SandboxManager-C3tFkiWz.js} +1 -1
- package/dist/assets/{SettingsPage-DuzHKBe_.js → SettingsPage-BZ2fBrHD.js} +1 -1
- package/dist/assets/{TailscalePage-C3y_bGkT.js → TailscalePage-DFRiFKcb.js} +1 -1
- package/dist/assets/index-C7nK12tB.js +142 -0
- package/dist/assets/index-OAVDFR0O.css +1 -0
- package/dist/assets/{sw-register-CUhVLAo0.js → sw-register-Dc72vh_Y.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/package.json +1 -1
- package/server/cli-launcher.test.ts +16 -0
- package/server/cli-launcher.ts +14 -0
- 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/routes.test.ts +29 -0
- package/server/routes.ts +14 -0
- package/server/service.test.ts +232 -0
- package/server/service.ts +286 -23
- package/dist/assets/index-BnF_YIhB.css +0 -1
- package/dist/assets/index-Cd0HGwTS.js +0 -141
package/server/routes.test.ts
CHANGED
|
@@ -310,6 +310,7 @@ function createMockLauncher() {
|
|
|
310
310
|
listSessions: vi.fn(() => []),
|
|
311
311
|
getSession: vi.fn(),
|
|
312
312
|
setArchived: vi.fn(),
|
|
313
|
+
setPinned: vi.fn(() => true),
|
|
313
314
|
removeSession: vi.fn(),
|
|
314
315
|
} as any;
|
|
315
316
|
}
|
|
@@ -1193,6 +1194,34 @@ describe("POST /api/sessions/:id/unarchive", () => {
|
|
|
1193
1194
|
});
|
|
1194
1195
|
});
|
|
1195
1196
|
|
|
1197
|
+
describe("POST /api/sessions/:id/pin", () => {
|
|
1198
|
+
const pin = (body: unknown) =>
|
|
1199
|
+
app.request("/api/sessions/s1/pin", {
|
|
1200
|
+
method: "POST",
|
|
1201
|
+
headers: { "Content-Type": "application/json" },
|
|
1202
|
+
body: JSON.stringify(body),
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
it("sets the pinned flag via the launcher", async () => {
|
|
1206
|
+
const res = await pin({ pinned: true });
|
|
1207
|
+
expect(res.status).toBe(200);
|
|
1208
|
+
expect(await res.json()).toEqual({ ok: true, pinned: true });
|
|
1209
|
+
expect(launcher.setPinned).toHaveBeenCalledWith("s1", true);
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
it("rejects a missing/non-boolean pinned value", async () => {
|
|
1213
|
+
const res = await pin({ pinned: "yes" });
|
|
1214
|
+
expect(res.status).toBe(400);
|
|
1215
|
+
expect(launcher.setPinned).not.toHaveBeenCalled();
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
it("returns 404 for an unknown session", async () => {
|
|
1219
|
+
launcher.setPinned.mockReturnValue(false);
|
|
1220
|
+
const res = await pin({ pinned: false });
|
|
1221
|
+
expect(res.status).toBe(404);
|
|
1222
|
+
});
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1196
1225
|
// ─── Environments ────────────────────────────────────────────────────────────
|
|
1197
1226
|
|
|
1198
1227
|
describe("GET /api/envs", () => {
|
package/server/routes.ts
CHANGED
|
@@ -1165,6 +1165,20 @@ export function createRoutes(
|
|
|
1165
1165
|
return c.json({ ok: true });
|
|
1166
1166
|
});
|
|
1167
1167
|
|
|
1168
|
+
// Pin/unpin a session to the top of its owner's sidebar. Ownership (session.userName
|
|
1169
|
+
// vs. the browser's own user name) is a UI concern — identity is not authenticated.
|
|
1170
|
+
api.post("/sessions/:id/pin", async (c) => {
|
|
1171
|
+
const id = c.req.param("id");
|
|
1172
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1173
|
+
if (typeof body.pinned !== "boolean") {
|
|
1174
|
+
return c.json({ error: "pinned (boolean) is required" }, 400);
|
|
1175
|
+
}
|
|
1176
|
+
if (!launcher.setPinned(id, body.pinned)) {
|
|
1177
|
+
return c.json({ error: "Session not found" }, 404);
|
|
1178
|
+
}
|
|
1179
|
+
return c.json({ ok: true, pinned: body.pinned });
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1168
1182
|
// ─── Recording Management ──────────────────────────────────
|
|
1169
1183
|
|
|
1170
1184
|
api.post("/sessions/:id/recording/start", (c) => {
|
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
|
+
});
|