macroclaw 0.41.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.41.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): 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 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): string => cmd === "bun pm bin -g" ? "/home/testuser/.bun/bin\n" : "");
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 and resolves bun global bin for 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"), "{}");
@@ -178,20 +189,17 @@ describe("install", () => {
178
189
  if (path === "/var/lib/systemd/linger/testuser") return true; // already lingering
179
190
  return realExistsSync(path);
180
191
  });
181
- mockExecSync.mockImplementation((cmd: string) => {
182
- if (cmd === "bun pm bin -g") return "/home/testuser/.bun/bin\n";
183
- return "";
184
- });
185
192
  const mgr = createManager({ home: tmpHome });
186
193
  mgr.install();
187
194
  rmSync(tmpHome, { recursive: true });
188
195
 
189
196
  expect(mockExecSync).toHaveBeenCalledWith("bun install -g macroclaw", expect.anything());
197
+ expect(mockExecSync).toHaveBeenCalledWith("/bin/bash -lc 'printf %s \"$PATH\"'", expect.anything());
190
198
  expect(mockExecSync).toHaveBeenCalledWith("bun pm bin -g", expect.anything());
191
199
  expect(mockExecSync).not.toHaveBeenCalledWith("which macroclaw", expect.anything());
192
200
  });
193
201
 
194
- it("surfaces bun global bin resolution failures for systemd", () => {
202
+ it("surfaces login shell PATH resolution failures for systemd", () => {
195
203
  const tmpHome = `/tmp/macroclaw-test-install-missing-path-${Date.now()}`;
196
204
  mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
197
205
  writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
@@ -202,7 +210,7 @@ describe("install", () => {
202
210
  return realExistsSync(path);
203
211
  });
204
212
  mockExecSync.mockImplementation((cmd: string) => {
205
- if (cmd === "bun pm bin -g") throw new Error("not found");
213
+ if (cmd === "/bin/bash -lc 'printf %s \"$PATH\"'") throw new Error("not found");
206
214
  return "";
207
215
  });
208
216
 
@@ -215,7 +223,7 @@ describe("install", () => {
215
223
  rmSync(tmpHome, { recursive: true });
216
224
  });
217
225
 
218
- it("installs launchd service with bash -lc and OAuth token", () => {
226
+ it("installs launchd service with direct macroclaw invocation and OAuth token", () => {
219
227
  const tmpHome = `/tmp/macroclaw-test-launchd-${Date.now()}`;
220
228
  mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
221
229
  writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
@@ -228,15 +236,13 @@ describe("install", () => {
228
236
  const plistPath = join(plistDir, "com.macroclaw.plist");
229
237
  expect(existsSync(plistPath)).toBe(true);
230
238
  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>");
239
+ expect(writtenContent).toContain("<string>macroclaw</string>");
240
+ expect(writtenContent).toContain("<string>start</string>");
235
241
  expect(writtenContent).toContain("<key>KeepAlive</key>");
236
242
  expect(writtenContent).toContain(".macroclaw/logs/stdout.log");
237
243
  expect(writtenContent).toContain("<key>EnvironmentVariables</key>");
238
244
  expect(writtenContent).toContain("<key>PATH</key>");
239
- expect(writtenContent).toContain("<string>/home/testuser/.bun/bin</string>");
245
+ expect(writtenContent).toContain(`<string>${DEFAULT_SERVICE_PATH}</string>`);
240
246
  expect(writtenContent).not.toContain("<key>HOME</key>");
241
247
  // OAuth token is preserved
242
248
  expect(writtenContent).toContain("<key>CLAUDE_CODE_OAUTH_TOKEN</key>");
@@ -246,14 +252,14 @@ describe("install", () => {
246
252
  rmSync(tmpHome, { recursive: true });
247
253
  });
248
254
 
249
- it("surfaces bun global bin resolution failures for launchd", () => {
255
+ it("surfaces login shell PATH resolution failures for launchd", () => {
250
256
  const tmpHome = `/tmp/macroclaw-test-launchd-missing-path-${Date.now()}`;
251
257
  mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
252
258
  writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
253
259
  mkdirSync(join(tmpHome, "Library/LaunchAgents"), { recursive: true });
254
260
 
255
261
  mockExecSync.mockImplementation((cmd: string) => {
256
- if (cmd === "bun pm bin -g") throw new Error("not found");
262
+ if (cmd === "/bin/bash -lc 'printf %s \"$PATH\"'") throw new Error("not found");
257
263
  return "";
258
264
  });
259
265
 
@@ -266,14 +272,14 @@ describe("install", () => {
266
272
  rmSync(tmpHome, { recursive: true });
267
273
  });
268
274
 
269
- it("surfaces bun global bin permission failures for launchd", () => {
275
+ it("surfaces login shell PATH permission failures for launchd", () => {
270
276
  const tmpHome = `/tmp/macroclaw-test-launchd-missing-bin-dir-${Date.now()}`;
271
277
  mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
272
278
  writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
273
279
  mkdirSync(join(tmpHome, "Library/LaunchAgents"), { recursive: true });
274
280
 
275
281
  mockExecSync.mockImplementation((cmd: string) => {
276
- if (cmd === "bun pm bin -g") throw new Error("permission denied");
282
+ if (cmd === "/bin/bash -lc 'printf %s \"$PATH\"'") throw new Error("permission denied");
277
283
  return "";
278
284
  });
279
285
 
@@ -295,7 +301,7 @@ describe("install", () => {
295
301
  const writtenContent = readFileSync(join(tmpHome, "Library/LaunchAgents/com.macroclaw.plist"), "utf-8");
296
302
  expect(writtenContent).toContain("<key>EnvironmentVariables</key>");
297
303
  expect(writtenContent).toContain("<key>PATH</key>");
298
- expect(writtenContent).toContain("<string>/home/testuser/.bun/bin</string>");
304
+ expect(writtenContent).toContain(`<string>${DEFAULT_SERVICE_PATH}</string>`);
299
305
  expect(writtenContent).not.toContain("CLAUDE_CODE_OAUTH_TOKEN");
300
306
  rmSync(tmpHome, { recursive: true });
301
307
  });
@@ -337,7 +343,7 @@ describe("install", () => {
337
343
  rmSync(tmpHome, { recursive: true });
338
344
  });
339
345
 
340
- 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", () => {
341
347
  const tmpHome = `/tmp/macroclaw-test-systemd-${Date.now()}`;
342
348
  mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
343
349
  writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
@@ -358,11 +364,11 @@ describe("install", () => {
358
364
  expect(unitContent).toContain("WantedBy=default.target");
359
365
  expect(unitContent).not.toContain("User=");
360
366
  expect(unitContent).not.toContain("Group=");
361
- // systemd seeds PATH with Bun's global bin; login shell can extend it via profile files
367
+ // systemd snapshots the login shell PATH and ensures Bun's global bin is included
362
368
  expect(unitContent).not.toContain("Environment=HOME=");
363
- expect(unitContent).toContain("Environment=PATH=/home/testuser/.bun/bin");
369
+ expect(unitContent).toContain(`Environment=PATH=${DEFAULT_SERVICE_PATH}`);
364
370
  expect(unitContent).toContain("WorkingDirectory=%h");
365
- expect(unitContent).toContain("ExecStart=/bin/bash -lc 'exec macroclaw start'");
371
+ expect(unitContent).toContain("ExecStart=macroclaw start");
366
372
 
367
373
  // Lingering enabled via sudo
368
374
  expect(mockExecSync).toHaveBeenCalledWith("sudo loginctl enable-linger testuser", expect.anything());
@@ -672,22 +678,44 @@ describe("update", () => {
672
678
  );
673
679
  });
674
680
 
675
- it("runs bun install without stop/start", () => {
681
+ it("stops, refreshes, and starts the service when updating launchd", () => {
676
682
  const tmpHome = `/tmp/macroclaw-test-updateld-${Date.now()}`;
677
683
  mkdirSync(join(tmpHome, "Library/LaunchAgents"), { recursive: true });
678
684
  writeFileSync(join(tmpHome, "Library/LaunchAgents/com.macroclaw.plist"), "test");
679
685
 
686
+ const calls: string[] = [];
687
+ let running = true;
680
688
  mockExecSync.mockImplementation((cmd: string) => {
689
+ calls.push(cmd);
690
+ if (cmd.startsWith("launchctl list ")) return running ? LAUNCHD_RUNNING : LAUNCHD_STOPPED;
681
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
+ }
682
701
  return "";
683
702
  });
684
703
  const mgr = createManager({ platform: "darwin", home: tmpHome });
685
704
  const result = mgr.update();
705
+
686
706
  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());
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);
689
716
  expect(result.previousVersion).toBe("0.6.0");
690
717
  expect(result.currentVersion).toBe("0.6.0");
718
+ expect(result.logTailCommand).toBe(`tail -f ${tmpHome}/.macroclaw/logs/*.log`);
691
719
  rmSync(tmpHome, { recursive: true });
692
720
  });
693
721
 
@@ -700,6 +728,8 @@ describe("update", () => {
700
728
  mockExecSync.mockImplementation((cmd: string) => {
701
729
  if (cmd.startsWith("launchctl list ")) return LAUNCHD_STOPPED;
702
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 "";
703
733
  if (cmd === "bun pm ls -g") return installCalled ? "macroclaw@0.7.0\n" : "macroclaw@0.6.0\n";
704
734
  return "";
705
735
  });
@@ -707,6 +737,7 @@ describe("update", () => {
707
737
  const result = mgr.update();
708
738
  expect(result.previousVersion).toBe("0.6.0");
709
739
  expect(result.currentVersion).toBe("0.7.0");
740
+ expect(result.logTailCommand).toBe(`tail -f ${tmpHome}/.macroclaw/logs/*.log`);
710
741
  rmSync(tmpHome, { recursive: true });
711
742
  });
712
743
 
@@ -717,6 +748,8 @@ describe("update", () => {
717
748
 
718
749
  mockExecSync.mockImplementation((cmd: string) => {
719
750
  if (cmd.startsWith("launchctl list ")) return LAUNCHD_STOPPED;
751
+ if (cmd === "macroclaw service refresh") return "";
752
+ if (cmd.includes("launchctl load")) return "";
720
753
  if (cmd === "bun pm ls -g") throw new Error("command not found");
721
754
  return "";
722
755
  });
@@ -724,6 +757,96 @@ describe("update", () => {
724
757
  const result = mgr.update();
725
758
  expect(result.previousVersion).toBe("unknown");
726
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");
727
850
  rmSync(tmpHome, { recursive: true });
728
851
  });
729
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,7 +86,7 @@ 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();
89
90
 
90
91
  const logDir = resolve(this.#home, ".macroclaw/logs");
91
92
  mkdirSync(logDir, { recursive: true });
@@ -93,7 +94,7 @@ export class SystemServiceManager {
93
94
  this.#exec(`launchctl unload ${this.serviceFilePath}`);
94
95
  }
95
96
 
96
- writeFileSync(this.serviceFilePath, this.#generateLaunchdPlist(bunGlobalBin, oauthToken));
97
+ writeFileSync(this.serviceFilePath, this.#generateLaunchdPlist(servicePath, oauthToken));
97
98
  log.debug({ filePath: this.serviceFilePath }, "Wrote launchd plist");
98
99
  this.#exec(`launchctl load ${this.serviceFilePath}`);
99
100
  }
@@ -109,7 +110,7 @@ export class SystemServiceManager {
109
110
  }
110
111
 
111
112
  this.#exec("bun install -g macroclaw");
112
- const bunGlobalBin = this.#getBunGlobalBinDir();
113
+ const servicePath = this.#getServicePath();
113
114
 
114
115
  // Enable lingering so user services run without an active login session
115
116
  const username = osUserInfo().username;
@@ -117,7 +118,7 @@ export class SystemServiceManager {
117
118
  this.#sudo(`loginctl enable-linger ${username}`);
118
119
  }
119
120
 
120
- const unitContent = this.#generateSystemdUnit(bunGlobalBin);
121
+ const unitContent = this.#generateSystemdUnit(servicePath);
121
122
  mkdirSync(dirname(this.serviceFilePath), { recursive: true });
122
123
  writeFileSync(this.serviceFilePath, unitContent);
123
124
  log.debug({ filePath: this.serviceFilePath }, "Wrote systemd unit");
@@ -192,12 +193,31 @@ export class SystemServiceManager {
192
193
  update(): UpdateResult {
193
194
  this.#requireInstalled();
194
195
 
196
+ const wasRunning = this.isRunning;
197
+ if (wasRunning) {
198
+ this.stop();
199
+ }
200
+
195
201
  const previousVersion = this.#getInstalledVersion();
196
202
  this.#exec("bun install -g macroclaw@latest");
203
+ this.#exec("macroclaw service refresh");
204
+ const logTailCommand = this.start();
197
205
  const currentVersion = this.#getInstalledVersion();
198
206
 
199
- log.debug({ previousVersion, currentVersion }, "Service updated");
200
- 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));
201
221
  }
202
222
 
203
223
  status(): ServiceStatus {
@@ -244,8 +264,23 @@ export class SystemServiceManager {
244
264
  return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).toString();
245
265
  }
246
266
 
247
- #getBunGlobalBinDir(): string {
248
- return this.#exec("bun pm bin -g").trim();
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];
249
284
  }
250
285
 
251
286
  #getInstalledVersion(): string {
@@ -276,9 +311,9 @@ export class SystemServiceManager {
276
311
  this.#exec(`sudo ${cmd}`);
277
312
  }
278
313
 
279
- #generateLaunchdPlist(bunGlobalBin: string, oauthToken?: string): string {
314
+ #generateLaunchdPlist(servicePath: string, oauthToken?: string): string {
280
315
  const logDir = resolve(this.#home, ".macroclaw/logs");
281
- const envVars = [`\n\t<key>PATH</key>\n\t\t<string>${bunGlobalBin}</string>`];
316
+ const envVars = [`\n\t<key>PATH</key>\n\t\t<string>${servicePath}</string>`];
282
317
  if (oauthToken) {
283
318
  envVars.push(`\n\t<key>CLAUDE_CODE_OAUTH_TOKEN</key>\n\t\t<string>${oauthToken}</string>`);
284
319
  }
@@ -291,9 +326,8 @@ export class SystemServiceManager {
291
326
  <string>com.macroclaw</string>
292
327
  <key>ProgramArguments</key>
293
328
  <array>
294
- <string>/bin/bash</string>
295
- <string>-lc</string>
296
- <string>exec macroclaw start</string>
329
+ <string>macroclaw</string>
330
+ <string>start</string>
297
331
  </array>
298
332
  <key>KeepAlive</key>
299
333
  <true/>
@@ -306,7 +340,7 @@ export class SystemServiceManager {
306
340
  `;
307
341
  }
308
342
 
309
- #generateSystemdUnit(bunGlobalBin: string): string {
343
+ #generateSystemdUnit(servicePath: string): string {
310
344
  return `[Unit]
311
345
  Description=Macroclaw - Telegram-to-Claude-Code bridge
312
346
  After=network.target
@@ -314,8 +348,8 @@ After=network.target
314
348
  [Service]
315
349
  Type=simple
316
350
  WorkingDirectory=%h
317
- Environment=PATH=${bunGlobalBin}
318
- ExecStart=/bin/bash -lc 'exec macroclaw start'
351
+ Environment=PATH=${servicePath}
352
+ ExecStart=macroclaw start
319
353
  Restart=on-failure
320
354
  RestartSec=5
321
355