macroclaw 0.40.0 → 0.42.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "macroclaw",
3
- "version": "0.40.0",
3
+ "version": "0.42.0",
4
4
  "description": "Telegram-to-Claude-Code bridge",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cli.test.ts CHANGED
@@ -134,7 +134,8 @@ function mockService(overrides?: Record<string, unknown>): SystemServiceManager
134
134
  start: mock(() => ""),
135
135
  stop: mock(() => {}),
136
136
  restart: mock(() => ""),
137
- update: mock(() => ({ previousVersion: "0.6.0", currentVersion: "0.7.0" })),
137
+ refresh: mock(() => {}),
138
+ update: mock(() => ({ previousVersion: "0.6.0", currentVersion: "0.7.0", logTailCommand: "tail -f /logs" })),
138
139
  isRunning: false,
139
140
  status: mock(() => ({ installed: false, running: false, platform: "systemd" as const })),
140
141
  logs: mock(() => "journalctl -u macroclaw -n 50 --no-pager"),
@@ -171,6 +172,13 @@ describe("Cli.service", () => {
171
172
  expect(stop).toHaveBeenCalled();
172
173
  });
173
174
 
175
+ it("runs refresh action", () => {
176
+ const refresh = mock(() => {});
177
+ const cli = new Cli({ systemService: mockService({ refresh }) });
178
+ cli.service("refresh");
179
+ expect(refresh).toHaveBeenCalled();
180
+ });
181
+
174
182
  it("runs restart action", () => {
175
183
  const restart = mock(() => "tail -f /logs");
176
184
  const cli = new Cli({ systemService: mockService({ restart }) });
@@ -178,26 +186,17 @@ describe("Cli.service", () => {
178
186
  expect(restart).toHaveBeenCalled();
179
187
  });
180
188
 
181
- it("runs update action — stops and starts when running", () => {
189
+ it("runs update action", () => {
182
190
  const stop = mock(() => {});
183
191
  const start = mock(() => "tail -f /logs");
184
- const update = mock(() => ({ previousVersion: "0.6.0", currentVersion: "0.7.0" }));
192
+ const update = mock(() => ({ previousVersion: "0.6.0", currentVersion: "0.7.0", logTailCommand: "tail -f /logs" }));
193
+ mockExecSync.mockClear();
185
194
  const cli = new Cli({ systemService: mockService({ stop, start, update, isRunning: true }) });
186
195
  cli.service("update");
187
- expect(stop).toHaveBeenCalled();
188
196
  expect(update).toHaveBeenCalled();
189
- expect(start).toHaveBeenCalled();
190
- });
191
-
192
- it("runs update action — skips stop but still starts when not running", () => {
193
- const stop = mock(() => {});
194
- const start = mock(() => "tail -f /logs");
195
- const update = mock(() => ({ previousVersion: "0.6.0", currentVersion: "0.7.0" }));
196
- const cli = new Cli({ systemService: mockService({ stop, start, update, isRunning: false }) });
197
- cli.service("update");
198
197
  expect(stop).not.toHaveBeenCalled();
199
- expect(update).toHaveBeenCalled();
200
- expect(start).toHaveBeenCalled();
198
+ expect(start).not.toHaveBeenCalled();
199
+ expect(mockExecSync).not.toHaveBeenCalledWith("macroclaw service refresh", expect.anything());
201
200
  });
202
201
 
203
202
  it("runs status action", () => {
package/src/cli.ts CHANGED
@@ -59,20 +59,18 @@ export class Cli {
59
59
  this.#serviceManager.stop();
60
60
  console.log("Service stopped.");
61
61
  break;
62
+ case "refresh":
63
+ this.#serviceManager.refresh();
64
+ console.log("Service refreshed.");
65
+ break;
62
66
  case "update": {
63
- if (this.#serviceManager.isRunning) {
64
- this.#serviceManager.stop();
65
- console.log("Service stopped.");
66
- }
67
67
  const result = this.#serviceManager.update();
68
68
  if (result.previousVersion === result.currentVersion) {
69
69
  console.log(`macroclaw v${result.currentVersion} (already up to date)`);
70
70
  } else {
71
71
  console.log(`Updated macroclaw v${result.previousVersion} → v${result.currentVersion}`);
72
72
  }
73
-
74
- const logCmd = this.#serviceManager.start();
75
- console.log(`Service started. Check logs:\n ${logCmd}`);
73
+ console.log(`Service started. Check logs:\n ${result.logTailCommand}`);
76
74
  break;
77
75
  }
78
76
  case "restart": {
@@ -173,6 +171,11 @@ const serviceUpdateCommand = defineCommand({
173
171
  run: () => { try { defaultCli.service("update"); } catch (err) { handleError(err); } },
174
172
  });
175
173
 
174
+ const serviceRefreshCommand = defineCommand({
175
+ meta: { name: "refresh", description: "Regenerate service files from current configuration" },
176
+ run: () => { try { defaultCli.service("refresh"); } catch (err) { handleError(err); } },
177
+ });
178
+
176
179
  const serviceStatusCommand = defineCommand({
177
180
  meta: { name: "status", description: "Show service installation and running status" },
178
181
  run: () => { try { defaultCli.service("status"); } catch (err) { handleError(err); } },
@@ -200,6 +203,7 @@ const serviceCommand = defineCommand({
200
203
  stop: serviceStopCommand,
201
204
  restart: serviceRestartCommand,
202
205
  update: serviceUpdateCommand,
206
+ refresh: serviceRefreshCommand,
203
207
  status: serviceStatusCommand,
204
208
  logs: serviceLogsCommand,
205
209
  },
@@ -7,7 +7,14 @@ const { existsSync: realExistsSync, mkdirSync, readFileSync, rmSync, writeFileSy
7
7
  const existsSync = realExistsSync;
8
8
 
9
9
  // Mock child_process and os — safe since no other tests depend on real execSync or userInfo
10
- const mockExecSync = mock((_cmd: string, _opts?: object) => "");
10
+ const DEFAULT_LOGIN_PATH = "/usr/local/bin:/usr/bin:/bin";
11
+ const DEFAULT_BUN_GLOBAL_BIN = "/home/testuser/.bun/bin";
12
+ const DEFAULT_SERVICE_PATH = `${DEFAULT_BUN_GLOBAL_BIN}:${DEFAULT_LOGIN_PATH}`;
13
+ const mockExecSync = mock((cmd: string, _opts?: object): string => {
14
+ if (cmd === "/bin/bash -lc 'printf %s \"$PATH\"'") return `${DEFAULT_LOGIN_PATH}\n`;
15
+ if (cmd === "bun pm bin -g") return `${DEFAULT_BUN_GLOBAL_BIN}\n`;
16
+ return "";
17
+ });
11
18
  const mockUserInfo = mock(() => ({ username: "testuser", homedir: "/home/testuser", uid: 1000, gid: 1000, shell: "/bin/bash" }));
12
19
  const mockExistsSync = mock((path: string) => realExistsSync(path));
13
20
 
@@ -43,7 +50,11 @@ beforeEach(() => {
43
50
  mockExecSync.mockClear();
44
51
  mockUserInfo.mockClear();
45
52
  mockExistsSync.mockClear();
46
- mockExecSync.mockImplementation((_cmd: string, _opts?: object) => "");
53
+ mockExecSync.mockImplementation((cmd: string, _opts?: object): string => {
54
+ if (cmd === "/bin/bash -lc 'printf %s \"$PATH\"'") return `${DEFAULT_LOGIN_PATH}\n`;
55
+ if (cmd === "bun pm bin -g") return `${DEFAULT_BUN_GLOBAL_BIN}\n`;
56
+ return "";
57
+ });
47
58
  mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: "/home/testuser", uid: 1000, gid: 1000, shell: "/bin/bash" }));
48
59
  mockExistsSync.mockImplementation((path: string) => realExistsSync(path));
49
60
  });
@@ -167,7 +178,7 @@ describe("install", () => {
167
178
  expect(() => mgr.install()).toThrow("Settings not found. Run `macroclaw setup` first.");
168
179
  });
169
180
 
170
- it("runs global install without resolving binary paths on systemd", () => {
181
+ it("runs global install and resolves login shell PATH for systemd", () => {
171
182
  const tmpHome = `/tmp/macroclaw-test-install-${Date.now()}`;
172
183
  mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
173
184
  writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
@@ -183,13 +194,36 @@ describe("install", () => {
183
194
  rmSync(tmpHome, { recursive: true });
184
195
 
185
196
  expect(mockExecSync).toHaveBeenCalledWith("bun install -g macroclaw", expect.anything());
186
- // systemd no longer resolves paths — bash -lc handles PATH at runtime
187
- expect(mockExecSync).not.toHaveBeenCalledWith("which bun", expect.anything());
188
- expect(mockExecSync).not.toHaveBeenCalledWith("which claude", expect.anything());
189
- expect(mockExecSync).not.toHaveBeenCalledWith("bun pm bin -g", expect.anything());
197
+ expect(mockExecSync).toHaveBeenCalledWith("/bin/bash -lc 'printf %s \"$PATH\"'", expect.anything());
198
+ expect(mockExecSync).toHaveBeenCalledWith("bun pm bin -g", expect.anything());
199
+ expect(mockExecSync).not.toHaveBeenCalledWith("which macroclaw", expect.anything());
190
200
  });
191
201
 
192
- it("installs launchd service with bash -lc and OAuth token", () => {
202
+ it("surfaces login shell PATH resolution failures for systemd", () => {
203
+ const tmpHome = `/tmp/macroclaw-test-install-missing-path-${Date.now()}`;
204
+ mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
205
+ writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
206
+
207
+ mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: tmpHome, uid: 1000, gid: 1000, shell: "/bin/bash" }));
208
+ mockExistsSync.mockImplementation((path: string) => {
209
+ if (path === "/var/lib/systemd/linger/testuser") return true;
210
+ return realExistsSync(path);
211
+ });
212
+ mockExecSync.mockImplementation((cmd: string) => {
213
+ if (cmd === "/bin/bash -lc 'printf %s \"$PATH\"'") throw new Error("not found");
214
+ return "";
215
+ });
216
+
217
+ const mgr = createManager({ home: tmpHome });
218
+ expect(() => mgr.install()).toThrow(
219
+ "not found",
220
+ );
221
+ expect(existsSync(join(tmpHome, ".config/systemd/user/macroclaw.service"))).toBe(false);
222
+ expect(mockExecSync).not.toHaveBeenCalledWith("systemctl --user daemon-reload", expect.anything());
223
+ rmSync(tmpHome, { recursive: true });
224
+ });
225
+
226
+ it("installs launchd service with direct macroclaw invocation and OAuth token", () => {
193
227
  const tmpHome = `/tmp/macroclaw-test-launchd-${Date.now()}`;
194
228
  mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
195
229
  writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
@@ -202,23 +236,57 @@ describe("install", () => {
202
236
  const plistPath = join(plistDir, "com.macroclaw.plist");
203
237
  expect(existsSync(plistPath)).toBe(true);
204
238
  const writtenContent = readFileSync(plistPath, "utf-8");
205
- // bash -lc pattern — no hardcoded binary paths
206
- expect(writtenContent).toContain("<string>/bin/bash</string>");
207
- expect(writtenContent).toContain("<string>-lc</string>");
208
- expect(writtenContent).toContain("<string>exec bun macroclaw start</string>");
239
+ expect(writtenContent).toContain("<string>macroclaw</string>");
240
+ expect(writtenContent).toContain("<string>start</string>");
209
241
  expect(writtenContent).toContain("<key>KeepAlive</key>");
210
242
  expect(writtenContent).toContain(".macroclaw/logs/stdout.log");
211
- // No PATH/HOME env vars — login shell provides them
212
- expect(writtenContent).not.toContain("<key>PATH</key>");
243
+ expect(writtenContent).toContain("<key>EnvironmentVariables</key>");
244
+ expect(writtenContent).toContain("<key>PATH</key>");
245
+ expect(writtenContent).toContain(`<string>${DEFAULT_SERVICE_PATH}</string>`);
213
246
  expect(writtenContent).not.toContain("<key>HOME</key>");
214
247
  // OAuth token is preserved
215
248
  expect(writtenContent).toContain("<key>CLAUDE_CODE_OAUTH_TOKEN</key>");
216
249
  expect(writtenContent).toContain("<string>sk-test-token</string>");
217
250
  expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining("launchctl load"), expect.anything());
218
- // No path resolution calls
219
- expect(mockExecSync).not.toHaveBeenCalledWith("which bun", expect.anything());
220
- expect(mockExecSync).not.toHaveBeenCalledWith("which claude", expect.anything());
221
- expect(mockExecSync).not.toHaveBeenCalledWith("bun pm bin -g", expect.anything());
251
+ expect(mockExecSync).not.toHaveBeenCalledWith("which macroclaw", expect.anything());
252
+ rmSync(tmpHome, { recursive: true });
253
+ });
254
+
255
+ it("surfaces login shell PATH resolution failures for launchd", () => {
256
+ const tmpHome = `/tmp/macroclaw-test-launchd-missing-path-${Date.now()}`;
257
+ mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
258
+ writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
259
+ mkdirSync(join(tmpHome, "Library/LaunchAgents"), { recursive: true });
260
+
261
+ mockExecSync.mockImplementation((cmd: string) => {
262
+ if (cmd === "/bin/bash -lc 'printf %s \"$PATH\"'") throw new Error("not found");
263
+ return "";
264
+ });
265
+
266
+ const mgr = createManager({ platform: "darwin", home: tmpHome });
267
+ expect(() => mgr.install("sk-test-token")).toThrow(
268
+ "not found",
269
+ );
270
+ expect(existsSync(join(tmpHome, "Library/LaunchAgents/com.macroclaw.plist"))).toBe(false);
271
+ expect(mockExecSync).not.toHaveBeenCalledWith(expect.stringContaining("launchctl load"), expect.anything());
272
+ rmSync(tmpHome, { recursive: true });
273
+ });
274
+
275
+ it("surfaces login shell PATH permission failures for launchd", () => {
276
+ const tmpHome = `/tmp/macroclaw-test-launchd-missing-bin-dir-${Date.now()}`;
277
+ mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
278
+ writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
279
+ mkdirSync(join(tmpHome, "Library/LaunchAgents"), { recursive: true });
280
+
281
+ mockExecSync.mockImplementation((cmd: string) => {
282
+ if (cmd === "/bin/bash -lc 'printf %s \"$PATH\"'") throw new Error("permission denied");
283
+ return "";
284
+ });
285
+
286
+ const mgr = createManager({ platform: "darwin", home: tmpHome });
287
+ expect(() => mgr.install("sk-test-token")).toThrow(
288
+ "permission denied",
289
+ );
222
290
  rmSync(tmpHome, { recursive: true });
223
291
  });
224
292
 
@@ -231,8 +299,10 @@ describe("install", () => {
231
299
  const mgr = createManager({ platform: "darwin", home: tmpHome });
232
300
  mgr.install();
233
301
  const writtenContent = readFileSync(join(tmpHome, "Library/LaunchAgents/com.macroclaw.plist"), "utf-8");
302
+ expect(writtenContent).toContain("<key>EnvironmentVariables</key>");
303
+ expect(writtenContent).toContain("<key>PATH</key>");
304
+ expect(writtenContent).toContain(`<string>${DEFAULT_SERVICE_PATH}</string>`);
234
305
  expect(writtenContent).not.toContain("CLAUDE_CODE_OAUTH_TOKEN");
235
- expect(writtenContent).not.toContain("<key>EnvironmentVariables</key>");
236
306
  rmSync(tmpHome, { recursive: true });
237
307
  });
238
308
 
@@ -273,7 +343,7 @@ describe("install", () => {
273
343
  rmSync(tmpHome, { recursive: true });
274
344
  });
275
345
 
276
- it("installs systemd user service with bash -lc and no hardcoded paths", () => {
346
+ it("installs systemd user service with direct macroclaw invocation and no hardcoded paths", () => {
277
347
  const tmpHome = `/tmp/macroclaw-test-systemd-${Date.now()}`;
278
348
  mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
279
349
  writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
@@ -294,11 +364,11 @@ describe("install", () => {
294
364
  expect(unitContent).toContain("WantedBy=default.target");
295
365
  expect(unitContent).not.toContain("User=");
296
366
  expect(unitContent).not.toContain("Group=");
297
- // bash -lc sources login profile for PATH no hardcoded Environment lines
367
+ // systemd snapshots the login shell PATH and ensures Bun's global bin is included
298
368
  expect(unitContent).not.toContain("Environment=HOME=");
299
- expect(unitContent).not.toContain("Environment=PATH=");
369
+ expect(unitContent).toContain(`Environment=PATH=${DEFAULT_SERVICE_PATH}`);
300
370
  expect(unitContent).toContain("WorkingDirectory=%h");
301
- expect(unitContent).toContain("ExecStart=/bin/bash -lc 'exec bun macroclaw start'");
371
+ expect(unitContent).toContain("ExecStart=macroclaw start");
302
372
 
303
373
  // Lingering enabled via sudo
304
374
  expect(mockExecSync).toHaveBeenCalledWith("sudo loginctl enable-linger testuser", expect.anything());
@@ -608,22 +678,44 @@ describe("update", () => {
608
678
  );
609
679
  });
610
680
 
611
- it("runs bun install without stop/start", () => {
681
+ it("stops, refreshes, and starts the service when updating launchd", () => {
612
682
  const tmpHome = `/tmp/macroclaw-test-updateld-${Date.now()}`;
613
683
  mkdirSync(join(tmpHome, "Library/LaunchAgents"), { recursive: true });
614
684
  writeFileSync(join(tmpHome, "Library/LaunchAgents/com.macroclaw.plist"), "test");
615
685
 
686
+ const calls: string[] = [];
687
+ let running = true;
616
688
  mockExecSync.mockImplementation((cmd: string) => {
689
+ calls.push(cmd);
690
+ if (cmd.startsWith("launchctl list ")) return running ? LAUNCHD_RUNNING : LAUNCHD_STOPPED;
617
691
  if (cmd === "bun pm ls -g") return "macroclaw@0.6.0\n";
692
+ if (cmd.includes("launchctl unload")) {
693
+ running = false;
694
+ return "";
695
+ }
696
+ if (cmd === "macroclaw service refresh") return "";
697
+ if (cmd.includes("launchctl load")) {
698
+ running = true;
699
+ return "";
700
+ }
618
701
  return "";
619
702
  });
620
703
  const mgr = createManager({ platform: "darwin", home: tmpHome });
621
704
  const result = mgr.update();
705
+
622
706
  expect(mockExecSync).toHaveBeenCalledWith("bun install -g macroclaw@latest", expect.anything());
623
- expect(mockExecSync).not.toHaveBeenCalledWith(expect.stringContaining("launchctl"), expect.anything());
624
- expect(mockExecSync).not.toHaveBeenCalledWith(expect.stringContaining("systemctl"), expect.anything());
707
+ expect(mockExecSync).toHaveBeenCalledWith("macroclaw service refresh", expect.anything());
708
+ const unloadIdx = calls.findIndex((call) => call.includes("launchctl unload"));
709
+ const installIdx = calls.indexOf("bun install -g macroclaw@latest");
710
+ const refreshIdx = calls.indexOf("macroclaw service refresh");
711
+ const loadIdx = calls.findIndex((call) => call.includes("launchctl load"));
712
+ expect(unloadIdx).toBeGreaterThan(-1);
713
+ expect(installIdx).toBeGreaterThan(unloadIdx);
714
+ expect(refreshIdx).toBeGreaterThan(installIdx);
715
+ expect(loadIdx).toBeGreaterThan(refreshIdx);
625
716
  expect(result.previousVersion).toBe("0.6.0");
626
717
  expect(result.currentVersion).toBe("0.6.0");
718
+ expect(result.logTailCommand).toBe(`tail -f ${tmpHome}/.macroclaw/logs/*.log`);
627
719
  rmSync(tmpHome, { recursive: true });
628
720
  });
629
721
 
@@ -636,6 +728,8 @@ describe("update", () => {
636
728
  mockExecSync.mockImplementation((cmd: string) => {
637
729
  if (cmd.startsWith("launchctl list ")) return LAUNCHD_STOPPED;
638
730
  if (cmd === "bun install -g macroclaw@latest") { installCalled = true; return ""; }
731
+ if (cmd === "macroclaw service refresh") return "";
732
+ if (cmd.includes("launchctl load")) return "";
639
733
  if (cmd === "bun pm ls -g") return installCalled ? "macroclaw@0.7.0\n" : "macroclaw@0.6.0\n";
640
734
  return "";
641
735
  });
@@ -643,6 +737,7 @@ describe("update", () => {
643
737
  const result = mgr.update();
644
738
  expect(result.previousVersion).toBe("0.6.0");
645
739
  expect(result.currentVersion).toBe("0.7.0");
740
+ expect(result.logTailCommand).toBe(`tail -f ${tmpHome}/.macroclaw/logs/*.log`);
646
741
  rmSync(tmpHome, { recursive: true });
647
742
  });
648
743
 
@@ -653,6 +748,8 @@ describe("update", () => {
653
748
 
654
749
  mockExecSync.mockImplementation((cmd: string) => {
655
750
  if (cmd.startsWith("launchctl list ")) return LAUNCHD_STOPPED;
751
+ if (cmd === "macroclaw service refresh") return "";
752
+ if (cmd.includes("launchctl load")) return "";
656
753
  if (cmd === "bun pm ls -g") throw new Error("command not found");
657
754
  return "";
658
755
  });
@@ -660,6 +757,96 @@ describe("update", () => {
660
757
  const result = mgr.update();
661
758
  expect(result.previousVersion).toBe("unknown");
662
759
  expect(result.currentVersion).toBe("unknown");
760
+ expect(result.logTailCommand).toBe(`tail -f ${tmpHome}/.macroclaw/logs/*.log`);
761
+ rmSync(tmpHome, { recursive: true });
762
+ });
763
+ });
764
+
765
+ describe("refresh", () => {
766
+ it("throws when service is not installed", () => {
767
+ const mgr = createManager({ platform: "darwin", home: "/nonexistent" });
768
+ expect(() => mgr.refresh()).toThrow(
769
+ "Service not installed. Run `macroclaw service install` first.",
770
+ );
771
+ });
772
+
773
+ it("refreshes launchd PATH snapshot and preserves oauth token", () => {
774
+ const tmpHome = `/tmp/macroclaw-test-refresh-launchd-${Date.now()}`;
775
+ const plistDir = join(tmpHome, "Library/LaunchAgents");
776
+ mkdirSync(plistDir, { recursive: true });
777
+ writeFileSync(join(plistDir, "com.macroclaw.plist"), `<?xml version="1.0" encoding="UTF-8"?>
778
+ <plist version="1.0">
779
+ <dict>
780
+ <key>EnvironmentVariables</key>
781
+ <dict>
782
+ <key>PATH</key>
783
+ <string>/old/path</string>
784
+ <key>CLAUDE_CODE_OAUTH_TOKEN</key>
785
+ <string>sk-test-token</string>
786
+ </dict>
787
+ </dict>
788
+ </plist>
789
+ `);
790
+
791
+ mockExecSync.mockImplementation((cmd: string) => {
792
+ if (cmd === "/bin/bash -lc 'printf %s \"$PATH\"'") return "/custom/bin:/usr/bin:/bin\n";
793
+ if (cmd === "bun pm bin -g") return "/home/testuser/.bun/bin\n";
794
+ return "";
795
+ });
796
+
797
+ const mgr = createManager({ platform: "darwin", home: tmpHome });
798
+ mgr.refresh();
799
+ const plist = readFileSync(join(plistDir, "com.macroclaw.plist"), "utf-8");
800
+
801
+ expect(mockExecSync).toHaveBeenCalledWith("/bin/bash -lc 'printf %s \"$PATH\"'", expect.anything());
802
+ expect(mockExecSync).toHaveBeenCalledWith("bun pm bin -g", expect.anything());
803
+ expect(plist).toContain("<string>/home/testuser/.bun/bin:/custom/bin:/usr/bin:/bin</string>");
804
+ expect(plist).toContain("<key>CLAUDE_CODE_OAUTH_TOKEN</key>");
805
+ expect(plist).toContain("<string>sk-test-token</string>");
806
+ rmSync(tmpHome, { recursive: true });
807
+ });
808
+
809
+ it("refreshes systemd PATH snapshot and reloads daemon", () => {
810
+ const tmpHome = `/tmp/macroclaw-test-refresh-systemd-${Date.now()}`;
811
+ const unitDir = join(tmpHome, ".config/systemd/user");
812
+ mkdirSync(unitDir, { recursive: true });
813
+ writeFileSync(join(unitDir, "macroclaw.service"), "test");
814
+
815
+ mockExecSync.mockImplementation((cmd: string) => {
816
+ if (cmd === "/bin/bash -lc 'printf %s \"$PATH\"'") return "/custom/bin:/usr/bin:/bin\n";
817
+ if (cmd === "bun pm bin -g") return "/home/testuser/.bun/bin\n";
818
+ return "";
819
+ });
820
+
821
+ const mgr = createManager({ platform: "linux", home: tmpHome });
822
+ mgr.refresh();
823
+ const unitContent = readFileSync(join(unitDir, "macroclaw.service"), "utf-8");
824
+
825
+ expect(mockExecSync).toHaveBeenCalledWith("/bin/bash -lc 'printf %s \"$PATH\"'", expect.anything());
826
+ expect(mockExecSync).toHaveBeenCalledWith("bun pm bin -g", expect.anything());
827
+ expect(mockExecSync).toHaveBeenCalledWith("systemctl --user daemon-reload", expect.anything());
828
+ expect(unitContent).toContain("Environment=PATH=/home/testuser/.bun/bin:/custom/bin:/usr/bin:/bin");
829
+ rmSync(tmpHome, { recursive: true });
830
+ });
831
+
832
+ it("does not duplicate bun global bin when it is already in login shell PATH", () => {
833
+ const tmpHome = `/tmp/macroclaw-test-refresh-systemd-path-present-${Date.now()}`;
834
+ const unitDir = join(tmpHome, ".config/systemd/user");
835
+ mkdirSync(unitDir, { recursive: true });
836
+ writeFileSync(join(unitDir, "macroclaw.service"), "test");
837
+
838
+ mockExecSync.mockImplementation((cmd: string) => {
839
+ if (cmd === "/bin/bash -lc 'printf %s \"$PATH\"'") return "/usr/local/bin:/home/testuser/.bun/bin:/usr/bin:/bin\n";
840
+ if (cmd === "bun pm bin -g") return "/home/testuser/.bun/bin\n";
841
+ return "";
842
+ });
843
+
844
+ const mgr = createManager({ platform: "linux", home: tmpHome });
845
+ mgr.refresh();
846
+ const unitContent = readFileSync(join(unitDir, "macroclaw.service"), "utf-8");
847
+
848
+ expect(unitContent).toContain("Environment=PATH=/usr/local/bin:/home/testuser/.bun/bin:/usr/bin:/bin");
849
+ expect(unitContent).not.toContain("Environment=PATH=/home/testuser/.bun/bin:/usr/local/bin:/home/testuser/.bun/bin:/usr/bin:/bin");
663
850
  rmSync(tmpHome, { recursive: true });
664
851
  });
665
852
  });
@@ -1,5 +1,5 @@
1
1
  import { execSync } from "node:child_process";
2
- import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { userInfo as osUserInfo } from "node:os";
4
4
  import { dirname, resolve } from "node:path";
5
5
  import { createLogger } from "./logger";
@@ -20,6 +20,7 @@ export interface ServiceStatus {
20
20
  export interface UpdateResult {
21
21
  previousVersion: string;
22
22
  currentVersion: string;
23
+ logTailCommand: string;
23
24
  }
24
25
 
25
26
  export class SystemServiceManager {
@@ -85,6 +86,7 @@ export class SystemServiceManager {
85
86
  }
86
87
 
87
88
  this.#exec("bun install -g macroclaw");
89
+ const servicePath = this.#getServicePath();
88
90
 
89
91
  const logDir = resolve(this.#home, ".macroclaw/logs");
90
92
  mkdirSync(logDir, { recursive: true });
@@ -92,7 +94,7 @@ export class SystemServiceManager {
92
94
  this.#exec(`launchctl unload ${this.serviceFilePath}`);
93
95
  }
94
96
 
95
- writeFileSync(this.serviceFilePath, this.#generateLaunchdPlist(oauthToken));
97
+ writeFileSync(this.serviceFilePath, this.#generateLaunchdPlist(servicePath, oauthToken));
96
98
  log.debug({ filePath: this.serviceFilePath }, "Wrote launchd plist");
97
99
  this.#exec(`launchctl load ${this.serviceFilePath}`);
98
100
  }
@@ -108,6 +110,7 @@ export class SystemServiceManager {
108
110
  }
109
111
 
110
112
  this.#exec("bun install -g macroclaw");
113
+ const servicePath = this.#getServicePath();
111
114
 
112
115
  // Enable lingering so user services run without an active login session
113
116
  const username = osUserInfo().username;
@@ -115,7 +118,7 @@ export class SystemServiceManager {
115
118
  this.#sudo(`loginctl enable-linger ${username}`);
116
119
  }
117
120
 
118
- const unitContent = this.#generateSystemdUnit();
121
+ const unitContent = this.#generateSystemdUnit(servicePath);
119
122
  mkdirSync(dirname(this.serviceFilePath), { recursive: true });
120
123
  writeFileSync(this.serviceFilePath, unitContent);
121
124
  log.debug({ filePath: this.serviceFilePath }, "Wrote systemd unit");
@@ -190,12 +193,31 @@ export class SystemServiceManager {
190
193
  update(): UpdateResult {
191
194
  this.#requireInstalled();
192
195
 
196
+ const wasRunning = this.isRunning;
197
+ if (wasRunning) {
198
+ this.stop();
199
+ }
200
+
193
201
  const previousVersion = this.#getInstalledVersion();
194
202
  this.#exec("bun install -g macroclaw@latest");
203
+ this.#exec("macroclaw service refresh");
204
+ const logTailCommand = this.start();
195
205
  const currentVersion = this.#getInstalledVersion();
196
206
 
197
- log.debug({ previousVersion, currentVersion }, "Service updated");
198
- return { previousVersion, currentVersion };
207
+ log.debug({ previousVersion, currentVersion, wasRunning }, "Service updated");
208
+ return { previousVersion, currentVersion, logTailCommand };
209
+ }
210
+
211
+ refresh(): void {
212
+ this.#requireInstalled();
213
+ const servicePath = this.#getServicePath();
214
+ if (this.#platform === "systemd") {
215
+ writeFileSync(this.serviceFilePath, this.#generateSystemdUnit(servicePath));
216
+ this.#exec("systemctl --user daemon-reload");
217
+ return;
218
+ }
219
+ const oauthToken = this.#getLaunchdOauthToken();
220
+ writeFileSync(this.serviceFilePath, this.#generateLaunchdPlist(servicePath, oauthToken));
199
221
  }
200
222
 
201
223
  status(): ServiceStatus {
@@ -242,6 +264,24 @@ export class SystemServiceManager {
242
264
  return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).toString();
243
265
  }
244
266
 
267
+ #getLoginShellPath(): string {
268
+ return this.#exec("/bin/bash -lc 'printf %s \"$PATH\"'").trim();
269
+ }
270
+
271
+ #getServicePath(): string {
272
+ const shellPath = this.#getLoginShellPath();
273
+ const bunGlobalBin = this.#exec("bun pm bin -g").trim();
274
+ const entries = shellPath.split(":").filter(Boolean);
275
+ if (entries.includes(bunGlobalBin)) return shellPath;
276
+ return [bunGlobalBin, ...entries].join(":");
277
+ }
278
+
279
+ #getLaunchdOauthToken(): string | undefined {
280
+ if (this.#platform !== "launchd" || !existsSync(this.serviceFilePath)) return undefined;
281
+ const plist = readFileSync(this.serviceFilePath, "utf-8");
282
+ const match = /<key>CLAUDE_CODE_OAUTH_TOKEN<\/key>\s*<string>([^<]+)<\/string>/.exec(plist);
283
+ return match?.[1];
284
+ }
245
285
 
246
286
  #getInstalledVersion(): string {
247
287
  try {
@@ -271,11 +311,13 @@ export class SystemServiceManager {
271
311
  this.#exec(`sudo ${cmd}`);
272
312
  }
273
313
 
274
- #generateLaunchdPlist(oauthToken?: string): string {
314
+ #generateLaunchdPlist(servicePath: string, oauthToken?: string): string {
275
315
  const logDir = resolve(this.#home, ".macroclaw/logs");
276
- const tokenEnvBlock = oauthToken
277
- ? `\n\t<key>EnvironmentVariables</key>\n\t<dict>\n\t\t<key>CLAUDE_CODE_OAUTH_TOKEN</key>\n\t\t<string>${oauthToken}</string>\n\t</dict>`
278
- : "";
316
+ const envVars = [`\n\t<key>PATH</key>\n\t\t<string>${servicePath}</string>`];
317
+ if (oauthToken) {
318
+ envVars.push(`\n\t<key>CLAUDE_CODE_OAUTH_TOKEN</key>\n\t\t<string>${oauthToken}</string>`);
319
+ }
320
+ const envBlock = `\n\t<key>EnvironmentVariables</key>\n\t<dict>${envVars.join("")}\n\t</dict>`;
279
321
  return `<?xml version="1.0" encoding="UTF-8"?>
280
322
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
281
323
  <plist version="1.0">
@@ -284,22 +326,21 @@ export class SystemServiceManager {
284
326
  <string>com.macroclaw</string>
285
327
  <key>ProgramArguments</key>
286
328
  <array>
287
- <string>/bin/bash</string>
288
- <string>-lc</string>
289
- <string>exec bun macroclaw start</string>
329
+ <string>macroclaw</string>
330
+ <string>start</string>
290
331
  </array>
291
332
  <key>KeepAlive</key>
292
333
  <true/>
293
334
  <key>StandardOutPath</key>
294
335
  <string>${logDir}/stdout.log</string>
295
336
  <key>StandardErrorPath</key>
296
- <string>${logDir}/stderr.log</string>${tokenEnvBlock}
337
+ <string>${logDir}/stderr.log</string>${envBlock}
297
338
  </dict>
298
339
  </plist>
299
340
  `;
300
341
  }
301
342
 
302
- #generateSystemdUnit(): string {
343
+ #generateSystemdUnit(servicePath: string): string {
303
344
  return `[Unit]
304
345
  Description=Macroclaw - Telegram-to-Claude-Code bridge
305
346
  After=network.target
@@ -307,7 +348,8 @@ After=network.target
307
348
  [Service]
308
349
  Type=simple
309
350
  WorkingDirectory=%h
310
- ExecStart=/bin/bash -lc 'exec bun macroclaw start'
351
+ Environment=PATH=${servicePath}
352
+ ExecStart=macroclaw start
311
353
  Restart=on-failure
312
354
  RestartSec=5
313
355