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 +1 -1
- package/src/system-service.test.ts +82 -18
- package/src/system-service.ts +18 -10
package/package.json
CHANGED
|
@@ -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((
|
|
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((
|
|
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
|
|
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
|
-
|
|
187
|
-
expect(mockExecSync).not.toHaveBeenCalledWith("which
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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
|
-
|
|
212
|
-
expect(writtenContent).
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
//
|
|
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).
|
|
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
|
|
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());
|
package/src/system-service.ts
CHANGED
|
@@ -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
|
|
277
|
-
|
|
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
|
|
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>${
|
|
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
|
-
|
|
317
|
+
Environment=PATH=${bunGlobalBin}
|
|
318
|
+
ExecStart=/bin/bash -lc 'exec macroclaw start'
|
|
311
319
|
Restart=on-failure
|
|
312
320
|
RestartSec=5
|
|
313
321
|
|