macroclaw 0.41.0 → 0.43.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.41.0",
3
+ "version": "0.43.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,15 @@ 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): string => cmd === "bun pm bin -g" ? "/home/testuser/.bun/bin\n" : "");
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 DEFAULT_EXECUTABLE_PATH = `${DEFAULT_BUN_GLOBAL_BIN}/macroclaw`;
14
+ const mockExecSync = mock((cmd: string, _opts?: object): string => {
15
+ if (cmd === "/bin/bash -lc 'printf %s \"$PATH\"'") return `${DEFAULT_LOGIN_PATH}\n`;
16
+ if (cmd === "bun pm bin -g") return `${DEFAULT_BUN_GLOBAL_BIN}\n`;
17
+ return "";
18
+ });
11
19
  const mockUserInfo = mock(() => ({ username: "testuser", homedir: "/home/testuser", uid: 1000, gid: 1000, shell: "/bin/bash" }));
12
20
  const mockExistsSync = mock((path: string) => realExistsSync(path));
13
21
 
@@ -43,7 +51,11 @@ beforeEach(() => {
43
51
  mockExecSync.mockClear();
44
52
  mockUserInfo.mockClear();
45
53
  mockExistsSync.mockClear();
46
- mockExecSync.mockImplementation((cmd: string, _opts?: object): string => cmd === "bun pm bin -g" ? "/home/testuser/.bun/bin\n" : "");
54
+ mockExecSync.mockImplementation((cmd: string, _opts?: object): string => {
55
+ if (cmd === "/bin/bash -lc 'printf %s \"$PATH\"'") return `${DEFAULT_LOGIN_PATH}\n`;
56
+ if (cmd === "bun pm bin -g") return `${DEFAULT_BUN_GLOBAL_BIN}\n`;
57
+ return "";
58
+ });
47
59
  mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: "/home/testuser", uid: 1000, gid: 1000, shell: "/bin/bash" }));
48
60
  mockExistsSync.mockImplementation((path: string) => realExistsSync(path));
49
61
  });
@@ -167,7 +179,7 @@ describe("install", () => {
167
179
  expect(() => mgr.install()).toThrow("Settings not found. Run `macroclaw setup` first.");
168
180
  });
169
181
 
170
- it("runs global install and resolves bun global bin for systemd", () => {
182
+ it("runs global install and resolves login shell PATH for systemd", () => {
171
183
  const tmpHome = `/tmp/macroclaw-test-install-${Date.now()}`;
172
184
  mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
173
185
  writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
@@ -178,20 +190,17 @@ describe("install", () => {
178
190
  if (path === "/var/lib/systemd/linger/testuser") return true; // already lingering
179
191
  return realExistsSync(path);
180
192
  });
181
- mockExecSync.mockImplementation((cmd: string) => {
182
- if (cmd === "bun pm bin -g") return "/home/testuser/.bun/bin\n";
183
- return "";
184
- });
185
193
  const mgr = createManager({ home: tmpHome });
186
194
  mgr.install();
187
195
  rmSync(tmpHome, { recursive: true });
188
196
 
189
197
  expect(mockExecSync).toHaveBeenCalledWith("bun install -g macroclaw", expect.anything());
198
+ expect(mockExecSync).toHaveBeenCalledWith("/bin/bash -lc 'printf %s \"$PATH\"'", expect.anything());
190
199
  expect(mockExecSync).toHaveBeenCalledWith("bun pm bin -g", expect.anything());
191
200
  expect(mockExecSync).not.toHaveBeenCalledWith("which macroclaw", expect.anything());
192
201
  });
193
202
 
194
- it("surfaces bun global bin resolution failures for systemd", () => {
203
+ it("surfaces login shell PATH resolution failures for systemd", () => {
195
204
  const tmpHome = `/tmp/macroclaw-test-install-missing-path-${Date.now()}`;
196
205
  mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
197
206
  writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
@@ -202,7 +211,7 @@ describe("install", () => {
202
211
  return realExistsSync(path);
203
212
  });
204
213
  mockExecSync.mockImplementation((cmd: string) => {
205
- if (cmd === "bun pm bin -g") throw new Error("not found");
214
+ if (cmd === "/bin/bash -lc 'printf %s \"$PATH\"'") throw new Error("not found");
206
215
  return "";
207
216
  });
208
217
 
@@ -215,7 +224,7 @@ describe("install", () => {
215
224
  rmSync(tmpHome, { recursive: true });
216
225
  });
217
226
 
218
- it("installs launchd service with bash -lc and OAuth token", () => {
227
+ it("installs launchd service with direct macroclaw invocation and OAuth token", () => {
219
228
  const tmpHome = `/tmp/macroclaw-test-launchd-${Date.now()}`;
220
229
  mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
221
230
  writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
@@ -228,15 +237,13 @@ describe("install", () => {
228
237
  const plistPath = join(plistDir, "com.macroclaw.plist");
229
238
  expect(existsSync(plistPath)).toBe(true);
230
239
  const writtenContent = readFileSync(plistPath, "utf-8");
231
- // bash -lc pattern — no hardcoded binary paths
232
- expect(writtenContent).toContain("<string>/bin/bash</string>");
233
- expect(writtenContent).toContain("<string>-lc</string>");
234
- expect(writtenContent).toContain("<string>exec macroclaw start</string>");
240
+ expect(writtenContent).toContain(`<string>${DEFAULT_EXECUTABLE_PATH}</string>`);
241
+ expect(writtenContent).toContain("<string>start</string>");
235
242
  expect(writtenContent).toContain("<key>KeepAlive</key>");
236
243
  expect(writtenContent).toContain(".macroclaw/logs/stdout.log");
237
244
  expect(writtenContent).toContain("<key>EnvironmentVariables</key>");
238
245
  expect(writtenContent).toContain("<key>PATH</key>");
239
- expect(writtenContent).toContain("<string>/home/testuser/.bun/bin</string>");
246
+ expect(writtenContent).toContain(`<string>${DEFAULT_SERVICE_PATH}</string>`);
240
247
  expect(writtenContent).not.toContain("<key>HOME</key>");
241
248
  // OAuth token is preserved
242
249
  expect(writtenContent).toContain("<key>CLAUDE_CODE_OAUTH_TOKEN</key>");
@@ -246,14 +253,14 @@ describe("install", () => {
246
253
  rmSync(tmpHome, { recursive: true });
247
254
  });
248
255
 
249
- it("surfaces bun global bin resolution failures for launchd", () => {
256
+ it("surfaces login shell PATH resolution failures for launchd", () => {
250
257
  const tmpHome = `/tmp/macroclaw-test-launchd-missing-path-${Date.now()}`;
251
258
  mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
252
259
  writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
253
260
  mkdirSync(join(tmpHome, "Library/LaunchAgents"), { recursive: true });
254
261
 
255
262
  mockExecSync.mockImplementation((cmd: string) => {
256
- if (cmd === "bun pm bin -g") throw new Error("not found");
263
+ if (cmd === "/bin/bash -lc 'printf %s \"$PATH\"'") throw new Error("not found");
257
264
  return "";
258
265
  });
259
266
 
@@ -266,14 +273,14 @@ describe("install", () => {
266
273
  rmSync(tmpHome, { recursive: true });
267
274
  });
268
275
 
269
- it("surfaces bun global bin permission failures for launchd", () => {
276
+ it("surfaces login shell PATH permission failures for launchd", () => {
270
277
  const tmpHome = `/tmp/macroclaw-test-launchd-missing-bin-dir-${Date.now()}`;
271
278
  mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
272
279
  writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
273
280
  mkdirSync(join(tmpHome, "Library/LaunchAgents"), { recursive: true });
274
281
 
275
282
  mockExecSync.mockImplementation((cmd: string) => {
276
- if (cmd === "bun pm bin -g") throw new Error("permission denied");
283
+ if (cmd === "/bin/bash -lc 'printf %s \"$PATH\"'") throw new Error("permission denied");
277
284
  return "";
278
285
  });
279
286
 
@@ -295,7 +302,7 @@ describe("install", () => {
295
302
  const writtenContent = readFileSync(join(tmpHome, "Library/LaunchAgents/com.macroclaw.plist"), "utf-8");
296
303
  expect(writtenContent).toContain("<key>EnvironmentVariables</key>");
297
304
  expect(writtenContent).toContain("<key>PATH</key>");
298
- expect(writtenContent).toContain("<string>/home/testuser/.bun/bin</string>");
305
+ expect(writtenContent).toContain(`<string>${DEFAULT_SERVICE_PATH}</string>`);
299
306
  expect(writtenContent).not.toContain("CLAUDE_CODE_OAUTH_TOKEN");
300
307
  rmSync(tmpHome, { recursive: true });
301
308
  });
@@ -337,7 +344,7 @@ describe("install", () => {
337
344
  rmSync(tmpHome, { recursive: true });
338
345
  });
339
346
 
340
- it("installs systemd user service with bash -lc and no hardcoded paths", () => {
347
+ it("installs systemd user service with direct macroclaw invocation and no hardcoded paths", () => {
341
348
  const tmpHome = `/tmp/macroclaw-test-systemd-${Date.now()}`;
342
349
  mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
343
350
  writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
@@ -358,11 +365,11 @@ describe("install", () => {
358
365
  expect(unitContent).toContain("WantedBy=default.target");
359
366
  expect(unitContent).not.toContain("User=");
360
367
  expect(unitContent).not.toContain("Group=");
361
- // systemd seeds PATH with Bun's global bin; login shell can extend it via profile files
368
+ // systemd snapshots the login shell PATH and ensures Bun's global bin is included
362
369
  expect(unitContent).not.toContain("Environment=HOME=");
363
- expect(unitContent).toContain("Environment=PATH=/home/testuser/.bun/bin");
370
+ expect(unitContent).toContain(`Environment=PATH=${DEFAULT_SERVICE_PATH}`);
364
371
  expect(unitContent).toContain("WorkingDirectory=%h");
365
- expect(unitContent).toContain("ExecStart=/bin/bash -lc 'exec macroclaw start'");
372
+ expect(unitContent).toContain(`ExecStart=${DEFAULT_EXECUTABLE_PATH} start`);
366
373
 
367
374
  // Lingering enabled via sudo
368
375
  expect(mockExecSync).toHaveBeenCalledWith("sudo loginctl enable-linger testuser", expect.anything());
@@ -672,22 +679,44 @@ describe("update", () => {
672
679
  );
673
680
  });
674
681
 
675
- it("runs bun install without stop/start", () => {
682
+ it("stops, refreshes, and starts the service when updating launchd", () => {
676
683
  const tmpHome = `/tmp/macroclaw-test-updateld-${Date.now()}`;
677
684
  mkdirSync(join(tmpHome, "Library/LaunchAgents"), { recursive: true });
678
685
  writeFileSync(join(tmpHome, "Library/LaunchAgents/com.macroclaw.plist"), "test");
679
686
 
687
+ const calls: string[] = [];
688
+ let running = true;
680
689
  mockExecSync.mockImplementation((cmd: string) => {
690
+ calls.push(cmd);
691
+ if (cmd.startsWith("launchctl list ")) return running ? LAUNCHD_RUNNING : LAUNCHD_STOPPED;
681
692
  if (cmd === "bun pm ls -g") return "macroclaw@0.6.0\n";
693
+ if (cmd.includes("launchctl unload")) {
694
+ running = false;
695
+ return "";
696
+ }
697
+ if (cmd === "macroclaw service refresh") return "";
698
+ if (cmd.includes("launchctl load")) {
699
+ running = true;
700
+ return "";
701
+ }
682
702
  return "";
683
703
  });
684
704
  const mgr = createManager({ platform: "darwin", home: tmpHome });
685
705
  const result = mgr.update();
706
+
686
707
  expect(mockExecSync).toHaveBeenCalledWith("bun install -g macroclaw@latest", expect.anything());
687
- expect(mockExecSync).not.toHaveBeenCalledWith(expect.stringContaining("launchctl"), expect.anything());
688
- expect(mockExecSync).not.toHaveBeenCalledWith(expect.stringContaining("systemctl"), expect.anything());
708
+ expect(mockExecSync).toHaveBeenCalledWith("macroclaw service refresh", expect.anything());
709
+ const unloadIdx = calls.findIndex((call) => call.includes("launchctl unload"));
710
+ const installIdx = calls.indexOf("bun install -g macroclaw@latest");
711
+ const refreshIdx = calls.indexOf("macroclaw service refresh");
712
+ const loadIdx = calls.findIndex((call) => call.includes("launchctl load"));
713
+ expect(unloadIdx).toBeGreaterThan(-1);
714
+ expect(installIdx).toBeGreaterThan(unloadIdx);
715
+ expect(refreshIdx).toBeGreaterThan(installIdx);
716
+ expect(loadIdx).toBeGreaterThan(refreshIdx);
689
717
  expect(result.previousVersion).toBe("0.6.0");
690
718
  expect(result.currentVersion).toBe("0.6.0");
719
+ expect(result.logTailCommand).toBe(`tail -f ${tmpHome}/.macroclaw/logs/*.log`);
691
720
  rmSync(tmpHome, { recursive: true });
692
721
  });
693
722
 
@@ -700,6 +729,8 @@ describe("update", () => {
700
729
  mockExecSync.mockImplementation((cmd: string) => {
701
730
  if (cmd.startsWith("launchctl list ")) return LAUNCHD_STOPPED;
702
731
  if (cmd === "bun install -g macroclaw@latest") { installCalled = true; return ""; }
732
+ if (cmd === "macroclaw service refresh") return "";
733
+ if (cmd.includes("launchctl load")) return "";
703
734
  if (cmd === "bun pm ls -g") return installCalled ? "macroclaw@0.7.0\n" : "macroclaw@0.6.0\n";
704
735
  return "";
705
736
  });
@@ -707,6 +738,7 @@ describe("update", () => {
707
738
  const result = mgr.update();
708
739
  expect(result.previousVersion).toBe("0.6.0");
709
740
  expect(result.currentVersion).toBe("0.7.0");
741
+ expect(result.logTailCommand).toBe(`tail -f ${tmpHome}/.macroclaw/logs/*.log`);
710
742
  rmSync(tmpHome, { recursive: true });
711
743
  });
712
744
 
@@ -717,6 +749,8 @@ describe("update", () => {
717
749
 
718
750
  mockExecSync.mockImplementation((cmd: string) => {
719
751
  if (cmd.startsWith("launchctl list ")) return LAUNCHD_STOPPED;
752
+ if (cmd === "macroclaw service refresh") return "";
753
+ if (cmd.includes("launchctl load")) return "";
720
754
  if (cmd === "bun pm ls -g") throw new Error("command not found");
721
755
  return "";
722
756
  });
@@ -724,6 +758,120 @@ describe("update", () => {
724
758
  const result = mgr.update();
725
759
  expect(result.previousVersion).toBe("unknown");
726
760
  expect(result.currentVersion).toBe("unknown");
761
+ expect(result.logTailCommand).toBe(`tail -f ${tmpHome}/.macroclaw/logs/*.log`);
762
+ rmSync(tmpHome, { recursive: true });
763
+ });
764
+ });
765
+
766
+ describe("refresh", () => {
767
+ it("throws when service is not installed", () => {
768
+ const mgr = createManager({ platform: "darwin", home: "/nonexistent" });
769
+ expect(() => mgr.refresh()).toThrow(
770
+ "Service not installed. Run `macroclaw service install` first.",
771
+ );
772
+ });
773
+
774
+ it("refreshes launchd PATH snapshot and preserves oauth token", () => {
775
+ const tmpHome = `/tmp/macroclaw-test-refresh-launchd-${Date.now()}`;
776
+ const plistDir = join(tmpHome, "Library/LaunchAgents");
777
+ mkdirSync(plistDir, { recursive: true });
778
+ writeFileSync(join(plistDir, "com.macroclaw.plist"), `<?xml version="1.0" encoding="UTF-8"?>
779
+ <plist version="1.0">
780
+ <dict>
781
+ <key>EnvironmentVariables</key>
782
+ <dict>
783
+ <key>PATH</key>
784
+ <string>/old/path</string>
785
+ <key>CLAUDE_CODE_OAUTH_TOKEN</key>
786
+ <string>sk-test-token</string>
787
+ </dict>
788
+ </dict>
789
+ </plist>
790
+ `);
791
+
792
+ mockExecSync.mockImplementation((cmd: string) => {
793
+ if (cmd === "/bin/bash -lc 'printf %s \"$PATH\"'") return "/custom/bin:/usr/bin:/bin\n";
794
+ if (cmd === "bun pm bin -g") return "/home/testuser/.bun/bin\n";
795
+ return "";
796
+ });
797
+
798
+ const mgr = createManager({ platform: "darwin", home: tmpHome });
799
+ mgr.refresh();
800
+ const plist = readFileSync(join(plistDir, "com.macroclaw.plist"), "utf-8");
801
+
802
+ expect(mockExecSync).toHaveBeenCalledWith("/bin/bash -lc 'printf %s \"$PATH\"'", expect.anything());
803
+ expect(mockExecSync).toHaveBeenCalledWith("bun pm bin -g", expect.anything());
804
+ expect(plist).toContain("<string>/home/testuser/.bun/bin:/custom/bin:/usr/bin:/bin</string>");
805
+ expect(plist).toContain("<string>/home/testuser/.bun/bin/macroclaw</string>");
806
+ expect(plist).toContain("<key>CLAUDE_CODE_OAUTH_TOKEN</key>");
807
+ expect(plist).toContain("<string>sk-test-token</string>");
808
+ rmSync(tmpHome, { recursive: true });
809
+ });
810
+
811
+ it("refreshes systemd PATH snapshot and reloads daemon", () => {
812
+ const tmpHome = `/tmp/macroclaw-test-refresh-systemd-${Date.now()}`;
813
+ const unitDir = join(tmpHome, ".config/systemd/user");
814
+ mkdirSync(unitDir, { recursive: true });
815
+ writeFileSync(join(unitDir, "macroclaw.service"), "test");
816
+
817
+ mockExecSync.mockImplementation((cmd: string) => {
818
+ if (cmd === "/bin/bash -lc 'printf %s \"$PATH\"'") return "/custom/bin:/usr/bin:/bin\n";
819
+ if (cmd === "bun pm bin -g") return "/home/testuser/.bun/bin\n";
820
+ return "";
821
+ });
822
+
823
+ const mgr = createManager({ platform: "linux", home: tmpHome });
824
+ mgr.refresh();
825
+ const unitContent = readFileSync(join(unitDir, "macroclaw.service"), "utf-8");
826
+
827
+ expect(mockExecSync).toHaveBeenCalledWith("/bin/bash -lc 'printf %s \"$PATH\"'", expect.anything());
828
+ expect(mockExecSync).toHaveBeenCalledWith("bun pm bin -g", expect.anything());
829
+ expect(mockExecSync).toHaveBeenCalledWith("systemctl --user daemon-reload", expect.anything());
830
+ expect(unitContent).toContain("Environment=PATH=/home/testuser/.bun/bin:/custom/bin:/usr/bin:/bin");
831
+ expect(unitContent).toContain("ExecStart=/home/testuser/.bun/bin/macroclaw start");
832
+ rmSync(tmpHome, { recursive: true });
833
+ });
834
+
835
+ it("does not duplicate bun global bin when it is already in login shell PATH", () => {
836
+ const tmpHome = `/tmp/macroclaw-test-refresh-systemd-path-present-${Date.now()}`;
837
+ const unitDir = join(tmpHome, ".config/systemd/user");
838
+ mkdirSync(unitDir, { recursive: true });
839
+ writeFileSync(join(unitDir, "macroclaw.service"), "test");
840
+
841
+ mockExecSync.mockImplementation((cmd: string) => {
842
+ if (cmd === "/bin/bash -lc 'printf %s \"$PATH\"'") return "/usr/local/bin:/home/testuser/.bun/bin:/usr/bin:/bin\n";
843
+ if (cmd === "bun pm bin -g") return "/home/testuser/.bun/bin\n";
844
+ return "";
845
+ });
846
+
847
+ const mgr = createManager({ platform: "linux", home: tmpHome });
848
+ mgr.refresh();
849
+ const unitContent = readFileSync(join(unitDir, "macroclaw.service"), "utf-8");
850
+
851
+ expect(unitContent).toContain("Environment=PATH=/home/testuser/.bun/bin:/usr/local/bin:/usr/bin:/bin");
852
+ expect(unitContent).not.toContain("Environment=PATH=/usr/local/bin:/home/testuser/.bun/bin:/usr/local/bin:/usr/bin:/bin");
853
+ rmSync(tmpHome, { recursive: true });
854
+ });
855
+
856
+ it("dedupes repeated PATH entries while keeping bun global bin first", () => {
857
+ const tmpHome = `/tmp/macroclaw-test-refresh-systemd-path-dedup-${Date.now()}`;
858
+ const unitDir = join(tmpHome, ".config/systemd/user");
859
+ mkdirSync(unitDir, { recursive: true });
860
+ writeFileSync(join(unitDir, "macroclaw.service"), "test");
861
+
862
+ mockExecSync.mockImplementation((cmd: string) => {
863
+ if (cmd === "/bin/bash -lc 'printf %s \"$PATH\"'") {
864
+ return "/home/testuser/.bun/bin:/usr/local/bin:/usr/bin:/usr/local/bin:/usr/bin:/bin:/home/testuser/.bun/bin\n";
865
+ }
866
+ if (cmd === "bun pm bin -g") return "/home/testuser/.bun/bin\n";
867
+ return "";
868
+ });
869
+
870
+ const mgr = createManager({ platform: "linux", home: tmpHome });
871
+ mgr.refresh();
872
+ const unitContent = readFileSync(join(unitDir, "macroclaw.service"), "utf-8");
873
+
874
+ expect(unitContent).toContain("Environment=PATH=/home/testuser/.bun/bin:/usr/local/bin:/usr/bin:/bin");
727
875
  rmSync(tmpHome, { recursive: true });
728
876
  });
729
877
  });
@@ -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,7 +86,8 @@ export class SystemServiceManager {
85
86
  }
86
87
 
87
88
  this.#exec("bun install -g macroclaw");
88
- const bunGlobalBin = this.#getBunGlobalBinDir();
89
+ const servicePath = this.#getServicePath();
90
+ const executablePath = this.#getMacroclawExecutablePath();
89
91
 
90
92
  const logDir = resolve(this.#home, ".macroclaw/logs");
91
93
  mkdirSync(logDir, { recursive: true });
@@ -93,7 +95,7 @@ export class SystemServiceManager {
93
95
  this.#exec(`launchctl unload ${this.serviceFilePath}`);
94
96
  }
95
97
 
96
- writeFileSync(this.serviceFilePath, this.#generateLaunchdPlist(bunGlobalBin, oauthToken));
98
+ writeFileSync(this.serviceFilePath, this.#generateLaunchdPlist(servicePath, executablePath, oauthToken));
97
99
  log.debug({ filePath: this.serviceFilePath }, "Wrote launchd plist");
98
100
  this.#exec(`launchctl load ${this.serviceFilePath}`);
99
101
  }
@@ -109,7 +111,8 @@ export class SystemServiceManager {
109
111
  }
110
112
 
111
113
  this.#exec("bun install -g macroclaw");
112
- const bunGlobalBin = this.#getBunGlobalBinDir();
114
+ const servicePath = this.#getServicePath();
115
+ const executablePath = this.#getMacroclawExecutablePath();
113
116
 
114
117
  // Enable lingering so user services run without an active login session
115
118
  const username = osUserInfo().username;
@@ -117,7 +120,7 @@ export class SystemServiceManager {
117
120
  this.#sudo(`loginctl enable-linger ${username}`);
118
121
  }
119
122
 
120
- const unitContent = this.#generateSystemdUnit(bunGlobalBin);
123
+ const unitContent = this.#generateSystemdUnit(servicePath, executablePath);
121
124
  mkdirSync(dirname(this.serviceFilePath), { recursive: true });
122
125
  writeFileSync(this.serviceFilePath, unitContent);
123
126
  log.debug({ filePath: this.serviceFilePath }, "Wrote systemd unit");
@@ -192,12 +195,32 @@ export class SystemServiceManager {
192
195
  update(): UpdateResult {
193
196
  this.#requireInstalled();
194
197
 
198
+ const wasRunning = this.isRunning;
199
+ if (wasRunning) {
200
+ this.stop();
201
+ }
202
+
195
203
  const previousVersion = this.#getInstalledVersion();
196
204
  this.#exec("bun install -g macroclaw@latest");
205
+ this.#exec("macroclaw service refresh");
206
+ const logTailCommand = this.start();
197
207
  const currentVersion = this.#getInstalledVersion();
198
208
 
199
- log.debug({ previousVersion, currentVersion }, "Service updated");
200
- return { previousVersion, currentVersion };
209
+ log.debug({ previousVersion, currentVersion, wasRunning }, "Service updated");
210
+ return { previousVersion, currentVersion, logTailCommand };
211
+ }
212
+
213
+ refresh(): void {
214
+ this.#requireInstalled();
215
+ const servicePath = this.#getServicePath();
216
+ const executablePath = this.#getMacroclawExecutablePath();
217
+ if (this.#platform === "systemd") {
218
+ writeFileSync(this.serviceFilePath, this.#generateSystemdUnit(servicePath, executablePath));
219
+ this.#exec("systemctl --user daemon-reload");
220
+ return;
221
+ }
222
+ const oauthToken = this.#getLaunchdOauthToken();
223
+ writeFileSync(this.serviceFilePath, this.#generateLaunchdPlist(servicePath, executablePath, oauthToken));
201
224
  }
202
225
 
203
226
  status(): ServiceStatus {
@@ -244,8 +267,26 @@ export class SystemServiceManager {
244
267
  return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).toString();
245
268
  }
246
269
 
247
- #getBunGlobalBinDir(): string {
248
- return this.#exec("bun pm bin -g").trim();
270
+ #getLoginShellPath(): string {
271
+ return this.#exec("/bin/bash -lc 'printf %s \"$PATH\"'").trim();
272
+ }
273
+
274
+ #getServicePath(): string {
275
+ const shellPath = this.#getLoginShellPath();
276
+ const bunGlobalBin = this.#exec("bun pm bin -g").trim();
277
+ const entries = [bunGlobalBin, ...shellPath.split(":").filter(Boolean)];
278
+ return [...new Set(entries)].join(":");
279
+ }
280
+
281
+ #getMacroclawExecutablePath(): string {
282
+ return resolve(this.#exec("bun pm bin -g").trim(), "macroclaw");
283
+ }
284
+
285
+ #getLaunchdOauthToken(): string | undefined {
286
+ if (this.#platform !== "launchd" || !existsSync(this.serviceFilePath)) return undefined;
287
+ const plist = readFileSync(this.serviceFilePath, "utf-8");
288
+ const match = /<key>CLAUDE_CODE_OAUTH_TOKEN<\/key>\s*<string>([^<]+)<\/string>/.exec(plist);
289
+ return match?.[1];
249
290
  }
250
291
 
251
292
  #getInstalledVersion(): string {
@@ -276,9 +317,9 @@ export class SystemServiceManager {
276
317
  this.#exec(`sudo ${cmd}`);
277
318
  }
278
319
 
279
- #generateLaunchdPlist(bunGlobalBin: string, oauthToken?: string): string {
320
+ #generateLaunchdPlist(servicePath: string, executablePath: string, oauthToken?: string): string {
280
321
  const logDir = resolve(this.#home, ".macroclaw/logs");
281
- const envVars = [`\n\t<key>PATH</key>\n\t\t<string>${bunGlobalBin}</string>`];
322
+ const envVars = [`\n\t<key>PATH</key>\n\t\t<string>${servicePath}</string>`];
282
323
  if (oauthToken) {
283
324
  envVars.push(`\n\t<key>CLAUDE_CODE_OAUTH_TOKEN</key>\n\t\t<string>${oauthToken}</string>`);
284
325
  }
@@ -291,9 +332,8 @@ export class SystemServiceManager {
291
332
  <string>com.macroclaw</string>
292
333
  <key>ProgramArguments</key>
293
334
  <array>
294
- <string>/bin/bash</string>
295
- <string>-lc</string>
296
- <string>exec macroclaw start</string>
335
+ <string>${executablePath}</string>
336
+ <string>start</string>
297
337
  </array>
298
338
  <key>KeepAlive</key>
299
339
  <true/>
@@ -306,7 +346,7 @@ export class SystemServiceManager {
306
346
  `;
307
347
  }
308
348
 
309
- #generateSystemdUnit(bunGlobalBin: string): string {
349
+ #generateSystemdUnit(servicePath: string, executablePath: string): string {
310
350
  return `[Unit]
311
351
  Description=Macroclaw - Telegram-to-Claude-Code bridge
312
352
  After=network.target
@@ -314,8 +354,8 @@ After=network.target
314
354
  [Service]
315
355
  Type=simple
316
356
  WorkingDirectory=%h
317
- Environment=PATH=${bunGlobalBin}
318
- ExecStart=/bin/bash -lc 'exec macroclaw start'
357
+ Environment=PATH=${servicePath}
358
+ ExecStart=${executablePath} start
319
359
  Restart=on-failure
320
360
  RestartSec=5
321
361