openclaw-bridge 0.2.2 → 0.3.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 +141 -0
- package/openclaw.plugin.json +31 -1
- package/package.json +4 -1
- package/src/cli.ts +842 -0
- package/src/config.ts +9 -0
- package/src/heartbeat.ts +26 -0
- package/src/index.ts +36 -1
- package/src/manager/hub-client.ts +114 -0
- package/src/manager/local-manager.ts +121 -0
- package/src/manager/pm2-bridge.ts +125 -0
- package/src/message-relay.ts +60 -31
- package/src/types.ts +12 -0
- package/_inbox/main/bridge-test.md +0 -1
- package/_inbox/pm/bridge-test.md +0 -1
- package/output/bridge-test.md +0 -1
package/src/cli.ts
ADDED
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { join, resolve, dirname, basename } from "node:path";
|
|
4
|
+
import { execSync, exec } from "node:child_process";
|
|
5
|
+
import { homedir, platform } from "node:os";
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
7
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
8
|
+
|
|
9
|
+
// ── Config ──────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
const CONFIG_DIR = join(homedir(), ".openclaw-bridge");
|
|
12
|
+
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
13
|
+
const IS_WINDOWS = platform() === "win32";
|
|
14
|
+
|
|
15
|
+
interface BridgeConfig {
|
|
16
|
+
hubUrl: string;
|
|
17
|
+
apiKey: string;
|
|
18
|
+
managerPass: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function loadConfig(): BridgeConfig | null {
|
|
22
|
+
if (!existsSync(CONFIG_FILE)) return null;
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf-8")) as BridgeConfig;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function saveConfig(config: BridgeConfig): void {
|
|
31
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
32
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function run(cmd: string, opts: { silent?: boolean } = {}): string {
|
|
38
|
+
try {
|
|
39
|
+
return execSync(cmd, {
|
|
40
|
+
encoding: "utf-8",
|
|
41
|
+
stdio: opts.silent ? ["pipe", "pipe", "pipe"] : ["inherit", "pipe", "inherit"],
|
|
42
|
+
}).trim();
|
|
43
|
+
} catch (err: any) {
|
|
44
|
+
if (opts.silent) return "";
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function runInherit(cmd: string): void {
|
|
50
|
+
execSync(cmd, { stdio: "inherit" });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function fetchJson(url: string, apiKey?: string): Promise<any> {
|
|
54
|
+
const headers: Record<string, string> = {};
|
|
55
|
+
if (apiKey) headers["x-api-key"] = apiKey;
|
|
56
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });
|
|
57
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
58
|
+
return res.json();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Resolve ~ to homedir for any path string */
|
|
62
|
+
function expandHome(p: string): string {
|
|
63
|
+
if (p.startsWith("~/") || p === "~") return join(homedir(), p.slice(2));
|
|
64
|
+
return p;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Find the ecosystem.config.cjs in common locations */
|
|
68
|
+
function findEcosystem(): string | null {
|
|
69
|
+
const candidates = [
|
|
70
|
+
join(process.cwd(), "ecosystem.config.cjs"),
|
|
71
|
+
join(dirname(process.cwd()), "ecosystem.config.cjs"),
|
|
72
|
+
IS_WINDOWS ? "C:\\openclaw-instances\\ecosystem.config.cjs" : "",
|
|
73
|
+
join(homedir(), "openclaw-instances", "ecosystem.config.cjs"),
|
|
74
|
+
join(homedir(), ".openclaw", "ecosystem.config.cjs"),
|
|
75
|
+
].filter(Boolean);
|
|
76
|
+
|
|
77
|
+
for (const p of candidates) {
|
|
78
|
+
if (existsSync(p)) return p;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Get the openclaw-instances directory from the ecosystem path */
|
|
84
|
+
function instancesDirFromEcosystem(ecosystemPath: string): string {
|
|
85
|
+
return dirname(ecosystemPath);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── PM2 helpers ──────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
interface Pm2Process {
|
|
91
|
+
name: string;
|
|
92
|
+
pid: number | string;
|
|
93
|
+
status: string;
|
|
94
|
+
memory: number;
|
|
95
|
+
restarts: number;
|
|
96
|
+
uptime: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function getPm2Processes(): Pm2Process[] {
|
|
100
|
+
try {
|
|
101
|
+
const out = run("pm2 jlist", { silent: true });
|
|
102
|
+
if (!out) return [];
|
|
103
|
+
const list = JSON.parse(out) as any[];
|
|
104
|
+
return list.map((p) => ({
|
|
105
|
+
name: p.name,
|
|
106
|
+
pid: p.pid ?? "-",
|
|
107
|
+
status: p.pm2_env?.status ?? "unknown",
|
|
108
|
+
memory: p.monit?.memory ?? 0,
|
|
109
|
+
restarts: p.pm2_env?.restart_time ?? 0,
|
|
110
|
+
uptime: p.pm2_env?.pm_uptime ?? 0,
|
|
111
|
+
}));
|
|
112
|
+
} catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function formatBytes(bytes: number): string {
|
|
118
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
119
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
120
|
+
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function formatUptime(ms: number): string {
|
|
124
|
+
if (!ms || ms <= 0) return "-";
|
|
125
|
+
const s = Math.floor((Date.now() - ms) / 1000);
|
|
126
|
+
if (s < 60) return `${s}s`;
|
|
127
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
128
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
|
129
|
+
return `${Math.floor(s / 86400)}d`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function printTable(processes: Pm2Process[]): void {
|
|
133
|
+
if (processes.length === 0) {
|
|
134
|
+
console.log(" (no processes found)");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const cols = {
|
|
139
|
+
name: Math.max(4, ...processes.map((p) => p.name.length)),
|
|
140
|
+
status: 8,
|
|
141
|
+
pid: 7,
|
|
142
|
+
memory: 8,
|
|
143
|
+
restarts: 8,
|
|
144
|
+
uptime: 8,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const header = [
|
|
148
|
+
"Name".padEnd(cols.name),
|
|
149
|
+
"Status".padEnd(cols.status),
|
|
150
|
+
"PID".padEnd(cols.pid),
|
|
151
|
+
"Memory".padEnd(cols.memory),
|
|
152
|
+
"Restarts".padEnd(cols.restarts),
|
|
153
|
+
"Uptime".padEnd(cols.uptime),
|
|
154
|
+
].join(" ");
|
|
155
|
+
|
|
156
|
+
const sep = "-".repeat(header.length);
|
|
157
|
+
console.log(`\n ${header}`);
|
|
158
|
+
console.log(` ${sep}`);
|
|
159
|
+
|
|
160
|
+
for (const p of processes) {
|
|
161
|
+
const statusIcon = p.status === "online" ? "online " : p.status.padEnd(cols.status);
|
|
162
|
+
const row = [
|
|
163
|
+
p.name.padEnd(cols.name),
|
|
164
|
+
statusIcon,
|
|
165
|
+
String(p.pid).padEnd(cols.pid),
|
|
166
|
+
formatBytes(p.memory).padEnd(cols.memory),
|
|
167
|
+
String(p.restarts).padEnd(cols.restarts),
|
|
168
|
+
formatUptime(p.uptime).padEnd(cols.uptime),
|
|
169
|
+
].join(" ");
|
|
170
|
+
console.log(` ${row}`);
|
|
171
|
+
}
|
|
172
|
+
console.log();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Commands ─────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
async function cmdSetup(): Promise<void> {
|
|
178
|
+
console.log("\nOpenClaw Bridge — Interactive Setup");
|
|
179
|
+
console.log("=====================================\n");
|
|
180
|
+
|
|
181
|
+
const rl = createInterface({ input, output });
|
|
182
|
+
|
|
183
|
+
const existing = loadConfig();
|
|
184
|
+
|
|
185
|
+
const hubUrl = (await rl.question(
|
|
186
|
+
`Hub URL [${existing?.hubUrl ?? "http://localhost:3080"}]: `
|
|
187
|
+
)).trim() || existing?.hubUrl || "http://localhost:3080";
|
|
188
|
+
|
|
189
|
+
const apiKey = (await rl.question(
|
|
190
|
+
`API Key [${existing?.apiKey ? "***" + existing.apiKey.slice(-4) : "none"}]: `
|
|
191
|
+
)).trim() || existing?.apiKey || "";
|
|
192
|
+
|
|
193
|
+
const managerPass = (await rl.question(
|
|
194
|
+
`Manager Password [${existing?.managerPass ? "***" : "none"}]: `
|
|
195
|
+
)).trim() || existing?.managerPass || "";
|
|
196
|
+
|
|
197
|
+
rl.close();
|
|
198
|
+
|
|
199
|
+
const config: BridgeConfig = { hubUrl, apiKey, managerPass };
|
|
200
|
+
saveConfig(config);
|
|
201
|
+
console.log(`\nConfig saved to ${CONFIG_FILE}`);
|
|
202
|
+
|
|
203
|
+
// Test connection
|
|
204
|
+
console.log(`\nTesting connection to ${hubUrl} ...`);
|
|
205
|
+
try {
|
|
206
|
+
await fetchJson(`${hubUrl}/api/v1/registry/discover`, apiKey);
|
|
207
|
+
console.log(" Connected to Hub successfully.");
|
|
208
|
+
} catch (err: any) {
|
|
209
|
+
console.log(` Could not reach Hub: ${err.message}`);
|
|
210
|
+
console.log(" (Config saved anyway — check your Hub URL and API key)");
|
|
211
|
+
}
|
|
212
|
+
console.log();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function cmdStatus(): Promise<void> {
|
|
216
|
+
console.log("\nOpenClaw Bridge — Status");
|
|
217
|
+
console.log("=========================\n");
|
|
218
|
+
|
|
219
|
+
// PM2 processes
|
|
220
|
+
console.log("PM2 Processes:");
|
|
221
|
+
const processes = getPm2Processes();
|
|
222
|
+
printTable(processes);
|
|
223
|
+
|
|
224
|
+
// Hub connection
|
|
225
|
+
const config = loadConfig();
|
|
226
|
+
if (!config) {
|
|
227
|
+
console.log("Hub: not configured (run: openclaw-bridge setup)");
|
|
228
|
+
} else {
|
|
229
|
+
process.stdout.write(`Hub (${config.hubUrl}): `);
|
|
230
|
+
try {
|
|
231
|
+
await fetchJson(`${config.hubUrl}/api/v1/registry/discover`, config.apiKey);
|
|
232
|
+
console.log("connected");
|
|
233
|
+
} catch (err: any) {
|
|
234
|
+
console.log(`unreachable — ${err.message}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
console.log();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function cmdStart(): Promise<void> {
|
|
241
|
+
const ecosystem = findEcosystem();
|
|
242
|
+
if (!ecosystem) {
|
|
243
|
+
console.error(
|
|
244
|
+
"Could not find ecosystem.config.cjs.\n" +
|
|
245
|
+
"Searched: current dir, parent dir, C:\\openclaw-instances, ~/openclaw-instances, ~/.openclaw\n" +
|
|
246
|
+
"Run from your openclaw-instances directory."
|
|
247
|
+
);
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
console.log(`\nStarting instances from: ${ecosystem}`);
|
|
252
|
+
const before = getPm2Processes().length;
|
|
253
|
+
runInherit(`pm2 start "${ecosystem}"`);
|
|
254
|
+
const after = getPm2Processes().length;
|
|
255
|
+
const started = Math.max(0, after - before);
|
|
256
|
+
console.log(`\nStarted ${started > 0 ? started : "all configured"} process(es). Run 'openclaw-bridge status' to verify.\n`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function cmdStop(): Promise<void> {
|
|
260
|
+
console.log("\nStopping all PM2 processes...");
|
|
261
|
+
runInherit("pm2 stop all");
|
|
262
|
+
console.log();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function cmdRestart(agent?: string): Promise<void> {
|
|
266
|
+
if (!agent) {
|
|
267
|
+
console.log("\nRestarting all PM2 processes...");
|
|
268
|
+
runInherit("pm2 restart all");
|
|
269
|
+
} else {
|
|
270
|
+
const withPrefix = `gw-${agent}`;
|
|
271
|
+
// Try gw- prefix first
|
|
272
|
+
const processes = getPm2Processes();
|
|
273
|
+
const hasPrefixed = processes.some((p) => p.name === withPrefix);
|
|
274
|
+
const target = hasPrefixed ? withPrefix : agent;
|
|
275
|
+
console.log(`\nRestarting ${target}...`);
|
|
276
|
+
try {
|
|
277
|
+
runInherit(`pm2 restart "${target}"`);
|
|
278
|
+
} catch {
|
|
279
|
+
// Fallback: try raw name if prefix attempt failed
|
|
280
|
+
if (target === withPrefix) {
|
|
281
|
+
console.log(` (gw- prefix not found, trying raw name: ${agent})`);
|
|
282
|
+
runInherit(`pm2 restart "${agent}"`);
|
|
283
|
+
} else {
|
|
284
|
+
throw new Error(`Process "${agent}" not found in PM2`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
console.log();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function cmdLogs(agent?: string): Promise<void> {
|
|
292
|
+
if (!agent) {
|
|
293
|
+
runInherit("pm2 logs --nostream --lines 50");
|
|
294
|
+
} else {
|
|
295
|
+
const withPrefix = `gw-${agent}`;
|
|
296
|
+
const processes = getPm2Processes();
|
|
297
|
+
const hasPrefixed = processes.some((p) => p.name === withPrefix);
|
|
298
|
+
const target = hasPrefixed ? withPrefix : agent;
|
|
299
|
+
try {
|
|
300
|
+
runInherit(`pm2 logs "${target}" --nostream --lines 100`);
|
|
301
|
+
} catch {
|
|
302
|
+
if (target === withPrefix) {
|
|
303
|
+
console.log(` (gw- prefix not found, trying raw name: ${agent})`);
|
|
304
|
+
runInherit(`pm2 logs "${agent}" --nostream --lines 100`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function cmdBackup(): Promise<void> {
|
|
311
|
+
console.log("\nOpenClaw Bridge — Backup");
|
|
312
|
+
console.log("=========================\n");
|
|
313
|
+
|
|
314
|
+
const ecosystem = findEcosystem();
|
|
315
|
+
if (!ecosystem) {
|
|
316
|
+
console.error("Could not find openclaw-instances directory.");
|
|
317
|
+
process.exit(1);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const instancesDir = instancesDirFromEcosystem(ecosystem);
|
|
321
|
+
const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
|
322
|
+
const backupName = `openclaw-backup-${timestamp}`;
|
|
323
|
+
const backupPath = join(process.cwd(), `${backupName}.tar.gz`);
|
|
324
|
+
|
|
325
|
+
const rl = createInterface({ input, output });
|
|
326
|
+
const password = (await rl.question("Encryption password for sensitive files: ")).trim();
|
|
327
|
+
rl.close();
|
|
328
|
+
|
|
329
|
+
if (!password) {
|
|
330
|
+
console.log("Password required for backup encryption.");
|
|
331
|
+
process.exit(1);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
console.log(`\nCreating backup of: ${instancesDir}`);
|
|
335
|
+
console.log(`Output: ${backupPath}`);
|
|
336
|
+
|
|
337
|
+
// Exclusions: node_modules/, */state/, */workspace/, *.log, .claude/
|
|
338
|
+
const excludes = [
|
|
339
|
+
"--exclude=*/node_modules",
|
|
340
|
+
"--exclude=node_modules",
|
|
341
|
+
"--exclude=*/state",
|
|
342
|
+
"--exclude=*/workspace",
|
|
343
|
+
"--exclude=*.log",
|
|
344
|
+
"--exclude=.claude",
|
|
345
|
+
"--exclude=*/.claude",
|
|
346
|
+
].join(" ");
|
|
347
|
+
|
|
348
|
+
if (IS_WINDOWS) {
|
|
349
|
+
// On Windows, use tar (available in Windows 10+)
|
|
350
|
+
runInherit(
|
|
351
|
+
`tar -czf "${backupPath}" ${excludes} -C "${dirname(instancesDir)}" "${basename(instancesDir)}"`
|
|
352
|
+
);
|
|
353
|
+
} else {
|
|
354
|
+
runInherit(
|
|
355
|
+
`tar -czf "${backupPath}" ${excludes} -C "${dirname(instancesDir)}" "${basename(instancesDir)}"`
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Encrypt sensitive files (openclaw.json, config.json) inside the archive
|
|
360
|
+
// We do this by listing them and producing encrypted sidecar files
|
|
361
|
+
console.log("\nEncrypting sensitive config files...");
|
|
362
|
+
const sensitiveFiles = findFilesRecursive(instancesDir, (f) =>
|
|
363
|
+
(f === "openclaw.json" || f === "config.json") &&
|
|
364
|
+
!f.includes("node_modules") && !f.includes("state") && !f.includes("workspace")
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
let encryptedCount = 0;
|
|
368
|
+
for (const filePath of sensitiveFiles) {
|
|
369
|
+
const encPath = `${filePath}.enc`;
|
|
370
|
+
try {
|
|
371
|
+
runInherit(
|
|
372
|
+
`openssl enc -aes-256-cbc -pbkdf2 -in "${filePath}" -out "${encPath}" -pass pass:"${password}"`
|
|
373
|
+
);
|
|
374
|
+
encryptedCount++;
|
|
375
|
+
} catch {
|
|
376
|
+
console.log(` Warning: could not encrypt ${filePath}`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (encryptedCount > 0) {
|
|
381
|
+
console.log(` Encrypted ${encryptedCount} config file(s) alongside backup.`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Report size
|
|
385
|
+
try {
|
|
386
|
+
const stats = statSync(backupPath);
|
|
387
|
+
console.log(`\nBackup complete!`);
|
|
388
|
+
console.log(` Location: ${backupPath}`);
|
|
389
|
+
console.log(` Size: ${formatBytes(stats.size)}`);
|
|
390
|
+
} catch {
|
|
391
|
+
console.log(`\nBackup file: ${backupPath}`);
|
|
392
|
+
}
|
|
393
|
+
console.log();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function findFilesRecursive(dir: string, predicate: (filename: string) => boolean): string[] {
|
|
397
|
+
const results: string[] = [];
|
|
398
|
+
if (!existsSync(dir)) return results;
|
|
399
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
400
|
+
for (const entry of entries) {
|
|
401
|
+
const fullPath = join(dir, entry.name);
|
|
402
|
+
if (entry.isDirectory()) {
|
|
403
|
+
if (entry.name !== "node_modules" && entry.name !== "state" && entry.name !== "workspace" && entry.name !== ".claude") {
|
|
404
|
+
results.push(...findFilesRecursive(fullPath, predicate));
|
|
405
|
+
}
|
|
406
|
+
} else if (predicate(entry.name)) {
|
|
407
|
+
results.push(fullPath);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return results;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function cmdCleanSessions(): Promise<void> {
|
|
414
|
+
console.log("\nOpenClaw Bridge — Clean Sessions");
|
|
415
|
+
console.log("=================================\n");
|
|
416
|
+
|
|
417
|
+
const ecosystem = findEcosystem();
|
|
418
|
+
if (!ecosystem) {
|
|
419
|
+
console.error("Could not find openclaw-instances directory.");
|
|
420
|
+
process.exit(1);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const instancesDir = instancesDirFromEcosystem(ecosystem);
|
|
424
|
+
let totalFiles = 0;
|
|
425
|
+
let totalBytes = 0;
|
|
426
|
+
|
|
427
|
+
// Find all */agent/sessions/ directories
|
|
428
|
+
if (!existsSync(instancesDir)) {
|
|
429
|
+
console.log("Instances directory not found.");
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const agentDirs = readdirSync(instancesDir, { withFileTypes: true })
|
|
434
|
+
.filter((e) => e.isDirectory())
|
|
435
|
+
.map((e) => e.name);
|
|
436
|
+
|
|
437
|
+
for (const agentDir of agentDirs) {
|
|
438
|
+
const sessionsDir = join(instancesDir, agentDir, "agent", "sessions");
|
|
439
|
+
if (!existsSync(sessionsDir)) continue;
|
|
440
|
+
|
|
441
|
+
const files = readdirSync(sessionsDir).filter((f) =>
|
|
442
|
+
/\.(deleted|reset)\.|old-session/.test(f)
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
for (const file of files) {
|
|
446
|
+
const filePath = join(sessionsDir, file);
|
|
447
|
+
try {
|
|
448
|
+
const stats = statSync(filePath);
|
|
449
|
+
totalBytes += stats.size;
|
|
450
|
+
unlinkSync(filePath);
|
|
451
|
+
totalFiles++;
|
|
452
|
+
console.log(` Deleted: ${agentDir}/agent/sessions/${file}`);
|
|
453
|
+
} catch {
|
|
454
|
+
console.log(` Warning: could not delete ${file}`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
console.log(`\nCleaned ${totalFiles} file(s), freed ${formatBytes(totalBytes)}.\n`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function cmdAddAgent(): Promise<void> {
|
|
463
|
+
console.log("\nOpenClaw Bridge — Add Agent Wizard");
|
|
464
|
+
console.log("====================================\n");
|
|
465
|
+
|
|
466
|
+
const ecosystem = findEcosystem();
|
|
467
|
+
if (!ecosystem) {
|
|
468
|
+
console.error("Could not find ecosystem.config.cjs.");
|
|
469
|
+
process.exit(1);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const instancesDir = instancesDirFromEcosystem(ecosystem);
|
|
473
|
+
const bridgeConfig = loadConfig();
|
|
474
|
+
|
|
475
|
+
const rl = createInterface({ input, output });
|
|
476
|
+
|
|
477
|
+
const agentName = (await rl.question("Agent name (e.g. Designer): ")).trim();
|
|
478
|
+
if (!agentName) {
|
|
479
|
+
rl.close();
|
|
480
|
+
console.error("Agent name is required.");
|
|
481
|
+
process.exit(1);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const suggestedId = agentName.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
|
|
485
|
+
const agentIdInput = (await rl.question(`Agent ID [${suggestedId}]: `)).trim();
|
|
486
|
+
const agentId = agentIdInput || suggestedId;
|
|
487
|
+
|
|
488
|
+
const description = (await rl.question(`Description [${agentName} agent]: `)).trim() || `${agentName} agent`;
|
|
489
|
+
|
|
490
|
+
const modelChoices = [
|
|
491
|
+
"claude-sonnet-4-5-20250514",
|
|
492
|
+
"claude-opus-4-5-20250514",
|
|
493
|
+
"gpt-4o",
|
|
494
|
+
"gpt-4o-mini",
|
|
495
|
+
"gemini-2.5-flash",
|
|
496
|
+
"gemini-2.5-pro",
|
|
497
|
+
];
|
|
498
|
+
console.log("\nAvailable models:");
|
|
499
|
+
modelChoices.forEach((m, i) => console.log(` ${i + 1}. ${m}`));
|
|
500
|
+
const modelInput = (await rl.question(`\nModel [1 = ${modelChoices[0]}]: `)).trim();
|
|
501
|
+
let model = modelChoices[0];
|
|
502
|
+
const modelNum = parseInt(modelInput, 10);
|
|
503
|
+
if (!isNaN(modelNum) && modelNum >= 1 && modelNum <= modelChoices.length) {
|
|
504
|
+
model = modelChoices[modelNum - 1];
|
|
505
|
+
} else if (modelInput && !isNaN(parseInt(modelInput, 10)) === false) {
|
|
506
|
+
model = modelInput; // custom model string
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
rl.close();
|
|
510
|
+
|
|
511
|
+
// Auto-assign port by reading ecosystem.config.cjs
|
|
512
|
+
let nextPort = 18790;
|
|
513
|
+
try {
|
|
514
|
+
const ecosystemContent = readFileSync(ecosystem, "utf-8");
|
|
515
|
+
const portMatches = [...ecosystemContent.matchAll(/PORT['":\s]*[=:]?\s*['"]?(\d{4,5})/g)];
|
|
516
|
+
if (portMatches.length > 0) {
|
|
517
|
+
const ports = portMatches.map((m) => parseInt(m[1], 10)).filter((p) => p >= 18780 && p <= 19999);
|
|
518
|
+
if (ports.length > 0) {
|
|
519
|
+
nextPort = Math.max(...ports) + 1;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
} catch {
|
|
523
|
+
// Keep default
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const agentDir = join(instancesDir, agentId);
|
|
527
|
+
if (existsSync(agentDir)) {
|
|
528
|
+
console.error(`\nDirectory already exists: ${agentDir}`);
|
|
529
|
+
process.exit(1);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Determine load paths (platform-specific)
|
|
533
|
+
const extensionsDir = IS_WINDOWS
|
|
534
|
+
? "C:\\\\openclaw-extensions"
|
|
535
|
+
: join(homedir(), "openclaw-extensions");
|
|
536
|
+
|
|
537
|
+
const hubUrl = bridgeConfig?.hubUrl ?? "http://localhost:3080";
|
|
538
|
+
const apiKey = bridgeConfig?.apiKey ?? "";
|
|
539
|
+
const managerPass = bridgeConfig?.managerPass ?? "";
|
|
540
|
+
|
|
541
|
+
// Create openclaw.json
|
|
542
|
+
const openclawJson = {
|
|
543
|
+
meta: { lastTouchedVersion: "2026.3.24" },
|
|
544
|
+
models: {
|
|
545
|
+
default: model,
|
|
546
|
+
mode: "merge",
|
|
547
|
+
},
|
|
548
|
+
plugins: {
|
|
549
|
+
allow: ["openclaw-bridge"],
|
|
550
|
+
load: { paths: [IS_WINDOWS ? "C:\\openclaw-extensions" : join(homedir(), "openclaw-extensions")] },
|
|
551
|
+
entries: {
|
|
552
|
+
"openclaw-bridge": {
|
|
553
|
+
enabled: true,
|
|
554
|
+
config: {
|
|
555
|
+
role: "normal",
|
|
556
|
+
agentId,
|
|
557
|
+
agentName,
|
|
558
|
+
description,
|
|
559
|
+
registry: {
|
|
560
|
+
baseUrl: hubUrl,
|
|
561
|
+
apiKey,
|
|
562
|
+
},
|
|
563
|
+
fileRelay: {
|
|
564
|
+
baseUrl: hubUrl,
|
|
565
|
+
apiKey,
|
|
566
|
+
},
|
|
567
|
+
localManager: {
|
|
568
|
+
enabled: true,
|
|
569
|
+
hubUrl,
|
|
570
|
+
managerPass,
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
gateway: {
|
|
577
|
+
http: {
|
|
578
|
+
endpoints: {
|
|
579
|
+
chatCompletions: { enabled: true },
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
// Create run.sh
|
|
586
|
+
const runSh = `#!/usr/bin/env bash
|
|
587
|
+
cd "$(dirname "$0")"
|
|
588
|
+
export OPENCLAW_HOME="$(pwd)/home"
|
|
589
|
+
export OPENCLAW_STATE_DIR="$(pwd)/state"
|
|
590
|
+
export OPENCLAW_CONFIG_PATH="$(pwd)/openclaw.json"
|
|
591
|
+
export NODE_OPTIONS="--max-old-space-size=256"
|
|
592
|
+
export OPENCLAW_PROFILE="${agentId}"
|
|
593
|
+
export OPENCLAW_GATEWAY_PORT="${nextPort}"
|
|
594
|
+
|
|
595
|
+
# Kill any orphan process on our port before starting
|
|
596
|
+
if [[ "$OSTYPE" == "darwin"* ]] || [[ "$OSTYPE" == "linux"* ]]; then
|
|
597
|
+
lsof -ti:$OPENCLAW_GATEWAY_PORT | xargs kill -9 2>/dev/null && sleep 1
|
|
598
|
+
elif command -v netstat &>/dev/null; then
|
|
599
|
+
orphan=$(netstat -ano 2>/dev/null | grep ":$OPENCLAW_GATEWAY_PORT.*LISTEN" | awk '{print $5}' | head -1)
|
|
600
|
+
[ -n "$orphan" ] && taskkill //F //PID $orphan 2>/dev/null && sleep 1
|
|
601
|
+
fi
|
|
602
|
+
|
|
603
|
+
exec openclaw gateway --port ${nextPort}
|
|
604
|
+
`;
|
|
605
|
+
|
|
606
|
+
// Create run.ps1
|
|
607
|
+
const runPs1 = `# Run script for ${agentName}
|
|
608
|
+
$PORT = ${nextPort}
|
|
609
|
+
$AgentDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
610
|
+
|
|
611
|
+
# Kill any process using our port (orphan cleanup)
|
|
612
|
+
try {
|
|
613
|
+
$connections = netstat -ano | Select-String ":$PORT "
|
|
614
|
+
foreach ($conn in $connections) {
|
|
615
|
+
$pid = ($conn -split '\\s+')[-1]
|
|
616
|
+
if ($pid -match '^\\d+$') {
|
|
617
|
+
Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
} catch {}
|
|
621
|
+
|
|
622
|
+
$env:OPENCLAW_GATEWAY_PORT = $PORT
|
|
623
|
+
$env:OPENCLAW_CONFIG_PATH = "$AgentDir\\openclaw.json"
|
|
624
|
+
|
|
625
|
+
openclaw start
|
|
626
|
+
`;
|
|
627
|
+
|
|
628
|
+
// Write files
|
|
629
|
+
mkdirSync(agentDir, { recursive: true });
|
|
630
|
+
writeFileSync(join(agentDir, "openclaw.json"), JSON.stringify(openclawJson, null, 2), "utf-8");
|
|
631
|
+
writeFileSync(join(agentDir, "run.sh"), runSh, { encoding: "utf-8", mode: 0o755 });
|
|
632
|
+
writeFileSync(join(agentDir, "run.ps1"), runPs1, "utf-8");
|
|
633
|
+
|
|
634
|
+
// Update ecosystem.config.cjs — add to instances array
|
|
635
|
+
let ecosystemContent = readFileSync(ecosystem, "utf-8");
|
|
636
|
+
const newEntry = ` { name: 'gw-${agentId}', dir: '${agentId}', port: '${nextPort}', profile: '${agentId}' },`;
|
|
637
|
+
// Insert before the closing ]; of the instances array
|
|
638
|
+
const instancesArrayEnd = /(\n\];)/;
|
|
639
|
+
if (instancesArrayEnd.test(ecosystemContent)) {
|
|
640
|
+
ecosystemContent = ecosystemContent.replace(
|
|
641
|
+
instancesArrayEnd,
|
|
642
|
+
`\n${newEntry}$1`
|
|
643
|
+
);
|
|
644
|
+
} else {
|
|
645
|
+
console.log(`\n ⚠️ Could not auto-update ecosystem.config.cjs. Add manually:`);
|
|
646
|
+
console.log(` ${newEntry}`);
|
|
647
|
+
}
|
|
648
|
+
writeFileSync(ecosystem, ecosystemContent, "utf-8");
|
|
649
|
+
|
|
650
|
+
console.log(`\nAgent "${agentName}" (${agentId}) created successfully!`);
|
|
651
|
+
console.log(`\n Directory: ${agentDir}`);
|
|
652
|
+
console.log(` Port: ${nextPort}`);
|
|
653
|
+
console.log(` Model: ${model}`);
|
|
654
|
+
console.log(` Config: ${join(agentDir, "openclaw.json")}`);
|
|
655
|
+
console.log(` Ecosystem: updated ${ecosystem}`);
|
|
656
|
+
console.log(`\nNext steps:`);
|
|
657
|
+
console.log(` 1. Review ${join(agentDir, "openclaw.json")}`);
|
|
658
|
+
console.log(` 2. Run: openclaw-bridge start`);
|
|
659
|
+
console.log(` 3. Run: openclaw-bridge status\n`);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async function cmdDoctor(): Promise<void> {
|
|
663
|
+
console.log("\nOpenClaw Bridge — Doctor");
|
|
664
|
+
console.log("=========================\n");
|
|
665
|
+
|
|
666
|
+
const checks: Array<{ label: string; ok: boolean; detail?: string }> = [];
|
|
667
|
+
|
|
668
|
+
// PM2 installed
|
|
669
|
+
{
|
|
670
|
+
let ok = false;
|
|
671
|
+
let detail = "";
|
|
672
|
+
try {
|
|
673
|
+
detail = run("pm2 --version", { silent: true });
|
|
674
|
+
ok = !!detail;
|
|
675
|
+
} catch {
|
|
676
|
+
detail = "not found";
|
|
677
|
+
}
|
|
678
|
+
checks.push({ label: "PM2 installed", ok, detail: ok ? `v${detail.trim()}` : detail });
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// openclaw CLI installed
|
|
682
|
+
{
|
|
683
|
+
let ok = false;
|
|
684
|
+
let detail = "";
|
|
685
|
+
try {
|
|
686
|
+
detail = run("openclaw --version", { silent: true });
|
|
687
|
+
ok = !!detail;
|
|
688
|
+
} catch {
|
|
689
|
+
detail = "not found";
|
|
690
|
+
}
|
|
691
|
+
checks.push({ label: "openclaw CLI installed", ok, detail: ok ? detail.trim() : detail });
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Node version
|
|
695
|
+
{
|
|
696
|
+
const nodeVer = process.version;
|
|
697
|
+
const major = parseInt(nodeVer.slice(1), 10);
|
|
698
|
+
const ok = major >= 18;
|
|
699
|
+
checks.push({ label: "Node.js version", ok, detail: `${nodeVer}${ok ? "" : " (need >=18)"}` });
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ecosystem.config.cjs
|
|
703
|
+
{
|
|
704
|
+
const ecosystem = findEcosystem();
|
|
705
|
+
const ok = !!ecosystem;
|
|
706
|
+
checks.push({ label: "ecosystem.config.cjs found", ok, detail: ok ? ecosystem! : "not found" });
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Hub reachable
|
|
710
|
+
const config = loadConfig();
|
|
711
|
+
if (!config) {
|
|
712
|
+
checks.push({ label: "Hub reachable", ok: false, detail: "not configured (run: openclaw-bridge setup)" });
|
|
713
|
+
} else {
|
|
714
|
+
let ok = false;
|
|
715
|
+
let detail = "";
|
|
716
|
+
try {
|
|
717
|
+
await fetchJson(`${config.hubUrl}/api/v1/registry/discover`, config.apiKey);
|
|
718
|
+
ok = true;
|
|
719
|
+
detail = config.hubUrl;
|
|
720
|
+
} catch (err: any) {
|
|
721
|
+
detail = `${config.hubUrl} — ${err.message}`;
|
|
722
|
+
}
|
|
723
|
+
checks.push({ label: "Hub reachable", ok, detail });
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Port conflicts (scan 18790-18799)
|
|
727
|
+
{
|
|
728
|
+
const portRange = Array.from({ length: 10 }, (_, i) => 18790 + i);
|
|
729
|
+
const conflictPorts: number[] = [];
|
|
730
|
+
for (const port of portRange) {
|
|
731
|
+
try {
|
|
732
|
+
const out = run(
|
|
733
|
+
IS_WINDOWS
|
|
734
|
+
? `netstat -ano | findstr ":${port} "`
|
|
735
|
+
: `ss -tlnp 2>/dev/null | grep :${port} || lsof -ti tcp:${port} 2>/dev/null || true`,
|
|
736
|
+
{ silent: true }
|
|
737
|
+
);
|
|
738
|
+
if (out.trim()) conflictPorts.push(port);
|
|
739
|
+
} catch {
|
|
740
|
+
// no conflict
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
const ok = conflictPorts.length === 0;
|
|
744
|
+
checks.push({
|
|
745
|
+
label: "Port conflicts (18790-18799)",
|
|
746
|
+
ok,
|
|
747
|
+
detail: ok ? "none" : `conflicts on: ${conflictPorts.join(", ")}`,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Print results
|
|
752
|
+
for (const check of checks) {
|
|
753
|
+
const icon = check.ok ? "✅" : "❌";
|
|
754
|
+
const detail = check.detail ? ` (${check.detail})` : "";
|
|
755
|
+
console.log(` ${icon} ${check.label}${detail}`);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const failCount = checks.filter((c) => !c.ok).length;
|
|
759
|
+
console.log(`\n${failCount === 0 ? "All checks passed." : `${failCount} issue(s) found.`}\n`);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// ── Usage ─────────────────────────────────────────────────────────────────────
|
|
763
|
+
|
|
764
|
+
function printUsage(): void {
|
|
765
|
+
console.log(`
|
|
766
|
+
openclaw-bridge — OpenClaw Bridge CLI
|
|
767
|
+
|
|
768
|
+
Usage:
|
|
769
|
+
openclaw-bridge <command> [args]
|
|
770
|
+
|
|
771
|
+
Commands:
|
|
772
|
+
setup Interactive setup (Hub URL, API key, manager password)
|
|
773
|
+
status Show PM2 processes and Hub connection status
|
|
774
|
+
start Start all openclaw instances via ecosystem.config.cjs
|
|
775
|
+
stop Stop all PM2 processes
|
|
776
|
+
restart [agent] Restart specific agent or all (gw- prefix auto-applied)
|
|
777
|
+
logs [agent] View PM2 logs for agent or all
|
|
778
|
+
backup Backup openclaw instances (tar.gz with encryption)
|
|
779
|
+
clean-sessions Clean old session files (*.deleted.*, *.reset.*, *.old-session*)
|
|
780
|
+
add-agent Wizard to create a new agent instance
|
|
781
|
+
doctor Diagnose common issues
|
|
782
|
+
|
|
783
|
+
Examples:
|
|
784
|
+
openclaw-bridge setup
|
|
785
|
+
openclaw-bridge status
|
|
786
|
+
openclaw-bridge start
|
|
787
|
+
openclaw-bridge restart writer
|
|
788
|
+
openclaw-bridge logs pm
|
|
789
|
+
openclaw-bridge add-agent
|
|
790
|
+
openclaw-bridge doctor
|
|
791
|
+
`);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// ── Entry point ───────────────────────────────────────────────────────────────
|
|
795
|
+
|
|
796
|
+
const command = process.argv[2];
|
|
797
|
+
const arg = process.argv[3];
|
|
798
|
+
|
|
799
|
+
(async () => {
|
|
800
|
+
switch (command) {
|
|
801
|
+
case "setup":
|
|
802
|
+
await cmdSetup();
|
|
803
|
+
break;
|
|
804
|
+
case "status":
|
|
805
|
+
await cmdStatus();
|
|
806
|
+
break;
|
|
807
|
+
case "start":
|
|
808
|
+
await cmdStart();
|
|
809
|
+
break;
|
|
810
|
+
case "stop":
|
|
811
|
+
await cmdStop();
|
|
812
|
+
break;
|
|
813
|
+
case "restart":
|
|
814
|
+
await cmdRestart(arg);
|
|
815
|
+
break;
|
|
816
|
+
case "logs":
|
|
817
|
+
await cmdLogs(arg);
|
|
818
|
+
break;
|
|
819
|
+
case "backup":
|
|
820
|
+
await cmdBackup();
|
|
821
|
+
break;
|
|
822
|
+
case "clean-sessions":
|
|
823
|
+
await cmdCleanSessions();
|
|
824
|
+
break;
|
|
825
|
+
case "add-agent":
|
|
826
|
+
await cmdAddAgent();
|
|
827
|
+
break;
|
|
828
|
+
case "doctor":
|
|
829
|
+
await cmdDoctor();
|
|
830
|
+
break;
|
|
831
|
+
case "--help":
|
|
832
|
+
case "-h":
|
|
833
|
+
case "help":
|
|
834
|
+
case undefined:
|
|
835
|
+
printUsage();
|
|
836
|
+
break;
|
|
837
|
+
default:
|
|
838
|
+
console.error(`Unknown command: ${command}\n`);
|
|
839
|
+
printUsage();
|
|
840
|
+
process.exit(1);
|
|
841
|
+
}
|
|
842
|
+
})();
|