macroclaw 0.30.0 → 0.32.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.ts +6 -6
- package/src/orchestrator.ts +41 -70
- package/src/prompts.test.ts +102 -162
- package/src/prompts.ts +62 -53
- package/src/setup.ts +1 -1
- package/src/system-service.test.ts +119 -85
- package/src/system-service.ts +35 -59
package/src/prompts.ts
CHANGED
|
@@ -67,26 +67,11 @@ Each button gets its own row. Max 27 characters per label — if options need mo
|
|
|
67
67
|
|
|
68
68
|
// --- Event builder ---
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
function escapeXml(text: string): string {
|
|
71
71
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
export type EventType =
|
|
77
|
-
| "user-message"
|
|
78
|
-
| "button-click"
|
|
79
|
-
| "schedule-trigger"
|
|
80
|
-
| "background-agent-start"
|
|
81
|
-
| "background-agent-result"
|
|
82
|
-
| "background-agent-progress"
|
|
83
|
-
| "peek"
|
|
84
|
-
| "health-check";
|
|
85
|
-
|
|
86
|
-
export interface EventInput {
|
|
87
|
-
name: string;
|
|
88
|
-
type: EventType;
|
|
89
|
-
session: SessionType;
|
|
74
|
+
interface BuildXmlFields {
|
|
90
75
|
text?: string;
|
|
91
76
|
files?: string[];
|
|
92
77
|
button?: string;
|
|
@@ -99,46 +84,40 @@ export interface EventInput {
|
|
|
99
84
|
result?: { text: string; files?: string[] };
|
|
100
85
|
}
|
|
101
86
|
|
|
102
|
-
|
|
87
|
+
function buildXml(name: string, type: string, session: string, fields: BuildXmlFields): string {
|
|
103
88
|
const lines: string[] = [
|
|
104
|
-
`<event name="${escapeXml(
|
|
89
|
+
`<event name="${escapeXml(name)}" type="${type}" session="${session}">`,
|
|
105
90
|
];
|
|
106
91
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
lines.push(`<backgrounded-event name="${escapeXml(input.backgroundedEvent)}" />`);
|
|
92
|
+
if (fields.backgroundedEvent) {
|
|
93
|
+
lines.push(`<backgrounded-event name="${escapeXml(fields.backgroundedEvent)}" />`);
|
|
110
94
|
}
|
|
111
95
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (
|
|
116
|
-
if (input.schedule.scheduledAt) attrs.push(`scheduled-at="${escapeXml(input.schedule.scheduledAt)}"`);
|
|
96
|
+
if (fields.schedule) {
|
|
97
|
+
const attrs = [`name="${escapeXml(fields.schedule.name)}"`];
|
|
98
|
+
if (fields.schedule.missedBy) attrs.push(`missed-by="${escapeXml(fields.schedule.missedBy)}"`);
|
|
99
|
+
if (fields.schedule.scheduledAt) attrs.push(`scheduled-at="${escapeXml(fields.schedule.scheduledAt)}"`);
|
|
117
100
|
lines.push(`<schedule ${attrs.join(" ")} />`);
|
|
118
101
|
}
|
|
119
102
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
lines.push(`<original-event name="${escapeXml(input.originalEvent)}" />`);
|
|
103
|
+
if (fields.originalEvent) {
|
|
104
|
+
lines.push(`<original-event name="${escapeXml(fields.originalEvent)}" />`);
|
|
123
105
|
}
|
|
124
106
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
lines.push(`<target-event name="${escapeXml(input.targetEvent)}" />`);
|
|
107
|
+
if (fields.targetEvent) {
|
|
108
|
+
lines.push(`<target-event name="${escapeXml(fields.targetEvent)}" />`);
|
|
128
109
|
}
|
|
129
110
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
lines.push(`<progress>${escapeXml(input.progress)}</progress>`);
|
|
111
|
+
if (fields.progress) {
|
|
112
|
+
lines.push(`<progress>${escapeXml(fields.progress)}</progress>`);
|
|
133
113
|
}
|
|
134
114
|
|
|
135
|
-
|
|
136
|
-
if (input.result) {
|
|
115
|
+
if (fields.result) {
|
|
137
116
|
lines.push("<result>");
|
|
138
|
-
lines.push(`<text>${escapeXml(
|
|
139
|
-
if (
|
|
117
|
+
lines.push(`<text>${escapeXml(fields.result.text)}</text>`);
|
|
118
|
+
if (fields.result.files?.length) {
|
|
140
119
|
lines.push("<files>");
|
|
141
|
-
for (const f of
|
|
120
|
+
for (const f of fields.result.files) {
|
|
142
121
|
lines.push(` <file path="${escapeXml(f)}" />`);
|
|
143
122
|
}
|
|
144
123
|
lines.push("</files>");
|
|
@@ -146,30 +125,60 @@ export function buildEvent(input: EventInput): string {
|
|
|
146
125
|
lines.push("</result>");
|
|
147
126
|
}
|
|
148
127
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
lines.push(`<button>${escapeXml(input.button)}</button>`);
|
|
128
|
+
if (fields.button) {
|
|
129
|
+
lines.push(`<button>${escapeXml(fields.button)}</button>`);
|
|
152
130
|
}
|
|
153
131
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
lines.push(`<text>${escapeXml(input.text)}</text>`);
|
|
132
|
+
if (fields.text) {
|
|
133
|
+
lines.push(`<text>${escapeXml(fields.text)}</text>`);
|
|
157
134
|
}
|
|
158
135
|
|
|
159
|
-
|
|
160
|
-
if (input.files?.length) {
|
|
136
|
+
if (fields.files?.length) {
|
|
161
137
|
lines.push("<files>");
|
|
162
|
-
for (const f of
|
|
138
|
+
for (const f of fields.files) {
|
|
163
139
|
lines.push(` <file path="${escapeXml(f)}" />`);
|
|
164
140
|
}
|
|
165
141
|
lines.push("</files>");
|
|
166
142
|
}
|
|
167
143
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
lines.push(`<instructions>${escapeXml(input.instructions)}</instructions>`);
|
|
144
|
+
if (fields.instructions) {
|
|
145
|
+
lines.push(`<instructions>${escapeXml(fields.instructions)}</instructions>`);
|
|
171
146
|
}
|
|
172
147
|
|
|
173
148
|
lines.push("</event>");
|
|
174
149
|
return lines.join("\n");
|
|
175
150
|
}
|
|
151
|
+
|
|
152
|
+
// --- Per-type event builders ---
|
|
153
|
+
|
|
154
|
+
export function userMessageEvent(name: string, text: string, opts?: { files?: string[]; backgroundedEvent?: string }): string {
|
|
155
|
+
return buildXml(name, "user-message", "main", { text, files: opts?.files, backgroundedEvent: opts?.backgroundedEvent });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function buttonClickEvent(name: string, button: string, opts?: { backgroundedEvent?: string }): string {
|
|
159
|
+
return buildXml(name, "button-click", "main", { button, backgroundedEvent: opts?.backgroundedEvent });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function scheduleTriggerEvent(name: string, schedule: { name: string; missedBy?: string; scheduledAt?: string }, text: string): string {
|
|
163
|
+
return buildXml(name, "schedule-trigger", "background", { schedule, text });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function backgroundAgentStartEvent(name: string, text: string): string {
|
|
167
|
+
return buildXml(name, "background-agent-start", "background", { text });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function backgroundAgentResultEvent(name: string, originalEvent: string, result: { text: string; files?: string[] }, instructions: string, opts?: { backgroundedEvent?: string }): string {
|
|
171
|
+
return buildXml(name, "background-agent-result", "main", { originalEvent, result, instructions, backgroundedEvent: opts?.backgroundedEvent });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function backgroundAgentProgressEvent(name: string, originalEvent: string, progress: string, instructions: string, opts?: { backgroundedEvent?: string }): string {
|
|
175
|
+
return buildXml(name, "background-agent-progress", "main", { originalEvent, progress, instructions, backgroundedEvent: opts?.backgroundedEvent });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function peekEvent(name: string, targetEvent: string, instructions: string): string {
|
|
179
|
+
return buildXml(name, "peek", "background", { targetEvent, instructions });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function healthCheckEvent(name: string, targetEvent: string, instructions: string): string {
|
|
183
|
+
return buildXml(name, "health-check", "background", { targetEvent, instructions });
|
|
184
|
+
}
|
package/src/setup.ts
CHANGED
|
@@ -113,7 +113,7 @@ export class SetupWizard {
|
|
|
113
113
|
async installService(): Promise<void> {
|
|
114
114
|
this.#io.open();
|
|
115
115
|
try {
|
|
116
|
-
const installAnswer = await this.#io.ask("Install as a
|
|
116
|
+
const installAnswer = await this.#io.ask("Install as a service? [Y/n]:");
|
|
117
117
|
if (installAnswer.toLowerCase() === "n" || installAnswer.toLowerCase() === "no") return;
|
|
118
118
|
|
|
119
119
|
await this.#doInstallService();
|
|
@@ -72,9 +72,9 @@ describe("serviceFilePath", () => {
|
|
|
72
72
|
expect(mgr.serviceFilePath).toContain("Library/LaunchAgents/com.macroclaw.plist");
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
-
it("returns systemd path for systemd", () => {
|
|
76
|
-
const mgr = createManager({ platform: "linux" });
|
|
77
|
-
expect(mgr.serviceFilePath).toBe("/
|
|
75
|
+
it("returns user systemd path for systemd", () => {
|
|
76
|
+
const mgr = createManager({ platform: "linux", home: "/home/testuser" });
|
|
77
|
+
expect(mgr.serviceFilePath).toBe("/home/testuser/.config/systemd/user/macroclaw.service");
|
|
78
78
|
});
|
|
79
79
|
});
|
|
80
80
|
|
|
@@ -128,7 +128,7 @@ describe("isRunning", () => {
|
|
|
128
128
|
|
|
129
129
|
it("returns true when systemd service is active", () => {
|
|
130
130
|
mockExecSync.mockImplementation((cmd: string) => {
|
|
131
|
-
if (cmd === "systemctl is-active macroclaw") return SYSTEMD_ACTIVE;
|
|
131
|
+
if (cmd === "systemctl --user is-active macroclaw") return SYSTEMD_ACTIVE;
|
|
132
132
|
return "";
|
|
133
133
|
});
|
|
134
134
|
const mgr = createManager();
|
|
@@ -137,7 +137,7 @@ describe("isRunning", () => {
|
|
|
137
137
|
|
|
138
138
|
it("returns false when systemd service is inactive", () => {
|
|
139
139
|
mockExecSync.mockImplementation((cmd: string) => {
|
|
140
|
-
if (cmd === "systemctl is-active macroclaw") return SYSTEMD_INACTIVE;
|
|
140
|
+
if (cmd === "systemctl --user is-active macroclaw") return SYSTEMD_INACTIVE;
|
|
141
141
|
return "";
|
|
142
142
|
});
|
|
143
143
|
const mgr = createManager();
|
|
@@ -146,7 +146,7 @@ describe("isRunning", () => {
|
|
|
146
146
|
|
|
147
147
|
it("returns false when systemctl throws", () => {
|
|
148
148
|
mockExecSync.mockImplementation((cmd: string) => {
|
|
149
|
-
if (cmd === "systemctl is-active macroclaw") throw new Error("not found");
|
|
149
|
+
if (cmd === "systemctl --user is-active macroclaw") throw new Error("not found");
|
|
150
150
|
return "";
|
|
151
151
|
});
|
|
152
152
|
const mgr = createManager();
|
|
@@ -163,12 +163,7 @@ describe("install", () => {
|
|
|
163
163
|
});
|
|
164
164
|
|
|
165
165
|
it("throws when settings.json is missing on Linux", () => {
|
|
166
|
-
|
|
167
|
-
if (cmd.startsWith("id -gn")) return "testuser\n";
|
|
168
|
-
return "";
|
|
169
|
-
});
|
|
170
|
-
mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: "/nonexistent", uid: 1000, gid: 1000, shell: "/bin/bash" }));
|
|
171
|
-
const mgr = createManager();
|
|
166
|
+
const mgr = createManager({ home: "/nonexistent" });
|
|
172
167
|
expect(() => mgr.install()).toThrow("Settings not found. Run `macroclaw setup` first.");
|
|
173
168
|
});
|
|
174
169
|
|
|
@@ -183,10 +178,14 @@ describe("install", () => {
|
|
|
183
178
|
if (cmd === "which bun") return `${tmpHome}/.bun/bin/bun\n`;
|
|
184
179
|
if (cmd === "which claude") return `${tmpHome}/.local/bin/claude\n`;
|
|
185
180
|
if (cmd === "bun pm bin -g") return `${tmpHome}/.bun/bin\n`;
|
|
186
|
-
if (cmd.startsWith("id -gn")) return "testuser\n";
|
|
187
181
|
return "";
|
|
188
182
|
});
|
|
189
183
|
mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: tmpHome, uid: 1000, gid: 1000, shell: "/bin/bash" }));
|
|
184
|
+
// Mock existsSync to handle linger check
|
|
185
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
186
|
+
if (path === "/var/lib/systemd/linger/testuser") return true; // already lingering
|
|
187
|
+
return realExistsSync(path);
|
|
188
|
+
});
|
|
190
189
|
const mgr = createManager({ home: tmpHome });
|
|
191
190
|
mgr.install();
|
|
192
191
|
rmSync(tmpHome, { recursive: true });
|
|
@@ -298,7 +297,7 @@ describe("install", () => {
|
|
|
298
297
|
rmSync(tmpHome, { recursive: true });
|
|
299
298
|
});
|
|
300
299
|
|
|
301
|
-
it("installs systemd service
|
|
300
|
+
it("installs systemd user service and writes unit file directly", () => {
|
|
302
301
|
const tmpHome = `/tmp/macroclaw-test-systemd-${Date.now()}`;
|
|
303
302
|
mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
|
|
304
303
|
writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
|
|
@@ -309,71 +308,63 @@ describe("install", () => {
|
|
|
309
308
|
if (cmd === "which bun") return `${tmpHome}/.bun/bin/bun\n`;
|
|
310
309
|
if (cmd === "which claude") return `${tmpHome}/.local/bin/claude\n`;
|
|
311
310
|
if (cmd === "bun pm bin -g") return `${tmpHome}/.bun/bin\n`;
|
|
312
|
-
if (cmd.startsWith("id -gn")) return "testuser\n";
|
|
313
311
|
return "";
|
|
314
312
|
});
|
|
315
313
|
mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: tmpHome, uid: 1000, gid: 1000, shell: "/bin/bash" }));
|
|
314
|
+
// Mock existsSync: linger file does not exist (triggers sudo loginctl)
|
|
315
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
316
|
+
if (path === "/var/lib/systemd/linger/testuser") return false;
|
|
317
|
+
return realExistsSync(path);
|
|
318
|
+
});
|
|
316
319
|
const mgr = createManager({ home: tmpHome });
|
|
317
320
|
mgr.install();
|
|
318
321
|
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
expect(
|
|
322
|
-
|
|
323
|
-
expect(
|
|
322
|
+
// Unit file written directly (no sudo cp)
|
|
323
|
+
const unitPath = join(tmpHome, ".config/systemd/user/macroclaw.service");
|
|
324
|
+
expect(existsSync(unitPath)).toBe(true);
|
|
325
|
+
const unitContent = readFileSync(unitPath, "utf-8");
|
|
326
|
+
expect(unitContent).toContain("WantedBy=default.target");
|
|
327
|
+
expect(unitContent).not.toContain("User=");
|
|
328
|
+
expect(unitContent).not.toContain("Group=");
|
|
329
|
+
expect(unitContent).toContain(`Environment=HOME=${tmpHome}`);
|
|
330
|
+
expect(unitContent).toContain(`ExecStart=${tmpHome}/.bun/bin/bun ${tmpHome}/.bun/bin/macroclaw start`);
|
|
331
|
+
|
|
332
|
+
// Lingering enabled via sudo
|
|
333
|
+
expect(mockExecSync).toHaveBeenCalledWith("sudo loginctl enable-linger testuser", expect.anything());
|
|
334
|
+
// User systemctl commands (no sudo)
|
|
335
|
+
expect(mockExecSync).toHaveBeenCalledWith("systemctl --user daemon-reload", expect.anything());
|
|
336
|
+
expect(mockExecSync).toHaveBeenCalledWith("systemctl --user enable macroclaw", expect.anything());
|
|
337
|
+
expect(mockExecSync).toHaveBeenCalledWith("systemctl --user start macroclaw", expect.anything());
|
|
338
|
+
// No sudo systemctl calls
|
|
339
|
+
for (const call of mockExecSync.mock.calls) {
|
|
340
|
+
expect(call[0]).not.toMatch(/^sudo systemctl/);
|
|
341
|
+
}
|
|
324
342
|
rmSync(tmpHome, { recursive: true });
|
|
325
343
|
});
|
|
326
344
|
|
|
327
|
-
it("
|
|
328
|
-
const tmpHome = `/tmp/macroclaw-test-
|
|
345
|
+
it("skips lingering when already enabled", () => {
|
|
346
|
+
const tmpHome = `/tmp/macroclaw-test-linger-${Date.now()}`;
|
|
329
347
|
mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
|
|
330
348
|
writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
|
|
331
349
|
mkdirSync(join(tmpHome, ".bun/bin"), { recursive: true });
|
|
332
350
|
writeFileSync(join(tmpHome, ".bun/bin/macroclaw"), "");
|
|
333
351
|
|
|
334
|
-
let tmpServicePath = "";
|
|
335
352
|
mockExecSync.mockImplementation((cmd: string) => {
|
|
336
|
-
if (cmd.startsWith("id -gn")) return "testuser\n";
|
|
337
353
|
if (cmd === "which bun") return `${tmpHome}/.bun/bin/bun\n`;
|
|
338
354
|
if (cmd === "which claude") return `${tmpHome}/.local/bin/claude\n`;
|
|
339
355
|
if (cmd === "bun pm bin -g") return `${tmpHome}/.bun/bin\n`;
|
|
340
|
-
if (cmd.startsWith("sudo cp")) {
|
|
341
|
-
tmpServicePath = cmd.split(" ")[2];
|
|
342
|
-
throw new Error("Permission denied");
|
|
343
|
-
}
|
|
344
356
|
return "";
|
|
345
357
|
});
|
|
346
358
|
mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: tmpHome, uid: 1000, gid: 1000, shell: "/bin/bash" }));
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
expect(existsSync(tmpServicePath)).toBe(false);
|
|
352
|
-
rmSync(tmpHome, { recursive: true });
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
it("uses os userInfo identity, not environment variables", () => {
|
|
356
|
-
const tmpHome = `/tmp/macroclaw-test-userinfo-${Date.now()}`;
|
|
357
|
-
mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
|
|
358
|
-
writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
|
|
359
|
-
mkdirSync(join(tmpHome, "bin"), { recursive: true });
|
|
360
|
-
writeFileSync(join(tmpHome, "bin/macroclaw"), "");
|
|
361
|
-
|
|
362
|
-
mockExecSync.mockImplementation((cmd: string) => {
|
|
363
|
-
if (cmd === "which bun") return "/usr/local/bin/bun\n";
|
|
364
|
-
if (cmd === "which claude") return "/usr/local/bin/claude\n";
|
|
365
|
-
if (cmd === "bun pm bin -g") return `${tmpHome}/bin\n`;
|
|
366
|
-
if (cmd === "id -gn deploy") return "deploy\n";
|
|
367
|
-
return "";
|
|
359
|
+
// Linger already enabled
|
|
360
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
361
|
+
if (path === "/var/lib/systemd/linger/testuser") return true;
|
|
362
|
+
return realExistsSync(path);
|
|
368
363
|
});
|
|
369
|
-
mockUserInfo.mockImplementation(() => ({ username: "deploy", homedir: tmpHome, uid: 1000, gid: 1000, shell: "/bin/bash" }));
|
|
370
364
|
const mgr = createManager({ home: tmpHome });
|
|
371
365
|
mgr.install();
|
|
372
366
|
|
|
373
|
-
|
|
374
|
-
const cpCall = mockExecSync.mock.calls.find(c => (c[0] as string).startsWith("sudo cp"));
|
|
375
|
-
expect(cpCall).toBeTruthy();
|
|
376
|
-
|
|
367
|
+
expect(mockExecSync).not.toHaveBeenCalledWith("sudo loginctl enable-linger testuser", expect.anything());
|
|
377
368
|
rmSync(tmpHome, { recursive: true });
|
|
378
369
|
});
|
|
379
370
|
|
|
@@ -388,10 +379,13 @@ describe("install", () => {
|
|
|
388
379
|
if (cmd === "which bun") return `${tmpHome}/.bun/bin/bun\n`;
|
|
389
380
|
if (cmd === "which claude") return `${tmpHome}/.local/bin/claude\n`;
|
|
390
381
|
if (cmd === "bun pm bin -g") return `${tmpHome}/.bun/bin\n`;
|
|
391
|
-
if (cmd.startsWith("id -gn")) return "testuser\n";
|
|
392
382
|
return "";
|
|
393
383
|
});
|
|
394
384
|
mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: tmpHome, uid: 1000, gid: 1000, shell: "/bin/bash" }));
|
|
385
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
386
|
+
if (path === "/var/lib/systemd/linger/testuser") return true;
|
|
387
|
+
return realExistsSync(path);
|
|
388
|
+
});
|
|
395
389
|
const mgr = createManager({ home: tmpHome });
|
|
396
390
|
mgr.install();
|
|
397
391
|
expect(mockExecSync).toHaveBeenCalledWith("bun install -g macroclaw", expect.anything());
|
|
@@ -405,11 +399,9 @@ describe("install", () => {
|
|
|
405
399
|
writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
|
|
406
400
|
|
|
407
401
|
mockExecSync.mockImplementation((cmd: string) => {
|
|
408
|
-
if (cmd.startsWith("id -gn")) return "testuser\n";
|
|
409
402
|
if (cmd === "which bun") throw new Error("not found");
|
|
410
403
|
return "";
|
|
411
404
|
});
|
|
412
|
-
mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: tmpHome, uid: 1000, gid: 1000, shell: "/bin/bash" }));
|
|
413
405
|
const mgr = createManager({ home: tmpHome });
|
|
414
406
|
expect(() => mgr.install()).toThrow("Could not resolve bun path. Is it installed?");
|
|
415
407
|
rmSync(tmpHome, { recursive: true });
|
|
@@ -423,13 +415,11 @@ describe("install", () => {
|
|
|
423
415
|
// Note: NOT creating macroclaw binary
|
|
424
416
|
|
|
425
417
|
mockExecSync.mockImplementation((cmd: string) => {
|
|
426
|
-
if (cmd.startsWith("id -gn")) return "testuser\n";
|
|
427
418
|
if (cmd === "which bun") return `${tmpHome}/.bun/bin/bun\n`;
|
|
428
419
|
if (cmd === "which claude") return `${tmpHome}/.local/bin/claude\n`;
|
|
429
420
|
if (cmd === "bun pm bin -g") return `${tmpHome}/.bun/bin\n`;
|
|
430
421
|
return "";
|
|
431
422
|
});
|
|
432
|
-
mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: tmpHome, uid: 1000, gid: 1000, shell: "/bin/bash" }));
|
|
433
423
|
const mgr = createManager({ home: tmpHome });
|
|
434
424
|
expect(() => mgr.install()).toThrow(`Could not find macroclaw in ${tmpHome}/.bun/bin`);
|
|
435
425
|
rmSync(tmpHome, { recursive: true });
|
|
@@ -500,33 +490,45 @@ describe("uninstall", () => {
|
|
|
500
490
|
rmSync(tmpHome, { recursive: true, force: true });
|
|
501
491
|
});
|
|
502
492
|
|
|
503
|
-
it("uninstalls running systemd service
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
493
|
+
it("uninstalls running systemd user service", () => {
|
|
494
|
+
const tmpHome = `/tmp/macroclaw-test-unsys-${Date.now()}`;
|
|
495
|
+
const unitDir = join(tmpHome, ".config/systemd/user");
|
|
496
|
+
mkdirSync(unitDir, { recursive: true });
|
|
497
|
+
const unitPath = join(unitDir, "macroclaw.service");
|
|
498
|
+
writeFileSync(unitPath, "test");
|
|
499
|
+
|
|
500
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
501
|
+
if (cmd === "systemctl --user is-active macroclaw") return SYSTEMD_ACTIVE;
|
|
502
|
+
return "";
|
|
503
|
+
});
|
|
504
|
+
const mgr = createManager({ platform: "linux", home: tmpHome });
|
|
505
|
+
mgr.uninstall();
|
|
506
|
+
expect(mockExecSync).toHaveBeenCalledWith("systemctl --user stop macroclaw", expect.anything());
|
|
507
|
+
expect(mockExecSync).toHaveBeenCalledWith("systemctl --user disable macroclaw", expect.anything());
|
|
508
|
+
expect(mockExecSync).toHaveBeenCalledWith("systemctl --user daemon-reload", expect.anything());
|
|
509
|
+
expect(existsSync(unitPath)).toBe(false);
|
|
510
|
+
// No sudo for systemctl
|
|
511
|
+
for (const call of mockExecSync.mock.calls) {
|
|
512
|
+
expect(call[0]).not.toMatch(/^sudo /);
|
|
513
|
+
}
|
|
514
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
512
515
|
});
|
|
513
516
|
|
|
514
|
-
it("
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
const plistDir = join(tmpHome, "Library/LaunchAgents");
|
|
520
|
-
mkdirSync(plistDir, { recursive: true });
|
|
521
|
-
writeFileSync(join(plistDir, "com.macroclaw.plist"), "test");
|
|
517
|
+
it("uninstalls stopped systemd user service without stopping", () => {
|
|
518
|
+
const tmpHome = `/tmp/macroclaw-test-unsys2-${Date.now()}`;
|
|
519
|
+
const unitDir = join(tmpHome, ".config/systemd/user");
|
|
520
|
+
mkdirSync(unitDir, { recursive: true });
|
|
521
|
+
writeFileSync(join(unitDir, "macroclaw.service"), "test");
|
|
522
522
|
|
|
523
523
|
mockExecSync.mockImplementation((cmd: string) => {
|
|
524
|
-
if (cmd
|
|
524
|
+
if (cmd === "systemctl --user is-active macroclaw") throw new Error("inactive");
|
|
525
525
|
return "";
|
|
526
526
|
});
|
|
527
|
-
const mgr = createManager({ platform: "
|
|
527
|
+
const mgr = createManager({ platform: "linux", home: tmpHome });
|
|
528
528
|
mgr.uninstall();
|
|
529
|
-
expect(mockExecSync).toHaveBeenCalledWith(
|
|
529
|
+
expect(mockExecSync).not.toHaveBeenCalledWith("systemctl --user stop macroclaw", expect.anything());
|
|
530
|
+
expect(mockExecSync).toHaveBeenCalledWith("systemctl --user disable macroclaw", expect.anything());
|
|
531
|
+
expect(mockExecSync).toHaveBeenCalledWith("systemctl --user daemon-reload", expect.anything());
|
|
530
532
|
rmSync(tmpHome, { recursive: true, force: true });
|
|
531
533
|
});
|
|
532
534
|
});
|
|
@@ -567,6 +569,22 @@ describe("start", () => {
|
|
|
567
569
|
expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining("launchctl load"), expect.anything());
|
|
568
570
|
rmSync(tmpHome, { recursive: true });
|
|
569
571
|
});
|
|
572
|
+
|
|
573
|
+
it("starts systemd user service", () => {
|
|
574
|
+
const tmpHome = `/tmp/macroclaw-test-startsys-${Date.now()}`;
|
|
575
|
+
const unitDir = join(tmpHome, ".config/systemd/user");
|
|
576
|
+
mkdirSync(unitDir, { recursive: true });
|
|
577
|
+
writeFileSync(join(unitDir, "macroclaw.service"), "test");
|
|
578
|
+
|
|
579
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
580
|
+
if (cmd === "systemctl --user is-active macroclaw") throw new Error("inactive");
|
|
581
|
+
return "";
|
|
582
|
+
});
|
|
583
|
+
const mgr = createManager({ platform: "linux", home: tmpHome });
|
|
584
|
+
mgr.start();
|
|
585
|
+
expect(mockExecSync).toHaveBeenCalledWith("systemctl --user start macroclaw", expect.anything());
|
|
586
|
+
rmSync(tmpHome, { recursive: true });
|
|
587
|
+
});
|
|
570
588
|
});
|
|
571
589
|
|
|
572
590
|
describe("stop", () => {
|
|
@@ -605,6 +623,22 @@ describe("stop", () => {
|
|
|
605
623
|
expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining("launchctl unload"), expect.anything());
|
|
606
624
|
rmSync(tmpHome, { recursive: true });
|
|
607
625
|
});
|
|
626
|
+
|
|
627
|
+
it("stops systemd user service", () => {
|
|
628
|
+
const tmpHome = `/tmp/macroclaw-test-stopsys-${Date.now()}`;
|
|
629
|
+
const unitDir = join(tmpHome, ".config/systemd/user");
|
|
630
|
+
mkdirSync(unitDir, { recursive: true });
|
|
631
|
+
writeFileSync(join(unitDir, "macroclaw.service"), "test");
|
|
632
|
+
|
|
633
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
634
|
+
if (cmd === "systemctl --user is-active macroclaw") return SYSTEMD_ACTIVE;
|
|
635
|
+
return "";
|
|
636
|
+
});
|
|
637
|
+
const mgr = createManager({ platform: "linux", home: tmpHome });
|
|
638
|
+
mgr.stop();
|
|
639
|
+
expect(mockExecSync).toHaveBeenCalledWith("systemctl --user stop macroclaw", expect.anything());
|
|
640
|
+
rmSync(tmpHome, { recursive: true });
|
|
641
|
+
});
|
|
608
642
|
});
|
|
609
643
|
|
|
610
644
|
describe("update", () => {
|
|
@@ -674,13 +708,13 @@ describe("update", () => {
|
|
|
674
708
|
describe("status", () => {
|
|
675
709
|
it("returns not installed, not running when service file missing", () => {
|
|
676
710
|
mockExecSync.mockImplementation((cmd: string) => {
|
|
677
|
-
if (cmd === "systemctl is-active macroclaw") throw new Error("not found");
|
|
711
|
+
if (cmd === "systemctl --user is-active macroclaw") throw new Error("not found");
|
|
678
712
|
return "";
|
|
679
713
|
});
|
|
680
714
|
mockExistsSync.mockReturnValue(false);
|
|
681
715
|
const mgr = createManager({ home: "/nonexistent" });
|
|
682
716
|
// Override isInstalled getter — on hosts where macroclaw is installed as a systemd
|
|
683
|
-
// service, existsSync
|
|
717
|
+
// service, existsSync for the user path might still return true
|
|
684
718
|
Object.defineProperty(mgr, "isInstalled", { get: () => false });
|
|
685
719
|
const s = mgr.status();
|
|
686
720
|
expect(s.installed).toBe(false);
|
|
@@ -731,14 +765,14 @@ describe("status", () => {
|
|
|
731
765
|
});
|
|
732
766
|
|
|
733
767
|
describe("logs", () => {
|
|
734
|
-
it("returns journalctl command for systemd", () => {
|
|
768
|
+
it("returns journalctl --user command for systemd", () => {
|
|
735
769
|
const mgr = createManager();
|
|
736
|
-
expect(mgr.logs()).toBe("journalctl -u macroclaw -n 50 --no-pager");
|
|
770
|
+
expect(mgr.logs()).toBe("journalctl --user -u macroclaw -n 50 --no-pager");
|
|
737
771
|
});
|
|
738
772
|
|
|
739
|
-
it("returns journalctl follow command for systemd", () => {
|
|
773
|
+
it("returns journalctl --user follow command for systemd", () => {
|
|
740
774
|
const mgr = createManager();
|
|
741
|
-
expect(mgr.logs(true)).toBe("journalctl -u macroclaw -f");
|
|
775
|
+
expect(mgr.logs(true)).toBe("journalctl --user -u macroclaw -f");
|
|
742
776
|
});
|
|
743
777
|
|
|
744
778
|
it("returns tail command for launchd", () => {
|