macroclaw 0.30.0 → 0.31.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.ts +6 -6
- package/src/setup.ts +1 -1
- package/src/system-service.test.ts +119 -85
- package/src/system-service.ts +35 -59
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -130,7 +130,7 @@ const setupCommand = defineCommand({
|
|
|
130
130
|
meta: { name: "setup", description: "Run the interactive setup wizard" },
|
|
131
131
|
args: {
|
|
132
132
|
"skip-service": { type: "boolean", description: "Skip the service installation prompt" },
|
|
133
|
-
"install-service": { type: "boolean", description: "Install as a
|
|
133
|
+
"install-service": { type: "boolean", description: "Install as a service without prompting" },
|
|
134
134
|
},
|
|
135
135
|
run: ({ args }) => defaultCli.setup({ skipService: args["skip-service"], installService: args["install-service"] }).catch(handleError),
|
|
136
136
|
});
|
|
@@ -141,7 +141,7 @@ const claudeCommand = defineCommand({
|
|
|
141
141
|
});
|
|
142
142
|
|
|
143
143
|
const serviceInstallCommand = defineCommand({
|
|
144
|
-
meta: { name: "install", description: "Install and start macroclaw as a
|
|
144
|
+
meta: { name: "install", description: "Install and start macroclaw as a service" },
|
|
145
145
|
args: {
|
|
146
146
|
token: { type: "string", description: "Claude OAuth token from `claude setup-token` (required on macOS)" },
|
|
147
147
|
},
|
|
@@ -149,17 +149,17 @@ const serviceInstallCommand = defineCommand({
|
|
|
149
149
|
});
|
|
150
150
|
|
|
151
151
|
const serviceUninstallCommand = defineCommand({
|
|
152
|
-
meta: { name: "uninstall", description: "Stop and remove the
|
|
152
|
+
meta: { name: "uninstall", description: "Stop and remove the service" },
|
|
153
153
|
run: () => { try { defaultCli.service("uninstall"); } catch (err) { handleError(err); } },
|
|
154
154
|
});
|
|
155
155
|
|
|
156
156
|
const serviceStartCommand = defineCommand({
|
|
157
|
-
meta: { name: "start", description: "Start the
|
|
157
|
+
meta: { name: "start", description: "Start the service" },
|
|
158
158
|
run: () => { try { defaultCli.service("start"); } catch (err) { handleError(err); } },
|
|
159
159
|
});
|
|
160
160
|
|
|
161
161
|
const serviceStopCommand = defineCommand({
|
|
162
|
-
meta: { name: "stop", description: "Stop the
|
|
162
|
+
meta: { name: "stop", description: "Stop the service" },
|
|
163
163
|
run: () => { try { defaultCli.service("stop"); } catch (err) { handleError(err); } },
|
|
164
164
|
});
|
|
165
165
|
|
|
@@ -182,7 +182,7 @@ const serviceLogsCommand = defineCommand({
|
|
|
182
182
|
});
|
|
183
183
|
|
|
184
184
|
const serviceCommand = defineCommand({
|
|
185
|
-
meta: { name: "service", description: "Manage macroclaw
|
|
185
|
+
meta: { name: "service", description: "Manage macroclaw service" },
|
|
186
186
|
subCommands: {
|
|
187
187
|
install: serviceInstallCommand,
|
|
188
188
|
uninstall: serviceUninstallCommand,
|
package/src/setup.ts
CHANGED
|
@@ -113,7 +113,7 @@ export class SetupWizard {
|
|
|
113
113
|
async installService(): Promise<void> {
|
|
114
114
|
this.#io.open();
|
|
115
115
|
try {
|
|
116
|
-
const installAnswer = await this.#io.ask("Install as a
|
|
116
|
+
const installAnswer = await this.#io.ask("Install as a service? [Y/n]:");
|
|
117
117
|
if (installAnswer.toLowerCase() === "n" || installAnswer.toLowerCase() === "no") return;
|
|
118
118
|
|
|
119
119
|
await this.#doInstallService();
|
|
@@ -72,9 +72,9 @@ describe("serviceFilePath", () => {
|
|
|
72
72
|
expect(mgr.serviceFilePath).toContain("Library/LaunchAgents/com.macroclaw.plist");
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
-
it("returns systemd path for systemd", () => {
|
|
76
|
-
const mgr = createManager({ platform: "linux" });
|
|
77
|
-
expect(mgr.serviceFilePath).toBe("/
|
|
75
|
+
it("returns user systemd path for systemd", () => {
|
|
76
|
+
const mgr = createManager({ platform: "linux", home: "/home/testuser" });
|
|
77
|
+
expect(mgr.serviceFilePath).toBe("/home/testuser/.config/systemd/user/macroclaw.service");
|
|
78
78
|
});
|
|
79
79
|
});
|
|
80
80
|
|
|
@@ -128,7 +128,7 @@ describe("isRunning", () => {
|
|
|
128
128
|
|
|
129
129
|
it("returns true when systemd service is active", () => {
|
|
130
130
|
mockExecSync.mockImplementation((cmd: string) => {
|
|
131
|
-
if (cmd === "systemctl is-active macroclaw") return SYSTEMD_ACTIVE;
|
|
131
|
+
if (cmd === "systemctl --user is-active macroclaw") return SYSTEMD_ACTIVE;
|
|
132
132
|
return "";
|
|
133
133
|
});
|
|
134
134
|
const mgr = createManager();
|
|
@@ -137,7 +137,7 @@ describe("isRunning", () => {
|
|
|
137
137
|
|
|
138
138
|
it("returns false when systemd service is inactive", () => {
|
|
139
139
|
mockExecSync.mockImplementation((cmd: string) => {
|
|
140
|
-
if (cmd === "systemctl is-active macroclaw") return SYSTEMD_INACTIVE;
|
|
140
|
+
if (cmd === "systemctl --user is-active macroclaw") return SYSTEMD_INACTIVE;
|
|
141
141
|
return "";
|
|
142
142
|
});
|
|
143
143
|
const mgr = createManager();
|
|
@@ -146,7 +146,7 @@ describe("isRunning", () => {
|
|
|
146
146
|
|
|
147
147
|
it("returns false when systemctl throws", () => {
|
|
148
148
|
mockExecSync.mockImplementation((cmd: string) => {
|
|
149
|
-
if (cmd === "systemctl is-active macroclaw") throw new Error("not found");
|
|
149
|
+
if (cmd === "systemctl --user is-active macroclaw") throw new Error("not found");
|
|
150
150
|
return "";
|
|
151
151
|
});
|
|
152
152
|
const mgr = createManager();
|
|
@@ -163,12 +163,7 @@ describe("install", () => {
|
|
|
163
163
|
});
|
|
164
164
|
|
|
165
165
|
it("throws when settings.json is missing on Linux", () => {
|
|
166
|
-
|
|
167
|
-
if (cmd.startsWith("id -gn")) return "testuser\n";
|
|
168
|
-
return "";
|
|
169
|
-
});
|
|
170
|
-
mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: "/nonexistent", uid: 1000, gid: 1000, shell: "/bin/bash" }));
|
|
171
|
-
const mgr = createManager();
|
|
166
|
+
const mgr = createManager({ home: "/nonexistent" });
|
|
172
167
|
expect(() => mgr.install()).toThrow("Settings not found. Run `macroclaw setup` first.");
|
|
173
168
|
});
|
|
174
169
|
|
|
@@ -183,10 +178,14 @@ describe("install", () => {
|
|
|
183
178
|
if (cmd === "which bun") return `${tmpHome}/.bun/bin/bun\n`;
|
|
184
179
|
if (cmd === "which claude") return `${tmpHome}/.local/bin/claude\n`;
|
|
185
180
|
if (cmd === "bun pm bin -g") return `${tmpHome}/.bun/bin\n`;
|
|
186
|
-
if (cmd.startsWith("id -gn")) return "testuser\n";
|
|
187
181
|
return "";
|
|
188
182
|
});
|
|
189
183
|
mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: tmpHome, uid: 1000, gid: 1000, shell: "/bin/bash" }));
|
|
184
|
+
// Mock existsSync to handle linger check
|
|
185
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
186
|
+
if (path === "/var/lib/systemd/linger/testuser") return true; // already lingering
|
|
187
|
+
return realExistsSync(path);
|
|
188
|
+
});
|
|
190
189
|
const mgr = createManager({ home: tmpHome });
|
|
191
190
|
mgr.install();
|
|
192
191
|
rmSync(tmpHome, { recursive: true });
|
|
@@ -298,7 +297,7 @@ describe("install", () => {
|
|
|
298
297
|
rmSync(tmpHome, { recursive: true });
|
|
299
298
|
});
|
|
300
299
|
|
|
301
|
-
it("installs systemd service
|
|
300
|
+
it("installs systemd user service and writes unit file directly", () => {
|
|
302
301
|
const tmpHome = `/tmp/macroclaw-test-systemd-${Date.now()}`;
|
|
303
302
|
mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
|
|
304
303
|
writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
|
|
@@ -309,71 +308,63 @@ describe("install", () => {
|
|
|
309
308
|
if (cmd === "which bun") return `${tmpHome}/.bun/bin/bun\n`;
|
|
310
309
|
if (cmd === "which claude") return `${tmpHome}/.local/bin/claude\n`;
|
|
311
310
|
if (cmd === "bun pm bin -g") return `${tmpHome}/.bun/bin\n`;
|
|
312
|
-
if (cmd.startsWith("id -gn")) return "testuser\n";
|
|
313
311
|
return "";
|
|
314
312
|
});
|
|
315
313
|
mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: tmpHome, uid: 1000, gid: 1000, shell: "/bin/bash" }));
|
|
314
|
+
// Mock existsSync: linger file does not exist (triggers sudo loginctl)
|
|
315
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
316
|
+
if (path === "/var/lib/systemd/linger/testuser") return false;
|
|
317
|
+
return realExistsSync(path);
|
|
318
|
+
});
|
|
316
319
|
const mgr = createManager({ home: tmpHome });
|
|
317
320
|
mgr.install();
|
|
318
321
|
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
expect(
|
|
322
|
-
|
|
323
|
-
expect(
|
|
322
|
+
// Unit file written directly (no sudo cp)
|
|
323
|
+
const unitPath = join(tmpHome, ".config/systemd/user/macroclaw.service");
|
|
324
|
+
expect(existsSync(unitPath)).toBe(true);
|
|
325
|
+
const unitContent = readFileSync(unitPath, "utf-8");
|
|
326
|
+
expect(unitContent).toContain("WantedBy=default.target");
|
|
327
|
+
expect(unitContent).not.toContain("User=");
|
|
328
|
+
expect(unitContent).not.toContain("Group=");
|
|
329
|
+
expect(unitContent).toContain(`Environment=HOME=${tmpHome}`);
|
|
330
|
+
expect(unitContent).toContain(`ExecStart=${tmpHome}/.bun/bin/bun ${tmpHome}/.bun/bin/macroclaw start`);
|
|
331
|
+
|
|
332
|
+
// Lingering enabled via sudo
|
|
333
|
+
expect(mockExecSync).toHaveBeenCalledWith("sudo loginctl enable-linger testuser", expect.anything());
|
|
334
|
+
// User systemctl commands (no sudo)
|
|
335
|
+
expect(mockExecSync).toHaveBeenCalledWith("systemctl --user daemon-reload", expect.anything());
|
|
336
|
+
expect(mockExecSync).toHaveBeenCalledWith("systemctl --user enable macroclaw", expect.anything());
|
|
337
|
+
expect(mockExecSync).toHaveBeenCalledWith("systemctl --user start macroclaw", expect.anything());
|
|
338
|
+
// No sudo systemctl calls
|
|
339
|
+
for (const call of mockExecSync.mock.calls) {
|
|
340
|
+
expect(call[0]).not.toMatch(/^sudo systemctl/);
|
|
341
|
+
}
|
|
324
342
|
rmSync(tmpHome, { recursive: true });
|
|
325
343
|
});
|
|
326
344
|
|
|
327
|
-
it("
|
|
328
|
-
const tmpHome = `/tmp/macroclaw-test-
|
|
345
|
+
it("skips lingering when already enabled", () => {
|
|
346
|
+
const tmpHome = `/tmp/macroclaw-test-linger-${Date.now()}`;
|
|
329
347
|
mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
|
|
330
348
|
writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
|
|
331
349
|
mkdirSync(join(tmpHome, ".bun/bin"), { recursive: true });
|
|
332
350
|
writeFileSync(join(tmpHome, ".bun/bin/macroclaw"), "");
|
|
333
351
|
|
|
334
|
-
let tmpServicePath = "";
|
|
335
352
|
mockExecSync.mockImplementation((cmd: string) => {
|
|
336
|
-
if (cmd.startsWith("id -gn")) return "testuser\n";
|
|
337
353
|
if (cmd === "which bun") return `${tmpHome}/.bun/bin/bun\n`;
|
|
338
354
|
if (cmd === "which claude") return `${tmpHome}/.local/bin/claude\n`;
|
|
339
355
|
if (cmd === "bun pm bin -g") return `${tmpHome}/.bun/bin\n`;
|
|
340
|
-
if (cmd.startsWith("sudo cp")) {
|
|
341
|
-
tmpServicePath = cmd.split(" ")[2];
|
|
342
|
-
throw new Error("Permission denied");
|
|
343
|
-
}
|
|
344
356
|
return "";
|
|
345
357
|
});
|
|
346
358
|
mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: tmpHome, uid: 1000, gid: 1000, shell: "/bin/bash" }));
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
expect(existsSync(tmpServicePath)).toBe(false);
|
|
352
|
-
rmSync(tmpHome, { recursive: true });
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
it("uses os userInfo identity, not environment variables", () => {
|
|
356
|
-
const tmpHome = `/tmp/macroclaw-test-userinfo-${Date.now()}`;
|
|
357
|
-
mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
|
|
358
|
-
writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
|
|
359
|
-
mkdirSync(join(tmpHome, "bin"), { recursive: true });
|
|
360
|
-
writeFileSync(join(tmpHome, "bin/macroclaw"), "");
|
|
361
|
-
|
|
362
|
-
mockExecSync.mockImplementation((cmd: string) => {
|
|
363
|
-
if (cmd === "which bun") return "/usr/local/bin/bun\n";
|
|
364
|
-
if (cmd === "which claude") return "/usr/local/bin/claude\n";
|
|
365
|
-
if (cmd === "bun pm bin -g") return `${tmpHome}/bin\n`;
|
|
366
|
-
if (cmd === "id -gn deploy") return "deploy\n";
|
|
367
|
-
return "";
|
|
359
|
+
// Linger already enabled
|
|
360
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
361
|
+
if (path === "/var/lib/systemd/linger/testuser") return true;
|
|
362
|
+
return realExistsSync(path);
|
|
368
363
|
});
|
|
369
|
-
mockUserInfo.mockImplementation(() => ({ username: "deploy", homedir: tmpHome, uid: 1000, gid: 1000, shell: "/bin/bash" }));
|
|
370
364
|
const mgr = createManager({ home: tmpHome });
|
|
371
365
|
mgr.install();
|
|
372
366
|
|
|
373
|
-
|
|
374
|
-
const cpCall = mockExecSync.mock.calls.find(c => (c[0] as string).startsWith("sudo cp"));
|
|
375
|
-
expect(cpCall).toBeTruthy();
|
|
376
|
-
|
|
367
|
+
expect(mockExecSync).not.toHaveBeenCalledWith("sudo loginctl enable-linger testuser", expect.anything());
|
|
377
368
|
rmSync(tmpHome, { recursive: true });
|
|
378
369
|
});
|
|
379
370
|
|
|
@@ -388,10 +379,13 @@ describe("install", () => {
|
|
|
388
379
|
if (cmd === "which bun") return `${tmpHome}/.bun/bin/bun\n`;
|
|
389
380
|
if (cmd === "which claude") return `${tmpHome}/.local/bin/claude\n`;
|
|
390
381
|
if (cmd === "bun pm bin -g") return `${tmpHome}/.bun/bin\n`;
|
|
391
|
-
if (cmd.startsWith("id -gn")) return "testuser\n";
|
|
392
382
|
return "";
|
|
393
383
|
});
|
|
394
384
|
mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: tmpHome, uid: 1000, gid: 1000, shell: "/bin/bash" }));
|
|
385
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
386
|
+
if (path === "/var/lib/systemd/linger/testuser") return true;
|
|
387
|
+
return realExistsSync(path);
|
|
388
|
+
});
|
|
395
389
|
const mgr = createManager({ home: tmpHome });
|
|
396
390
|
mgr.install();
|
|
397
391
|
expect(mockExecSync).toHaveBeenCalledWith("bun install -g macroclaw", expect.anything());
|
|
@@ -405,11 +399,9 @@ describe("install", () => {
|
|
|
405
399
|
writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
|
|
406
400
|
|
|
407
401
|
mockExecSync.mockImplementation((cmd: string) => {
|
|
408
|
-
if (cmd.startsWith("id -gn")) return "testuser\n";
|
|
409
402
|
if (cmd === "which bun") throw new Error("not found");
|
|
410
403
|
return "";
|
|
411
404
|
});
|
|
412
|
-
mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: tmpHome, uid: 1000, gid: 1000, shell: "/bin/bash" }));
|
|
413
405
|
const mgr = createManager({ home: tmpHome });
|
|
414
406
|
expect(() => mgr.install()).toThrow("Could not resolve bun path. Is it installed?");
|
|
415
407
|
rmSync(tmpHome, { recursive: true });
|
|
@@ -423,13 +415,11 @@ describe("install", () => {
|
|
|
423
415
|
// Note: NOT creating macroclaw binary
|
|
424
416
|
|
|
425
417
|
mockExecSync.mockImplementation((cmd: string) => {
|
|
426
|
-
if (cmd.startsWith("id -gn")) return "testuser\n";
|
|
427
418
|
if (cmd === "which bun") return `${tmpHome}/.bun/bin/bun\n`;
|
|
428
419
|
if (cmd === "which claude") return `${tmpHome}/.local/bin/claude\n`;
|
|
429
420
|
if (cmd === "bun pm bin -g") return `${tmpHome}/.bun/bin\n`;
|
|
430
421
|
return "";
|
|
431
422
|
});
|
|
432
|
-
mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: tmpHome, uid: 1000, gid: 1000, shell: "/bin/bash" }));
|
|
433
423
|
const mgr = createManager({ home: tmpHome });
|
|
434
424
|
expect(() => mgr.install()).toThrow(`Could not find macroclaw in ${tmpHome}/.bun/bin`);
|
|
435
425
|
rmSync(tmpHome, { recursive: true });
|
|
@@ -500,33 +490,45 @@ describe("uninstall", () => {
|
|
|
500
490
|
rmSync(tmpHome, { recursive: true, force: true });
|
|
501
491
|
});
|
|
502
492
|
|
|
503
|
-
it("uninstalls running systemd service
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
493
|
+
it("uninstalls running systemd user service", () => {
|
|
494
|
+
const tmpHome = `/tmp/macroclaw-test-unsys-${Date.now()}`;
|
|
495
|
+
const unitDir = join(tmpHome, ".config/systemd/user");
|
|
496
|
+
mkdirSync(unitDir, { recursive: true });
|
|
497
|
+
const unitPath = join(unitDir, "macroclaw.service");
|
|
498
|
+
writeFileSync(unitPath, "test");
|
|
499
|
+
|
|
500
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
501
|
+
if (cmd === "systemctl --user is-active macroclaw") return SYSTEMD_ACTIVE;
|
|
502
|
+
return "";
|
|
503
|
+
});
|
|
504
|
+
const mgr = createManager({ platform: "linux", home: tmpHome });
|
|
505
|
+
mgr.uninstall();
|
|
506
|
+
expect(mockExecSync).toHaveBeenCalledWith("systemctl --user stop macroclaw", expect.anything());
|
|
507
|
+
expect(mockExecSync).toHaveBeenCalledWith("systemctl --user disable macroclaw", expect.anything());
|
|
508
|
+
expect(mockExecSync).toHaveBeenCalledWith("systemctl --user daemon-reload", expect.anything());
|
|
509
|
+
expect(existsSync(unitPath)).toBe(false);
|
|
510
|
+
// No sudo for systemctl
|
|
511
|
+
for (const call of mockExecSync.mock.calls) {
|
|
512
|
+
expect(call[0]).not.toMatch(/^sudo /);
|
|
513
|
+
}
|
|
514
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
512
515
|
});
|
|
513
516
|
|
|
514
|
-
it("
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
const plistDir = join(tmpHome, "Library/LaunchAgents");
|
|
520
|
-
mkdirSync(plistDir, { recursive: true });
|
|
521
|
-
writeFileSync(join(plistDir, "com.macroclaw.plist"), "test");
|
|
517
|
+
it("uninstalls stopped systemd user service without stopping", () => {
|
|
518
|
+
const tmpHome = `/tmp/macroclaw-test-unsys2-${Date.now()}`;
|
|
519
|
+
const unitDir = join(tmpHome, ".config/systemd/user");
|
|
520
|
+
mkdirSync(unitDir, { recursive: true });
|
|
521
|
+
writeFileSync(join(unitDir, "macroclaw.service"), "test");
|
|
522
522
|
|
|
523
523
|
mockExecSync.mockImplementation((cmd: string) => {
|
|
524
|
-
if (cmd
|
|
524
|
+
if (cmd === "systemctl --user is-active macroclaw") throw new Error("inactive");
|
|
525
525
|
return "";
|
|
526
526
|
});
|
|
527
|
-
const mgr = createManager({ platform: "
|
|
527
|
+
const mgr = createManager({ platform: "linux", home: tmpHome });
|
|
528
528
|
mgr.uninstall();
|
|
529
|
-
expect(mockExecSync).toHaveBeenCalledWith(
|
|
529
|
+
expect(mockExecSync).not.toHaveBeenCalledWith("systemctl --user stop macroclaw", expect.anything());
|
|
530
|
+
expect(mockExecSync).toHaveBeenCalledWith("systemctl --user disable macroclaw", expect.anything());
|
|
531
|
+
expect(mockExecSync).toHaveBeenCalledWith("systemctl --user daemon-reload", expect.anything());
|
|
530
532
|
rmSync(tmpHome, { recursive: true, force: true });
|
|
531
533
|
});
|
|
532
534
|
});
|
|
@@ -567,6 +569,22 @@ describe("start", () => {
|
|
|
567
569
|
expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining("launchctl load"), expect.anything());
|
|
568
570
|
rmSync(tmpHome, { recursive: true });
|
|
569
571
|
});
|
|
572
|
+
|
|
573
|
+
it("starts systemd user service", () => {
|
|
574
|
+
const tmpHome = `/tmp/macroclaw-test-startsys-${Date.now()}`;
|
|
575
|
+
const unitDir = join(tmpHome, ".config/systemd/user");
|
|
576
|
+
mkdirSync(unitDir, { recursive: true });
|
|
577
|
+
writeFileSync(join(unitDir, "macroclaw.service"), "test");
|
|
578
|
+
|
|
579
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
580
|
+
if (cmd === "systemctl --user is-active macroclaw") throw new Error("inactive");
|
|
581
|
+
return "";
|
|
582
|
+
});
|
|
583
|
+
const mgr = createManager({ platform: "linux", home: tmpHome });
|
|
584
|
+
mgr.start();
|
|
585
|
+
expect(mockExecSync).toHaveBeenCalledWith("systemctl --user start macroclaw", expect.anything());
|
|
586
|
+
rmSync(tmpHome, { recursive: true });
|
|
587
|
+
});
|
|
570
588
|
});
|
|
571
589
|
|
|
572
590
|
describe("stop", () => {
|
|
@@ -605,6 +623,22 @@ describe("stop", () => {
|
|
|
605
623
|
expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining("launchctl unload"), expect.anything());
|
|
606
624
|
rmSync(tmpHome, { recursive: true });
|
|
607
625
|
});
|
|
626
|
+
|
|
627
|
+
it("stops systemd user service", () => {
|
|
628
|
+
const tmpHome = `/tmp/macroclaw-test-stopsys-${Date.now()}`;
|
|
629
|
+
const unitDir = join(tmpHome, ".config/systemd/user");
|
|
630
|
+
mkdirSync(unitDir, { recursive: true });
|
|
631
|
+
writeFileSync(join(unitDir, "macroclaw.service"), "test");
|
|
632
|
+
|
|
633
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
634
|
+
if (cmd === "systemctl --user is-active macroclaw") return SYSTEMD_ACTIVE;
|
|
635
|
+
return "";
|
|
636
|
+
});
|
|
637
|
+
const mgr = createManager({ platform: "linux", home: tmpHome });
|
|
638
|
+
mgr.stop();
|
|
639
|
+
expect(mockExecSync).toHaveBeenCalledWith("systemctl --user stop macroclaw", expect.anything());
|
|
640
|
+
rmSync(tmpHome, { recursive: true });
|
|
641
|
+
});
|
|
608
642
|
});
|
|
609
643
|
|
|
610
644
|
describe("update", () => {
|
|
@@ -674,13 +708,13 @@ describe("update", () => {
|
|
|
674
708
|
describe("status", () => {
|
|
675
709
|
it("returns not installed, not running when service file missing", () => {
|
|
676
710
|
mockExecSync.mockImplementation((cmd: string) => {
|
|
677
|
-
if (cmd === "systemctl is-active macroclaw") throw new Error("not found");
|
|
711
|
+
if (cmd === "systemctl --user is-active macroclaw") throw new Error("not found");
|
|
678
712
|
return "";
|
|
679
713
|
});
|
|
680
714
|
mockExistsSync.mockReturnValue(false);
|
|
681
715
|
const mgr = createManager({ home: "/nonexistent" });
|
|
682
716
|
// Override isInstalled getter — on hosts where macroclaw is installed as a systemd
|
|
683
|
-
// service, existsSync
|
|
717
|
+
// service, existsSync for the user path might still return true
|
|
684
718
|
Object.defineProperty(mgr, "isInstalled", { get: () => false });
|
|
685
719
|
const s = mgr.status();
|
|
686
720
|
expect(s.installed).toBe(false);
|
|
@@ -731,14 +765,14 @@ describe("status", () => {
|
|
|
731
765
|
});
|
|
732
766
|
|
|
733
767
|
describe("logs", () => {
|
|
734
|
-
it("returns journalctl command for systemd", () => {
|
|
768
|
+
it("returns journalctl --user command for systemd", () => {
|
|
735
769
|
const mgr = createManager();
|
|
736
|
-
expect(mgr.logs()).toBe("journalctl -u macroclaw -n 50 --no-pager");
|
|
770
|
+
expect(mgr.logs()).toBe("journalctl --user -u macroclaw -n 50 --no-pager");
|
|
737
771
|
});
|
|
738
772
|
|
|
739
|
-
it("returns journalctl follow command for systemd", () => {
|
|
773
|
+
it("returns journalctl --user follow command for systemd", () => {
|
|
740
774
|
const mgr = createManager();
|
|
741
|
-
expect(mgr.logs(true)).toBe("journalctl -u macroclaw -f");
|
|
775
|
+
expect(mgr.logs(true)).toBe("journalctl --user -u macroclaw -f");
|
|
742
776
|
});
|
|
743
777
|
|
|
744
778
|
it("returns tail command for launchd", () => {
|
package/src/system-service.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
2
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { userInfo as osUserInfo
|
|
3
|
+
import { userInfo as osUserInfo } from "node:os";
|
|
5
4
|
import { dirname, join, resolve } from "node:path";
|
|
6
5
|
import { createLogger } from "./logger";
|
|
7
6
|
|
|
@@ -10,12 +9,6 @@ const LAUNCHD_LABEL = "com.macroclaw";
|
|
|
10
9
|
|
|
11
10
|
export type Platform = "launchd" | "systemd";
|
|
12
11
|
|
|
13
|
-
interface LinuxUser {
|
|
14
|
-
user: string;
|
|
15
|
-
group: string;
|
|
16
|
-
home: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
12
|
export interface ServiceStatus {
|
|
20
13
|
installed: boolean;
|
|
21
14
|
running: boolean;
|
|
@@ -51,7 +44,7 @@ export class SystemServiceManager {
|
|
|
51
44
|
get serviceFilePath(): string {
|
|
52
45
|
return this.#platform === "launchd"
|
|
53
46
|
? resolve(this.#home, "Library/LaunchAgents/com.macroclaw.plist")
|
|
54
|
-
: "/
|
|
47
|
+
: resolve(this.#home, ".config/systemd/user/macroclaw.service");
|
|
55
48
|
}
|
|
56
49
|
|
|
57
50
|
get isInstalled(): boolean {
|
|
@@ -68,7 +61,7 @@ export class SystemServiceManager {
|
|
|
68
61
|
}
|
|
69
62
|
}
|
|
70
63
|
try {
|
|
71
|
-
const out = this.#exec("systemctl is-active macroclaw");
|
|
64
|
+
const out = this.#exec("systemctl --user is-active macroclaw");
|
|
72
65
|
return out.trim() === "active";
|
|
73
66
|
} catch {
|
|
74
67
|
return false;
|
|
@@ -110,10 +103,13 @@ export class SystemServiceManager {
|
|
|
110
103
|
}
|
|
111
104
|
|
|
112
105
|
#installSystemd(): void {
|
|
113
|
-
const
|
|
106
|
+
const settingsPath = resolve(this.#home, ".macroclaw/settings.json");
|
|
107
|
+
if (!existsSync(settingsPath)) {
|
|
108
|
+
throw new Error("Settings not found. Run `macroclaw setup` first.");
|
|
109
|
+
}
|
|
114
110
|
|
|
115
111
|
if (this.isRunning) {
|
|
116
|
-
this.#
|
|
112
|
+
this.#exec("systemctl --user stop macroclaw");
|
|
117
113
|
}
|
|
118
114
|
|
|
119
115
|
this.#exec("bun install -g macroclaw");
|
|
@@ -123,12 +119,19 @@ export class SystemServiceManager {
|
|
|
123
119
|
|
|
124
120
|
const pathDirs = [...new Set([dirname(bunPath), dirname(claudePath), dirname(macroclawPath)])];
|
|
125
121
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
122
|
+
// Enable lingering so user services run without an active login session
|
|
123
|
+
const username = osUserInfo().username;
|
|
124
|
+
if (!existsSync(`/var/lib/systemd/linger/${username}`)) {
|
|
125
|
+
this.#sudo(`loginctl enable-linger ${username}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const unitContent = this.#generateSystemdUnit(bunPath, macroclawPath, pathDirs);
|
|
129
|
+
mkdirSync(dirname(this.serviceFilePath), { recursive: true });
|
|
130
|
+
writeFileSync(this.serviceFilePath, unitContent);
|
|
131
|
+
log.debug({ filePath: this.serviceFilePath }, "Wrote systemd unit");
|
|
132
|
+
this.#exec("systemctl --user daemon-reload");
|
|
133
|
+
this.#exec("systemctl --user enable macroclaw");
|
|
134
|
+
this.#exec("systemctl --user start macroclaw");
|
|
132
135
|
}
|
|
133
136
|
|
|
134
137
|
uninstall(): void {
|
|
@@ -141,11 +144,11 @@ export class SystemServiceManager {
|
|
|
141
144
|
rmSync(this.serviceFilePath);
|
|
142
145
|
} else {
|
|
143
146
|
if (this.isRunning) {
|
|
144
|
-
this.#
|
|
147
|
+
this.#exec("systemctl --user stop macroclaw");
|
|
145
148
|
}
|
|
146
|
-
try { this.#
|
|
147
|
-
|
|
148
|
-
this.#
|
|
149
|
+
try { this.#exec("systemctl --user disable macroclaw"); } catch { /* already disabled */ }
|
|
150
|
+
rmSync(this.serviceFilePath);
|
|
151
|
+
this.#exec("systemctl --user daemon-reload");
|
|
149
152
|
}
|
|
150
153
|
|
|
151
154
|
log.debug("Service uninstalled");
|
|
@@ -161,7 +164,7 @@ export class SystemServiceManager {
|
|
|
161
164
|
if (this.#platform === "launchd") {
|
|
162
165
|
this.#exec(`launchctl load ${this.serviceFilePath}`);
|
|
163
166
|
} else {
|
|
164
|
-
this.#
|
|
167
|
+
this.#exec("systemctl --user start macroclaw");
|
|
165
168
|
}
|
|
166
169
|
|
|
167
170
|
log.debug("Service started");
|
|
@@ -178,7 +181,7 @@ export class SystemServiceManager {
|
|
|
178
181
|
if (this.#platform === "launchd") {
|
|
179
182
|
this.#exec(`launchctl unload ${this.serviceFilePath}`);
|
|
180
183
|
} else {
|
|
181
|
-
this.#
|
|
184
|
+
this.#exec("systemctl --user stop macroclaw");
|
|
182
185
|
}
|
|
183
186
|
|
|
184
187
|
log.debug("Service stopped");
|
|
@@ -211,7 +214,7 @@ export class SystemServiceManager {
|
|
|
211
214
|
} catch { /* best effort */ }
|
|
212
215
|
} else {
|
|
213
216
|
try {
|
|
214
|
-
const out = this.#exec("systemctl show macroclaw --property=MainPID,ActiveEnterTimestamp --no-pager");
|
|
217
|
+
const out = this.#exec("systemctl --user show macroclaw --property=MainPID,ActiveEnterTimestamp --no-pager");
|
|
215
218
|
const pidMatch = /MainPID=(\d+)/.exec(out);
|
|
216
219
|
if (pidMatch && pidMatch[1] !== "0") result.pid = Number(pidMatch[1]);
|
|
217
220
|
const tsMatch = /ActiveEnterTimestamp=(.+)/.exec(out);
|
|
@@ -231,8 +234,8 @@ export class SystemServiceManager {
|
|
|
231
234
|
: `tail -n 50 ${logDir}/stdout.log`;
|
|
232
235
|
}
|
|
233
236
|
return follow
|
|
234
|
-
? "journalctl -u macroclaw -f"
|
|
235
|
-
: "journalctl -u macroclaw -n 50 --no-pager";
|
|
237
|
+
? "journalctl --user -u macroclaw -f"
|
|
238
|
+
: "journalctl --user -u macroclaw -n 50 --no-pager";
|
|
236
239
|
}
|
|
237
240
|
|
|
238
241
|
#exec(cmd: string): string {
|
|
@@ -278,38 +281,13 @@ export class SystemServiceManager {
|
|
|
278
281
|
const logDir = resolve(this.#home, ".macroclaw/logs");
|
|
279
282
|
return `tail -f ${logDir}/*.log`;
|
|
280
283
|
}
|
|
281
|
-
return "journalctl -u macroclaw -f";
|
|
284
|
+
return "journalctl --user -u macroclaw -f";
|
|
282
285
|
}
|
|
283
286
|
|
|
284
287
|
#sudo(cmd: string): void {
|
|
285
288
|
this.#exec(`sudo ${cmd}`);
|
|
286
289
|
}
|
|
287
290
|
|
|
288
|
-
/** Write unit content to a temp file, then sudo-copy it into /etc/systemd/system/. */
|
|
289
|
-
#writeSystemdUnit(content: string): void {
|
|
290
|
-
const tmpPath = join(tmpdir(), `macroclaw-${randomUUID()}.service`);
|
|
291
|
-
writeFileSync(tmpPath, content);
|
|
292
|
-
try {
|
|
293
|
-
this.#sudo(`cp ${tmpPath} ${this.serviceFilePath}`);
|
|
294
|
-
} finally {
|
|
295
|
-
try { rmSync(tmpPath); } catch { /* best-effort cleanup */ }
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
#resolveLinuxUser(): LinuxUser {
|
|
300
|
-
const info = osUserInfo();
|
|
301
|
-
const user = info.username;
|
|
302
|
-
const home = info.homedir;
|
|
303
|
-
const group = this.#exec(`id -gn ${user}`).trim();
|
|
304
|
-
|
|
305
|
-
const settingsPath = resolve(home, ".macroclaw/settings.json");
|
|
306
|
-
if (!existsSync(settingsPath)) {
|
|
307
|
-
throw new Error("Settings not found. Run `macroclaw setup` first.");
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
return { user, group, home };
|
|
311
|
-
}
|
|
312
|
-
|
|
313
291
|
#generateLaunchdPlist(bunPath: string, macroclawPath: string, pathDirs: string[], oauthToken?: string): string {
|
|
314
292
|
const logDir = resolve(this.#home, ".macroclaw/logs");
|
|
315
293
|
const tokenEnv = oauthToken ? `\n\t\t<key>CLAUDE_CODE_OAUTH_TOKEN</key>\n\t\t<string>${oauthToken}</string>` : "";
|
|
@@ -343,24 +321,22 @@ export class SystemServiceManager {
|
|
|
343
321
|
`;
|
|
344
322
|
}
|
|
345
323
|
|
|
346
|
-
#generateSystemdUnit(bunPath: string, macroclawPath: string,
|
|
324
|
+
#generateSystemdUnit(bunPath: string, macroclawPath: string, pathDirs: string[]): string {
|
|
347
325
|
return `[Unit]
|
|
348
326
|
Description=Macroclaw - Telegram-to-Claude-Code bridge
|
|
349
327
|
After=network.target
|
|
350
328
|
|
|
351
329
|
[Service]
|
|
352
330
|
Type=simple
|
|
353
|
-
|
|
354
|
-
Group=${target.group}
|
|
355
|
-
Environment=HOME=${target.home}
|
|
331
|
+
Environment=HOME=${this.#home}
|
|
356
332
|
Environment=PATH=${pathDirs.join(":")}
|
|
357
|
-
WorkingDirectory=${
|
|
333
|
+
WorkingDirectory=${this.#home}
|
|
358
334
|
ExecStart=${bunPath} ${macroclawPath} start
|
|
359
335
|
Restart=on-failure
|
|
360
336
|
RestartSec=5
|
|
361
337
|
|
|
362
338
|
[Install]
|
|
363
|
-
WantedBy=
|
|
339
|
+
WantedBy=default.target
|
|
364
340
|
`;
|
|
365
341
|
}
|
|
366
342
|
}
|