macroclaw 0.3.0 → 0.4.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/README.md +33 -27
- package/bin/macroclaw.js +3 -1
- package/package.json +4 -3
- package/src/claude.ts +1 -1
- package/src/cli.test.ts +293 -0
- package/src/cli.ts +186 -0
- package/src/index.ts +31 -41
- package/src/logger.test.ts +18 -7
- package/src/logger.ts +17 -7
- package/src/service.test.ts +590 -0
- package/src/service.ts +346 -0
- package/src/setup.test.ts +160 -3
- package/src/setup.ts +63 -8
package/src/service.ts
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { userInfo as osUserInfo, tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { createLogger } from "./logger";
|
|
7
|
+
|
|
8
|
+
const log = createLogger("service");
|
|
9
|
+
const LAUNCHD_LABEL = "com.macroclaw";
|
|
10
|
+
|
|
11
|
+
export type Platform = "launchd" | "systemd";
|
|
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
|
+
interface LinuxUser {
|
|
48
|
+
user: string;
|
|
49
|
+
group: string;
|
|
50
|
+
home: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SystemService {
|
|
54
|
+
install: (oauthToken?: string) => string;
|
|
55
|
+
uninstall: () => void;
|
|
56
|
+
start: () => string;
|
|
57
|
+
stop: () => void;
|
|
58
|
+
update: () => string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class ServiceManager implements SystemService {
|
|
62
|
+
readonly #deps: ServiceDeps;
|
|
63
|
+
readonly #platform: Platform;
|
|
64
|
+
|
|
65
|
+
constructor(deps?: Partial<ServiceDeps>) {
|
|
66
|
+
this.#deps = { ...defaultDeps(), ...deps };
|
|
67
|
+
this.#platform = detectPlatform(this.#deps.platform);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get platform(): Platform {
|
|
71
|
+
return this.#platform;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get serviceFilePath(): string {
|
|
75
|
+
return this.#platform === "launchd"
|
|
76
|
+
? resolve(this.#deps.home, "Library/LaunchAgents/com.macroclaw.plist")
|
|
77
|
+
: "/etc/systemd/system/macroclaw.service";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get isInstalled(): boolean {
|
|
81
|
+
return this.#deps.existsSync(this.serviceFilePath);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get isRunning(): boolean {
|
|
85
|
+
if (this.#platform === "launchd") {
|
|
86
|
+
try {
|
|
87
|
+
const out = this.#deps.execSync(`launchctl list ${LAUNCHD_LABEL}`);
|
|
88
|
+
// If the PID line shows a number (not "-"), the service is running
|
|
89
|
+
return /"PID"\s*=\s*\d+/.test(out);
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const out = this.#deps.execSync("systemctl is-active macroclaw");
|
|
96
|
+
return out.trim() === "active";
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
install(oauthToken?: string): string {
|
|
103
|
+
if (this.#platform === "launchd") {
|
|
104
|
+
this.#installLaunchd(oauthToken);
|
|
105
|
+
} else {
|
|
106
|
+
this.#installSystemd();
|
|
107
|
+
}
|
|
108
|
+
log.debug("Service installed and started");
|
|
109
|
+
return this.#logTailCommand();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#installLaunchd(oauthToken?: string): void {
|
|
113
|
+
const settingsPath = resolve(this.#deps.home, ".macroclaw/settings.json");
|
|
114
|
+
if (!this.#deps.existsSync(settingsPath)) {
|
|
115
|
+
throw new Error("Settings not found. Run `macroclaw setup` first.");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
this.#deps.execSync("bun install -g macroclaw");
|
|
119
|
+
const bunPath = this.#resolvePath("bun");
|
|
120
|
+
const claudePath = this.#resolvePath("claude");
|
|
121
|
+
const macroclawPath = this.#resolveGlobalBinPath("macroclaw");
|
|
122
|
+
|
|
123
|
+
const pathDirs = [...new Set([dirname(bunPath), dirname(claudePath), dirname(macroclawPath)])];
|
|
124
|
+
|
|
125
|
+
const logDir = resolve(this.#deps.home, ".macroclaw/logs");
|
|
126
|
+
this.#deps.mkdirSync(logDir, { recursive: true });
|
|
127
|
+
if (this.isRunning) {
|
|
128
|
+
this.#deps.execSync(`launchctl unload ${this.serviceFilePath}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this.#deps.writeFileSync(this.serviceFilePath, this.#generateLaunchdPlist(bunPath, macroclawPath, pathDirs, oauthToken));
|
|
132
|
+
log.debug({ filePath: this.serviceFilePath }, "Wrote launchd plist");
|
|
133
|
+
this.#deps.execSync(`launchctl load ${this.serviceFilePath}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#installSystemd(): void {
|
|
137
|
+
const target = this.#resolveLinuxUser();
|
|
138
|
+
|
|
139
|
+
if (this.isRunning) {
|
|
140
|
+
this.#sudo("systemctl stop macroclaw");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
this.#deps.execSync("bun install -g macroclaw");
|
|
144
|
+
const bunPath = this.#resolvePath("bun");
|
|
145
|
+
const claudePath = this.#resolvePath("claude");
|
|
146
|
+
const macroclawPath = this.#resolveGlobalBinPath("macroclaw");
|
|
147
|
+
|
|
148
|
+
const pathDirs = [...new Set([dirname(bunPath), dirname(claudePath), dirname(macroclawPath)])];
|
|
149
|
+
|
|
150
|
+
const unitContent = this.#generateSystemdUnit(bunPath, macroclawPath, target, pathDirs);
|
|
151
|
+
this.#writeSystemdUnit(unitContent);
|
|
152
|
+
log.debug({ filePath: this.serviceFilePath, user: target.user }, "Wrote systemd unit");
|
|
153
|
+
this.#sudo("systemctl daemon-reload");
|
|
154
|
+
this.#sudo("systemctl enable macroclaw");
|
|
155
|
+
this.#sudo("systemctl start macroclaw");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
uninstall(): void {
|
|
159
|
+
this.#requireInstalled();
|
|
160
|
+
|
|
161
|
+
if (this.#platform === "launchd") {
|
|
162
|
+
if (this.isRunning) {
|
|
163
|
+
this.#deps.execSync(`launchctl unload ${this.serviceFilePath}`);
|
|
164
|
+
}
|
|
165
|
+
this.#deps.rmSync(this.serviceFilePath);
|
|
166
|
+
} else {
|
|
167
|
+
if (this.isRunning) {
|
|
168
|
+
this.#sudo("systemctl stop macroclaw");
|
|
169
|
+
}
|
|
170
|
+
try { this.#sudo("systemctl disable macroclaw"); } catch { /* already disabled */ }
|
|
171
|
+
this.#sudo(`rm ${this.serviceFilePath}`);
|
|
172
|
+
this.#sudo("systemctl daemon-reload");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
log.debug("Service uninstalled");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
start(): string {
|
|
179
|
+
this.#requireInstalled();
|
|
180
|
+
|
|
181
|
+
if (this.isRunning) {
|
|
182
|
+
throw new Error("Service is already running.");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (this.#platform === "launchd") {
|
|
186
|
+
this.#deps.execSync(`launchctl load ${this.serviceFilePath}`);
|
|
187
|
+
} else {
|
|
188
|
+
this.#sudo("systemctl start macroclaw");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
log.debug("Service started");
|
|
192
|
+
return this.#logTailCommand();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
stop(): void {
|
|
196
|
+
this.#requireInstalled();
|
|
197
|
+
|
|
198
|
+
if (!this.isRunning) {
|
|
199
|
+
throw new Error("Service is not running.");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (this.#platform === "launchd") {
|
|
203
|
+
this.#deps.execSync(`launchctl unload ${this.serviceFilePath}`);
|
|
204
|
+
} else {
|
|
205
|
+
this.#sudo("systemctl stop macroclaw");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
log.debug("Service stopped");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
update(): string {
|
|
212
|
+
this.#requireInstalled();
|
|
213
|
+
|
|
214
|
+
if (this.#platform === "launchd") {
|
|
215
|
+
if (this.isRunning) {
|
|
216
|
+
this.#deps.execSync(`launchctl unload ${this.serviceFilePath}`);
|
|
217
|
+
}
|
|
218
|
+
this.#deps.execSync("bun install -g macroclaw@latest");
|
|
219
|
+
this.#deps.execSync(`launchctl load ${this.serviceFilePath}`);
|
|
220
|
+
} else {
|
|
221
|
+
if (this.isRunning) {
|
|
222
|
+
this.#sudo("systemctl stop macroclaw");
|
|
223
|
+
}
|
|
224
|
+
this.#deps.execSync("bun install -g macroclaw@latest");
|
|
225
|
+
this.#sudo("systemctl start macroclaw");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
log.debug("Service updated (reinstalled, restarted)");
|
|
229
|
+
return this.#logTailCommand();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
#resolvePath(binary: string): string {
|
|
233
|
+
try {
|
|
234
|
+
return this.#deps.execSync(`which ${binary}`).trim();
|
|
235
|
+
} catch {
|
|
236
|
+
throw new Error(`Could not resolve ${binary} path. Is it installed?`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#resolveGlobalBinPath(binary: string): string {
|
|
241
|
+
const binDir = this.#deps.execSync("bun pm bin -g").trim();
|
|
242
|
+
const binPath = join(binDir, binary);
|
|
243
|
+
if (!this.#deps.existsSync(binPath)) {
|
|
244
|
+
throw new Error(`Could not find ${binary} in ${binDir}. Is it installed?`);
|
|
245
|
+
}
|
|
246
|
+
return binPath;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
#requireInstalled(): void {
|
|
251
|
+
if (!this.isInstalled) {
|
|
252
|
+
throw new Error("Service not installed. Run `macroclaw service install` first.");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#logTailCommand(): string {
|
|
257
|
+
if (this.#platform === "launchd") {
|
|
258
|
+
const logDir = resolve(this.#deps.home, ".macroclaw/logs");
|
|
259
|
+
return `tail -f ${logDir}/*.log`;
|
|
260
|
+
}
|
|
261
|
+
return "journalctl -u macroclaw -f";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#sudo(cmd: string): void {
|
|
265
|
+
this.#deps.execSync(`sudo ${cmd}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Write unit content to a temp file, then sudo-copy it into /etc/systemd/system/. */
|
|
269
|
+
#writeSystemdUnit(content: string): void {
|
|
270
|
+
const tmpPath = join(this.#deps.tmpdir(), `macroclaw-${this.#deps.randomUUID()}.service`);
|
|
271
|
+
this.#deps.writeFileSync(tmpPath, content);
|
|
272
|
+
try {
|
|
273
|
+
this.#sudo(`cp ${tmpPath} ${this.serviceFilePath}`);
|
|
274
|
+
} finally {
|
|
275
|
+
try { this.#deps.rmSync(tmpPath); } catch { /* best-effort cleanup */ }
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
#resolveLinuxUser(): LinuxUser {
|
|
280
|
+
const info = this.#deps.userInfo();
|
|
281
|
+
const user = info.username;
|
|
282
|
+
const home = info.homedir;
|
|
283
|
+
const group = this.#deps.execSync(`id -gn ${user}`).trim();
|
|
284
|
+
|
|
285
|
+
const settingsPath = resolve(home, ".macroclaw/settings.json");
|
|
286
|
+
if (!this.#deps.existsSync(settingsPath)) {
|
|
287
|
+
throw new Error(`Settings not found. Run \`macroclaw setup\` first.`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return { user, group, home };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
#generateLaunchdPlist(bunPath: string, macroclawPath: string, pathDirs: string[], oauthToken?: string): string {
|
|
294
|
+
const logDir = resolve(this.#deps.home, ".macroclaw/logs");
|
|
295
|
+
const tokenEnv = oauthToken ? `\n\t\t<key>CLAUDE_CODE_OAUTH_TOKEN</key>\n\t\t<string>${oauthToken}</string>` : "";
|
|
296
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
297
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
298
|
+
<plist version="1.0">
|
|
299
|
+
<dict>
|
|
300
|
+
<key>Label</key>
|
|
301
|
+
<string>com.macroclaw</string>
|
|
302
|
+
<key>ProgramArguments</key>
|
|
303
|
+
<array>
|
|
304
|
+
<string>${bunPath}</string>
|
|
305
|
+
<string>${macroclawPath}</string>
|
|
306
|
+
<string>start</string>
|
|
307
|
+
</array>
|
|
308
|
+
<key>KeepAlive</key>
|
|
309
|
+
<true/>
|
|
310
|
+
<key>StandardOutPath</key>
|
|
311
|
+
<string>${logDir}/stdout.log</string>
|
|
312
|
+
<key>StandardErrorPath</key>
|
|
313
|
+
<string>${logDir}/stderr.log</string>
|
|
314
|
+
<key>EnvironmentVariables</key>
|
|
315
|
+
<dict>
|
|
316
|
+
<key>HOME</key>
|
|
317
|
+
<string>${this.#deps.home}</string>
|
|
318
|
+
<key>PATH</key>
|
|
319
|
+
<string>${pathDirs.join(":")}</string>${tokenEnv}
|
|
320
|
+
</dict>
|
|
321
|
+
</dict>
|
|
322
|
+
</plist>
|
|
323
|
+
`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
#generateSystemdUnit(bunPath: string, macroclawPath: string, target: LinuxUser, pathDirs: string[]): string {
|
|
327
|
+
return `[Unit]
|
|
328
|
+
Description=Macroclaw - Telegram-to-Claude-Code bridge
|
|
329
|
+
After=network.target
|
|
330
|
+
|
|
331
|
+
[Service]
|
|
332
|
+
Type=simple
|
|
333
|
+
User=${target.user}
|
|
334
|
+
Group=${target.group}
|
|
335
|
+
Environment=HOME=${target.home}
|
|
336
|
+
Environment=PATH=${pathDirs.join(":")}
|
|
337
|
+
WorkingDirectory=${target.home}
|
|
338
|
+
ExecStart=${bunPath} ${macroclawPath} start
|
|
339
|
+
Restart=on-failure
|
|
340
|
+
RestartSec=5
|
|
341
|
+
|
|
342
|
+
[Install]
|
|
343
|
+
WantedBy=multi-user.target
|
|
344
|
+
`;
|
|
345
|
+
}
|
|
346
|
+
}
|
package/src/setup.test.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
2
|
import type { SetupIO } from "./setup";
|
|
3
3
|
|
|
4
|
+
// Mock child_process so resolveClaudePath doesn't hit real `which`
|
|
5
|
+
mock.module("node:child_process", () => ({
|
|
6
|
+
execSync: (_cmd: string) => "/mock/bin/claude\n",
|
|
7
|
+
}));
|
|
8
|
+
|
|
4
9
|
// Mock Grammy Bot
|
|
5
10
|
const mockBotInit = mock(async () => {});
|
|
6
11
|
const mockBotStart = mock(() => {});
|
|
@@ -42,15 +47,27 @@ mock.module("grammy", () => ({
|
|
|
42
47
|
},
|
|
43
48
|
}));
|
|
44
49
|
|
|
45
|
-
const
|
|
50
|
+
const mockInstall = mock(() => "tail -f /mock/logs");
|
|
51
|
+
|
|
52
|
+
function createMockServiceInstaller() {
|
|
53
|
+
return {
|
|
54
|
+
install: mockInstall,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const { resolveClaudePath, runSetupWizard } = await import("./setup");
|
|
46
59
|
|
|
47
|
-
function createMockIO(inputs: string[]): SetupIO {
|
|
60
|
+
function createMockIO(inputs: string[]): SetupIO & { written: string[]; closed: boolean } {
|
|
48
61
|
let index = 0;
|
|
49
62
|
const written: string[] = [];
|
|
50
|
-
|
|
63
|
+
const io = {
|
|
51
64
|
ask: async () => inputs[index++] ?? "",
|
|
52
65
|
write: (msg: string) => { written.push(msg); },
|
|
66
|
+
close: () => { io.closed = true; },
|
|
67
|
+
written,
|
|
68
|
+
closed: false,
|
|
53
69
|
};
|
|
70
|
+
return io;
|
|
54
71
|
}
|
|
55
72
|
|
|
56
73
|
// Save/restore env vars
|
|
@@ -68,6 +85,7 @@ beforeEach(() => {
|
|
|
68
85
|
mockSetMyCommands.mockClear();
|
|
69
86
|
mockBotCatchHandler = null;
|
|
70
87
|
mockBotCommandHandler = null;
|
|
88
|
+
mockInstall.mockImplementation(() => "tail -f /mock/logs");
|
|
71
89
|
});
|
|
72
90
|
|
|
73
91
|
afterEach(() => {
|
|
@@ -85,6 +103,7 @@ describe("runSetupWizard", () => {
|
|
|
85
103
|
"opus", // model
|
|
86
104
|
"/my/ws", // workspace
|
|
87
105
|
"sk-test", // openai key
|
|
106
|
+
"", // no service install
|
|
88
107
|
]);
|
|
89
108
|
|
|
90
109
|
const settings = await runSetupWizard(io);
|
|
@@ -110,6 +129,7 @@ describe("runSetupWizard", () => {
|
|
|
110
129
|
"", // accept default model
|
|
111
130
|
"", // accept default workspace
|
|
112
131
|
"", // accept default openai key
|
|
132
|
+
"", // no service install
|
|
113
133
|
]);
|
|
114
134
|
|
|
115
135
|
const settings = await runSetupWizard(io);
|
|
@@ -128,6 +148,7 @@ describe("runSetupWizard", () => {
|
|
|
128
148
|
"", // press enter for default model
|
|
129
149
|
"", // press enter for default workspace
|
|
130
150
|
"", // press enter for no openai key
|
|
151
|
+
"", // no service install
|
|
131
152
|
]);
|
|
132
153
|
|
|
133
154
|
const settings = await runSetupWizard(io);
|
|
@@ -144,6 +165,7 @@ describe("runSetupWizard", () => {
|
|
|
144
165
|
"",
|
|
145
166
|
"",
|
|
146
167
|
"",
|
|
168
|
+
"", // no service install
|
|
147
169
|
]);
|
|
148
170
|
|
|
149
171
|
await runSetupWizard(io);
|
|
@@ -167,6 +189,7 @@ describe("runSetupWizard", () => {
|
|
|
167
189
|
"",
|
|
168
190
|
"",
|
|
169
191
|
"",
|
|
192
|
+
"", // no service install
|
|
170
193
|
]);
|
|
171
194
|
|
|
172
195
|
const settings = await runSetupWizard(io);
|
|
@@ -183,6 +206,7 @@ describe("runSetupWizard", () => {
|
|
|
183
206
|
"",
|
|
184
207
|
"",
|
|
185
208
|
"",
|
|
209
|
+
"", // no service install
|
|
186
210
|
]);
|
|
187
211
|
|
|
188
212
|
const settings = await runSetupWizard(io);
|
|
@@ -198,6 +222,7 @@ describe("runSetupWizard", () => {
|
|
|
198
222
|
"",
|
|
199
223
|
"",
|
|
200
224
|
"",
|
|
225
|
+
"", // no service install
|
|
201
226
|
]);
|
|
202
227
|
|
|
203
228
|
const settings = await runSetupWizard(io);
|
|
@@ -212,6 +237,7 @@ describe("runSetupWizard", () => {
|
|
|
212
237
|
"",
|
|
213
238
|
"",
|
|
214
239
|
"",
|
|
240
|
+
"", // no service install
|
|
215
241
|
]);
|
|
216
242
|
|
|
217
243
|
await runSetupWizard(io);
|
|
@@ -229,6 +255,7 @@ describe("runSetupWizard", () => {
|
|
|
229
255
|
"",
|
|
230
256
|
"",
|
|
231
257
|
"",
|
|
258
|
+
"", // no service install
|
|
232
259
|
]);
|
|
233
260
|
|
|
234
261
|
await runSetupWizard(io);
|
|
@@ -238,4 +265,134 @@ describe("runSetupWizard", () => {
|
|
|
238
265
|
mockBotCommandHandler!({ chat: { id: 12345 }, reply: mockReply });
|
|
239
266
|
expect(mockReply).toHaveBeenCalledWith("12345");
|
|
240
267
|
});
|
|
268
|
+
|
|
269
|
+
it("calls onSettingsReady before service install prompt", async () => {
|
|
270
|
+
const order: string[] = [];
|
|
271
|
+
const installer = { install: () => { order.push("install"); return ""; } };
|
|
272
|
+
const onSettingsReady = () => { order.push("save"); };
|
|
273
|
+
const io = createMockIO([
|
|
274
|
+
"tok",
|
|
275
|
+
"123",
|
|
276
|
+
"",
|
|
277
|
+
"",
|
|
278
|
+
"",
|
|
279
|
+
"y",
|
|
280
|
+
"sk-test-token", // oauth token (macOS)
|
|
281
|
+
]);
|
|
282
|
+
|
|
283
|
+
await runSetupWizard(io, { serviceInstaller: installer, onSettingsReady, platform: "darwin" });
|
|
284
|
+
|
|
285
|
+
expect(order).toEqual(["save", "install"]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("closes io before running service install", async () => {
|
|
289
|
+
let closedBeforeInstall = false;
|
|
290
|
+
const io = createMockIO([
|
|
291
|
+
"tok",
|
|
292
|
+
"123",
|
|
293
|
+
"",
|
|
294
|
+
"",
|
|
295
|
+
"",
|
|
296
|
+
"y",
|
|
297
|
+
"sk-test-token", // oauth token (macOS)
|
|
298
|
+
]);
|
|
299
|
+
const installer = { install: () => { closedBeforeInstall = io.closed; return ""; } };
|
|
300
|
+
|
|
301
|
+
await runSetupWizard(io, { serviceInstaller: installer, platform: "darwin" });
|
|
302
|
+
|
|
303
|
+
expect(closedBeforeInstall).toBe(true);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("installs service when user answers yes", async () => {
|
|
307
|
+
mockInstall.mockClear();
|
|
308
|
+
const installer = createMockServiceInstaller();
|
|
309
|
+
const io = createMockIO([
|
|
310
|
+
"tok",
|
|
311
|
+
"123",
|
|
312
|
+
"",
|
|
313
|
+
"",
|
|
314
|
+
"",
|
|
315
|
+
"y",
|
|
316
|
+
"sk-test-token", // oauth token (macOS)
|
|
317
|
+
]);
|
|
318
|
+
|
|
319
|
+
await runSetupWizard(io, { serviceInstaller: installer, platform: "darwin" });
|
|
320
|
+
|
|
321
|
+
expect(mockInstall).toHaveBeenCalled();
|
|
322
|
+
expect(io.written).toContainEqual(expect.stringContaining("Service installed and started."));
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("skips service install when user answers no", async () => {
|
|
326
|
+
mockInstall.mockClear();
|
|
327
|
+
const installer = createMockServiceInstaller();
|
|
328
|
+
const io = createMockIO([
|
|
329
|
+
"tok",
|
|
330
|
+
"123",
|
|
331
|
+
"",
|
|
332
|
+
"",
|
|
333
|
+
"",
|
|
334
|
+
"n", // no to service install
|
|
335
|
+
]);
|
|
336
|
+
|
|
337
|
+
await runSetupWizard(io, { serviceInstaller: installer });
|
|
338
|
+
|
|
339
|
+
expect(mockInstall).not.toHaveBeenCalled();
|
|
340
|
+
expect(io.closed).toBe(true);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("skips service install when oauth token is empty on macOS", async () => {
|
|
344
|
+
mockInstall.mockClear();
|
|
345
|
+
const installer = createMockServiceInstaller();
|
|
346
|
+
const io = createMockIO([
|
|
347
|
+
"tok",
|
|
348
|
+
"123",
|
|
349
|
+
"",
|
|
350
|
+
"",
|
|
351
|
+
"",
|
|
352
|
+
"y",
|
|
353
|
+
"", // empty oauth token
|
|
354
|
+
]);
|
|
355
|
+
|
|
356
|
+
const settings = await runSetupWizard(io, { serviceInstaller: installer, platform: "darwin" });
|
|
357
|
+
|
|
358
|
+
expect(mockInstall).not.toHaveBeenCalled();
|
|
359
|
+
expect(io.written).toContainEqual(expect.stringContaining("No token provided"));
|
|
360
|
+
expect(settings.botToken).toBe("tok");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("handles service install failure gracefully", async () => {
|
|
364
|
+
mockInstall.mockImplementation(() => { throw new Error("Permission denied"); });
|
|
365
|
+
const installer = createMockServiceInstaller();
|
|
366
|
+
const io = createMockIO([
|
|
367
|
+
"tok",
|
|
368
|
+
"123",
|
|
369
|
+
"",
|
|
370
|
+
"",
|
|
371
|
+
"",
|
|
372
|
+
"yes",
|
|
373
|
+
"sk-test-token", // oauth token (macOS)
|
|
374
|
+
]);
|
|
375
|
+
|
|
376
|
+
await runSetupWizard(io, { serviceInstaller: installer, platform: "darwin" });
|
|
377
|
+
|
|
378
|
+
expect(io.written).toContainEqual(expect.stringContaining("Service installation failed: Permission denied"));
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("fails fast when claude CLI is not found", async () => {
|
|
382
|
+
const io = createMockIO([]);
|
|
383
|
+
await expect(
|
|
384
|
+
runSetupWizard(io, { resolveClaude: () => { throw new Error("Claude Code CLI not found."); } }),
|
|
385
|
+
).rejects.toThrow("Claude Code CLI not found.");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("resolveClaudePath returns trimmed path on success", () => {
|
|
389
|
+
const result = resolveClaudePath(() => "/usr/local/bin/claude\n");
|
|
390
|
+
expect(result).toBe("/usr/local/bin/claude");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("resolveClaudePath throws when claude is not found", () => {
|
|
394
|
+
expect(() => resolveClaudePath(() => { throw new Error("not found"); })).toThrow(
|
|
395
|
+
"Claude Code CLI not found.",
|
|
396
|
+
);
|
|
397
|
+
});
|
|
241
398
|
});
|