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 +1 -1
- package/src/cli.test.ts +14 -15
- package/src/cli.ts +11 -7
- package/src/system-service.test.ts +175 -27
- package/src/system-service.ts +57 -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,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
|
|
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 =>
|
|
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
|
|
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
|
|
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 === "
|
|
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
|
|
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
|
-
|
|
232
|
-
expect(writtenContent).toContain("<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(
|
|
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
|
|
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 === "
|
|
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
|
|
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 === "
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
370
|
+
expect(unitContent).toContain(`Environment=PATH=${DEFAULT_SERVICE_PATH}`);
|
|
364
371
|
expect(unitContent).toContain("WorkingDirectory=%h");
|
|
365
|
-
expect(unitContent).toContain(
|
|
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("
|
|
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).
|
|
688
|
-
|
|
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
|
});
|
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,8 @@ export class SystemServiceManager {
|
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
this.#exec("bun install -g macroclaw");
|
|
88
|
-
const
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
#
|
|
248
|
-
return this.#exec("
|
|
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(
|
|
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>${
|
|
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
|
|
295
|
-
<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(
|
|
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=${
|
|
318
|
-
ExecStart
|
|
357
|
+
Environment=PATH=${servicePath}
|
|
358
|
+
ExecStart=${executablePath} start
|
|
319
359
|
Restart=on-failure
|
|
320
360
|
RestartSec=5
|
|
321
361
|
|