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/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
+ })();