macroclaw 0.12.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/README.md +4 -3
- package/package.json +1 -1
- package/src/cli.test.ts +91 -131
- package/src/cli.ts +38 -77
- package/src/index.ts +7 -14
- package/src/logger.test.ts +7 -7
- package/src/logger.ts +1 -1
- package/src/settings.test.ts +65 -28
- package/src/settings.ts +81 -60
- package/src/setup.test.ts +63 -67
- package/src/setup.ts +159 -117
- package/src/system-service.test.ts +699 -0
- package/src/{service.ts → system-service.ts} +53 -89
- package/src/service.test.ts +0 -702
package/src/settings.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
2
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { type Settings, SettingsManager } from "./settings";
|
|
5
5
|
|
|
6
6
|
const tmpDir = "/tmp/macroclaw-settings-test";
|
|
7
7
|
|
|
@@ -17,9 +17,14 @@ afterEach(() => {
|
|
|
17
17
|
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true });
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
-
describe("
|
|
21
|
-
it("
|
|
22
|
-
|
|
20
|
+
describe("SettingsManager.load", () => {
|
|
21
|
+
it("exits when file does not exist", () => {
|
|
22
|
+
const mockExit = mock((_code?: number) => { throw new Error("exit"); });
|
|
23
|
+
const origExit = process.exit;
|
|
24
|
+
process.exit = mockExit as typeof process.exit;
|
|
25
|
+
expect(() => new SettingsManager(tmpDir).load()).toThrow("exit");
|
|
26
|
+
process.exit = origExit;
|
|
27
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
23
28
|
});
|
|
24
29
|
|
|
25
30
|
it("reads and validates settings from file", () => {
|
|
@@ -28,7 +33,7 @@ describe("loadSettings", () => {
|
|
|
28
33
|
botToken: "123:ABC",
|
|
29
34
|
chatId: "12345678",
|
|
30
35
|
}));
|
|
31
|
-
const settings =
|
|
36
|
+
const settings = new SettingsManager(tmpDir).load();
|
|
32
37
|
expect(settings).toEqual({
|
|
33
38
|
botToken: "123:ABC",
|
|
34
39
|
chatId: "12345678",
|
|
@@ -49,7 +54,7 @@ describe("loadSettings", () => {
|
|
|
49
54
|
logLevel: "info",
|
|
50
55
|
pinoramaUrl: "http://localhost:6200",
|
|
51
56
|
}));
|
|
52
|
-
const settings =
|
|
57
|
+
const settings = new SettingsManager(tmpDir).load();
|
|
53
58
|
expect(settings).toEqual({
|
|
54
59
|
botToken: "tok",
|
|
55
60
|
chatId: "123",
|
|
@@ -70,7 +75,7 @@ describe("loadSettings", () => {
|
|
|
70
75
|
process.exit = mockExit as any;
|
|
71
76
|
|
|
72
77
|
try {
|
|
73
|
-
|
|
78
|
+
new SettingsManager(tmpDir).load();
|
|
74
79
|
} catch {
|
|
75
80
|
// expected
|
|
76
81
|
}
|
|
@@ -88,7 +93,7 @@ describe("loadSettings", () => {
|
|
|
88
93
|
process.exit = mockExit as any;
|
|
89
94
|
|
|
90
95
|
try {
|
|
91
|
-
|
|
96
|
+
new SettingsManager(tmpDir).load();
|
|
92
97
|
} catch {
|
|
93
98
|
// expected
|
|
94
99
|
}
|
|
@@ -110,7 +115,7 @@ describe("loadSettings", () => {
|
|
|
110
115
|
process.exit = mockExit as any;
|
|
111
116
|
|
|
112
117
|
try {
|
|
113
|
-
|
|
118
|
+
new SettingsManager(tmpDir).load();
|
|
114
119
|
} catch {
|
|
115
120
|
// expected
|
|
116
121
|
}
|
|
@@ -120,22 +125,48 @@ describe("loadSettings", () => {
|
|
|
120
125
|
});
|
|
121
126
|
});
|
|
122
127
|
|
|
123
|
-
describe("
|
|
128
|
+
describe("SettingsManager.save", () => {
|
|
124
129
|
it("creates directory and writes file", () => {
|
|
125
|
-
|
|
126
|
-
|
|
130
|
+
const mgr = new SettingsManager(tmpDir);
|
|
131
|
+
mgr.save(validSettings);
|
|
132
|
+
const saved = mgr.load();
|
|
127
133
|
expect(saved).toEqual(validSettings);
|
|
128
134
|
});
|
|
129
135
|
|
|
130
136
|
it("overwrites existing file", () => {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
137
|
+
const mgr = new SettingsManager(tmpDir);
|
|
138
|
+
mgr.save(validSettings);
|
|
139
|
+
const updated = { ...validSettings, model: "opus" as const };
|
|
140
|
+
mgr.save(updated);
|
|
141
|
+
expect(mgr.load()).toEqual(updated);
|
|
135
142
|
});
|
|
136
143
|
});
|
|
137
144
|
|
|
138
|
-
describe("
|
|
145
|
+
describe("SettingsManager.loadRaw", () => {
|
|
146
|
+
it("returns null when file does not exist", () => {
|
|
147
|
+
expect(new SettingsManager("/nonexistent/path").loadRaw()).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("reads and parses valid settings file", () => {
|
|
151
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
152
|
+
writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({ botToken: "tok", chatId: "123" }));
|
|
153
|
+
expect(new SettingsManager(tmpDir).loadRaw()).toEqual({ botToken: "tok", chatId: "123" });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("returns null for invalid JSON", () => {
|
|
157
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
158
|
+
writeFileSync(join(tmpDir, "settings.json"), "not json");
|
|
159
|
+
expect(new SettingsManager(tmpDir).loadRaw()).toBeNull();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("returns null for non-object JSON", () => {
|
|
163
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
164
|
+
writeFileSync(join(tmpDir, "settings.json"), '"just a string"');
|
|
165
|
+
expect(new SettingsManager(tmpDir).loadRaw()).toBeNull();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("SettingsManager.applyEnvOverrides", () => {
|
|
139
170
|
const envVars = [
|
|
140
171
|
"TELEGRAM_BOT_TOKEN", "AUTHORIZED_CHAT_ID", "MODEL",
|
|
141
172
|
"WORKSPACE", "OPENAI_API_KEY", "LOG_LEVEL", "PINORAMA_URL",
|
|
@@ -157,14 +188,14 @@ describe("applyEnvOverrides", () => {
|
|
|
157
188
|
});
|
|
158
189
|
|
|
159
190
|
it("returns original settings when no env vars set", () => {
|
|
160
|
-
const { settings, overrides } = applyEnvOverrides(validSettings);
|
|
191
|
+
const { settings, overrides } = new SettingsManager(tmpDir).applyEnvOverrides(validSettings);
|
|
161
192
|
expect(settings).toEqual(validSettings);
|
|
162
193
|
expect(overrides.size).toBe(0);
|
|
163
194
|
});
|
|
164
195
|
|
|
165
196
|
it("overrides model from MODEL env var", () => {
|
|
166
197
|
process.env.MODEL = "opus";
|
|
167
|
-
const { settings, overrides } = applyEnvOverrides(validSettings);
|
|
198
|
+
const { settings, overrides } = new SettingsManager(tmpDir).applyEnvOverrides(validSettings);
|
|
168
199
|
expect(settings.model).toBe("opus");
|
|
169
200
|
expect(overrides.has("model")).toBe(true);
|
|
170
201
|
});
|
|
@@ -173,7 +204,7 @@ describe("applyEnvOverrides", () => {
|
|
|
173
204
|
process.env.TELEGRAM_BOT_TOKEN = "override-token";
|
|
174
205
|
process.env.AUTHORIZED_CHAT_ID = "99999";
|
|
175
206
|
process.env.OPENAI_API_KEY = "sk-override";
|
|
176
|
-
const { settings, overrides } = applyEnvOverrides(validSettings);
|
|
207
|
+
const { settings, overrides } = new SettingsManager(tmpDir).applyEnvOverrides(validSettings);
|
|
177
208
|
expect(settings.botToken).toBe("override-token");
|
|
178
209
|
expect(settings.chatId).toBe("99999");
|
|
179
210
|
expect(settings.openaiApiKey).toBe("sk-override");
|
|
@@ -184,7 +215,7 @@ describe("applyEnvOverrides", () => {
|
|
|
184
215
|
process.env.WORKSPACE = "/override/path";
|
|
185
216
|
process.env.LOG_LEVEL = "error";
|
|
186
217
|
process.env.PINORAMA_URL = "http://override:6200";
|
|
187
|
-
const { settings, overrides } = applyEnvOverrides(validSettings);
|
|
218
|
+
const { settings, overrides } = new SettingsManager(tmpDir).applyEnvOverrides(validSettings);
|
|
188
219
|
expect(settings.workspace).toBe("/override/path");
|
|
189
220
|
expect(settings.logLevel).toBe("error");
|
|
190
221
|
expect(settings.pinoramaUrl).toBe("http://override:6200");
|
|
@@ -194,13 +225,13 @@ describe("applyEnvOverrides", () => {
|
|
|
194
225
|
});
|
|
195
226
|
});
|
|
196
227
|
|
|
197
|
-
describe("
|
|
228
|
+
describe("SettingsManager.print", () => {
|
|
198
229
|
it("does not throw", () => {
|
|
199
|
-
expect(() =>
|
|
230
|
+
expect(() => new SettingsManager(tmpDir).print(validSettings, new Set())).not.toThrow();
|
|
200
231
|
});
|
|
201
232
|
|
|
202
233
|
it("does not throw with overrides", () => {
|
|
203
|
-
expect(() =>
|
|
234
|
+
expect(() => new SettingsManager(tmpDir).print(validSettings, new Set(["model"]))).not.toThrow();
|
|
204
235
|
});
|
|
205
236
|
|
|
206
237
|
it("does not throw with optional fields set", () => {
|
|
@@ -209,12 +240,18 @@ describe("printSettings", () => {
|
|
|
209
240
|
openaiApiKey: "sk-1234567890",
|
|
210
241
|
pinoramaUrl: "http://localhost:6200",
|
|
211
242
|
};
|
|
212
|
-
expect(() =>
|
|
243
|
+
expect(() => new SettingsManager(tmpDir).print(full, new Set(["model", "openaiApiKey"]))).not.toThrow();
|
|
213
244
|
});
|
|
214
245
|
|
|
215
246
|
it("masks botToken showing only last 4 chars", () => {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
247
|
+
expect(() => new SettingsManager(tmpDir).print({ ...validSettings, botToken: "ab" }, new Set())).not.toThrow();
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("SettingsManager.envMapping", () => {
|
|
252
|
+
it("is a static property with all settings keys", () => {
|
|
253
|
+
expect(SettingsManager.envMapping.botToken).toBe("TELEGRAM_BOT_TOKEN");
|
|
254
|
+
expect(SettingsManager.envMapping.chatId).toBe("AUTHORIZED_CHAT_ID");
|
|
255
|
+
expect(SettingsManager.envMapping.model).toBe("MODEL");
|
|
219
256
|
});
|
|
220
257
|
});
|
package/src/settings.ts
CHANGED
|
@@ -7,8 +7,8 @@ const log = createLogger("settings");
|
|
|
7
7
|
|
|
8
8
|
export const settingsSchema = z.object({
|
|
9
9
|
botToken: z.string(),
|
|
10
|
-
chatId: z.string(),
|
|
11
|
-
model: z.
|
|
10
|
+
chatId: z.string().regex(/^-?\d+$/, "Must be a numeric Telegram chat ID"),
|
|
11
|
+
model: z.enum(["haiku", "sonnet", "opus"]).default("sonnet"),
|
|
12
12
|
workspace: z.string().default("~/.macroclaw-workspace"),
|
|
13
13
|
openaiApiKey: z.string().optional(),
|
|
14
14
|
logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
|
@@ -17,78 +17,99 @@ export const settingsSchema = z.object({
|
|
|
17
17
|
|
|
18
18
|
export type Settings = z.infer<typeof settingsSchema>;
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
export function maskValue(key: string, value: string | undefined): string {
|
|
21
|
+
if (value === undefined) return "(not set)";
|
|
22
|
+
if (key === "botToken" || key === "openaiApiKey") {
|
|
23
|
+
return value.length > 4 ? `****${value.slice(-4)}` : "****";
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
21
27
|
|
|
22
|
-
|
|
23
|
-
const path = join(dir, "settings.json");
|
|
24
|
-
if (!existsSync(path)) return null as unknown as Settings;
|
|
28
|
+
const defaultDir = resolve(process.env.HOME || "~", ".macroclaw");
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
export class SettingsManager {
|
|
31
|
+
readonly #dir: string;
|
|
32
|
+
|
|
33
|
+
static readonly envMapping: Record<keyof Settings, string> = {
|
|
34
|
+
botToken: "TELEGRAM_BOT_TOKEN",
|
|
35
|
+
chatId: "AUTHORIZED_CHAT_ID",
|
|
36
|
+
model: "MODEL",
|
|
37
|
+
workspace: "WORKSPACE",
|
|
38
|
+
openaiApiKey: "OPENAI_API_KEY",
|
|
39
|
+
logLevel: "LOG_LEVEL",
|
|
40
|
+
pinoramaUrl: "PINORAMA_URL",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
constructor(dir: string = defaultDir) {
|
|
44
|
+
this.#dir = dir;
|
|
32
45
|
}
|
|
33
46
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
log.error({ issues: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) }, "settings.json validation failed");
|
|
37
|
-
process.exit(1);
|
|
47
|
+
get dir(): string {
|
|
48
|
+
return this.#dir;
|
|
38
49
|
}
|
|
39
50
|
|
|
40
|
-
|
|
41
|
-
|
|
51
|
+
load(): Settings {
|
|
52
|
+
const path = join(this.#dir, "settings.json");
|
|
53
|
+
if (!existsSync(path)) {
|
|
54
|
+
log.error({ path }, "settings.json not found. Run `macroclaw setup` first.");
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
42
57
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
58
|
+
let raw: unknown;
|
|
59
|
+
try {
|
|
60
|
+
raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
61
|
+
} catch {
|
|
62
|
+
raw = null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const result = settingsSchema.safeParse(raw);
|
|
66
|
+
if (!result.success) {
|
|
67
|
+
log.error({ issues: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) }, "settings.json validation failed");
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return result.data;
|
|
72
|
+
}
|
|
47
73
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
logLevel: "LOG_LEVEL",
|
|
57
|
-
pinoramaUrl: "PINORAMA_URL",
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
export function applyEnvOverrides(settings: Settings): { settings: Settings; overrides: Set<string> } {
|
|
61
|
-
const merged = { ...settings };
|
|
62
|
-
const overrides = new Set<string>();
|
|
63
|
-
|
|
64
|
-
for (const [key, envVar] of Object.entries(envMapping)) {
|
|
65
|
-
const value = process.env[envVar];
|
|
66
|
-
if (value !== undefined) {
|
|
67
|
-
(merged as Record<string, unknown>)[key] = value;
|
|
68
|
-
overrides.add(key);
|
|
74
|
+
loadRaw(): Record<string, unknown> | null {
|
|
75
|
+
const path = join(this.#dir, "settings.json");
|
|
76
|
+
if (!existsSync(path)) return null;
|
|
77
|
+
try {
|
|
78
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
79
|
+
return typeof raw === "object" && raw !== null ? raw : null;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
69
82
|
}
|
|
70
83
|
}
|
|
71
84
|
|
|
72
|
-
|
|
73
|
-
}
|
|
85
|
+
save(settings: Settings): void {
|
|
86
|
+
mkdirSync(this.#dir, { recursive: true });
|
|
87
|
+
writeFileSync(join(this.#dir, "settings.json"), `${JSON.stringify(settings, null, 2)}\n`);
|
|
88
|
+
}
|
|
74
89
|
|
|
75
|
-
|
|
90
|
+
applyEnvOverrides(settings: Settings): { settings: Settings; overrides: Set<string> } {
|
|
91
|
+
const merged = { ...settings };
|
|
92
|
+
const overrides = new Set<string>();
|
|
76
93
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
94
|
+
for (const [key, envVar] of Object.entries(SettingsManager.envMapping)) {
|
|
95
|
+
const value = process.env[envVar];
|
|
96
|
+
if (value !== undefined) {
|
|
97
|
+
(merged as Record<string, unknown>)[key] = value;
|
|
98
|
+
overrides.add(key);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { settings: merged, overrides };
|
|
81
103
|
}
|
|
82
|
-
return value;
|
|
83
|
-
}
|
|
84
104
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
105
|
+
print(settings: Settings, overrides: Set<string>): void {
|
|
106
|
+
const lines = ["Settings:"];
|
|
107
|
+
for (const key of Object.keys(SettingsManager.envMapping) as (keyof Settings)[]) {
|
|
108
|
+
const value = settings[key];
|
|
109
|
+
const masked = maskValue(key, value);
|
|
110
|
+
const suffix = overrides.has(key) ? " (env)" : "";
|
|
111
|
+
lines.push(` ${key}: ${masked}${suffix}`);
|
|
112
|
+
}
|
|
113
|
+
log.info(lines.join("\n"));
|
|
92
114
|
}
|
|
93
|
-
log.info(lines.join("\n"));
|
|
94
115
|
}
|