macroclaw 0.40.0 → 0.41.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.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "Telegram-to-Claude-Code bridge",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -7,7 +7,7 @@ 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 mockExecSync = mock((_cmd: string, _opts?: object) => "");
10
+ const mockExecSync = mock((cmd: string, _opts?: object): string => cmd === "bun pm bin -g" ? "/home/testuser/.bun/bin\n" : "");
11
11
  const mockUserInfo = mock(() => ({ username: "testuser", homedir: "/home/testuser", uid: 1000, gid: 1000, shell: "/bin/bash" }));
12
12
  const mockExistsSync = mock((path: string) => realExistsSync(path));
13
13
 
@@ -43,7 +43,7 @@ beforeEach(() => {
43
43
  mockExecSync.mockClear();
44
44
  mockUserInfo.mockClear();
45
45
  mockExistsSync.mockClear();
46
- mockExecSync.mockImplementation((_cmd: string, _opts?: object) => "");
46
+ mockExecSync.mockImplementation((cmd: string, _opts?: object): string => cmd === "bun pm bin -g" ? "/home/testuser/.bun/bin\n" : "");
47
47
  mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: "/home/testuser", uid: 1000, gid: 1000, shell: "/bin/bash" }));
48
48
  mockExistsSync.mockImplementation((path: string) => realExistsSync(path));
49
49
  });
@@ -167,7 +167,7 @@ describe("install", () => {
167
167
  expect(() => mgr.install()).toThrow("Settings not found. Run `macroclaw setup` first.");
168
168
  });
169
169
 
170
- it("runs global install without resolving binary paths on systemd", () => {
170
+ it("runs global install and resolves bun global bin for systemd", () => {
171
171
  const tmpHome = `/tmp/macroclaw-test-install-${Date.now()}`;
172
172
  mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
173
173
  writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
@@ -178,15 +178,41 @@ describe("install", () => {
178
178
  if (path === "/var/lib/systemd/linger/testuser") return true; // already lingering
179
179
  return realExistsSync(path);
180
180
  });
181
+ mockExecSync.mockImplementation((cmd: string) => {
182
+ if (cmd === "bun pm bin -g") return "/home/testuser/.bun/bin\n";
183
+ return "";
184
+ });
181
185
  const mgr = createManager({ home: tmpHome });
182
186
  mgr.install();
183
187
  rmSync(tmpHome, { recursive: true });
184
188
 
185
189
  expect(mockExecSync).toHaveBeenCalledWith("bun install -g macroclaw", expect.anything());
186
- // systemd no longer resolves paths — bash -lc handles PATH at runtime
187
- expect(mockExecSync).not.toHaveBeenCalledWith("which bun", expect.anything());
188
- expect(mockExecSync).not.toHaveBeenCalledWith("which claude", expect.anything());
189
- expect(mockExecSync).not.toHaveBeenCalledWith("bun pm bin -g", expect.anything());
190
+ expect(mockExecSync).toHaveBeenCalledWith("bun pm bin -g", expect.anything());
191
+ expect(mockExecSync).not.toHaveBeenCalledWith("which macroclaw", expect.anything());
192
+ });
193
+
194
+ it("surfaces bun global bin resolution failures for systemd", () => {
195
+ const tmpHome = `/tmp/macroclaw-test-install-missing-path-${Date.now()}`;
196
+ mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
197
+ writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
198
+
199
+ mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: tmpHome, uid: 1000, gid: 1000, shell: "/bin/bash" }));
200
+ mockExistsSync.mockImplementation((path: string) => {
201
+ if (path === "/var/lib/systemd/linger/testuser") return true;
202
+ return realExistsSync(path);
203
+ });
204
+ mockExecSync.mockImplementation((cmd: string) => {
205
+ if (cmd === "bun pm bin -g") throw new Error("not found");
206
+ return "";
207
+ });
208
+
209
+ const mgr = createManager({ home: tmpHome });
210
+ expect(() => mgr.install()).toThrow(
211
+ "not found",
212
+ );
213
+ expect(existsSync(join(tmpHome, ".config/systemd/user/macroclaw.service"))).toBe(false);
214
+ expect(mockExecSync).not.toHaveBeenCalledWith("systemctl --user daemon-reload", expect.anything());
215
+ rmSync(tmpHome, { recursive: true });
190
216
  });
191
217
 
192
218
  it("installs launchd service with bash -lc and OAuth token", () => {
@@ -205,20 +231,56 @@ describe("install", () => {
205
231
  // bash -lc pattern — no hardcoded binary paths
206
232
  expect(writtenContent).toContain("<string>/bin/bash</string>");
207
233
  expect(writtenContent).toContain("<string>-lc</string>");
208
- expect(writtenContent).toContain("<string>exec bun macroclaw start</string>");
234
+ expect(writtenContent).toContain("<string>exec macroclaw start</string>");
209
235
  expect(writtenContent).toContain("<key>KeepAlive</key>");
210
236
  expect(writtenContent).toContain(".macroclaw/logs/stdout.log");
211
- // No PATH/HOME env vars — login shell provides them
212
- expect(writtenContent).not.toContain("<key>PATH</key>");
237
+ expect(writtenContent).toContain("<key>EnvironmentVariables</key>");
238
+ expect(writtenContent).toContain("<key>PATH</key>");
239
+ expect(writtenContent).toContain("<string>/home/testuser/.bun/bin</string>");
213
240
  expect(writtenContent).not.toContain("<key>HOME</key>");
214
241
  // OAuth token is preserved
215
242
  expect(writtenContent).toContain("<key>CLAUDE_CODE_OAUTH_TOKEN</key>");
216
243
  expect(writtenContent).toContain("<string>sk-test-token</string>");
217
244
  expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining("launchctl load"), expect.anything());
218
- // No path resolution calls
219
- expect(mockExecSync).not.toHaveBeenCalledWith("which bun", expect.anything());
220
- expect(mockExecSync).not.toHaveBeenCalledWith("which claude", expect.anything());
221
- expect(mockExecSync).not.toHaveBeenCalledWith("bun pm bin -g", expect.anything());
245
+ expect(mockExecSync).not.toHaveBeenCalledWith("which macroclaw", expect.anything());
246
+ rmSync(tmpHome, { recursive: true });
247
+ });
248
+
249
+ it("surfaces bun global bin resolution failures for launchd", () => {
250
+ const tmpHome = `/tmp/macroclaw-test-launchd-missing-path-${Date.now()}`;
251
+ mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
252
+ writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
253
+ mkdirSync(join(tmpHome, "Library/LaunchAgents"), { recursive: true });
254
+
255
+ mockExecSync.mockImplementation((cmd: string) => {
256
+ if (cmd === "bun pm bin -g") throw new Error("not found");
257
+ return "";
258
+ });
259
+
260
+ const mgr = createManager({ platform: "darwin", home: tmpHome });
261
+ expect(() => mgr.install("sk-test-token")).toThrow(
262
+ "not found",
263
+ );
264
+ expect(existsSync(join(tmpHome, "Library/LaunchAgents/com.macroclaw.plist"))).toBe(false);
265
+ expect(mockExecSync).not.toHaveBeenCalledWith(expect.stringContaining("launchctl load"), expect.anything());
266
+ rmSync(tmpHome, { recursive: true });
267
+ });
268
+
269
+ it("surfaces bun global bin permission failures for launchd", () => {
270
+ const tmpHome = `/tmp/macroclaw-test-launchd-missing-bin-dir-${Date.now()}`;
271
+ mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
272
+ writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
273
+ mkdirSync(join(tmpHome, "Library/LaunchAgents"), { recursive: true });
274
+
275
+ mockExecSync.mockImplementation((cmd: string) => {
276
+ if (cmd === "bun pm bin -g") throw new Error("permission denied");
277
+ return "";
278
+ });
279
+
280
+ const mgr = createManager({ platform: "darwin", home: tmpHome });
281
+ expect(() => mgr.install("sk-test-token")).toThrow(
282
+ "permission denied",
283
+ );
222
284
  rmSync(tmpHome, { recursive: true });
223
285
  });
224
286
 
@@ -231,8 +293,10 @@ describe("install", () => {
231
293
  const mgr = createManager({ platform: "darwin", home: tmpHome });
232
294
  mgr.install();
233
295
  const writtenContent = readFileSync(join(tmpHome, "Library/LaunchAgents/com.macroclaw.plist"), "utf-8");
296
+ expect(writtenContent).toContain("<key>EnvironmentVariables</key>");
297
+ expect(writtenContent).toContain("<key>PATH</key>");
298
+ expect(writtenContent).toContain("<string>/home/testuser/.bun/bin</string>");
234
299
  expect(writtenContent).not.toContain("CLAUDE_CODE_OAUTH_TOKEN");
235
- expect(writtenContent).not.toContain("<key>EnvironmentVariables</key>");
236
300
  rmSync(tmpHome, { recursive: true });
237
301
  });
238
302
 
@@ -294,11 +358,11 @@ describe("install", () => {
294
358
  expect(unitContent).toContain("WantedBy=default.target");
295
359
  expect(unitContent).not.toContain("User=");
296
360
  expect(unitContent).not.toContain("Group=");
297
- // bash -lc sources login profile for PATH no hardcoded Environment lines
361
+ // systemd seeds PATH with Bun's global bin; login shell can extend it via profile files
298
362
  expect(unitContent).not.toContain("Environment=HOME=");
299
- expect(unitContent).not.toContain("Environment=PATH=");
363
+ expect(unitContent).toContain("Environment=PATH=/home/testuser/.bun/bin");
300
364
  expect(unitContent).toContain("WorkingDirectory=%h");
301
- expect(unitContent).toContain("ExecStart=/bin/bash -lc 'exec bun macroclaw start'");
365
+ expect(unitContent).toContain("ExecStart=/bin/bash -lc 'exec macroclaw start'");
302
366
 
303
367
  // Lingering enabled via sudo
304
368
  expect(mockExecSync).toHaveBeenCalledWith("sudo loginctl enable-linger testuser", expect.anything());
@@ -85,6 +85,7 @@ export class SystemServiceManager {
85
85
  }
86
86
 
87
87
  this.#exec("bun install -g macroclaw");
88
+ const bunGlobalBin = this.#getBunGlobalBinDir();
88
89
 
89
90
  const logDir = resolve(this.#home, ".macroclaw/logs");
90
91
  mkdirSync(logDir, { recursive: true });
@@ -92,7 +93,7 @@ export class SystemServiceManager {
92
93
  this.#exec(`launchctl unload ${this.serviceFilePath}`);
93
94
  }
94
95
 
95
- writeFileSync(this.serviceFilePath, this.#generateLaunchdPlist(oauthToken));
96
+ writeFileSync(this.serviceFilePath, this.#generateLaunchdPlist(bunGlobalBin, oauthToken));
96
97
  log.debug({ filePath: this.serviceFilePath }, "Wrote launchd plist");
97
98
  this.#exec(`launchctl load ${this.serviceFilePath}`);
98
99
  }
@@ -108,6 +109,7 @@ export class SystemServiceManager {
108
109
  }
109
110
 
110
111
  this.#exec("bun install -g macroclaw");
112
+ const bunGlobalBin = this.#getBunGlobalBinDir();
111
113
 
112
114
  // Enable lingering so user services run without an active login session
113
115
  const username = osUserInfo().username;
@@ -115,7 +117,7 @@ export class SystemServiceManager {
115
117
  this.#sudo(`loginctl enable-linger ${username}`);
116
118
  }
117
119
 
118
- const unitContent = this.#generateSystemdUnit();
120
+ const unitContent = this.#generateSystemdUnit(bunGlobalBin);
119
121
  mkdirSync(dirname(this.serviceFilePath), { recursive: true });
120
122
  writeFileSync(this.serviceFilePath, unitContent);
121
123
  log.debug({ filePath: this.serviceFilePath }, "Wrote systemd unit");
@@ -242,6 +244,9 @@ export class SystemServiceManager {
242
244
  return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).toString();
243
245
  }
244
246
 
247
+ #getBunGlobalBinDir(): string {
248
+ return this.#exec("bun pm bin -g").trim();
249
+ }
245
250
 
246
251
  #getInstalledVersion(): string {
247
252
  try {
@@ -271,11 +276,13 @@ export class SystemServiceManager {
271
276
  this.#exec(`sudo ${cmd}`);
272
277
  }
273
278
 
274
- #generateLaunchdPlist(oauthToken?: string): string {
279
+ #generateLaunchdPlist(bunGlobalBin: string, oauthToken?: string): string {
275
280
  const logDir = resolve(this.#home, ".macroclaw/logs");
276
- const tokenEnvBlock = oauthToken
277
- ? `\n\t<key>EnvironmentVariables</key>\n\t<dict>\n\t\t<key>CLAUDE_CODE_OAUTH_TOKEN</key>\n\t\t<string>${oauthToken}</string>\n\t</dict>`
278
- : "";
281
+ const envVars = [`\n\t<key>PATH</key>\n\t\t<string>${bunGlobalBin}</string>`];
282
+ if (oauthToken) {
283
+ envVars.push(`\n\t<key>CLAUDE_CODE_OAUTH_TOKEN</key>\n\t\t<string>${oauthToken}</string>`);
284
+ }
285
+ const envBlock = `\n\t<key>EnvironmentVariables</key>\n\t<dict>${envVars.join("")}\n\t</dict>`;
279
286
  return `<?xml version="1.0" encoding="UTF-8"?>
280
287
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
281
288
  <plist version="1.0">
@@ -286,20 +293,20 @@ export class SystemServiceManager {
286
293
  <array>
287
294
  <string>/bin/bash</string>
288
295
  <string>-lc</string>
289
- <string>exec bun macroclaw start</string>
296
+ <string>exec macroclaw start</string>
290
297
  </array>
291
298
  <key>KeepAlive</key>
292
299
  <true/>
293
300
  <key>StandardOutPath</key>
294
301
  <string>${logDir}/stdout.log</string>
295
302
  <key>StandardErrorPath</key>
296
- <string>${logDir}/stderr.log</string>${tokenEnvBlock}
303
+ <string>${logDir}/stderr.log</string>${envBlock}
297
304
  </dict>
298
305
  </plist>
299
306
  `;
300
307
  }
301
308
 
302
- #generateSystemdUnit(): string {
309
+ #generateSystemdUnit(bunGlobalBin: string): string {
303
310
  return `[Unit]
304
311
  Description=Macroclaw - Telegram-to-Claude-Code bridge
305
312
  After=network.target
@@ -307,7 +314,8 @@ After=network.target
307
314
  [Service]
308
315
  Type=simple
309
316
  WorkingDirectory=%h
310
- ExecStart=/bin/bash -lc 'exec bun macroclaw start'
317
+ Environment=PATH=${bunGlobalBin}
318
+ ExecStart=/bin/bash -lc 'exec macroclaw start'
311
319
  Restart=on-failure
312
320
  RestartSec=5
313
321