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/docker.js ADDED
@@ -0,0 +1,272 @@
1
+ import { execSync, spawn, spawnSync } from 'child_process';
2
+ import { getInstanceDir } from './metadata.js';
3
+
4
+ // Check if Docker is running
5
+ export function isDockerRunning() {
6
+ try {
7
+ execSync('docker ps', { stdio: 'ignore' });
8
+ return true;
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+
14
+ // Check if the docker binary is installed (not necessarily running)
15
+ export function isDockerInstalled() {
16
+ try {
17
+ const cmd = process.platform === 'win32' ? 'where docker' : 'which docker';
18
+ execSync(cmd, { stdio: 'ignore' });
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ // Check if our base image already exists (skip rebuild if so)
26
+ export function imageExists() {
27
+ try {
28
+ const out = execSync('docker images -q openclaw-spawn-base:latest', { encoding: 'utf8' }).trim();
29
+ return out.length > 0;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ // Check if container exists
36
+ export function containerExists(containerName) {
37
+ try {
38
+ execSync(`docker ps -a --filter name=${containerName} --format '{{.Names}}'`, { stdio: 'pipe' });
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ // Get container status
46
+ export function getContainerStatus(containerName) {
47
+ try {
48
+ const output = execSync(
49
+ `docker ps -a --filter name=${containerName} --format '{{.Status}}'`,
50
+ { encoding: 'utf8' }
51
+ );
52
+ if (output.includes('Up')) return 'running';
53
+ if (output.includes('Exited')) return 'stopped';
54
+ return 'unknown';
55
+ } catch {
56
+ return 'not-found';
57
+ }
58
+ }
59
+
60
+ // Create and start container
61
+ export function createContainer(name, port) {
62
+ const instanceDir = getInstanceDir(name);
63
+ const containerName = `openclaw-${name}`;
64
+
65
+ const cmd = [
66
+ 'docker', 'run',
67
+ '-d',
68
+ '--name', containerName,
69
+ '-e', 'HOME=/home/node',
70
+ '-e', 'PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright',
71
+ '-p', `${port}:${port}`, // Gateway port
72
+ '-p', `${port + 2}:${port + 2}`, // Browser control service (gateway.port + 2)
73
+ '-p', `${port + 11}:18800`, // Chrome CDP port
74
+ '-p', `${port + 20}:6080`, // noVNC browser view
75
+ '-v', `${instanceDir}/.openclaw:/home/node/.openclaw`,
76
+ '-v', `${instanceDir}/workspace:/workspace`,
77
+ '-v', `/var/run/docker.sock:/var/run/docker.sock`,
78
+ '--network', 'openclaw-network',
79
+ '--shm-size', '1g', // Chrome needs more than the default 64MB for multiple tabs
80
+ 'openclaw-spawn-base:latest'
81
+ // No CMD override โ€” Dockerfile CMD runs: Xvfb :99 ... & tail -f /dev/null
82
+ ];
83
+
84
+ try {
85
+ execSync(cmd.join(' '), { stdio: 'inherit' });
86
+ return true;
87
+ } catch (error) {
88
+ console.error('Failed to create container:', error.message);
89
+ return false;
90
+ }
91
+ }
92
+
93
+ // Execute command in container (interactive)
94
+ export function execInContainer(containerName, command, detached = false) {
95
+ if (detached) {
96
+ // Run in background
97
+ const process = spawn('docker', ['exec', '-d', containerName, 'sh', '-c', command], {
98
+ stdio: 'inherit',
99
+ shell: false
100
+ });
101
+
102
+ return new Promise((resolve, reject) => {
103
+ process.on('close', (code) => {
104
+ if (code === 0) {
105
+ resolve(true);
106
+ } else {
107
+ reject(new Error(`Command exited with code ${code}`));
108
+ }
109
+ });
110
+
111
+ process.on('error', (error) => {
112
+ reject(error);
113
+ });
114
+ });
115
+ } else {
116
+ // Run interactively
117
+ const process = spawn('docker', ['exec', '-it', containerName, 'sh', '-c', command], {
118
+ stdio: 'inherit',
119
+ shell: false
120
+ });
121
+
122
+ return new Promise((resolve, reject) => {
123
+ process.on('close', (code) => {
124
+ if (code === 0) {
125
+ resolve(true);
126
+ } else {
127
+ reject(new Error(`Command exited with code ${code}`));
128
+ }
129
+ });
130
+
131
+ process.on('error', (error) => {
132
+ reject(error);
133
+ });
134
+ });
135
+ }
136
+ }
137
+
138
+ // Stop container
139
+ export function stopContainer(containerName) {
140
+ try {
141
+ execSync(`docker stop ${containerName}`, { stdio: 'inherit' });
142
+ return true;
143
+ } catch (error) {
144
+ console.error('Failed to stop container:', error.message);
145
+ return false;
146
+ }
147
+ }
148
+
149
+ // Start container
150
+ export function startContainer(containerName) {
151
+ try {
152
+ execSync(`docker start ${containerName}`, { stdio: 'inherit' });
153
+ return true;
154
+ } catch (error) {
155
+ console.error('Failed to start container:', error.message);
156
+ return false;
157
+ }
158
+ }
159
+
160
+ // Remove container
161
+ export function removeContainer(containerName) {
162
+ try {
163
+ execSync(`docker rm -f ${containerName}`, { stdio: 'inherit' });
164
+ return true;
165
+ } catch (error) {
166
+ console.error('Failed to remove container:', error.message);
167
+ return false;
168
+ }
169
+ }
170
+
171
+ // Show container logs
172
+ export function showLogs(containerName, follow = false) {
173
+ const cmd = follow ?
174
+ `docker logs -f ${containerName}` :
175
+ `docker logs ${containerName}`;
176
+
177
+ try {
178
+ execSync(cmd, { stdio: 'inherit' });
179
+ return true;
180
+ } catch (error) {
181
+ console.error('Failed to show logs:', error.message);
182
+ return false;
183
+ }
184
+ }
185
+
186
+ // Build base image
187
+ export function buildBaseImage() {
188
+ try {
189
+ execSync('docker build -t openclaw-spawn-base:latest .', {
190
+ stdio: 'inherit',
191
+ cwd: process.cwd()
192
+ });
193
+ return true;
194
+ } catch (error) {
195
+ console.error('Failed to build image:', error.message);
196
+ return false;
197
+ }
198
+ }
199
+
200
+ // Start VNC services inside container (x11vnc + websockify/noVNC)
201
+ export function startVnc(containerName) {
202
+ try {
203
+ // Start x11vnc if not already running (serves Xvfb display :99 as VNC on port 5900)
204
+ execSync(
205
+ `docker exec -d ${containerName} sh -c "pgrep -x x11vnc > /dev/null || x11vnc -display :99 -forever -nopw -rfbport 5900 -quiet"`,
206
+ { stdio: 'ignore' }
207
+ );
208
+ execSync('sleep 1', { stdio: 'ignore' });
209
+ // Kill any existing websockify first so we only ever have one instance on port 6080.
210
+ // Use spawnSync with explicit argv โ€” avoids shell interpretation and pkill self-kill issues.
211
+ spawnSync('docker', ['exec', containerName, 'pkill', '-f', 'websockify'], { stdio: 'pipe' });
212
+ execSync('sleep 0.5', { stdio: 'ignore' });
213
+ spawnSync('docker', ['exec', '-d', containerName, 'websockify', '--web', '/usr/share/novnc', '6080', 'localhost:5900'], { stdio: 'pipe' });
214
+ execSync('sleep 1', { stdio: 'ignore' });
215
+ return true;
216
+ } catch (error) {
217
+ console.error('Failed to start VNC:', error.message);
218
+ return false;
219
+ }
220
+ }
221
+
222
+ // Start a visible Chrome on the Xvfb display for browser takeover / VNC interaction.
223
+ // Uses --disable-gpu for Docker compatibility and --remote-debugging-port so the gateway can attach via CDP.
224
+ export function startVisibleChrome(containerName, cdpPort) {
225
+ try {
226
+ // Use a dedicated temp profile to avoid restoring old session tabs.
227
+ // Stale session tabs cause playwright connectOverCDP to hang indefinitely.
228
+ // Extra flags reduce background workers/network activity that can destabilize
229
+ // the CDP WebSocket connection when browsing heavy sites like YouTube.
230
+ execSync(
231
+ `docker exec -d ${containerName} sh -c "/home/node/openclaw-chromium` +
232
+ ` --no-sandbox` +
233
+ ` --disable-gpu` +
234
+ ` --disable-dev-shm-usage` +
235
+ ` --remote-debugging-port=${cdpPort}` +
236
+ ` --user-data-dir=/tmp/openclaw-vnc-profile` +
237
+ ` --no-first-run` +
238
+ ` --no-default-browser-check` +
239
+ ` --disable-background-networking` +
240
+ ` --disable-extensions` +
241
+ ` --metrics-recording-only` +
242
+ ` --safebrowsing-disable-auto-update` +
243
+ ` 2>/dev/null"`,
244
+ { stdio: 'ignore' }
245
+ );
246
+ // Give Chrome 3 seconds to start and expose its CDP port
247
+ execSync('sleep 3', { stdio: 'ignore' });
248
+ return true;
249
+ } catch (error) {
250
+ console.error('Failed to start Chrome:', error.message);
251
+ return false;
252
+ }
253
+ }
254
+
255
+ // Stop visible Chrome and any orphaned VNC services
256
+ export function stopVisibleChrome(containerName) {
257
+ spawnSync('docker', ['exec', containerName, 'pkill', '-f', 'openclaw-chromium'], { stdio: 'pipe' });
258
+ return true;
259
+ }
260
+
261
+ // Create Docker network
262
+ export function ensureNetwork() {
263
+ try {
264
+ execSync('docker network inspect openclaw-network', { stdio: 'ignore' });
265
+ } catch {
266
+ try {
267
+ execSync('docker network create openclaw-network', { stdio: 'inherit' });
268
+ } catch (error) {
269
+ console.error('Failed to create network:', error.message);
270
+ }
271
+ }
272
+ }
@@ -0,0 +1,166 @@
1
+ import os from 'os';
2
+ import path from 'path';
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
4
+
5
+ const SWARM_DIR = path.join(os.homedir(), '.openclaw-spawn');
6
+ const METADATA_FILE = path.join(SWARM_DIR, 'instances.json');
7
+ const INSTANCES_DIR = path.join(SWARM_DIR, 'instances');
8
+
9
+ // Ensure directories exist
10
+ export function ensureDirectories() {
11
+ if (!existsSync(SWARM_DIR)) {
12
+ mkdirSync(SWARM_DIR, { recursive: true });
13
+ }
14
+ if (!existsSync(INSTANCES_DIR)) {
15
+ mkdirSync(INSTANCES_DIR, { recursive: true });
16
+ }
17
+ if (!existsSync(METADATA_FILE)) {
18
+ writeFileSync(METADATA_FILE, JSON.stringify({ instances: {}, nextPort: 18789 }, null, 2));
19
+ }
20
+ }
21
+
22
+ // Read metadata
23
+ export function readMetadata() {
24
+ ensureDirectories();
25
+ return JSON.parse(readFileSync(METADATA_FILE, 'utf8'));
26
+ }
27
+
28
+ // Write metadata
29
+ export function writeMetadata(data) {
30
+ ensureDirectories();
31
+ writeFileSync(METADATA_FILE, JSON.stringify(data, null, 2));
32
+ }
33
+
34
+ // Get all instances
35
+ export function getInstances() {
36
+ const data = readMetadata();
37
+ return data.instances;
38
+ }
39
+
40
+ // Get instance by name
41
+ export function getInstance(name) {
42
+ const instances = getInstances();
43
+ return instances[name];
44
+ }
45
+
46
+ // Add instance
47
+ export function addInstance(name, port) {
48
+ const data = readMetadata();
49
+ data.instances[name] = {
50
+ container: `openclaw-${name}`,
51
+ port,
52
+ created: new Date().toISOString(),
53
+ status: 'created'
54
+ };
55
+ data.nextPort = port + 220;
56
+ writeMetadata(data);
57
+
58
+ // Create instance directories
59
+ const instanceDir = path.join(INSTANCES_DIR, name);
60
+ mkdirSync(path.join(instanceDir, '.openclaw'), { recursive: true });
61
+ mkdirSync(path.join(instanceDir, 'workspace'), { recursive: true });
62
+
63
+ return data.instances[name];
64
+ }
65
+
66
+ // Remove instance
67
+ export function removeInstance(name) {
68
+ const data = readMetadata();
69
+ if (name === '__all__') {
70
+ // Special case for cleanup: reset everything
71
+ data.instances = {};
72
+ data.nextPort = 18789;
73
+ } else {
74
+ delete data.instances[name];
75
+ }
76
+ writeMetadata(data);
77
+ }
78
+
79
+ // Get next available port
80
+ export function getNextPort() {
81
+ const data = readMetadata();
82
+ return data.nextPort;
83
+ }
84
+
85
+ // Get instance directory
86
+ export function getInstanceDir(name) {
87
+ return path.join(INSTANCES_DIR, name);
88
+ }
89
+
90
+ // Read gateway auth token from instance's openclaw.json (for dashboard URL)
91
+ export function getInstanceGatewayToken(name) {
92
+ const configPath = path.join(INSTANCES_DIR, name, '.openclaw', 'openclaw.json');
93
+ if (!existsSync(configPath)) return null;
94
+ try {
95
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
96
+ return config?.gateway?.auth?.token ?? null;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ // Set gateway.port in instance's openclaw.json so internal port matches host port
103
+ export function setInstanceGatewayPort(name, port) {
104
+ const configPath = path.join(INSTANCES_DIR, name, '.openclaw', 'openclaw.json');
105
+ if (!existsSync(configPath)) return;
106
+ try {
107
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
108
+ if (!config.gateway) config.gateway = {};
109
+ config.gateway.port = port;
110
+ // Don't set bind - let CLI flag --bind lan override for gateway process only
111
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
112
+ } catch {
113
+ // ignore
114
+ }
115
+ }
116
+
117
+ // Enable browser takeover mode: we launch Chrome ourselves on Xvfb, gateway attaches via CDP.
118
+ // Sets attachOnly:true so the gateway never tries to launch Chrome itself (avoids Playwright launch issues in Docker).
119
+ // headless stays false for bookkeeping but is irrelevant when attachOnly:true.
120
+ export function enableBrowserTakeover(name) {
121
+ const configPath = path.join(INSTANCES_DIR, name, '.openclaw', 'openclaw.json');
122
+ if (!existsSync(configPath)) return;
123
+ try {
124
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
125
+ if (!config.browser) return;
126
+ config.browser.attachOnly = true;
127
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
128
+ } catch {
129
+ // ignore
130
+ }
131
+ }
132
+
133
+ // Restore normal headless mode โ€” undo browser takeover
134
+ export function disableBrowserTakeover(name) {
135
+ const configPath = path.join(INSTANCES_DIR, name, '.openclaw', 'openclaw.json');
136
+ if (!existsSync(configPath)) return;
137
+ try {
138
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
139
+ if (!config.browser) return;
140
+ config.browser.attachOnly = false;
141
+ config.browser.headless = true;
142
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
143
+ } catch {
144
+ // ignore
145
+ }
146
+ }
147
+
148
+ // Enable browser tool with managed Chromium (openclaw profile, CDP port 18800)
149
+ export function enableBrowserTool(name) {
150
+ const configPath = path.join(INSTANCES_DIR, name, '.openclaw', 'openclaw.json');
151
+ if (!existsSync(configPath)) return;
152
+ try {
153
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
154
+ if (!config.browser) config.browser = {};
155
+ config.browser.enabled = true;
156
+ config.browser.defaultProfile = 'openclaw';
157
+ config.browser.headless = true; // Required for Docker/headless environments
158
+ config.browser.noSandbox = true; // Required for Docker (Chrome sandbox needs kernel features)
159
+ config.browser.executablePath = '/home/node/openclaw-chromium';
160
+ if (!config.browser.profiles) config.browser.profiles = {};
161
+ config.browser.profiles.openclaw = { cdpPort: 18800, color: '#FF4500' };
162
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
163
+ } catch {
164
+ // ignore
165
+ }
166
+ }
@@ -0,0 +1,72 @@
1
+ import inquirer from 'inquirer';
2
+ import { getInstances } from './metadata.js';
3
+ import { getContainerStatus } from './docker.js';
4
+
5
+ // Show instance selector
6
+ export async function selectInstance(allowAddNew = true) {
7
+ const instances = getInstances();
8
+ const choices = [];
9
+
10
+ // Add existing instances
11
+ for (const [name, instance] of Object.entries(instances)) {
12
+ const status = getContainerStatus(instance.container);
13
+ const statusIcon = status === 'running' ? '๐ŸŸข' : status === 'stopped' ? '๐Ÿ”ด' : 'โšช';
14
+ choices.push({
15
+ name: `${statusIcon} ${name} (port ${instance.port}, ${status})`,
16
+ value: name,
17
+ short: name
18
+ });
19
+ }
20
+
21
+ // Add "new instance" option
22
+ if (allowAddNew) {
23
+ choices.push({
24
+ name: 'โž• Add new instance',
25
+ value: '__new__',
26
+ short: 'New instance'
27
+ });
28
+ }
29
+
30
+ if (choices.length === 0) {
31
+ return '__new__';
32
+ }
33
+
34
+ const answer = await inquirer.prompt([{
35
+ type: 'list',
36
+ name: 'instance',
37
+ message: 'Select instance:',
38
+ choices
39
+ }]);
40
+
41
+ return answer.instance;
42
+ }
43
+
44
+ // Prompt for new instance name
45
+ export async function promptInstanceName() {
46
+ const answer = await inquirer.prompt([{
47
+ type: 'input',
48
+ name: 'name',
49
+ message: 'Enter instance name:',
50
+ validate: (input) => {
51
+ if (!input) return 'Name cannot be empty';
52
+ if (!/^[a-z0-9-]+$/.test(input)) return 'Use only lowercase letters, numbers, and hyphens';
53
+ const instances = getInstances();
54
+ if (instances[input]) return 'Instance already exists';
55
+ return true;
56
+ }
57
+ }]);
58
+
59
+ return answer.name;
60
+ }
61
+
62
+ // Confirm action
63
+ export async function confirm(message) {
64
+ const answer = await inquirer.prompt([{
65
+ type: 'confirm',
66
+ name: 'confirmed',
67
+ message,
68
+ default: false
69
+ }]);
70
+
71
+ return answer.confirmed;
72
+ }
package/test/verify.js ADDED
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Quick test script to verify the CLI works
4
+ import { execSync } from 'child_process';
5
+
6
+ console.log('๐Ÿงช Testing OpenClaw Swarm CLI\n');
7
+
8
+ // Test 1: List instances
9
+ console.log('1๏ธโƒฃ Testing list command...');
10
+ try {
11
+ execSync('openclaw-spawn list', { stdio: 'inherit' });
12
+ console.log('โœ… List command works\n');
13
+ } catch (error) {
14
+ console.log('โŒ List command failed\n');
15
+ }
16
+
17
+ // Test 2: Check instance status
18
+ console.log('2๏ธโƒฃ Testing instance status...');
19
+ try {
20
+ execSync('docker ps | grep openclaw', { stdio: 'inherit' });
21
+ console.log('โœ… Container is running\n');
22
+ } catch (error) {
23
+ console.log('โŒ No containers running\n');
24
+ }
25
+
26
+ // Test 3: Execute simple command in container
27
+ console.log('3๏ธโƒฃ Testing command execution...');
28
+ try {
29
+ execSync('docker exec openclaw-oc-1 openclaw --version', { stdio: 'inherit' });
30
+ console.log('โœ… Can execute commands in container\n');
31
+ } catch (error) {
32
+ console.log('โŒ Failed to execute command\n');
33
+ }
34
+
35
+ // Test 4: Check OpenClaw health
36
+ console.log('4๏ธโƒฃ Testing OpenClaw installation...');
37
+ try {
38
+ execSync('docker exec openclaw-oc-1 which openclaw', { stdio: 'inherit' });
39
+ console.log('โœ… OpenClaw is installed\n');
40
+ } catch (error) {
41
+ console.log('โŒ OpenClaw not found\n');
42
+ }
43
+
44
+ // Test 5: Check workspace
45
+ console.log('5๏ธโƒฃ Testing workspace access...');
46
+ try {
47
+ execSync('docker exec openclaw-oc-1 ls -la /workspace', { stdio: 'inherit' });
48
+ console.log('โœ… Workspace is accessible\n');
49
+ } catch (error) {
50
+ console.log('โŒ Workspace not accessible\n');
51
+ }
52
+
53
+ console.log('\n๐ŸŽ‰ CLI tests complete!');
54
+ console.log('\nTo test interactive commands manually:');
55
+ console.log(' openclaw-spawn onboard # Run onboarding wizard');
56
+ console.log(' openclaw-spawn gateway # Start gateway');
57
+ console.log(' openclaw-spawn tui # Open TUI');