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 +1 -1
- package/src/cli.test.ts +14 -15
- package/src/cli.ts +11 -7
- package/src/system-service.test.ts +150 -27
- package/src/system-service.ts +51 -17
package/package.json
CHANGED
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
|
-
|
|
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
|
|
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(
|
|
200
|
-
expect(
|
|
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
|
|
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 =>
|
|
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
|
|
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
|
|
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 === "
|
|
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
|
|
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
|
-
|
|
232
|
-
expect(writtenContent).toContain("<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(
|
|
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
|
|
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 === "
|
|
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
|
|
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 === "
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
369
|
+
expect(unitContent).toContain(`Environment=PATH=${DEFAULT_SERVICE_PATH}`);
|
|
364
370
|
expect(unitContent).toContain("WorkingDirectory=%h");
|
|
365
|
-
expect(unitContent).toContain("ExecStart
|
|
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("
|
|
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).
|
|
688
|
-
|
|
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
|
});
|
package/src/system-service.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
#
|
|
248
|
-
return this.#exec("
|
|
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(
|
|
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>${
|
|
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
|
|
295
|
-
<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(
|
|
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=${
|
|
318
|
-
ExecStart
|
|
351
|
+
Environment=PATH=${servicePath}
|
|
352
|
+
ExecStart=macroclaw start
|
|
319
353
|
Restart=on-failure
|
|
320
354
|
RestartSec=5
|
|
321
355
|
|