openclaw-warden 0.1.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/LICENSE +21 -0
- package/README.md +221 -0
- package/package.json +44 -0
- package/src/check-agent-probe.js +60 -0
- package/src/check-health.js +89 -0
- package/src/warden.js +994 -0
package/src/warden.js
ADDED
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import fsp from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import Ajv from "ajv";
|
|
10
|
+
import addFormats from "ajv-formats";
|
|
11
|
+
|
|
12
|
+
const CWD = process.cwd();
|
|
13
|
+
const DEFAULT_CONFIG_NAME = "warden.config.json";
|
|
14
|
+
const DEFAULT_CONFIG_PATH = path.join(CWD, DEFAULT_CONFIG_NAME);
|
|
15
|
+
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const DAEMON_DIR = path.join(os.tmpdir(), "openclaw-warden");
|
|
17
|
+
const DAEMON_PID = path.join(DAEMON_DIR, "warden.pid");
|
|
18
|
+
const DAEMON_LOG = path.join(DAEMON_DIR, "warden.log");
|
|
19
|
+
|
|
20
|
+
function resolveGlobalConfigDir() {
|
|
21
|
+
if (process.platform === "darwin") {
|
|
22
|
+
return path.join(
|
|
23
|
+
os.homedir(),
|
|
24
|
+
"Library",
|
|
25
|
+
"Application Support",
|
|
26
|
+
"openclaw-warden",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
if (process.platform === "win32") {
|
|
30
|
+
const appData =
|
|
31
|
+
process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
|
32
|
+
return path.join(appData, "openclaw-warden");
|
|
33
|
+
}
|
|
34
|
+
const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
35
|
+
return path.join(xdg, "openclaw-warden");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resolveConfigPath() {
|
|
39
|
+
if (process.env.WARDEN_CONFIG) return process.env.WARDEN_CONFIG;
|
|
40
|
+
if (fs.existsSync(DEFAULT_CONFIG_PATH)) return DEFAULT_CONFIG_PATH;
|
|
41
|
+
return path.join(resolveGlobalConfigDir(), DEFAULT_CONFIG_NAME);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isProcessAlive(pid) {
|
|
45
|
+
if (!pid) return false;
|
|
46
|
+
try {
|
|
47
|
+
process.kill(pid, 0);
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function readPid() {
|
|
55
|
+
try {
|
|
56
|
+
const raw = await fsp.readFile(DAEMON_PID, "utf8");
|
|
57
|
+
const pid = Number(raw.trim());
|
|
58
|
+
return Number.isFinite(pid) ? pid : null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function writePid(pid) {
|
|
65
|
+
await ensureDir(path.dirname(DAEMON_PID));
|
|
66
|
+
await fsp.writeFile(DAEMON_PID, `${pid}\n`, "utf8");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function clearPid() {
|
|
70
|
+
try {
|
|
71
|
+
await fsp.unlink(DAEMON_PID);
|
|
72
|
+
} catch {
|
|
73
|
+
// ignore
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getDefaultConfig() {
|
|
78
|
+
return {
|
|
79
|
+
paths: {
|
|
80
|
+
repoConfig: "./config/openclaw.json",
|
|
81
|
+
liveConfig: "~/.openclaw/openclaw.json",
|
|
82
|
+
schemaFile: "./state/schema.json",
|
|
83
|
+
},
|
|
84
|
+
schema: {
|
|
85
|
+
source: "git",
|
|
86
|
+
repoUrl: "https://github.com/openclaw/openclaw.git",
|
|
87
|
+
ref: "main",
|
|
88
|
+
checkoutDir: "./state/openclaw",
|
|
89
|
+
useLocalDeps: true,
|
|
90
|
+
exportCommand:
|
|
91
|
+
"node --import tsx -e \"import { buildConfigSchema } from './src/config/schema.ts'; console.log(JSON.stringify(buildConfigSchema(), null, 2));\"",
|
|
92
|
+
},
|
|
93
|
+
git: {
|
|
94
|
+
enabled: true,
|
|
95
|
+
autoInit: true,
|
|
96
|
+
},
|
|
97
|
+
heartbeat: {
|
|
98
|
+
intervalMinutes: 5,
|
|
99
|
+
waitSeconds: [30, 40, 50],
|
|
100
|
+
checkCommand: "node ./src/check-health.js",
|
|
101
|
+
agentProbe: {
|
|
102
|
+
enabled: true,
|
|
103
|
+
fallbackAgentId: "main",
|
|
104
|
+
command: "node ./src/check-agent-probe.js",
|
|
105
|
+
},
|
|
106
|
+
notifyOnRestart: true,
|
|
107
|
+
notifyCommand:
|
|
108
|
+
'openclaw agent --agent {agentId} -m "[warden] gateway restarted after failed health check" --channel last --deliver',
|
|
109
|
+
restartCommand: "openclaw gateway restart",
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function nowIso() {
|
|
115
|
+
return new Date().toISOString();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let logFilePath = null;
|
|
119
|
+
|
|
120
|
+
async function appendLog(line) {
|
|
121
|
+
if (!logFilePath) return;
|
|
122
|
+
try {
|
|
123
|
+
await ensureDir(path.dirname(logFilePath));
|
|
124
|
+
await fsp.appendFile(logFilePath, `${line}\n`, "utf8");
|
|
125
|
+
} catch {
|
|
126
|
+
// Swallow log file errors to avoid breaking the monitor loop.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function logInfo(msg) {
|
|
131
|
+
const line = `[${nowIso()}] ${msg}`;
|
|
132
|
+
console.log(line);
|
|
133
|
+
void appendLog(line);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function logWarn(msg) {
|
|
137
|
+
const line = `[${nowIso()}] WARN: ${msg}`;
|
|
138
|
+
console.warn(line);
|
|
139
|
+
void appendLog(line);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function logError(msg) {
|
|
143
|
+
const line = `[${nowIso()}] ERROR: ${msg}`;
|
|
144
|
+
console.error(line);
|
|
145
|
+
void appendLog(line);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function expandHome(inputPath) {
|
|
149
|
+
if (!inputPath) return inputPath;
|
|
150
|
+
if (inputPath.startsWith("~/")) {
|
|
151
|
+
return path.join(os.homedir(), inputPath.slice(2));
|
|
152
|
+
}
|
|
153
|
+
return inputPath;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function resolvePath(inputPath) {
|
|
157
|
+
if (!inputPath) return inputPath;
|
|
158
|
+
const expanded = expandHome(inputPath);
|
|
159
|
+
return path.isAbsolute(expanded) ? expanded : path.resolve(CWD, expanded);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function resolvePathWithBase(inputPath, baseDir) {
|
|
163
|
+
if (!inputPath) return inputPath;
|
|
164
|
+
const expanded = expandHome(inputPath);
|
|
165
|
+
return path.isAbsolute(expanded) ? expanded : path.resolve(baseDir, expanded);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function readJson(filePath) {
|
|
169
|
+
const raw = await fsp.readFile(filePath, "utf8");
|
|
170
|
+
return JSON.parse(raw);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function writeJson(filePath, value) {
|
|
174
|
+
const data = JSON.stringify(value, null, 2);
|
|
175
|
+
await fsp.writeFile(filePath, data, "utf8");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function ensureDir(dirPath) {
|
|
179
|
+
await fsp.mkdir(dirPath, { recursive: true });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function fileSha256(filePath) {
|
|
183
|
+
const data = await fsp.readFile(filePath);
|
|
184
|
+
return crypto.createHash("sha256").update(data).digest("hex");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function formatCmd(template, vars) {
|
|
188
|
+
let out = template;
|
|
189
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
190
|
+
out = out.replaceAll(`{${key}}`, String(value));
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function buildCommand(template, config, extraVars) {
|
|
196
|
+
const configDir = path.dirname(config.__configPath || DEFAULT_CONFIG_PATH);
|
|
197
|
+
const repoConfigPath = resolvePathWithBase(
|
|
198
|
+
config.paths.repoConfig,
|
|
199
|
+
configDir,
|
|
200
|
+
);
|
|
201
|
+
const liveConfigPath = resolvePathWithBase(
|
|
202
|
+
config.paths.liveConfig,
|
|
203
|
+
configDir,
|
|
204
|
+
);
|
|
205
|
+
const healthCachePath = path.join(
|
|
206
|
+
os.tmpdir(),
|
|
207
|
+
"openclaw-warden",
|
|
208
|
+
"health.json",
|
|
209
|
+
);
|
|
210
|
+
let healthCache = null;
|
|
211
|
+
if (fs.existsSync(healthCachePath)) {
|
|
212
|
+
try {
|
|
213
|
+
const raw = fs.readFileSync(healthCachePath, "utf8");
|
|
214
|
+
healthCache = JSON.parse(raw);
|
|
215
|
+
} catch {
|
|
216
|
+
healthCache = null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const baseVars = {
|
|
220
|
+
repoConfig: repoConfigPath,
|
|
221
|
+
liveConfig: liveConfigPath,
|
|
222
|
+
target: config.heartbeat?.target ?? "",
|
|
223
|
+
agentId:
|
|
224
|
+
healthCache?.agentId ??
|
|
225
|
+
config.heartbeat?.agentProbe?.fallbackAgentId ??
|
|
226
|
+
"",
|
|
227
|
+
sessionId: healthCache?.sessionId ?? "",
|
|
228
|
+
sessionKey: healthCache?.sessionKey ?? "",
|
|
229
|
+
};
|
|
230
|
+
return formatCmd(template, { ...baseVars, ...extraVars });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function execShell(command, options = {}) {
|
|
234
|
+
return await new Promise((resolve) => {
|
|
235
|
+
const child = spawn(command, {
|
|
236
|
+
shell: true,
|
|
237
|
+
cwd: options.cwd || CWD,
|
|
238
|
+
env: options.env || process.env,
|
|
239
|
+
});
|
|
240
|
+
let stdout = "";
|
|
241
|
+
let stderr = "";
|
|
242
|
+
child.stdout.on("data", (d) => {
|
|
243
|
+
stdout += d.toString();
|
|
244
|
+
});
|
|
245
|
+
child.stderr.on("data", (d) => {
|
|
246
|
+
stderr += d.toString();
|
|
247
|
+
});
|
|
248
|
+
child.on("close", (code) => {
|
|
249
|
+
resolve({ code, stdout, stderr });
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function loadConfig() {
|
|
255
|
+
const configPath = resolveConfigPath();
|
|
256
|
+
if (!fs.existsSync(configPath)) {
|
|
257
|
+
throw new Error(`Config not found: ${configPath}`);
|
|
258
|
+
}
|
|
259
|
+
const config = await readJson(configPath);
|
|
260
|
+
config.__configPath = configPath;
|
|
261
|
+
if (config?.logging?.file) {
|
|
262
|
+
logFilePath = resolvePath(config.logging.file);
|
|
263
|
+
} else {
|
|
264
|
+
logFilePath = DAEMON_LOG;
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
config,
|
|
268
|
+
configPath,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function daemonStart() {
|
|
273
|
+
const existingPid = await readPid();
|
|
274
|
+
if (existingPid && isProcessAlive(existingPid)) {
|
|
275
|
+
logInfo(`Daemon already running (pid ${existingPid}).`);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
await ensureDir(DAEMON_DIR);
|
|
279
|
+
const out = fs.openSync(DAEMON_LOG, "a");
|
|
280
|
+
const err = fs.openSync(DAEMON_LOG, "a");
|
|
281
|
+
const child = spawn(
|
|
282
|
+
process.execPath,
|
|
283
|
+
[fileURLToPath(import.meta.url), "run"],
|
|
284
|
+
{
|
|
285
|
+
detached: true,
|
|
286
|
+
stdio: ["ignore", out, err],
|
|
287
|
+
env: { ...process.env, WARDEN_DAEMON: "1" },
|
|
288
|
+
},
|
|
289
|
+
);
|
|
290
|
+
await writePid(child.pid);
|
|
291
|
+
child.unref();
|
|
292
|
+
logInfo(`Daemon started (pid ${child.pid}).`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function daemonStop() {
|
|
296
|
+
const pid = await readPid();
|
|
297
|
+
if (!pid) {
|
|
298
|
+
logWarn("Daemon pid not found.");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
process.kill(pid, "SIGTERM");
|
|
303
|
+
} catch (err) {
|
|
304
|
+
logWarn(`Failed to stop daemon: ${String(err)}`);
|
|
305
|
+
}
|
|
306
|
+
await clearPid();
|
|
307
|
+
logInfo(`Daemon stopped (pid ${pid}).`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function daemonStatus() {
|
|
311
|
+
const pid = await readPid();
|
|
312
|
+
if (pid && isProcessAlive(pid)) {
|
|
313
|
+
logInfo(`Daemon running (pid ${pid}).`);
|
|
314
|
+
} else {
|
|
315
|
+
logInfo("Daemon not running.");
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function resolveWardenCommand() {
|
|
320
|
+
if (process.env.WARDEN_COMMAND) return process.env.WARDEN_COMMAND;
|
|
321
|
+
return "openclaw-warden";
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function renderSystemdService(configPath) {
|
|
325
|
+
const execCmd = resolveWardenCommand();
|
|
326
|
+
return `[Unit]
|
|
327
|
+
Description=OpenClaw Warden
|
|
328
|
+
After=network.target
|
|
329
|
+
|
|
330
|
+
[Service]
|
|
331
|
+
Type=simple
|
|
332
|
+
Environment=WARDEN_CONFIG=${configPath}
|
|
333
|
+
ExecStart=${execCmd} run
|
|
334
|
+
Restart=always
|
|
335
|
+
RestartSec=5
|
|
336
|
+
|
|
337
|
+
[Install]
|
|
338
|
+
WantedBy=default.target
|
|
339
|
+
`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function renderLaunchdService(configPath) {
|
|
343
|
+
const execCmd = resolveWardenCommand();
|
|
344
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
345
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
346
|
+
<plist version="1.0">
|
|
347
|
+
<dict>
|
|
348
|
+
<key>Label</key>
|
|
349
|
+
<string>ai.openclaw.warden</string>
|
|
350
|
+
<key>ProgramArguments</key>
|
|
351
|
+
<array>
|
|
352
|
+
<string>${execCmd}</string>
|
|
353
|
+
<string>run</string>
|
|
354
|
+
</array>
|
|
355
|
+
<key>EnvironmentVariables</key>
|
|
356
|
+
<dict>
|
|
357
|
+
<key>WARDEN_CONFIG</key>
|
|
358
|
+
<string>${configPath}</string>
|
|
359
|
+
</dict>
|
|
360
|
+
<key>KeepAlive</key>
|
|
361
|
+
<true/>
|
|
362
|
+
<key>RunAtLoad</key>
|
|
363
|
+
<true/>
|
|
364
|
+
<key>StandardOutPath</key>
|
|
365
|
+
<string>${DAEMON_LOG}</string>
|
|
366
|
+
<key>StandardErrorPath</key>
|
|
367
|
+
<string>${DAEMON_LOG}</string>
|
|
368
|
+
</dict>
|
|
369
|
+
</plist>
|
|
370
|
+
`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function renderWindowsTask(configPath) {
|
|
374
|
+
const execCmd = resolveWardenCommand();
|
|
375
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
376
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
377
|
+
<RegistrationInfo>
|
|
378
|
+
<Name>OpenClawWarden</Name>
|
|
379
|
+
</RegistrationInfo>
|
|
380
|
+
<Triggers>
|
|
381
|
+
<LogonTrigger>
|
|
382
|
+
<Enabled>true</Enabled>
|
|
383
|
+
</LogonTrigger>
|
|
384
|
+
</Triggers>
|
|
385
|
+
<Principals>
|
|
386
|
+
<Principal id="Author">
|
|
387
|
+
<LogonType>InteractiveToken</LogonType>
|
|
388
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
389
|
+
</Principal>
|
|
390
|
+
</Principals>
|
|
391
|
+
<Settings>
|
|
392
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
393
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
394
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
395
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
396
|
+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
397
|
+
<IdleSettings>
|
|
398
|
+
<StopOnIdleEnd>false</StopOnIdleEnd>
|
|
399
|
+
<RestartOnIdle>false</RestartOnIdle>
|
|
400
|
+
</IdleSettings>
|
|
401
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
402
|
+
<Enabled>true</Enabled>
|
|
403
|
+
</Settings>
|
|
404
|
+
<Actions Context="Author">
|
|
405
|
+
<Exec>
|
|
406
|
+
<Command>${execCmd}</Command>
|
|
407
|
+
<Arguments>run</Arguments>
|
|
408
|
+
<WorkingDirectory>${path.dirname(configPath)}</WorkingDirectory>
|
|
409
|
+
</Exec>
|
|
410
|
+
</Actions>
|
|
411
|
+
</Task>
|
|
412
|
+
`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function ensureDefaultConfig({ scope } = {}) {
|
|
416
|
+
const configPath =
|
|
417
|
+
scope === "global"
|
|
418
|
+
? path.join(resolveGlobalConfigDir(), DEFAULT_CONFIG_NAME)
|
|
419
|
+
: DEFAULT_CONFIG_PATH;
|
|
420
|
+
if (fs.existsSync(configPath)) {
|
|
421
|
+
return configPath;
|
|
422
|
+
}
|
|
423
|
+
await ensureDir(path.dirname(configPath));
|
|
424
|
+
await writeJson(configPath, getDefaultConfig());
|
|
425
|
+
logInfo(`Created default config: ${configPath}`);
|
|
426
|
+
return configPath;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function ensureGitRepo(autoInit) {
|
|
430
|
+
const gitDir = path.join(CWD, ".git");
|
|
431
|
+
if (fs.existsSync(gitDir)) return true;
|
|
432
|
+
if (!autoInit) return false;
|
|
433
|
+
const res = await execShell("git init");
|
|
434
|
+
if (res.code !== 0) {
|
|
435
|
+
logWarn(`git init failed: ${res.stderr.trim()}`);
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function gitCommitIfChanged(files, message) {
|
|
442
|
+
const status = await execShell("git status --porcelain");
|
|
443
|
+
if (status.code !== 0) {
|
|
444
|
+
logWarn(`git status failed: ${status.stderr.trim()}`);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const changed = status.stdout
|
|
448
|
+
.split("\n")
|
|
449
|
+
.filter(Boolean)
|
|
450
|
+
.some((line) => files.some((file) => line.includes(file)));
|
|
451
|
+
if (!changed) return;
|
|
452
|
+
|
|
453
|
+
const addCmd = `git add ${files.map((f) => `"${f}"`).join(" ")}`;
|
|
454
|
+
const addRes = await execShell(addCmd);
|
|
455
|
+
if (addRes.code !== 0) {
|
|
456
|
+
logWarn(`git add failed: ${addRes.stderr.trim()}`);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const commitRes = await execShell(`git commit -m "${message}"`);
|
|
460
|
+
if (commitRes.code !== 0) {
|
|
461
|
+
logWarn(`git commit failed: ${commitRes.stderr.trim()}`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function loadSchema(schemaPath) {
|
|
466
|
+
if (!fs.existsSync(schemaPath)) {
|
|
467
|
+
throw new Error(`Schema file not found: ${schemaPath}`);
|
|
468
|
+
}
|
|
469
|
+
const schema = await readJson(schemaPath);
|
|
470
|
+
return schema;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function extractSchemaFromCommandOutput(raw) {
|
|
474
|
+
let data = null;
|
|
475
|
+
try {
|
|
476
|
+
data = JSON.parse(raw);
|
|
477
|
+
} catch (err) {
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
if (data && typeof data === "object") {
|
|
481
|
+
if (data.schema) return data.schema;
|
|
482
|
+
if (data.payload && data.payload.schema) return data.payload.schema;
|
|
483
|
+
if (data.result && data.result.schema) return data.result.schema;
|
|
484
|
+
}
|
|
485
|
+
return data;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function ensureGitCheckout(schemaConfig) {
|
|
489
|
+
const repoUrl = schemaConfig.repoUrl;
|
|
490
|
+
const ref = schemaConfig.ref || "main";
|
|
491
|
+
const checkoutDir = resolvePath(
|
|
492
|
+
schemaConfig.checkoutDir || "./state/openclaw",
|
|
493
|
+
);
|
|
494
|
+
await ensureDir(path.dirname(checkoutDir));
|
|
495
|
+
|
|
496
|
+
if (!fs.existsSync(checkoutDir)) {
|
|
497
|
+
const cloneCmd = `git clone --filter=blob:none ${repoUrl} \"${checkoutDir}\"`;
|
|
498
|
+
logInfo(`Cloning OpenClaw: ${cloneCmd}`);
|
|
499
|
+
const cloneRes = await execShell(cloneCmd);
|
|
500
|
+
if (cloneRes.code !== 0) {
|
|
501
|
+
throw new Error(`git clone failed: ${cloneRes.stderr.trim()}`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const fetchRes = await execShell("git fetch --all --prune", {
|
|
506
|
+
cwd: checkoutDir,
|
|
507
|
+
});
|
|
508
|
+
if (fetchRes.code !== 0) {
|
|
509
|
+
throw new Error(`git fetch failed: ${fetchRes.stderr.trim()}`);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const checkoutRes = await execShell(`git checkout ${ref}`, {
|
|
513
|
+
cwd: checkoutDir,
|
|
514
|
+
});
|
|
515
|
+
if (checkoutRes.code !== 0) {
|
|
516
|
+
throw new Error(`git checkout failed: ${checkoutRes.stderr.trim()}`);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return checkoutDir;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function maybeInstallDeps(schemaConfig, cwd) {
|
|
523
|
+
const installCommand = schemaConfig.installCommand;
|
|
524
|
+
if (!installCommand) return;
|
|
525
|
+
|
|
526
|
+
const nodeModulesDir = path.join(cwd, "node_modules");
|
|
527
|
+
const lockfilePath = fs.existsSync(path.join(cwd, "pnpm-lock.yaml"))
|
|
528
|
+
? path.join(cwd, "pnpm-lock.yaml")
|
|
529
|
+
: fs.existsSync(path.join(cwd, "package-lock.json"))
|
|
530
|
+
? path.join(cwd, "package-lock.json")
|
|
531
|
+
: fs.existsSync(path.join(cwd, "yarn.lock"))
|
|
532
|
+
? path.join(cwd, "yarn.lock")
|
|
533
|
+
: null;
|
|
534
|
+
const hashStampPath = path.join(cwd, ".warden-lockhash");
|
|
535
|
+
|
|
536
|
+
let needsInstall = !fs.existsSync(nodeModulesDir);
|
|
537
|
+
if (!needsInstall && lockfilePath) {
|
|
538
|
+
try {
|
|
539
|
+
const currentHash = await fileSha256(lockfilePath);
|
|
540
|
+
const savedHash = fs.existsSync(hashStampPath)
|
|
541
|
+
? (await fsp.readFile(hashStampPath, "utf8")).trim()
|
|
542
|
+
: "";
|
|
543
|
+
if (!savedHash || savedHash !== currentHash) {
|
|
544
|
+
needsInstall = true;
|
|
545
|
+
}
|
|
546
|
+
} catch (err) {
|
|
547
|
+
logWarn(`Lockfile hash check failed: ${String(err)}`);
|
|
548
|
+
needsInstall = true;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (!needsInstall) return;
|
|
553
|
+
|
|
554
|
+
logInfo(`Installing OpenClaw deps: ${installCommand}`);
|
|
555
|
+
const res = await execShell(installCommand, { cwd });
|
|
556
|
+
if (res.code !== 0) {
|
|
557
|
+
throw new Error(`Install failed: ${res.stderr.trim()}`);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (lockfilePath) {
|
|
561
|
+
try {
|
|
562
|
+
const currentHash = await fileSha256(lockfilePath);
|
|
563
|
+
await fsp.writeFile(hashStampPath, `${currentHash}\n`, "utf8");
|
|
564
|
+
} catch (err) {
|
|
565
|
+
logWarn(`Failed to write lockfile hash: ${String(err)}`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function buildNodePathEnv(extraNodePath) {
|
|
571
|
+
const delimiter = path.delimiter;
|
|
572
|
+
const current = process.env.NODE_PATH || "";
|
|
573
|
+
const parts = [extraNodePath, current].filter(Boolean);
|
|
574
|
+
return parts.join(delimiter);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function updateSchema(config) {
|
|
578
|
+
const configDir = path.dirname(config.__configPath || DEFAULT_CONFIG_PATH);
|
|
579
|
+
const schemaPath = resolvePathWithBase(config.paths.schemaFile, configDir);
|
|
580
|
+
await ensureDir(path.dirname(schemaPath));
|
|
581
|
+
const schemaCfg = config.schema || {};
|
|
582
|
+
const source = schemaCfg.source || "command";
|
|
583
|
+
|
|
584
|
+
let cwd = CWD;
|
|
585
|
+
let command = schemaCfg.command;
|
|
586
|
+
let execEnv = process.env;
|
|
587
|
+
|
|
588
|
+
if (source === "git") {
|
|
589
|
+
if (!schemaCfg.repoUrl) {
|
|
590
|
+
throw new Error("schema.repoUrl is required when schema.source=git");
|
|
591
|
+
}
|
|
592
|
+
cwd = await ensureGitCheckout({
|
|
593
|
+
...schemaCfg,
|
|
594
|
+
checkoutDir: resolvePathWithBase(schemaCfg.checkoutDir, configDir),
|
|
595
|
+
});
|
|
596
|
+
const useLocalDeps = Boolean(schemaCfg.useLocalDeps);
|
|
597
|
+
if (!useLocalDeps) {
|
|
598
|
+
await maybeInstallDeps(schemaCfg, cwd);
|
|
599
|
+
} else {
|
|
600
|
+
let localNodePath = null;
|
|
601
|
+
if (schemaCfg.nodePath) {
|
|
602
|
+
const cwdNodePath = resolvePath(schemaCfg.nodePath);
|
|
603
|
+
if (fs.existsSync(cwdNodePath)) {
|
|
604
|
+
localNodePath = cwdNodePath;
|
|
605
|
+
} else {
|
|
606
|
+
const scriptNodePath = path.resolve(
|
|
607
|
+
SCRIPT_DIR,
|
|
608
|
+
"..",
|
|
609
|
+
schemaCfg.nodePath,
|
|
610
|
+
);
|
|
611
|
+
if (fs.existsSync(scriptNodePath)) {
|
|
612
|
+
localNodePath = scriptNodePath;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
if (!localNodePath) {
|
|
617
|
+
localNodePath = path.resolve(SCRIPT_DIR, "..", "node_modules");
|
|
618
|
+
}
|
|
619
|
+
execEnv = {
|
|
620
|
+
...process.env,
|
|
621
|
+
NODE_PATH: buildNodePathEnv(localNodePath),
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
command = schemaCfg.exportCommand;
|
|
625
|
+
if (!command) {
|
|
626
|
+
throw new Error(
|
|
627
|
+
"schema.exportCommand is required when schema.source=git",
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
} else {
|
|
631
|
+
cwd = schemaCfg.cwd ? resolvePath(schemaCfg.cwd) : CWD;
|
|
632
|
+
if (!command) {
|
|
633
|
+
throw new Error("schema.command is required in warden.config.json");
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
logInfo(`Running schema command: ${command}`);
|
|
638
|
+
const res = await execShell(command, { cwd, env: execEnv });
|
|
639
|
+
if (res.code !== 0) {
|
|
640
|
+
throw new Error(`Schema command failed: ${res.stderr.trim()}`);
|
|
641
|
+
}
|
|
642
|
+
const schema = extractSchemaFromCommandOutput(res.stdout.trim());
|
|
643
|
+
if (!schema || typeof schema !== "object") {
|
|
644
|
+
throw new Error("Failed to parse schema JSON from command output");
|
|
645
|
+
}
|
|
646
|
+
await writeJson(schemaPath, schema);
|
|
647
|
+
logInfo(`Schema updated: ${schemaPath}`);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async function validateConfig(config) {
|
|
651
|
+
const configDir = path.dirname(config.__configPath || DEFAULT_CONFIG_PATH);
|
|
652
|
+
const repoConfigPath = resolvePathWithBase(
|
|
653
|
+
config.paths.repoConfig,
|
|
654
|
+
configDir,
|
|
655
|
+
);
|
|
656
|
+
const schemaPath = resolvePathWithBase(config.paths.schemaFile, configDir);
|
|
657
|
+
const raw = await fsp.readFile(repoConfigPath, "utf8");
|
|
658
|
+
let json = null;
|
|
659
|
+
try {
|
|
660
|
+
json = JSON.parse(raw);
|
|
661
|
+
} catch (err) {
|
|
662
|
+
throw new Error(`Invalid JSON: ${(err && err.message) || String(err)}`);
|
|
663
|
+
}
|
|
664
|
+
const schema = await loadSchema(schemaPath);
|
|
665
|
+
const ajv = new Ajv({
|
|
666
|
+
allErrors: true,
|
|
667
|
+
strict: false,
|
|
668
|
+
allowUnionTypes: true,
|
|
669
|
+
});
|
|
670
|
+
addFormats(ajv);
|
|
671
|
+
const validate = ajv.compile(schema);
|
|
672
|
+
const ok = validate(json);
|
|
673
|
+
if (!ok) {
|
|
674
|
+
const errors = (validate.errors || []).map((e) => {
|
|
675
|
+
const at = e.instancePath || e.schemaPath || "";
|
|
676
|
+
return `- ${at} ${e.message || "invalid"}`;
|
|
677
|
+
});
|
|
678
|
+
const detail = errors.join("\n");
|
|
679
|
+
throw new Error(`Schema validation failed:\n${detail}`);
|
|
680
|
+
}
|
|
681
|
+
return true;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async function atomicCopy(srcPath, destPath) {
|
|
685
|
+
const dir = path.dirname(destPath);
|
|
686
|
+
await ensureDir(dir);
|
|
687
|
+
const tempPath = path.join(
|
|
688
|
+
dir,
|
|
689
|
+
`.${path.basename(destPath)}.tmp-${Date.now()}`,
|
|
690
|
+
);
|
|
691
|
+
await fsp.copyFile(srcPath, tempPath);
|
|
692
|
+
await fsp.rename(tempPath, destPath);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function syncPull(config) {
|
|
696
|
+
const configDir = path.dirname(config.__configPath || DEFAULT_CONFIG_PATH);
|
|
697
|
+
const repoConfigPath = resolvePathWithBase(
|
|
698
|
+
config.paths.repoConfig,
|
|
699
|
+
configDir,
|
|
700
|
+
);
|
|
701
|
+
const liveConfigPath = resolvePathWithBase(
|
|
702
|
+
config.paths.liveConfig,
|
|
703
|
+
configDir,
|
|
704
|
+
);
|
|
705
|
+
if (!fs.existsSync(liveConfigPath)) {
|
|
706
|
+
throw new Error(`Live config not found: ${liveConfigPath}`);
|
|
707
|
+
}
|
|
708
|
+
await ensureDir(path.dirname(repoConfigPath));
|
|
709
|
+
await fsp.copyFile(liveConfigPath, repoConfigPath);
|
|
710
|
+
logInfo(`Pulled live config -> repo: ${repoConfigPath}`);
|
|
711
|
+
|
|
712
|
+
if (config.git?.enabled) {
|
|
713
|
+
const gitReady = await ensureGitRepo(Boolean(config.git?.autoInit));
|
|
714
|
+
if (gitReady) {
|
|
715
|
+
await gitCommitIfChanged(
|
|
716
|
+
[path.relative(CWD, repoConfigPath)],
|
|
717
|
+
`sync pull ${nowIso()}`,
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async function syncPush(config) {
|
|
724
|
+
const configDir = path.dirname(config.__configPath || DEFAULT_CONFIG_PATH);
|
|
725
|
+
const repoConfigPath = resolvePathWithBase(
|
|
726
|
+
config.paths.repoConfig,
|
|
727
|
+
configDir,
|
|
728
|
+
);
|
|
729
|
+
const liveConfigPath = resolvePathWithBase(
|
|
730
|
+
config.paths.liveConfig,
|
|
731
|
+
configDir,
|
|
732
|
+
);
|
|
733
|
+
await validateConfig(config);
|
|
734
|
+
await atomicCopy(repoConfigPath, liveConfigPath);
|
|
735
|
+
logInfo(`Pushed repo config -> live: ${liveConfigPath}`);
|
|
736
|
+
|
|
737
|
+
if (config.git?.enabled) {
|
|
738
|
+
const gitReady = await ensureGitRepo(Boolean(config.git?.autoInit));
|
|
739
|
+
if (gitReady) {
|
|
740
|
+
await gitCommitIfChanged(
|
|
741
|
+
[path.relative(CWD, repoConfigPath)],
|
|
742
|
+
`sync push ${nowIso()}`,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async function initWarden(config) {
|
|
749
|
+
const configDir = path.dirname(config.__configPath || DEFAULT_CONFIG_PATH);
|
|
750
|
+
const repoConfigPath = resolvePathWithBase(
|
|
751
|
+
config.paths.repoConfig,
|
|
752
|
+
configDir,
|
|
753
|
+
);
|
|
754
|
+
const liveConfigPath = resolvePathWithBase(
|
|
755
|
+
config.paths.liveConfig,
|
|
756
|
+
configDir,
|
|
757
|
+
);
|
|
758
|
+
await ensureDir(path.dirname(repoConfigPath));
|
|
759
|
+
|
|
760
|
+
if (fs.existsSync(liveConfigPath) && !fs.existsSync(repoConfigPath)) {
|
|
761
|
+
await fsp.copyFile(liveConfigPath, repoConfigPath);
|
|
762
|
+
logInfo(`Seeded repo config from live: ${repoConfigPath}`);
|
|
763
|
+
} else if (!fs.existsSync(repoConfigPath)) {
|
|
764
|
+
await writeJson(repoConfigPath, {});
|
|
765
|
+
logInfo(`Created empty repo config: ${repoConfigPath}`);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (config.git?.enabled) {
|
|
769
|
+
const gitReady = await ensureGitRepo(Boolean(config.git?.autoInit));
|
|
770
|
+
if (gitReady) {
|
|
771
|
+
await gitCommitIfChanged(
|
|
772
|
+
[path.relative(CWD, repoConfigPath)],
|
|
773
|
+
`init ${nowIso()}`,
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async function watchConfig(config) {
|
|
780
|
+
const configDir = path.dirname(config.__configPath || DEFAULT_CONFIG_PATH);
|
|
781
|
+
const repoConfigPath = resolvePathWithBase(
|
|
782
|
+
config.paths.repoConfig,
|
|
783
|
+
configDir,
|
|
784
|
+
);
|
|
785
|
+
if (!fs.existsSync(repoConfigPath)) {
|
|
786
|
+
throw new Error(`Repo config not found: ${repoConfigPath}`);
|
|
787
|
+
}
|
|
788
|
+
logInfo(`Watching ${repoConfigPath} for changes...`);
|
|
789
|
+
let timer = null;
|
|
790
|
+
const schedule = () => {
|
|
791
|
+
if (timer) clearTimeout(timer);
|
|
792
|
+
timer = setTimeout(async () => {
|
|
793
|
+
try {
|
|
794
|
+
await syncPush(config);
|
|
795
|
+
logInfo("Applied config after change.");
|
|
796
|
+
} catch (err) {
|
|
797
|
+
logError(String(err && err.message ? err.message : err));
|
|
798
|
+
}
|
|
799
|
+
}, 400);
|
|
800
|
+
};
|
|
801
|
+
fs.watch(repoConfigPath, { persistent: true }, () => schedule());
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function sleep(ms) {
|
|
805
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
async function runHeartbeatOnce(config) {
|
|
809
|
+
const hb = config.heartbeat || {};
|
|
810
|
+
const sendCommand = hb.sendCommand;
|
|
811
|
+
const checkCommand = hb.checkCommand;
|
|
812
|
+
const agentProbeEnabled = Boolean(hb.agentProbe?.enabled);
|
|
813
|
+
const agentProbeCommand = hb.agentProbe?.command;
|
|
814
|
+
const notifyOnRestart = Boolean(hb.notifyOnRestart);
|
|
815
|
+
const notifyCommand = hb.notifyCommand;
|
|
816
|
+
const restartCommand = hb.restartCommand || "openclaw gateway restart";
|
|
817
|
+
const waitSeconds = Array.isArray(hb.waitSeconds)
|
|
818
|
+
? hb.waitSeconds
|
|
819
|
+
: [30, 40, 50];
|
|
820
|
+
|
|
821
|
+
if (!checkCommand) {
|
|
822
|
+
logWarn("Heartbeat checkCommand not configured. Skipping heartbeat.");
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
827
|
+
const send = async () => {
|
|
828
|
+
const cmd = buildCommand(sendCommand, config, { id });
|
|
829
|
+
logInfo(`Heartbeat send: ${cmd}`);
|
|
830
|
+
const res = await execShell(cmd);
|
|
831
|
+
if (res.code !== 0) {
|
|
832
|
+
logWarn(`Heartbeat send failed: ${res.stderr.trim()}`);
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
const check = async () => {
|
|
837
|
+
const cmd = buildCommand(checkCommand, config, { id });
|
|
838
|
+
const res = await execShell(cmd);
|
|
839
|
+
if (res.code !== 0) return false;
|
|
840
|
+
if (!agentProbeEnabled) return true;
|
|
841
|
+
if (!agentProbeCommand) {
|
|
842
|
+
logWarn("agentProbe enabled but command not configured; skipping probe.");
|
|
843
|
+
return true;
|
|
844
|
+
}
|
|
845
|
+
const probeCmd = buildCommand(agentProbeCommand, config, { id });
|
|
846
|
+
logInfo(`Agent probe: ${probeCmd}`);
|
|
847
|
+
const probeRes = await execShell(probeCmd);
|
|
848
|
+
return probeRes.code === 0;
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
if (sendCommand) {
|
|
852
|
+
await send();
|
|
853
|
+
}
|
|
854
|
+
for (let i = 0; i < waitSeconds.length; i += 1) {
|
|
855
|
+
await sleep(waitSeconds[i] * 1000);
|
|
856
|
+
const ok = await check();
|
|
857
|
+
if (ok) {
|
|
858
|
+
logInfo("Heartbeat reply received.");
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
if (i < waitSeconds.length - 1) {
|
|
862
|
+
if (sendCommand) {
|
|
863
|
+
await send();
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
logWarn("Heartbeat failed after retries. Restarting gateway...");
|
|
869
|
+
const res = await execShell(buildCommand(restartCommand, config, { id }));
|
|
870
|
+
if (res.code !== 0) {
|
|
871
|
+
logWarn(`Restart command failed: ${res.stderr.trim()}`);
|
|
872
|
+
}
|
|
873
|
+
if (notifyOnRestart && notifyCommand) {
|
|
874
|
+
const cmd = buildCommand(notifyCommand, config, { id });
|
|
875
|
+
logInfo(`Notify restart: ${cmd}`);
|
|
876
|
+
const notifyRes = await execShell(cmd);
|
|
877
|
+
if (notifyRes.code !== 0) {
|
|
878
|
+
logWarn(`Notify command failed: ${notifyRes.stderr.trim()}`);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
async function runHeartbeatLoop(config) {
|
|
884
|
+
const intervalMinutes = config.heartbeat?.intervalMinutes ?? 5;
|
|
885
|
+
const intervalMs = Math.max(1, Number(intervalMinutes)) * 60 * 1000;
|
|
886
|
+
let running = false;
|
|
887
|
+
const tick = async () => {
|
|
888
|
+
if (running) return;
|
|
889
|
+
running = true;
|
|
890
|
+
try {
|
|
891
|
+
await runHeartbeatOnce(config);
|
|
892
|
+
} finally {
|
|
893
|
+
running = false;
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
await tick();
|
|
897
|
+
setInterval(tick, intervalMs);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
async function main() {
|
|
901
|
+
const [cmd] = process.argv.slice(2);
|
|
902
|
+
let config = null;
|
|
903
|
+
if (cmd === "init") {
|
|
904
|
+
const scope = process.argv.includes("--global") ? "global" : "local";
|
|
905
|
+
await ensureDefaultConfig({ scope });
|
|
906
|
+
}
|
|
907
|
+
const loaded = await loadConfig();
|
|
908
|
+
config = loaded.config;
|
|
909
|
+
|
|
910
|
+
switch (cmd) {
|
|
911
|
+
case "init":
|
|
912
|
+
await initWarden(config);
|
|
913
|
+
break;
|
|
914
|
+
case "config:pull":
|
|
915
|
+
case "config-pull":
|
|
916
|
+
case "pull":
|
|
917
|
+
await syncPull(config);
|
|
918
|
+
break;
|
|
919
|
+
case "config:push":
|
|
920
|
+
case "config-push":
|
|
921
|
+
case "push":
|
|
922
|
+
await syncPush(config);
|
|
923
|
+
break;
|
|
924
|
+
case "config:validate":
|
|
925
|
+
case "config-validate":
|
|
926
|
+
case "validate":
|
|
927
|
+
await validateConfig(config);
|
|
928
|
+
logInfo("Config is valid.");
|
|
929
|
+
break;
|
|
930
|
+
case "schema:update":
|
|
931
|
+
await updateSchema(config);
|
|
932
|
+
break;
|
|
933
|
+
case "watch":
|
|
934
|
+
await watchConfig(config);
|
|
935
|
+
break;
|
|
936
|
+
case "heartbeat":
|
|
937
|
+
await runHeartbeatLoop(config);
|
|
938
|
+
break;
|
|
939
|
+
case "run":
|
|
940
|
+
await watchConfig(config);
|
|
941
|
+
await runHeartbeatLoop(config);
|
|
942
|
+
break;
|
|
943
|
+
case "daemon:start":
|
|
944
|
+
case "daemon-start":
|
|
945
|
+
await daemonStart();
|
|
946
|
+
break;
|
|
947
|
+
case "daemon:stop":
|
|
948
|
+
case "daemon-stop":
|
|
949
|
+
await daemonStop();
|
|
950
|
+
break;
|
|
951
|
+
case "daemon:status":
|
|
952
|
+
case "daemon-status":
|
|
953
|
+
await daemonStatus();
|
|
954
|
+
break;
|
|
955
|
+
case "service:template":
|
|
956
|
+
case "service-template": {
|
|
957
|
+
const cfgPath = resolveConfigPath();
|
|
958
|
+
const configPath = resolvePath(cfgPath);
|
|
959
|
+
if (process.platform === "darwin") {
|
|
960
|
+
console.log(renderLaunchdService(configPath));
|
|
961
|
+
} else if (process.platform === "win32") {
|
|
962
|
+
console.log(renderWindowsTask(configPath));
|
|
963
|
+
} else {
|
|
964
|
+
console.log(renderSystemdService(configPath));
|
|
965
|
+
}
|
|
966
|
+
break;
|
|
967
|
+
}
|
|
968
|
+
case "help":
|
|
969
|
+
case undefined:
|
|
970
|
+
console.log(
|
|
971
|
+
`openclaw-warden commands:\n\n` +
|
|
972
|
+
` init Seed repo config + init git\n` +
|
|
973
|
+
` schema:update Fetch schema from OpenClaw source\n` +
|
|
974
|
+
` config:validate (alias: validate)\n` +
|
|
975
|
+
` config:pull (alias: pull)\n` +
|
|
976
|
+
` config:push (alias: push)\n` +
|
|
977
|
+
` watch Watch repo config and auto-apply on changes\n` +
|
|
978
|
+
` heartbeat Run heartbeat loop\n` +
|
|
979
|
+
` run Watch + heartbeat\n` +
|
|
980
|
+
` daemon:start Run in background (pid/log in os.tmpdir)\n` +
|
|
981
|
+
` daemon:stop Stop background daemon\n` +
|
|
982
|
+
` daemon:status Check daemon status\n` +
|
|
983
|
+
` service:template Print systemd/launchd/task template\n`,
|
|
984
|
+
);
|
|
985
|
+
break;
|
|
986
|
+
default:
|
|
987
|
+
throw new Error(`Unknown command: ${cmd}`);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
main().catch((err) => {
|
|
992
|
+
logError(err && err.message ? err.message : String(err));
|
|
993
|
+
process.exit(1);
|
|
994
|
+
});
|