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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "macroclaw",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Telegram-to-Claude-Code bridge",
5
5
  "license": "MIT",
6
6
  "type": "module",
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 system service without prompting" },
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 system service" },
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 system service" },
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 system service" },
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 system service" },
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 system service" },
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 system service? [Y/n]: ");
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("/etc/systemd/system/macroclaw.service");
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
- mockExecSync.mockImplementation((cmd: string) => {
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 with PATH via temp file and sudo cp", () => {
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
- // Elevated operations use sudo
320
- expect(mockExecSync).toHaveBeenCalledWith(expect.stringMatching(/^sudo cp \/tmp\/macroclaw-.+\.service \/etc\/systemd\/system\/macroclaw\.service$/), expect.anything());
321
- expect(mockExecSync).toHaveBeenCalledWith("sudo systemctl daemon-reload", expect.anything());
322
- expect(mockExecSync).toHaveBeenCalledWith("sudo systemctl enable macroclaw", expect.anything());
323
- expect(mockExecSync).toHaveBeenCalledWith("sudo systemctl start macroclaw", expect.anything());
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("cleans up temp file even when sudo cp fails", () => {
328
- const tmpHome = `/tmp/macroclaw-test-cleanup-${Date.now()}`;
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
- const mgr = createManager({ home: tmpHome });
348
- expect(() => mgr.install()).toThrow("Permission denied");
349
- // Temp file should be cleaned up
350
- expect(tmpServicePath).toBeTruthy();
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
- // Verify the unit content was passed to sudo cp
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 via sudo", () => {
504
- // systemd serviceFilePath is /etc/systemd/... which exists on Linux
505
- // We mock isInstalled by ensuring the file "exists" via execSync behavior
506
- // Actually isInstalled uses real existsSync — need a real file at the systemd path
507
- // Since we can't write to /etc, we test the commands that would be called
508
- // by using a launchd path with a real file but checking systemd commands aren't needed here
509
- // Actually, let's just verify the throws case works — the systemd uninstall path
510
- // requires /etc/systemd/system/macroclaw.service to exist, which we can't create in tests
511
- // Skip this already covered by the launchd tests above and the sudo assertions below
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("calls correct sudo commands for systemd uninstall", () => {
515
- // Create a tmpHome-based manager and manually test the calls
516
- // We need isInstalled to return true — for systemd that's /etc/systemd/system/macroclaw.service
517
- // Since we can't create that, we verify via the launchd path
518
- const tmpHome = `/tmp/macroclaw-test-unsys-${Date.now()}`;
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.startsWith("launchctl list ")) return LAUNCHD_RUNNING;
524
+ if (cmd === "systemctl --user is-active macroclaw") throw new Error("inactive");
525
525
  return "";
526
526
  });
527
- const mgr = createManager({ platform: "darwin", home: tmpHome });
527
+ const mgr = createManager({ platform: "linux", home: tmpHome });
528
528
  mgr.uninstall();
529
- expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining("launchctl unload"), expect.anything());
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("/etc/systemd/system/macroclaw.service") returns true
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", () => {
@@ -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, tmpdir } from "node:os";
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
- : "/etc/systemd/system/macroclaw.service";
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 target = this.#resolveLinuxUser();
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.#sudo("systemctl stop macroclaw");
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
- const unitContent = this.#generateSystemdUnit(bunPath, macroclawPath, target, pathDirs);
127
- this.#writeSystemdUnit(unitContent);
128
- log.debug({ filePath: this.serviceFilePath, user: target.user }, "Wrote systemd unit");
129
- this.#sudo("systemctl daemon-reload");
130
- this.#sudo("systemctl enable macroclaw");
131
- this.#sudo("systemctl start macroclaw");
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.#sudo("systemctl stop macroclaw");
147
+ this.#exec("systemctl --user stop macroclaw");
145
148
  }
146
- try { this.#sudo("systemctl disable macroclaw"); } catch { /* already disabled */ }
147
- this.#sudo(`rm ${this.serviceFilePath}`);
148
- this.#sudo("systemctl daemon-reload");
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.#sudo("systemctl start macroclaw");
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.#sudo("systemctl stop macroclaw");
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, target: LinuxUser, pathDirs: string[]): 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
- User=${target.user}
354
- Group=${target.group}
355
- Environment=HOME=${target.home}
331
+ Environment=HOME=${this.#home}
356
332
  Environment=PATH=${pathDirs.join(":")}
357
- WorkingDirectory=${target.home}
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=multi-user.target
339
+ WantedBy=default.target
364
340
  `;
365
341
  }
366
342
  }