macroclaw 0.3.0 → 0.4.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/src/service.ts ADDED
@@ -0,0 +1,346 @@
1
+ import { execSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import { userInfo as osUserInfo, tmpdir } from "node:os";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { createLogger } from "./logger";
7
+
8
+ const log = createLogger("service");
9
+ const LAUNCHD_LABEL = "com.macroclaw";
10
+
11
+ export type Platform = "launchd" | "systemd";
12
+
13
+ export interface ServiceDeps {
14
+ existsSync: (path: string) => boolean;
15
+ writeFileSync: (path: string, data: string) => void;
16
+ mkdirSync: (path: string, opts?: { recursive: boolean }) => void;
17
+ rmSync: (path: string) => void;
18
+ execSync: (cmd: string, opts?: object) => string;
19
+ tmpdir: () => string;
20
+ randomUUID: () => string;
21
+ userInfo: () => { username: string; homedir: string };
22
+ platform: string;
23
+ home: string;
24
+ }
25
+
26
+ function defaultDeps(): ServiceDeps {
27
+ return {
28
+ existsSync,
29
+ writeFileSync,
30
+ mkdirSync: (path, opts) => mkdirSync(path, opts),
31
+ rmSync,
32
+ execSync: (cmd, opts) => execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], ...opts }).toString(),
33
+ tmpdir,
34
+ randomUUID,
35
+ userInfo: () => ({ username: osUserInfo().username, homedir: osUserInfo().homedir }),
36
+ platform: process.platform,
37
+ home: process.env.HOME || "~",
38
+ };
39
+ }
40
+
41
+ function detectPlatform(platform: string): Platform {
42
+ if (platform === "darwin") return "launchd";
43
+ if (platform === "linux") return "systemd";
44
+ throw new Error("Unsupported platform. Only macOS (launchd) and Linux (systemd) are supported.");
45
+ }
46
+
47
+ interface LinuxUser {
48
+ user: string;
49
+ group: string;
50
+ home: string;
51
+ }
52
+
53
+ export interface SystemService {
54
+ install: (oauthToken?: string) => string;
55
+ uninstall: () => void;
56
+ start: () => string;
57
+ stop: () => void;
58
+ update: () => string;
59
+ }
60
+
61
+ export class ServiceManager implements SystemService {
62
+ readonly #deps: ServiceDeps;
63
+ readonly #platform: Platform;
64
+
65
+ constructor(deps?: Partial<ServiceDeps>) {
66
+ this.#deps = { ...defaultDeps(), ...deps };
67
+ this.#platform = detectPlatform(this.#deps.platform);
68
+ }
69
+
70
+ get platform(): Platform {
71
+ return this.#platform;
72
+ }
73
+
74
+ get serviceFilePath(): string {
75
+ return this.#platform === "launchd"
76
+ ? resolve(this.#deps.home, "Library/LaunchAgents/com.macroclaw.plist")
77
+ : "/etc/systemd/system/macroclaw.service";
78
+ }
79
+
80
+ get isInstalled(): boolean {
81
+ return this.#deps.existsSync(this.serviceFilePath);
82
+ }
83
+
84
+ get isRunning(): boolean {
85
+ if (this.#platform === "launchd") {
86
+ try {
87
+ const out = this.#deps.execSync(`launchctl list ${LAUNCHD_LABEL}`);
88
+ // If the PID line shows a number (not "-"), the service is running
89
+ return /"PID"\s*=\s*\d+/.test(out);
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+ try {
95
+ const out = this.#deps.execSync("systemctl is-active macroclaw");
96
+ return out.trim() === "active";
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ install(oauthToken?: string): string {
103
+ if (this.#platform === "launchd") {
104
+ this.#installLaunchd(oauthToken);
105
+ } else {
106
+ this.#installSystemd();
107
+ }
108
+ log.debug("Service installed and started");
109
+ return this.#logTailCommand();
110
+ }
111
+
112
+ #installLaunchd(oauthToken?: string): void {
113
+ const settingsPath = resolve(this.#deps.home, ".macroclaw/settings.json");
114
+ if (!this.#deps.existsSync(settingsPath)) {
115
+ throw new Error("Settings not found. Run `macroclaw setup` first.");
116
+ }
117
+
118
+ this.#deps.execSync("bun install -g macroclaw");
119
+ const bunPath = this.#resolvePath("bun");
120
+ const claudePath = this.#resolvePath("claude");
121
+ const macroclawPath = this.#resolveGlobalBinPath("macroclaw");
122
+
123
+ const pathDirs = [...new Set([dirname(bunPath), dirname(claudePath), dirname(macroclawPath)])];
124
+
125
+ const logDir = resolve(this.#deps.home, ".macroclaw/logs");
126
+ this.#deps.mkdirSync(logDir, { recursive: true });
127
+ if (this.isRunning) {
128
+ this.#deps.execSync(`launchctl unload ${this.serviceFilePath}`);
129
+ }
130
+
131
+ this.#deps.writeFileSync(this.serviceFilePath, this.#generateLaunchdPlist(bunPath, macroclawPath, pathDirs, oauthToken));
132
+ log.debug({ filePath: this.serviceFilePath }, "Wrote launchd plist");
133
+ this.#deps.execSync(`launchctl load ${this.serviceFilePath}`);
134
+ }
135
+
136
+ #installSystemd(): void {
137
+ const target = this.#resolveLinuxUser();
138
+
139
+ if (this.isRunning) {
140
+ this.#sudo("systemctl stop macroclaw");
141
+ }
142
+
143
+ this.#deps.execSync("bun install -g macroclaw");
144
+ const bunPath = this.#resolvePath("bun");
145
+ const claudePath = this.#resolvePath("claude");
146
+ const macroclawPath = this.#resolveGlobalBinPath("macroclaw");
147
+
148
+ const pathDirs = [...new Set([dirname(bunPath), dirname(claudePath), dirname(macroclawPath)])];
149
+
150
+ const unitContent = this.#generateSystemdUnit(bunPath, macroclawPath, target, pathDirs);
151
+ this.#writeSystemdUnit(unitContent);
152
+ log.debug({ filePath: this.serviceFilePath, user: target.user }, "Wrote systemd unit");
153
+ this.#sudo("systemctl daemon-reload");
154
+ this.#sudo("systemctl enable macroclaw");
155
+ this.#sudo("systemctl start macroclaw");
156
+ }
157
+
158
+ uninstall(): void {
159
+ this.#requireInstalled();
160
+
161
+ if (this.#platform === "launchd") {
162
+ if (this.isRunning) {
163
+ this.#deps.execSync(`launchctl unload ${this.serviceFilePath}`);
164
+ }
165
+ this.#deps.rmSync(this.serviceFilePath);
166
+ } else {
167
+ if (this.isRunning) {
168
+ this.#sudo("systemctl stop macroclaw");
169
+ }
170
+ try { this.#sudo("systemctl disable macroclaw"); } catch { /* already disabled */ }
171
+ this.#sudo(`rm ${this.serviceFilePath}`);
172
+ this.#sudo("systemctl daemon-reload");
173
+ }
174
+
175
+ log.debug("Service uninstalled");
176
+ }
177
+
178
+ start(): string {
179
+ this.#requireInstalled();
180
+
181
+ if (this.isRunning) {
182
+ throw new Error("Service is already running.");
183
+ }
184
+
185
+ if (this.#platform === "launchd") {
186
+ this.#deps.execSync(`launchctl load ${this.serviceFilePath}`);
187
+ } else {
188
+ this.#sudo("systemctl start macroclaw");
189
+ }
190
+
191
+ log.debug("Service started");
192
+ return this.#logTailCommand();
193
+ }
194
+
195
+ stop(): void {
196
+ this.#requireInstalled();
197
+
198
+ if (!this.isRunning) {
199
+ throw new Error("Service is not running.");
200
+ }
201
+
202
+ if (this.#platform === "launchd") {
203
+ this.#deps.execSync(`launchctl unload ${this.serviceFilePath}`);
204
+ } else {
205
+ this.#sudo("systemctl stop macroclaw");
206
+ }
207
+
208
+ log.debug("Service stopped");
209
+ }
210
+
211
+ update(): string {
212
+ this.#requireInstalled();
213
+
214
+ if (this.#platform === "launchd") {
215
+ if (this.isRunning) {
216
+ this.#deps.execSync(`launchctl unload ${this.serviceFilePath}`);
217
+ }
218
+ this.#deps.execSync("bun install -g macroclaw@latest");
219
+ this.#deps.execSync(`launchctl load ${this.serviceFilePath}`);
220
+ } else {
221
+ if (this.isRunning) {
222
+ this.#sudo("systemctl stop macroclaw");
223
+ }
224
+ this.#deps.execSync("bun install -g macroclaw@latest");
225
+ this.#sudo("systemctl start macroclaw");
226
+ }
227
+
228
+ log.debug("Service updated (reinstalled, restarted)");
229
+ return this.#logTailCommand();
230
+ }
231
+
232
+ #resolvePath(binary: string): string {
233
+ try {
234
+ return this.#deps.execSync(`which ${binary}`).trim();
235
+ } catch {
236
+ throw new Error(`Could not resolve ${binary} path. Is it installed?`);
237
+ }
238
+ }
239
+
240
+ #resolveGlobalBinPath(binary: string): string {
241
+ const binDir = this.#deps.execSync("bun pm bin -g").trim();
242
+ const binPath = join(binDir, binary);
243
+ if (!this.#deps.existsSync(binPath)) {
244
+ throw new Error(`Could not find ${binary} in ${binDir}. Is it installed?`);
245
+ }
246
+ return binPath;
247
+ }
248
+
249
+
250
+ #requireInstalled(): void {
251
+ if (!this.isInstalled) {
252
+ throw new Error("Service not installed. Run `macroclaw service install` first.");
253
+ }
254
+ }
255
+
256
+ #logTailCommand(): string {
257
+ if (this.#platform === "launchd") {
258
+ const logDir = resolve(this.#deps.home, ".macroclaw/logs");
259
+ return `tail -f ${logDir}/*.log`;
260
+ }
261
+ return "journalctl -u macroclaw -f";
262
+ }
263
+
264
+ #sudo(cmd: string): void {
265
+ this.#deps.execSync(`sudo ${cmd}`);
266
+ }
267
+
268
+ /** Write unit content to a temp file, then sudo-copy it into /etc/systemd/system/. */
269
+ #writeSystemdUnit(content: string): void {
270
+ const tmpPath = join(this.#deps.tmpdir(), `macroclaw-${this.#deps.randomUUID()}.service`);
271
+ this.#deps.writeFileSync(tmpPath, content);
272
+ try {
273
+ this.#sudo(`cp ${tmpPath} ${this.serviceFilePath}`);
274
+ } finally {
275
+ try { this.#deps.rmSync(tmpPath); } catch { /* best-effort cleanup */ }
276
+ }
277
+ }
278
+
279
+ #resolveLinuxUser(): LinuxUser {
280
+ const info = this.#deps.userInfo();
281
+ const user = info.username;
282
+ const home = info.homedir;
283
+ const group = this.#deps.execSync(`id -gn ${user}`).trim();
284
+
285
+ const settingsPath = resolve(home, ".macroclaw/settings.json");
286
+ if (!this.#deps.existsSync(settingsPath)) {
287
+ throw new Error(`Settings not found. Run \`macroclaw setup\` first.`);
288
+ }
289
+
290
+ return { user, group, home };
291
+ }
292
+
293
+ #generateLaunchdPlist(bunPath: string, macroclawPath: string, pathDirs: string[], oauthToken?: string): string {
294
+ const logDir = resolve(this.#deps.home, ".macroclaw/logs");
295
+ const tokenEnv = oauthToken ? `\n\t\t<key>CLAUDE_CODE_OAUTH_TOKEN</key>\n\t\t<string>${oauthToken}</string>` : "";
296
+ return `<?xml version="1.0" encoding="UTF-8"?>
297
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
298
+ <plist version="1.0">
299
+ <dict>
300
+ <key>Label</key>
301
+ <string>com.macroclaw</string>
302
+ <key>ProgramArguments</key>
303
+ <array>
304
+ <string>${bunPath}</string>
305
+ <string>${macroclawPath}</string>
306
+ <string>start</string>
307
+ </array>
308
+ <key>KeepAlive</key>
309
+ <true/>
310
+ <key>StandardOutPath</key>
311
+ <string>${logDir}/stdout.log</string>
312
+ <key>StandardErrorPath</key>
313
+ <string>${logDir}/stderr.log</string>
314
+ <key>EnvironmentVariables</key>
315
+ <dict>
316
+ <key>HOME</key>
317
+ <string>${this.#deps.home}</string>
318
+ <key>PATH</key>
319
+ <string>${pathDirs.join(":")}</string>${tokenEnv}
320
+ </dict>
321
+ </dict>
322
+ </plist>
323
+ `;
324
+ }
325
+
326
+ #generateSystemdUnit(bunPath: string, macroclawPath: string, target: LinuxUser, pathDirs: string[]): string {
327
+ return `[Unit]
328
+ Description=Macroclaw - Telegram-to-Claude-Code bridge
329
+ After=network.target
330
+
331
+ [Service]
332
+ Type=simple
333
+ User=${target.user}
334
+ Group=${target.group}
335
+ Environment=HOME=${target.home}
336
+ Environment=PATH=${pathDirs.join(":")}
337
+ WorkingDirectory=${target.home}
338
+ ExecStart=${bunPath} ${macroclawPath} start
339
+ Restart=on-failure
340
+ RestartSec=5
341
+
342
+ [Install]
343
+ WantedBy=multi-user.target
344
+ `;
345
+ }
346
+ }
package/src/setup.test.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
2
2
  import type { SetupIO } from "./setup";
3
3
 
4
+ // Mock child_process so resolveClaudePath doesn't hit real `which`
5
+ mock.module("node:child_process", () => ({
6
+ execSync: (_cmd: string) => "/mock/bin/claude\n",
7
+ }));
8
+
4
9
  // Mock Grammy Bot
5
10
  const mockBotInit = mock(async () => {});
6
11
  const mockBotStart = mock(() => {});
@@ -42,15 +47,27 @@ mock.module("grammy", () => ({
42
47
  },
43
48
  }));
44
49
 
45
- const { runSetupWizard } = await import("./setup");
50
+ const mockInstall = mock(() => "tail -f /mock/logs");
51
+
52
+ function createMockServiceInstaller() {
53
+ return {
54
+ install: mockInstall,
55
+ };
56
+ }
57
+
58
+ const { resolveClaudePath, runSetupWizard } = await import("./setup");
46
59
 
47
- function createMockIO(inputs: string[]): SetupIO {
60
+ function createMockIO(inputs: string[]): SetupIO & { written: string[]; closed: boolean } {
48
61
  let index = 0;
49
62
  const written: string[] = [];
50
- return {
63
+ const io = {
51
64
  ask: async () => inputs[index++] ?? "",
52
65
  write: (msg: string) => { written.push(msg); },
66
+ close: () => { io.closed = true; },
67
+ written,
68
+ closed: false,
53
69
  };
70
+ return io;
54
71
  }
55
72
 
56
73
  // Save/restore env vars
@@ -68,6 +85,7 @@ beforeEach(() => {
68
85
  mockSetMyCommands.mockClear();
69
86
  mockBotCatchHandler = null;
70
87
  mockBotCommandHandler = null;
88
+ mockInstall.mockImplementation(() => "tail -f /mock/logs");
71
89
  });
72
90
 
73
91
  afterEach(() => {
@@ -85,6 +103,7 @@ describe("runSetupWizard", () => {
85
103
  "opus", // model
86
104
  "/my/ws", // workspace
87
105
  "sk-test", // openai key
106
+ "", // no service install
88
107
  ]);
89
108
 
90
109
  const settings = await runSetupWizard(io);
@@ -110,6 +129,7 @@ describe("runSetupWizard", () => {
110
129
  "", // accept default model
111
130
  "", // accept default workspace
112
131
  "", // accept default openai key
132
+ "", // no service install
113
133
  ]);
114
134
 
115
135
  const settings = await runSetupWizard(io);
@@ -128,6 +148,7 @@ describe("runSetupWizard", () => {
128
148
  "", // press enter for default model
129
149
  "", // press enter for default workspace
130
150
  "", // press enter for no openai key
151
+ "", // no service install
131
152
  ]);
132
153
 
133
154
  const settings = await runSetupWizard(io);
@@ -144,6 +165,7 @@ describe("runSetupWizard", () => {
144
165
  "",
145
166
  "",
146
167
  "",
168
+ "", // no service install
147
169
  ]);
148
170
 
149
171
  await runSetupWizard(io);
@@ -167,6 +189,7 @@ describe("runSetupWizard", () => {
167
189
  "",
168
190
  "",
169
191
  "",
192
+ "", // no service install
170
193
  ]);
171
194
 
172
195
  const settings = await runSetupWizard(io);
@@ -183,6 +206,7 @@ describe("runSetupWizard", () => {
183
206
  "",
184
207
  "",
185
208
  "",
209
+ "", // no service install
186
210
  ]);
187
211
 
188
212
  const settings = await runSetupWizard(io);
@@ -198,6 +222,7 @@ describe("runSetupWizard", () => {
198
222
  "",
199
223
  "",
200
224
  "",
225
+ "", // no service install
201
226
  ]);
202
227
 
203
228
  const settings = await runSetupWizard(io);
@@ -212,6 +237,7 @@ describe("runSetupWizard", () => {
212
237
  "",
213
238
  "",
214
239
  "",
240
+ "", // no service install
215
241
  ]);
216
242
 
217
243
  await runSetupWizard(io);
@@ -229,6 +255,7 @@ describe("runSetupWizard", () => {
229
255
  "",
230
256
  "",
231
257
  "",
258
+ "", // no service install
232
259
  ]);
233
260
 
234
261
  await runSetupWizard(io);
@@ -238,4 +265,134 @@ describe("runSetupWizard", () => {
238
265
  mockBotCommandHandler!({ chat: { id: 12345 }, reply: mockReply });
239
266
  expect(mockReply).toHaveBeenCalledWith("12345");
240
267
  });
268
+
269
+ it("calls onSettingsReady before service install prompt", async () => {
270
+ const order: string[] = [];
271
+ const installer = { install: () => { order.push("install"); return ""; } };
272
+ const onSettingsReady = () => { order.push("save"); };
273
+ const io = createMockIO([
274
+ "tok",
275
+ "123",
276
+ "",
277
+ "",
278
+ "",
279
+ "y",
280
+ "sk-test-token", // oauth token (macOS)
281
+ ]);
282
+
283
+ await runSetupWizard(io, { serviceInstaller: installer, onSettingsReady, platform: "darwin" });
284
+
285
+ expect(order).toEqual(["save", "install"]);
286
+ });
287
+
288
+ it("closes io before running service install", async () => {
289
+ let closedBeforeInstall = false;
290
+ const io = createMockIO([
291
+ "tok",
292
+ "123",
293
+ "",
294
+ "",
295
+ "",
296
+ "y",
297
+ "sk-test-token", // oauth token (macOS)
298
+ ]);
299
+ const installer = { install: () => { closedBeforeInstall = io.closed; return ""; } };
300
+
301
+ await runSetupWizard(io, { serviceInstaller: installer, platform: "darwin" });
302
+
303
+ expect(closedBeforeInstall).toBe(true);
304
+ });
305
+
306
+ it("installs service when user answers yes", async () => {
307
+ mockInstall.mockClear();
308
+ const installer = createMockServiceInstaller();
309
+ const io = createMockIO([
310
+ "tok",
311
+ "123",
312
+ "",
313
+ "",
314
+ "",
315
+ "y",
316
+ "sk-test-token", // oauth token (macOS)
317
+ ]);
318
+
319
+ await runSetupWizard(io, { serviceInstaller: installer, platform: "darwin" });
320
+
321
+ expect(mockInstall).toHaveBeenCalled();
322
+ expect(io.written).toContainEqual(expect.stringContaining("Service installed and started."));
323
+ });
324
+
325
+ it("skips service install when user answers no", async () => {
326
+ mockInstall.mockClear();
327
+ const installer = createMockServiceInstaller();
328
+ const io = createMockIO([
329
+ "tok",
330
+ "123",
331
+ "",
332
+ "",
333
+ "",
334
+ "n", // no to service install
335
+ ]);
336
+
337
+ await runSetupWizard(io, { serviceInstaller: installer });
338
+
339
+ expect(mockInstall).not.toHaveBeenCalled();
340
+ expect(io.closed).toBe(true);
341
+ });
342
+
343
+ it("skips service install when oauth token is empty on macOS", async () => {
344
+ mockInstall.mockClear();
345
+ const installer = createMockServiceInstaller();
346
+ const io = createMockIO([
347
+ "tok",
348
+ "123",
349
+ "",
350
+ "",
351
+ "",
352
+ "y",
353
+ "", // empty oauth token
354
+ ]);
355
+
356
+ const settings = await runSetupWizard(io, { serviceInstaller: installer, platform: "darwin" });
357
+
358
+ expect(mockInstall).not.toHaveBeenCalled();
359
+ expect(io.written).toContainEqual(expect.stringContaining("No token provided"));
360
+ expect(settings.botToken).toBe("tok");
361
+ });
362
+
363
+ it("handles service install failure gracefully", async () => {
364
+ mockInstall.mockImplementation(() => { throw new Error("Permission denied"); });
365
+ const installer = createMockServiceInstaller();
366
+ const io = createMockIO([
367
+ "tok",
368
+ "123",
369
+ "",
370
+ "",
371
+ "",
372
+ "yes",
373
+ "sk-test-token", // oauth token (macOS)
374
+ ]);
375
+
376
+ await runSetupWizard(io, { serviceInstaller: installer, platform: "darwin" });
377
+
378
+ expect(io.written).toContainEqual(expect.stringContaining("Service installation failed: Permission denied"));
379
+ });
380
+
381
+ it("fails fast when claude CLI is not found", async () => {
382
+ const io = createMockIO([]);
383
+ await expect(
384
+ runSetupWizard(io, { resolveClaude: () => { throw new Error("Claude Code CLI not found."); } }),
385
+ ).rejects.toThrow("Claude Code CLI not found.");
386
+ });
387
+
388
+ it("resolveClaudePath returns trimmed path on success", () => {
389
+ const result = resolveClaudePath(() => "/usr/local/bin/claude\n");
390
+ expect(result).toBe("/usr/local/bin/claude");
391
+ });
392
+
393
+ it("resolveClaudePath throws when claude is not found", () => {
394
+ expect(() => resolveClaudePath(() => { throw new Error("not found"); })).toThrow(
395
+ "Claude Code CLI not found.",
396
+ );
397
+ });
241
398
  });