macroclaw 0.13.0 → 0.14.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.test.ts +3 -3
- package/src/cli.ts +4 -4
- package/src/setup.ts +1 -1
- package/src/system-service.test.ts +699 -0
- package/src/{service.ts → system-service.ts} +53 -89
- package/src/service.test.ts +0 -702
|
@@ -10,40 +10,6 @@ const LAUNCHD_LABEL = "com.macroclaw";
|
|
|
10
10
|
|
|
11
11
|
export type Platform = "launchd" | "systemd";
|
|
12
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
13
|
interface LinuxUser {
|
|
48
14
|
user: string;
|
|
49
15
|
group: string;
|
|
@@ -58,23 +24,19 @@ export interface ServiceStatus {
|
|
|
58
24
|
uptime?: string;
|
|
59
25
|
}
|
|
60
26
|
|
|
61
|
-
export
|
|
62
|
-
install: (oauthToken?: string) => string;
|
|
63
|
-
uninstall: () => void;
|
|
64
|
-
start: () => string;
|
|
65
|
-
stop: () => void;
|
|
66
|
-
update: () => string;
|
|
67
|
-
status: () => ServiceStatus;
|
|
68
|
-
logs: (follow?: boolean) => string;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export class ServiceManager implements SystemService {
|
|
72
|
-
readonly #deps: ServiceDeps;
|
|
27
|
+
export class SystemServiceManager {
|
|
73
28
|
readonly #platform: Platform;
|
|
29
|
+
readonly #home: string;
|
|
30
|
+
|
|
31
|
+
constructor(opts?: { platform?: string; home?: string }) {
|
|
32
|
+
this.#platform = SystemServiceManager.#detectPlatform(opts?.platform ?? process.platform);
|
|
33
|
+
this.#home = opts?.home ?? process.env.HOME ?? "~";
|
|
34
|
+
}
|
|
74
35
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
36
|
+
static #detectPlatform(platform: string): Platform {
|
|
37
|
+
if (platform === "darwin") return "launchd";
|
|
38
|
+
if (platform === "linux") return "systemd";
|
|
39
|
+
throw new Error("Unsupported platform. Only macOS (launchd) and Linux (systemd) are supported.");
|
|
78
40
|
}
|
|
79
41
|
|
|
80
42
|
get platform(): Platform {
|
|
@@ -83,26 +45,25 @@ export class ServiceManager implements SystemService {
|
|
|
83
45
|
|
|
84
46
|
get serviceFilePath(): string {
|
|
85
47
|
return this.#platform === "launchd"
|
|
86
|
-
? resolve(this.#
|
|
48
|
+
? resolve(this.#home, "Library/LaunchAgents/com.macroclaw.plist")
|
|
87
49
|
: "/etc/systemd/system/macroclaw.service";
|
|
88
50
|
}
|
|
89
51
|
|
|
90
52
|
get isInstalled(): boolean {
|
|
91
|
-
return
|
|
53
|
+
return existsSync(this.serviceFilePath);
|
|
92
54
|
}
|
|
93
55
|
|
|
94
56
|
get isRunning(): boolean {
|
|
95
57
|
if (this.#platform === "launchd") {
|
|
96
58
|
try {
|
|
97
|
-
const out = this.#
|
|
98
|
-
// If the PID line shows a number (not "-"), the service is running
|
|
59
|
+
const out = this.#exec(`launchctl list ${LAUNCHD_LABEL}`);
|
|
99
60
|
return /"PID"\s*=\s*\d+/.test(out);
|
|
100
61
|
} catch {
|
|
101
62
|
return false;
|
|
102
63
|
}
|
|
103
64
|
}
|
|
104
65
|
try {
|
|
105
|
-
const out = this.#
|
|
66
|
+
const out = this.#exec("systemctl is-active macroclaw");
|
|
106
67
|
return out.trim() === "active";
|
|
107
68
|
} catch {
|
|
108
69
|
return false;
|
|
@@ -120,27 +81,27 @@ export class ServiceManager implements SystemService {
|
|
|
120
81
|
}
|
|
121
82
|
|
|
122
83
|
#installLaunchd(oauthToken?: string): void {
|
|
123
|
-
const settingsPath = resolve(this.#
|
|
124
|
-
if (!
|
|
84
|
+
const settingsPath = resolve(this.#home, ".macroclaw/settings.json");
|
|
85
|
+
if (!existsSync(settingsPath)) {
|
|
125
86
|
throw new Error("Settings not found. Run `macroclaw setup` first.");
|
|
126
87
|
}
|
|
127
88
|
|
|
128
|
-
this.#
|
|
89
|
+
this.#exec("bun install -g macroclaw");
|
|
129
90
|
const bunPath = this.#resolvePath("bun");
|
|
130
91
|
const claudePath = this.#resolvePath("claude");
|
|
131
92
|
const macroclawPath = this.#resolveGlobalBinPath("macroclaw");
|
|
132
93
|
|
|
133
94
|
const pathDirs = [...new Set([dirname(bunPath), dirname(claudePath), dirname(macroclawPath)])];
|
|
134
95
|
|
|
135
|
-
const logDir = resolve(this.#
|
|
136
|
-
|
|
96
|
+
const logDir = resolve(this.#home, ".macroclaw/logs");
|
|
97
|
+
mkdirSync(logDir, { recursive: true });
|
|
137
98
|
if (this.isRunning) {
|
|
138
|
-
this.#
|
|
99
|
+
this.#exec(`launchctl unload ${this.serviceFilePath}`);
|
|
139
100
|
}
|
|
140
101
|
|
|
141
|
-
|
|
102
|
+
writeFileSync(this.serviceFilePath, this.#generateLaunchdPlist(bunPath, macroclawPath, pathDirs, oauthToken));
|
|
142
103
|
log.debug({ filePath: this.serviceFilePath }, "Wrote launchd plist");
|
|
143
|
-
this.#
|
|
104
|
+
this.#exec(`launchctl load ${this.serviceFilePath}`);
|
|
144
105
|
}
|
|
145
106
|
|
|
146
107
|
#installSystemd(): void {
|
|
@@ -150,7 +111,7 @@ export class ServiceManager implements SystemService {
|
|
|
150
111
|
this.#sudo("systemctl stop macroclaw");
|
|
151
112
|
}
|
|
152
113
|
|
|
153
|
-
this.#
|
|
114
|
+
this.#exec("bun install -g macroclaw");
|
|
154
115
|
const bunPath = this.#resolvePath("bun");
|
|
155
116
|
const claudePath = this.#resolvePath("claude");
|
|
156
117
|
const macroclawPath = this.#resolveGlobalBinPath("macroclaw");
|
|
@@ -170,9 +131,9 @@ export class ServiceManager implements SystemService {
|
|
|
170
131
|
|
|
171
132
|
if (this.#platform === "launchd") {
|
|
172
133
|
if (this.isRunning) {
|
|
173
|
-
this.#
|
|
134
|
+
this.#exec(`launchctl unload ${this.serviceFilePath}`);
|
|
174
135
|
}
|
|
175
|
-
|
|
136
|
+
rmSync(this.serviceFilePath);
|
|
176
137
|
} else {
|
|
177
138
|
if (this.isRunning) {
|
|
178
139
|
this.#sudo("systemctl stop macroclaw");
|
|
@@ -193,7 +154,7 @@ export class ServiceManager implements SystemService {
|
|
|
193
154
|
}
|
|
194
155
|
|
|
195
156
|
if (this.#platform === "launchd") {
|
|
196
|
-
this.#
|
|
157
|
+
this.#exec(`launchctl load ${this.serviceFilePath}`);
|
|
197
158
|
} else {
|
|
198
159
|
this.#sudo("systemctl start macroclaw");
|
|
199
160
|
}
|
|
@@ -210,7 +171,7 @@ export class ServiceManager implements SystemService {
|
|
|
210
171
|
}
|
|
211
172
|
|
|
212
173
|
if (this.#platform === "launchd") {
|
|
213
|
-
this.#
|
|
174
|
+
this.#exec(`launchctl unload ${this.serviceFilePath}`);
|
|
214
175
|
} else {
|
|
215
176
|
this.#sudo("systemctl stop macroclaw");
|
|
216
177
|
}
|
|
@@ -223,15 +184,15 @@ export class ServiceManager implements SystemService {
|
|
|
223
184
|
|
|
224
185
|
if (this.#platform === "launchd") {
|
|
225
186
|
if (this.isRunning) {
|
|
226
|
-
this.#
|
|
187
|
+
this.#exec(`launchctl unload ${this.serviceFilePath}`);
|
|
227
188
|
}
|
|
228
|
-
this.#
|
|
229
|
-
this.#
|
|
189
|
+
this.#exec("bun install -g macroclaw@latest");
|
|
190
|
+
this.#exec(`launchctl load ${this.serviceFilePath}`);
|
|
230
191
|
} else {
|
|
231
192
|
if (this.isRunning) {
|
|
232
193
|
this.#sudo("systemctl stop macroclaw");
|
|
233
194
|
}
|
|
234
|
-
this.#
|
|
195
|
+
this.#exec("bun install -g macroclaw@latest");
|
|
235
196
|
this.#sudo("systemctl start macroclaw");
|
|
236
197
|
}
|
|
237
198
|
|
|
@@ -249,13 +210,13 @@ export class ServiceManager implements SystemService {
|
|
|
249
210
|
if (result.running) {
|
|
250
211
|
if (this.#platform === "launchd") {
|
|
251
212
|
try {
|
|
252
|
-
const out = this.#
|
|
213
|
+
const out = this.#exec(`launchctl list ${LAUNCHD_LABEL}`);
|
|
253
214
|
const pidMatch = /"PID"\s*=\s*(\d+)/.exec(out);
|
|
254
215
|
if (pidMatch) result.pid = Number(pidMatch[1]);
|
|
255
216
|
} catch { /* best effort */ }
|
|
256
217
|
} else {
|
|
257
218
|
try {
|
|
258
|
-
const out = this.#
|
|
219
|
+
const out = this.#exec("systemctl show macroclaw --property=MainPID,ActiveEnterTimestamp --no-pager");
|
|
259
220
|
const pidMatch = /MainPID=(\d+)/.exec(out);
|
|
260
221
|
if (pidMatch && pidMatch[1] !== "0") result.pid = Number(pidMatch[1]);
|
|
261
222
|
const tsMatch = /ActiveEnterTimestamp=(.+)/.exec(out);
|
|
@@ -269,7 +230,7 @@ export class ServiceManager implements SystemService {
|
|
|
269
230
|
|
|
270
231
|
logs(follow = false): string {
|
|
271
232
|
if (this.#platform === "launchd") {
|
|
272
|
-
const logDir = resolve(this.#
|
|
233
|
+
const logDir = resolve(this.#home, ".macroclaw/logs");
|
|
273
234
|
return follow
|
|
274
235
|
? `tail -f ${logDir}/stdout.log ${logDir}/stderr.log`
|
|
275
236
|
: `tail -n 50 ${logDir}/stdout.log`;
|
|
@@ -279,24 +240,27 @@ export class ServiceManager implements SystemService {
|
|
|
279
240
|
: "journalctl -u macroclaw -n 50 --no-pager";
|
|
280
241
|
}
|
|
281
242
|
|
|
243
|
+
#exec(cmd: string): string {
|
|
244
|
+
return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).toString();
|
|
245
|
+
}
|
|
246
|
+
|
|
282
247
|
#resolvePath(binary: string): string {
|
|
283
248
|
try {
|
|
284
|
-
return this.#
|
|
249
|
+
return this.#exec(`which ${binary}`).trim();
|
|
285
250
|
} catch {
|
|
286
251
|
throw new Error(`Could not resolve ${binary} path. Is it installed?`);
|
|
287
252
|
}
|
|
288
253
|
}
|
|
289
254
|
|
|
290
255
|
#resolveGlobalBinPath(binary: string): string {
|
|
291
|
-
const binDir = this.#
|
|
256
|
+
const binDir = this.#exec("bun pm bin -g").trim();
|
|
292
257
|
const binPath = join(binDir, binary);
|
|
293
|
-
if (!
|
|
258
|
+
if (!existsSync(binPath)) {
|
|
294
259
|
throw new Error(`Could not find ${binary} in ${binDir}. Is it installed?`);
|
|
295
260
|
}
|
|
296
261
|
return binPath;
|
|
297
262
|
}
|
|
298
263
|
|
|
299
|
-
|
|
300
264
|
#requireInstalled(): void {
|
|
301
265
|
if (!this.isInstalled) {
|
|
302
266
|
throw new Error("Service not installed. Run `macroclaw service install` first.");
|
|
@@ -305,43 +269,43 @@ export class ServiceManager implements SystemService {
|
|
|
305
269
|
|
|
306
270
|
#logTailCommand(): string {
|
|
307
271
|
if (this.#platform === "launchd") {
|
|
308
|
-
const logDir = resolve(this.#
|
|
272
|
+
const logDir = resolve(this.#home, ".macroclaw/logs");
|
|
309
273
|
return `tail -f ${logDir}/*.log`;
|
|
310
274
|
}
|
|
311
275
|
return "journalctl -u macroclaw -f";
|
|
312
276
|
}
|
|
313
277
|
|
|
314
278
|
#sudo(cmd: string): void {
|
|
315
|
-
this.#
|
|
279
|
+
this.#exec(`sudo ${cmd}`);
|
|
316
280
|
}
|
|
317
281
|
|
|
318
282
|
/** Write unit content to a temp file, then sudo-copy it into /etc/systemd/system/. */
|
|
319
283
|
#writeSystemdUnit(content: string): void {
|
|
320
|
-
const tmpPath = join(
|
|
321
|
-
|
|
284
|
+
const tmpPath = join(tmpdir(), `macroclaw-${randomUUID()}.service`);
|
|
285
|
+
writeFileSync(tmpPath, content);
|
|
322
286
|
try {
|
|
323
287
|
this.#sudo(`cp ${tmpPath} ${this.serviceFilePath}`);
|
|
324
288
|
} finally {
|
|
325
|
-
try {
|
|
289
|
+
try { rmSync(tmpPath); } catch { /* best-effort cleanup */ }
|
|
326
290
|
}
|
|
327
291
|
}
|
|
328
292
|
|
|
329
293
|
#resolveLinuxUser(): LinuxUser {
|
|
330
|
-
const info =
|
|
294
|
+
const info = osUserInfo();
|
|
331
295
|
const user = info.username;
|
|
332
296
|
const home = info.homedir;
|
|
333
|
-
const group = this.#
|
|
297
|
+
const group = this.#exec(`id -gn ${user}`).trim();
|
|
334
298
|
|
|
335
299
|
const settingsPath = resolve(home, ".macroclaw/settings.json");
|
|
336
|
-
if (!
|
|
337
|
-
throw new Error(
|
|
300
|
+
if (!existsSync(settingsPath)) {
|
|
301
|
+
throw new Error("Settings not found. Run `macroclaw setup` first.");
|
|
338
302
|
}
|
|
339
303
|
|
|
340
304
|
return { user, group, home };
|
|
341
305
|
}
|
|
342
306
|
|
|
343
307
|
#generateLaunchdPlist(bunPath: string, macroclawPath: string, pathDirs: string[], oauthToken?: string): string {
|
|
344
|
-
const logDir = resolve(this.#
|
|
308
|
+
const logDir = resolve(this.#home, ".macroclaw/logs");
|
|
345
309
|
const tokenEnv = oauthToken ? `\n\t\t<key>CLAUDE_CODE_OAUTH_TOKEN</key>\n\t\t<string>${oauthToken}</string>` : "";
|
|
346
310
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
347
311
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -364,7 +328,7 @@ export class ServiceManager implements SystemService {
|
|
|
364
328
|
<key>EnvironmentVariables</key>
|
|
365
329
|
<dict>
|
|
366
330
|
<key>HOME</key>
|
|
367
|
-
<string>${this.#
|
|
331
|
+
<string>${this.#home}</string>
|
|
368
332
|
<key>PATH</key>
|
|
369
333
|
<string>${pathDirs.join(":")}</string>${tokenEnv}
|
|
370
334
|
</dict>
|