openclaw-spawn 1.0.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.js ADDED
@@ -0,0 +1,629 @@
1
+ import chalk from 'chalk';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { execSync, spawnSync } from 'child_process';
5
+ import {
6
+ getInstances,
7
+ getInstance,
8
+ addInstance,
9
+ removeInstance as removeInstanceMetadata,
10
+ getNextPort,
11
+ getInstanceGatewayToken,
12
+ setInstanceGatewayPort,
13
+ enableBrowserTool,
14
+ enableBrowserTakeover
15
+ } from './metadata.js';
16
+ import {
17
+ isDockerRunning,
18
+ isDockerInstalled,
19
+ imageExists,
20
+ getContainerStatus,
21
+ createContainer,
22
+ execInContainer,
23
+ stopContainer,
24
+ startContainer,
25
+ removeContainer,
26
+ showLogs,
27
+ buildBaseImage,
28
+ ensureNetwork,
29
+ startVnc,
30
+ startVisibleChrome,
31
+ stopVisibleChrome
32
+ } from './docker.js';
33
+ import { selectInstance, promptInstanceName, confirm } from './selector.js';
34
+
35
+ // Management commands handled by openclaw-spawn itself
36
+ const MANAGEMENT_COMMANDS = ['list', 'remove', 'stop', 'start', 'logs', 'build', 'cleanup'];
37
+
38
+ // CLI entry point
39
+ export async function cli(args) {
40
+ const command = args[0];
41
+
42
+ // init runs its own Docker checks — must come before the global Docker running check
43
+ if (command === 'init') {
44
+ return await initCommand();
45
+ }
46
+
47
+ // Check Docker
48
+ if (!isDockerRunning()) {
49
+ console.error(chalk.red('✗ Docker is not running. Please start Docker Desktop.'));
50
+ process.exit(1);
51
+ }
52
+
53
+ // Management commands
54
+ if (command === 'list') {
55
+ return await listInstances();
56
+ }
57
+
58
+ if (command === 'remove') {
59
+ const name = args[1];
60
+ if (!name) {
61
+ console.error(chalk.red('✗ Please specify instance name'));
62
+ process.exit(1);
63
+ }
64
+ return await removeInstanceCommand(name);
65
+ }
66
+
67
+ if (command === 'stop') {
68
+ const name = args[1];
69
+ if (!name) {
70
+ console.error(chalk.red('✗ Please specify instance name'));
71
+ process.exit(1);
72
+ }
73
+ return await stopInstanceCommand(name);
74
+ }
75
+
76
+ if (command === 'start') {
77
+ const name = args[1];
78
+ if (!name) {
79
+ console.error(chalk.red('✗ Please specify instance name'));
80
+ process.exit(1);
81
+ }
82
+ return await startInstanceCommand(name);
83
+ }
84
+
85
+ if (command === 'logs') {
86
+ const name = args[1];
87
+ const follow = args.includes('-f') || args.includes('--follow');
88
+ if (!name) {
89
+ console.error(chalk.red('✗ Please specify instance name'));
90
+ process.exit(1);
91
+ }
92
+ return await showLogsCommand(name, follow);
93
+ }
94
+
95
+ if (command === 'build') {
96
+ return await buildCommand();
97
+ }
98
+
99
+ if (command === 'cleanup') {
100
+ return await cleanupCommand();
101
+ }
102
+
103
+ if (command === 'browser') {
104
+ if (args[1] === 'stop') {
105
+ return await browserStopCommand(args[2]);
106
+ }
107
+ return await browserCommand(args[1]);
108
+ }
109
+
110
+ // All other commands: route to OpenClaw in container
111
+ const detach = args.includes('-d') || args.includes('--detach');
112
+ const filteredArgs = args.filter(a => a !== '-d' && a !== '--detach');
113
+ return await proxyOpenClawCommand(filteredArgs, detach);
114
+ }
115
+
116
+ // Smart first-time setup wizard
117
+ async function initCommand() {
118
+ console.log(chalk.blue.bold('\n🦞 OpenClaw Swarm — Setup Wizard\n'));
119
+
120
+ // ── Step 1: Docker installed? ─────────────────────────────────────────────
121
+ console.log(chalk.bold('Step 1/5 Check Docker is installed'));
122
+ if (!isDockerInstalled()) {
123
+ console.log(chalk.yellow(' Docker not found. Installing...\n'));
124
+ try {
125
+ if (process.platform === 'darwin') {
126
+ // Check for Homebrew
127
+ try {
128
+ execSync('which brew', { stdio: 'ignore' });
129
+ } catch {
130
+ console.log(chalk.red(' Homebrew is required to auto-install Docker on macOS.'));
131
+ console.log(chalk.yellow(' Install Homebrew first: https://brew.sh'));
132
+ console.log(chalk.yellow(' Then re-run: openclaw-spawn init\n'));
133
+ process.exit(1);
134
+ }
135
+ execSync('brew install --cask docker', { stdio: 'inherit' });
136
+ } else if (process.platform === 'linux') {
137
+ // Try apt, fall back to yum
138
+ try {
139
+ execSync('which apt-get', { stdio: 'ignore' });
140
+ execSync('sudo apt-get update && sudo apt-get install -y docker.io', { stdio: 'inherit' });
141
+ execSync('sudo systemctl start docker && sudo systemctl enable docker', { stdio: 'inherit' });
142
+ } catch {
143
+ execSync('sudo yum install -y docker', { stdio: 'inherit' });
144
+ execSync('sudo systemctl start docker && sudo systemctl enable docker', { stdio: 'inherit' });
145
+ }
146
+ } else {
147
+ console.log(chalk.yellow(' Auto-install is not supported on Windows.'));
148
+ console.log(chalk.yellow(' Download Docker Desktop: https://docs.docker.com/desktop/windows/'));
149
+ console.log(chalk.yellow(' Then re-run: openclaw-spawn init\n'));
150
+ process.exit(1);
151
+ }
152
+ console.log(chalk.green(' ✓ Docker installed\n'));
153
+ } catch (err) {
154
+ console.error(chalk.red(` ✗ Failed to install Docker: ${err.message}`));
155
+ process.exit(1);
156
+ }
157
+ } else {
158
+ console.log(chalk.green(' ✓ Docker is installed\n'));
159
+ }
160
+
161
+ // ── Step 2: Docker running? ───────────────────────────────────────────────
162
+ console.log(chalk.bold('Step 2/5 Check Docker is running'));
163
+ if (!isDockerRunning()) {
164
+ console.log(chalk.yellow(' Docker is not running. Starting it...'));
165
+ if (process.platform === 'darwin') {
166
+ try { execSync('open -a Docker', { stdio: 'ignore' }); } catch {}
167
+ } else if (process.platform === 'linux') {
168
+ try { execSync('sudo systemctl start docker', { stdio: 'ignore' }); } catch {}
169
+ } else if (process.platform === 'win32') {
170
+ try { execSync('start "" "C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe"', { stdio: 'ignore' }); } catch {}
171
+ }
172
+ // Poll until Docker is ready (up to 60s)
173
+ let ready = false;
174
+ for (let i = 0; i < 20; i++) {
175
+ if (isDockerRunning()) { ready = true; break; }
176
+ process.stdout.write(chalk.dim(` Waiting for Docker to start... ${(i + 1) * 3}s\r`));
177
+ await new Promise(r => setTimeout(r, 3000));
178
+ }
179
+ if (!ready) {
180
+ console.error(chalk.red('\n ✗ Docker did not start within 60s. Please start it manually and re-run init.'));
181
+ process.exit(1);
182
+ }
183
+ process.stdout.write('\n');
184
+ }
185
+ console.log(chalk.green(' ✓ Docker is running\n'));
186
+
187
+ // ── Step 3: Base image built? ─────────────────────────────────────────────
188
+ console.log(chalk.bold('Step 3/5 Build base Docker image'));
189
+ if (imageExists()) {
190
+ console.log(chalk.green(' ✓ Image already built, skipping\n'));
191
+ } else {
192
+ console.log(chalk.dim(' Building openclaw-spawn-base:latest (this takes a few minutes the first time)...\n'));
193
+ const ok = buildBaseImage();
194
+ if (!ok) {
195
+ console.error(chalk.red(' ✗ Image build failed'));
196
+ process.exit(1);
197
+ }
198
+ console.log(chalk.green(' ✓ Image built\n'));
199
+ }
200
+
201
+ // ── Step 4: Onboard new instance ─────────────────────────────────────────
202
+ console.log(chalk.bold('Step 4/5 Set up OpenClaw instance'));
203
+ ensureNetwork();
204
+ console.log(chalk.dim(' Running OpenClaw onboarding wizard...\n'));
205
+ const instanceName = await promptInstanceName();
206
+ const port = getNextPort();
207
+ console.log(chalk.blue(`\n Creating instance ${instanceName} on port ${port}...`));
208
+ addInstance(instanceName, port);
209
+ const created = createContainer(instanceName, port);
210
+ if (!created) {
211
+ console.error(chalk.red(' ✗ Failed to create container'));
212
+ removeInstanceMetadata(instanceName);
213
+ process.exit(1);
214
+ }
215
+ const instance = getInstance(instanceName);
216
+ await execInContainer(instance.container, 'openclaw onboard', false);
217
+ setInstanceGatewayPort(instanceName, instance.port);
218
+ enableBrowserTool(instanceName);
219
+ console.log(chalk.green(`\n ✓ Instance ${instanceName} ready\n`));
220
+
221
+ // ── Step 5: Start gateway ─────────────────────────────────────────────────
222
+ console.log(chalk.bold('Step 5/5 Start gateway'));
223
+ const inst = getInstance(instanceName);
224
+
225
+ // Bootstrap Chrome then start gateway
226
+ stopVisibleChrome(inst.container);
227
+ startVisibleChrome(inst.container, 18800);
228
+ enableBrowserTakeover(instanceName);
229
+ setInstanceGatewayPort(instanceName, inst.port);
230
+ await execInContainer(inst.container, 'openclaw gateway --bind lan', true);
231
+ execSync('sleep 2', { stdio: 'ignore' });
232
+ console.log(chalk.green(' ✓ Gateway started\n'));
233
+
234
+ // ── Done ──────────────────────────────────────────────────────────────────
235
+ console.log(chalk.green.bold('✓ OpenClaw Swarm is ready!\n'));
236
+ console.log(chalk.blue('Next steps:'));
237
+ console.log(chalk.cyan(` openclaw-spawn tui`) + chalk.dim(' # chat with your agent'));
238
+ console.log(chalk.cyan(` openclaw-spawn browser`) + chalk.dim(' # open VNC to see/control the browser'));
239
+ console.log(chalk.cyan(` openclaw-spawn dashboard`) + chalk.dim(' # open the web dashboard\n'));
240
+ }
241
+
242
+ // Show help
243
+ function showHelp() {
244
+ console.log(`
245
+ ${chalk.blue.bold('OpenClaw Swarm')} - Docker orchestrator for multiple OpenClaw instances
246
+
247
+ ${chalk.yellow('Usage:')}
248
+ openclaw-spawn <command> [options]
249
+
250
+ ${chalk.yellow('OpenClaw Commands:')} (auto-selects instance)
251
+ onboard Run OpenClaw onboarding wizard
252
+ gateway [-d] Start OpenClaw gateway (-d for background)
253
+ tui Open OpenClaw TUI
254
+ channels Manage channels
255
+ devices Manage devices
256
+ dashboard Open dashboard
257
+ ... Any other OpenClaw command
258
+
259
+ ${chalk.yellow('Setup:')}
260
+ init First-time setup wizard (installs Docker, builds image, onboards)
261
+
262
+ ${chalk.yellow('Management Commands:')}
263
+ list List all instances
264
+ remove <name> Stop and remove instance
265
+ stop <name> Stop instance
266
+ start <name> Start instance
267
+ logs <name> [-f] Show instance logs
268
+ build Build Docker base image
269
+ cleanup Remove all containers and reset metadata
270
+ browser [name] Open VNC tab to see/control the agent's browser
271
+ browser stop [name] Close VNC view (agent browser keeps running)
272
+
273
+ ${chalk.yellow('Examples:')}
274
+ openclaw-spawn onboard # Select instance and run onboard
275
+ openclaw-spawn gateway -d # Select instance and start gateway
276
+ openclaw-spawn list # List all instances
277
+ openclaw-spawn logs worker1 -f # Follow logs for worker1
278
+ `);
279
+ }
280
+
281
+ // Proxy OpenClaw command
282
+ async function proxyOpenClawCommand(args, detach = false) {
283
+ let command = args.length > 0 ? args.join(' ') : 'onboard';
284
+
285
+ // Select or create instance
286
+ const selected = await selectInstance(true);
287
+
288
+ let instanceName;
289
+ if (selected === '__new__') {
290
+ instanceName = await promptInstanceName();
291
+ const port = getNextPort();
292
+
293
+ console.log(chalk.blue(`\n📦 Creating instance ${instanceName} on port ${port}...`));
294
+
295
+ // Ensure network exists
296
+ ensureNetwork();
297
+
298
+ // Add to metadata
299
+ addInstance(instanceName, port);
300
+
301
+ // Create container
302
+ const created = createContainer(instanceName, port);
303
+ if (!created) {
304
+ console.error(chalk.red('✗ Failed to create container'));
305
+ removeInstanceMetadata(instanceName);
306
+ process.exit(1);
307
+ }
308
+
309
+ console.log(chalk.green(`✓ Created instance ${instanceName}`));
310
+ } else {
311
+ instanceName = selected;
312
+ const instance = getInstance(instanceName);
313
+
314
+ // Check if container is running
315
+ const status = getContainerStatus(instance.container);
316
+ if (status === 'stopped') {
317
+ console.log(chalk.yellow(`⚠ Instance ${instanceName} is stopped. Starting...`));
318
+ startContainer(instance.container);
319
+ } else if (status === 'not-found') {
320
+ console.log(chalk.yellow(`⚠ Container not found. Recreating...`));
321
+ createContainer(instanceName, instance.port);
322
+ }
323
+ }
324
+
325
+ // Execute OpenClaw command
326
+ const instance = getInstance(instanceName);
327
+
328
+ // Ensure gateway port in config matches host port
329
+ // Add --bind lan for gateway commands (required for Docker containers)
330
+ if (command.startsWith('gateway')) {
331
+ setInstanceGatewayPort(instanceName, instance.port);
332
+ // Add --bind lan if not already present (gateway must bind to 0.0.0.0 in container)
333
+ if (!command.includes('--bind')) {
334
+ command = command.replace(/^gateway/, 'gateway --bind lan');
335
+ }
336
+ }
337
+
338
+ // When starting gateway in background: bootstrap Chrome first so the browser tool
339
+ // is ready the moment the gateway comes up — no separate `browser` command needed.
340
+ // CDP port is always 18800 inside the container (mapped to host port+11 externally).
341
+ if (command.startsWith('gateway') && detach) {
342
+ stopVisibleChrome(instance.container);
343
+ startVisibleChrome(instance.container, 18800);
344
+ enableBrowserTakeover(instanceName);
345
+ }
346
+
347
+ if (detach) {
348
+ console.log(chalk.blue(`\n🦞 Starting in background: openclaw ${command}`));
349
+ console.log(chalk.dim(`Container: ${instance.container}\n`));
350
+
351
+ try {
352
+ await execInContainer(instance.container, `openclaw ${command}`, true);
353
+ console.log(chalk.green(`✓ Command started in background`));
354
+ console.log(chalk.dim(`View logs: openclaw-spawn logs ${instanceName} -f`));
355
+ } catch (error) {
356
+ console.error(chalk.red(`\n✗ Command failed: ${error.message}`));
357
+ process.exit(1);
358
+ }
359
+ } else {
360
+ console.log(chalk.blue(`\n🦞 Running: openclaw ${command}`));
361
+ console.log(chalk.dim(`Container: ${instance.container}\n`));
362
+
363
+ try {
364
+ await execInContainer(instance.container, `openclaw ${command}`, false);
365
+ if (command === 'onboard') {
366
+ setInstanceGatewayPort(instanceName, instance.port);
367
+ enableBrowserTool(instanceName);
368
+ }
369
+ if (command.startsWith('dashboard')) {
370
+ const token = getInstanceGatewayToken(instanceName);
371
+ const url = token
372
+ ? `http://localhost:${instance.port}/#token=${token}`
373
+ : `http://localhost:${instance.port}/`;
374
+ console.log(chalk.blue(`\n📌 Instance ${instanceName} → open on your machine:`));
375
+ console.log(chalk.cyan(` ${url}`));
376
+ }
377
+ } catch (error) {
378
+ console.error(chalk.red(`\n✗ Command failed: ${error.message}`));
379
+ process.exit(1);
380
+ }
381
+ }
382
+ }
383
+
384
+ // Open live browser view via noVNC.
385
+ // Instead of asking the gateway to launch Chrome (which has Playwright/GPU issues in Docker non-headless),
386
+ // we launch Chrome ourselves on the Xvfb display and set attachOnly:true so the gateway just attaches
387
+ // to our already-running Chrome via CDP. Both the agent and the user share the same browser session.
388
+ async function browserCommand(name) {
389
+ let instanceName = name;
390
+
391
+ if (!instanceName) {
392
+ const selected = await selectInstance(false);
393
+ if (!selected || selected === '__new__') {
394
+ console.error(chalk.red('✗ No instance selected'));
395
+ process.exit(1);
396
+ }
397
+ instanceName = selected;
398
+ }
399
+
400
+ const instance = getInstance(instanceName);
401
+ if (!instance) {
402
+ console.error(chalk.red(`✗ Instance ${instanceName} not found`));
403
+ process.exit(1);
404
+ }
405
+
406
+ // Ensure container is running
407
+ const status = getContainerStatus(instance.container);
408
+ if (status === 'stopped') {
409
+ console.log(chalk.yellow(`⚠ Instance ${instanceName} is stopped. Starting...`));
410
+ startContainer(instance.container);
411
+ } else if (status === 'not-found') {
412
+ console.error(chalk.red(`✗ Container not found. Run: openclaw-spawn start ${instanceName}`));
413
+ process.exit(1);
414
+ }
415
+
416
+ // Check Xvfb is available — only present in containers built with the new image
417
+ let xvfbAvailable = false;
418
+ try {
419
+ const check = execSync(
420
+ `docker exec ${instance.container} sh -c "command -v Xvfb > /dev/null 2>&1 && echo yes || echo no"`,
421
+ { encoding: 'utf8' }
422
+ ).trim();
423
+ xvfbAvailable = check === 'yes';
424
+ } catch {}
425
+
426
+ if (!xvfbAvailable) {
427
+ console.error(chalk.red('\n✗ This container was built without VNC support.'));
428
+ console.error(chalk.yellow(' Rebuild the image and recreate the container:'));
429
+ console.error(chalk.dim(`\n openclaw-spawn build`));
430
+ console.error(chalk.dim(` openclaw-spawn cleanup`));
431
+ console.error(chalk.dim(` openclaw-spawn onboard\n`));
432
+ process.exit(1);
433
+ }
434
+
435
+ console.log(chalk.blue(`\n🖥 Starting browser view for ${instanceName}...`));
436
+
437
+ // Chrome is already running (started by `gateway -d`). Just attach VNC services to the display.
438
+ console.log(chalk.dim(' Starting VNC services...'));
439
+ startVnc(instance.container);
440
+
441
+ const vncUrl = `http://localhost:${instance.port + 20}/vnc.html?autoconnect=true&reconnect=true`;
442
+
443
+ console.log(chalk.green(`\n✓ Browser view ready!`));
444
+ console.log(chalk.blue(`\n📌 Instance ${instanceName} → open on your machine:`));
445
+ console.log(chalk.cyan(` ${vncUrl}`));
446
+ console.log(chalk.dim(`\n You and the agent share the same Chrome session.`));
447
+ console.log(chalk.dim(` Log in, solve captchas, or fill credentials — the agent picks it up automatically.`));
448
+ console.log(chalk.dim(`\n When done: openclaw-spawn browser stop ${instanceName}\n`));
449
+
450
+ // Auto-open VNC tab
451
+ try {
452
+ const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
453
+ execSync(`${openCmd} "${vncUrl}"`, { stdio: 'ignore' });
454
+ } catch {}
455
+ }
456
+
457
+ // Restore normal headless mode — stop visible Chrome, re-enable headless, restart gateway
458
+ async function browserStopCommand(name) {
459
+ let instanceName = name;
460
+
461
+ if (!instanceName) {
462
+ const selected = await selectInstance(false);
463
+ if (!selected || selected === '__new__') {
464
+ console.error(chalk.red('✗ No instance selected'));
465
+ process.exit(1);
466
+ }
467
+ instanceName = selected;
468
+ }
469
+
470
+ const instance = getInstance(instanceName);
471
+ if (!instance) {
472
+ console.error(chalk.red(`✗ Instance ${instanceName} not found`));
473
+ process.exit(1);
474
+ }
475
+
476
+ console.log(chalk.blue(`\n🔄 Stopping VNC view for ${instanceName}...`));
477
+
478
+ // Stop VNC services only — Chrome and gateway keep running so the agent can still use the browser
479
+ spawnSync('docker', ['exec', instance.container, 'pkill', '-f', 'websockify'], { stdio: 'pipe' });
480
+ spawnSync('docker', ['exec', instance.container, 'pkill', '-x', 'x11vnc'], { stdio: 'pipe' });
481
+
482
+ console.log(chalk.green(`\n✓ VNC stopped. Agent browser tool is still active.`));
483
+ }
484
+
485
+ // List instances
486
+ async function listInstances() {
487
+ const instances = getInstances();
488
+
489
+ if (Object.keys(instances).length === 0) {
490
+ console.log(chalk.yellow('No instances found. Run a command to create one!'));
491
+ return;
492
+ }
493
+
494
+ console.log(chalk.blue.bold('\n📋 OpenClaw Instances:\n'));
495
+
496
+ for (const [name, instance] of Object.entries(instances)) {
497
+ const status = getContainerStatus(instance.container);
498
+ const statusIcon = status === 'running' ? '🟢' : status === 'stopped' ? '🔴' : '⚪';
499
+ const statusColor = status === 'running' ? chalk.green : status === 'stopped' ? chalk.red : chalk.gray;
500
+
501
+ console.log(`${statusIcon} ${chalk.bold(name)}`);
502
+ console.log(` Port: ${instance.port}`);
503
+ console.log(` Status: ${statusColor(status)}`);
504
+ console.log(` Container: ${instance.container}`);
505
+ console.log(` Created: ${new Date(instance.created).toLocaleString()}`);
506
+ if (status === 'running') {
507
+ console.log(` Browser: ${chalk.cyan(`http://localhost:${instance.port + 20}/vnc.html?autoconnect=true`)}`);
508
+ }
509
+ console.log();
510
+ }
511
+ }
512
+
513
+ // Remove instance
514
+ async function removeInstanceCommand(name) {
515
+ const instance = getInstance(name);
516
+ if (!instance) {
517
+ console.error(chalk.red(`✗ Instance ${name} not found`));
518
+ process.exit(1);
519
+ }
520
+
521
+ const confirmed = await confirm(`Remove instance ${name}? This will delete all data.`);
522
+ if (!confirmed) {
523
+ console.log(chalk.yellow('Cancelled'));
524
+ return;
525
+ }
526
+
527
+ console.log(chalk.blue(`Removing instance ${name}...`));
528
+ removeContainer(instance.container);
529
+ removeInstanceMetadata(name);
530
+ console.log(chalk.green(`✓ Removed instance ${name}`));
531
+ }
532
+
533
+ // Stop instance
534
+ async function stopInstanceCommand(name) {
535
+ const instance = getInstance(name);
536
+ if (!instance) {
537
+ console.error(chalk.red(`✗ Instance ${name} not found`));
538
+ process.exit(1);
539
+ }
540
+
541
+ console.log(chalk.blue(`Stopping instance ${name}...`));
542
+ stopContainer(instance.container);
543
+ console.log(chalk.green(`✓ Stopped instance ${name}`));
544
+ }
545
+
546
+ // Start instance
547
+ async function startInstanceCommand(name) {
548
+ const instance = getInstance(name);
549
+ if (!instance) {
550
+ console.error(chalk.red(`✗ Instance ${name} not found`));
551
+ process.exit(1);
552
+ }
553
+
554
+ console.log(chalk.blue(`Starting instance ${name}...`));
555
+ startContainer(instance.container);
556
+ console.log(chalk.green(`✓ Started instance ${name}`));
557
+ }
558
+
559
+ // Show logs
560
+ async function showLogsCommand(name, follow) {
561
+ const instance = getInstance(name);
562
+ if (!instance) {
563
+ console.error(chalk.red(`✗ Instance ${name} not found`));
564
+ process.exit(1);
565
+ }
566
+
567
+ showLogs(instance.container, follow);
568
+ }
569
+
570
+ // Build base image
571
+ async function buildCommand() {
572
+ console.log(chalk.blue('Building Docker base image...'));
573
+ console.log(chalk.dim('This may take a few minutes on first build.\n'));
574
+
575
+ const success = buildBaseImage();
576
+ if (success) {
577
+ console.log(chalk.green('\n✓ Build complete!'));
578
+ } else {
579
+ console.error(chalk.red('\n✗ Build failed'));
580
+ process.exit(1);
581
+ }
582
+ }
583
+
584
+ // Cleanup - remove all containers and reset metadata
585
+ async function cleanupCommand() {
586
+ const confirmed = await confirm('Remove all OpenClaw instances and reset metadata?');
587
+ if (!confirmed) {
588
+ console.log(chalk.yellow('Cancelled'));
589
+ return;
590
+ }
591
+
592
+ console.log(chalk.blue('🧹 Cleaning up OpenClaw Swarm...\n'));
593
+
594
+ // Get all instances
595
+ const instances = getInstances();
596
+ const instanceNames = Object.keys(instances);
597
+
598
+ if (instanceNames.length > 0) {
599
+ console.log(chalk.dim('Stopping and removing containers...'));
600
+ for (const name of instanceNames) {
601
+ const instance = instances[name];
602
+ const status = getContainerStatus(instance.container);
603
+ if (status === 'running') {
604
+ stopContainer(instance.container);
605
+ }
606
+ if (status !== 'not-found') {
607
+ removeContainer(instance.container);
608
+ }
609
+ }
610
+ }
611
+
612
+ // Clear instance configs (but keep directory structure)
613
+ console.log(chalk.dim('Clearing instance configs...'));
614
+ const instancesDir = path.join(os.homedir(), '.openclaw-spawn', 'instances');
615
+ try {
616
+ // Remove all files in .openclaw subdirectories
617
+ execSync(`find "${instancesDir}" -mindepth 2 -type f -delete 2>/dev/null || true`, { stdio: 'ignore' });
618
+ // Remove empty subdirectories
619
+ execSync(`find "${instancesDir}" -mindepth 2 -type d -empty -delete 2>/dev/null || true`, { stdio: 'ignore' });
620
+ } catch (error) {
621
+ // Ignore errors (directory might not exist)
622
+ }
623
+
624
+ // Reset metadata
625
+ console.log(chalk.dim('Clearing metadata...'));
626
+ removeInstanceMetadata('__all__'); // Will clear in metadata module
627
+
628
+ console.log(chalk.green('\n✅ Cleanup complete! Re-add instances with: openclaw-spawn onboard'));
629
+ }