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