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