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.
@@ -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 SystemService {
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;
27
+ export interface UpdateResult {
28
+ previousVersion: string;
29
+ currentVersion: string;
69
30
  }
70
31
 
71
- export class ServiceManager implements SystemService {
72
- readonly #deps: ServiceDeps;
32
+ export class SystemServiceManager {
73
33
  readonly #platform: Platform;
34
+ readonly #home: string;
74
35
 
75
- constructor(deps?: Partial<ServiceDeps>) {
76
- this.#deps = { ...defaultDeps(), ...deps };
77
- this.#platform = detectPlatform(this.#deps.platform);
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.#deps.home, "Library/LaunchAgents/com.macroclaw.plist")
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 this.#deps.existsSync(this.serviceFilePath);
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.#deps.execSync(`launchctl list ${LAUNCHD_LABEL}`);
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.#deps.execSync("systemctl is-active macroclaw");
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.#deps.home, ".macroclaw/settings.json");
124
- if (!this.#deps.existsSync(settingsPath)) {
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.#deps.execSync("bun install -g macroclaw");
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.#deps.home, ".macroclaw/logs");
136
- this.#deps.mkdirSync(logDir, { recursive: true });
101
+ const logDir = resolve(this.#home, ".macroclaw/logs");
102
+ mkdirSync(logDir, { recursive: true });
137
103
  if (this.isRunning) {
138
- this.#deps.execSync(`launchctl unload ${this.serviceFilePath}`);
104
+ this.#exec(`launchctl unload ${this.serviceFilePath}`);
139
105
  }
140
106
 
141
- this.#deps.writeFileSync(this.serviceFilePath, this.#generateLaunchdPlist(bunPath, macroclawPath, pathDirs, oauthToken));
107
+ writeFileSync(this.serviceFilePath, this.#generateLaunchdPlist(bunPath, macroclawPath, pathDirs, oauthToken));
142
108
  log.debug({ filePath: this.serviceFilePath }, "Wrote launchd plist");
143
- this.#deps.execSync(`launchctl load ${this.serviceFilePath}`);
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.#deps.execSync("bun install -g macroclaw");
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.#deps.execSync(`launchctl unload ${this.serviceFilePath}`);
139
+ this.#exec(`launchctl unload ${this.serviceFilePath}`);
174
140
  }
175
- this.#deps.rmSync(this.serviceFilePath);
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.#deps.execSync(`launchctl load ${this.serviceFilePath}`);
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.#deps.execSync(`launchctl unload ${this.serviceFilePath}`);
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(): string {
187
+ update(): UpdateResult {
222
188
  this.#requireInstalled();
223
189
 
224
- if (this.#platform === "launchd") {
225
- if (this.isRunning) {
226
- this.#deps.execSync(`launchctl unload ${this.serviceFilePath}`);
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("Service updated (reinstalled, restarted)");
239
- return this.#logTailCommand();
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.#deps.execSync(`launchctl list ${LAUNCHD_LABEL}`);
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.#deps.execSync("systemctl show macroclaw --property=MainPID,ActiveEnterTimestamp --no-pager");
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.#deps.home, ".macroclaw/logs");
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.#deps.execSync(`which ${binary}`).trim();
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.#deps.execSync("bun pm bin -g").trim();
251
+ const binDir = this.#exec("bun pm bin -g").trim();
292
252
  const binPath = join(binDir, binary);
293
- if (!this.#deps.existsSync(binPath)) {
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.#deps.home, ".macroclaw/logs");
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.#deps.execSync(`sudo ${cmd}`);
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(this.#deps.tmpdir(), `macroclaw-${this.#deps.randomUUID()}.service`);
321
- this.#deps.writeFileSync(tmpPath, content);
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 { this.#deps.rmSync(tmpPath); } catch { /* best-effort cleanup */ }
295
+ try { rmSync(tmpPath); } catch { /* best-effort cleanup */ }
326
296
  }
327
297
  }
328
298
 
329
299
  #resolveLinuxUser(): LinuxUser {
330
- const info = this.#deps.userInfo();
300
+ const info = osUserInfo();
331
301
  const user = info.username;
332
302
  const home = info.homedir;
333
- const group = this.#deps.execSync(`id -gn ${user}`).trim();
303
+ const group = this.#exec(`id -gn ${user}`).trim();
334
304
 
335
305
  const settingsPath = resolve(home, ".macroclaw/settings.json");
336
- if (!this.#deps.existsSync(settingsPath)) {
337
- throw new Error(`Settings not found. Run \`macroclaw setup\` first.`);
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.#deps.home, ".macroclaw/logs");
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.#deps.home}</string>
337
+ <string>${this.#home}</string>
368
338
  <key>PATH</key>
369
339
  <string>${pathDirs.join(":")}</string>${tokenEnv}
370
340
  </dict>