@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.
Files changed (32) hide show
  1. package/bin/cli.ts +9 -1
  2. package/dist/assets/{AgentsPage-D_ZGmkrU.js → AgentsPage-Byq8wvdn.js} +1 -1
  3. package/dist/assets/{CronManager-u5KuicVz.js → CronManager-Bq2lOMbZ.js} +1 -1
  4. package/dist/assets/{DashboardPage-Dab8XyGT.js → DashboardPage-V77HIt8S.js} +1 -1
  5. package/dist/assets/{IntegrationsPage-Bc7SgfqX.js → IntegrationsPage-C3aW9cDI.js} +1 -1
  6. package/dist/assets/{LinearOAuthSettingsPage-CxyO36qY.js → LinearOAuthSettingsPage-_eAbciJN.js} +1 -1
  7. package/dist/assets/{LinearSettingsPage-BWfwYF05.js → LinearSettingsPage-DDHFHt7j.js} +1 -1
  8. package/dist/assets/{MagicUIDashboard-CfD7iyXm.js → MagicUIDashboard-Cl18AKjV.js} +1 -1
  9. package/dist/assets/{MagicUIView-CO6pZB0M.js → MagicUIView-C0aiMTet.js} +1 -1
  10. package/dist/assets/{Playground-BiB_VDIl.js → Playground-CvoHRMBd.js} +1 -1
  11. package/dist/assets/{PromptsPage-BIE70OT1.js → PromptsPage-CzTs1_Se.js} +1 -1
  12. package/dist/assets/{RunsPage-BAblQKAW.js → RunsPage-B7xru-Xy.js} +1 -1
  13. package/dist/assets/{SandboxManager-DsR3hQz1.js → SandboxManager-C3tFkiWz.js} +1 -1
  14. package/dist/assets/{SettingsPage-DuzHKBe_.js → SettingsPage-BZ2fBrHD.js} +1 -1
  15. package/dist/assets/{TailscalePage-C3y_bGkT.js → TailscalePage-DFRiFKcb.js} +1 -1
  16. package/dist/assets/index-C7nK12tB.js +142 -0
  17. package/dist/assets/index-OAVDFR0O.css +1 -0
  18. package/dist/assets/{sw-register-CUhVLAo0.js → sw-register-Dc72vh_Y.js} +1 -1
  19. package/dist/index.html +2 -2
  20. package/dist/sw.js +1 -1
  21. package/package.json +1 -1
  22. package/server/cli-launcher.test.ts +16 -0
  23. package/server/cli-launcher.ts +14 -0
  24. package/server/index.ts +2 -2
  25. package/server/routes/system-routes.test.ts +57 -0
  26. package/server/routes/system-routes.ts +20 -24
  27. package/server/routes.test.ts +29 -0
  28. package/server/routes.ts +14 -0
  29. package/server/service.test.ts +232 -0
  30. package/server/service.ts +286 -23
  31. package/dist/assets/index-BnF_YIhB.css +0 -1
  32. package/dist/assets/index-Cd0HGwTS.js +0 -141
@@ -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) => {
@@ -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
+ });