@rmbk/compeek 0.2.3 → 0.2.5
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/bin/compeek.mjs +431 -49
- package/package.json +12 -4
package/bin/compeek.mjs
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// compeek CLI — zero dependencies, Node.js built-ins only
|
|
4
|
-
// Usage: npx compeek [start|stop|status|logs|open]
|
|
4
|
+
// Usage: npx compeek [start|stop|status|logs|open|mcp]
|
|
5
5
|
|
|
6
6
|
import { execSync, spawn } from 'node:child_process';
|
|
7
7
|
import http from 'node:http';
|
|
8
8
|
import crypto from 'node:crypto';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
9
11
|
|
|
10
12
|
const IMAGE = 'ghcr.io/uburuntu/compeek:latest';
|
|
13
|
+
const VM_IMAGES = {
|
|
14
|
+
windows: 'dockurr/windows:latest',
|
|
15
|
+
macos: 'dockurr/macos:latest',
|
|
16
|
+
};
|
|
17
|
+
const SIDECAR_IMAGE = IMAGE;
|
|
11
18
|
const CONTAINER_PREFIX = 'compeek-';
|
|
12
19
|
const DASHBOARD_URL = 'https://compeek.rmbk.me';
|
|
13
20
|
const HEALTH_TIMEOUT = 30_000;
|
|
21
|
+
const VM_HEALTH_TIMEOUT = 180_000; // VMs take longer to boot
|
|
14
22
|
const HEALTH_INTERVAL = 1_000;
|
|
15
23
|
|
|
16
24
|
// ── ANSI Colors ──────────────────────────────────────────
|
|
@@ -49,6 +57,38 @@ function hasDocker() {
|
|
|
49
57
|
}
|
|
50
58
|
}
|
|
51
59
|
|
|
60
|
+
function hasKvm() {
|
|
61
|
+
try {
|
|
62
|
+
run('ls /dev/kvm', { allowFail: false });
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function validateOsRequirements(os) {
|
|
70
|
+
if (os === 'linux') return;
|
|
71
|
+
const platform = process.platform;
|
|
72
|
+
const osLabel = os === 'windows' ? 'Windows' : 'macOS';
|
|
73
|
+
|
|
74
|
+
if (platform === 'darwin') {
|
|
75
|
+
console.error(`\n ${c.red}${osLabel} containers require a Linux host with KVM.${c.reset}`);
|
|
76
|
+
console.error(` They do not work on macOS Docker Desktop.\n`);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
if (platform === 'win32' && os === 'macos') {
|
|
80
|
+
console.error(`\n ${c.red}macOS containers require a Linux host with KVM.${c.reset}`);
|
|
81
|
+
console.error(` They do not work on Windows Docker Desktop.\n`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
if (platform === 'linux' && !hasKvm()) {
|
|
85
|
+
console.error(`\n ${c.red}KVM is not available (/dev/kvm not found).${c.reset}`);
|
|
86
|
+
console.error(` ${osLabel} containers require hardware virtualization.`);
|
|
87
|
+
console.error(` Enable virtualization in BIOS and ensure the kvm kernel module is loaded.\n`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
52
92
|
function listContainers() {
|
|
53
93
|
const out = run(
|
|
54
94
|
`docker ps -a --filter "name=^${CONTAINER_PREFIX}" --format "{{.Names}}\\t{{.Status}}\\t{{.Ports}}"`,
|
|
@@ -137,9 +177,61 @@ function waitForHealth(host, port, timeout) {
|
|
|
137
177
|
});
|
|
138
178
|
}
|
|
139
179
|
|
|
140
|
-
function
|
|
180
|
+
function waitForTunnelUrls(host, port, timeout) {
|
|
181
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
182
|
+
let frame = 0;
|
|
183
|
+
let spinner;
|
|
184
|
+
|
|
185
|
+
if (isColorSupported) {
|
|
186
|
+
spinner = setInterval(() => {
|
|
187
|
+
process.stdout.write(`\r ${c.cyan}${frames[frame]}${c.reset} Waiting for tunnel URLs...`);
|
|
188
|
+
frame = (frame + 1) % frames.length;
|
|
189
|
+
}, 80);
|
|
190
|
+
} else {
|
|
191
|
+
console.log(' Waiting for tunnel URLs...');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return new Promise((resolve) => {
|
|
195
|
+
const start = Date.now();
|
|
196
|
+
const check = () => {
|
|
197
|
+
const req = http.get(`http://${host}:${port}/api/info`, { timeout: 2000 }, (res) => {
|
|
198
|
+
let body = '';
|
|
199
|
+
res.on('data', d => body += d);
|
|
200
|
+
res.on('end', () => {
|
|
201
|
+
try {
|
|
202
|
+
const data = JSON.parse(body);
|
|
203
|
+
if (data.tunnel?.apiUrl && data.tunnel?.vncUrl) {
|
|
204
|
+
if (spinner) {
|
|
205
|
+
clearInterval(spinner);
|
|
206
|
+
process.stdout.write(`\r ${c.green}✓${c.reset} Tunnels ready \n`);
|
|
207
|
+
}
|
|
208
|
+
return resolve(data.tunnel);
|
|
209
|
+
}
|
|
210
|
+
} catch { /* retry */ }
|
|
211
|
+
retry();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
req.on('error', retry);
|
|
215
|
+
req.on('timeout', () => { req.destroy(); retry(); });
|
|
216
|
+
};
|
|
217
|
+
const retry = () => {
|
|
218
|
+
if (Date.now() - start > timeout) {
|
|
219
|
+
if (spinner) {
|
|
220
|
+
clearInterval(spinner);
|
|
221
|
+
process.stdout.write(`\r ${c.yellow}!${c.reset} Tunnel timed out (local-only mode)\n`);
|
|
222
|
+
}
|
|
223
|
+
return resolve(null);
|
|
224
|
+
}
|
|
225
|
+
setTimeout(check, HEALTH_INTERVAL);
|
|
226
|
+
};
|
|
227
|
+
check();
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function buildConnectionString(name, apiHost, apiPort, vncHost, vncPort, vncPassword, osType) {
|
|
141
232
|
const config = { name, type: 'compeek', apiHost, apiPort, vncHost, vncPort };
|
|
142
233
|
if (vncPassword) config.vncPassword = vncPassword;
|
|
234
|
+
if (osType && osType !== 'linux') config.osType = osType;
|
|
143
235
|
return Buffer.from(JSON.stringify(config)).toString('base64');
|
|
144
236
|
}
|
|
145
237
|
|
|
@@ -156,6 +248,33 @@ function openUrl(url) {
|
|
|
156
248
|
|
|
157
249
|
// ── Commands ─────────────────────────────────────────────
|
|
158
250
|
|
|
251
|
+
function detectRunningContainer() {
|
|
252
|
+
const containers = listContainers().filter(c => c.status.startsWith('Up'));
|
|
253
|
+
if (containers.length === 0) return null;
|
|
254
|
+
|
|
255
|
+
const name = containers[0].name;
|
|
256
|
+
const inspect = run(`docker inspect --format '{{json .NetworkSettings.Ports}}' ${name}`, { allowFail: true });
|
|
257
|
+
if (!inspect) return null;
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const ports = JSON.parse(inspect);
|
|
261
|
+
const apiBinding = ports['3000/tcp']?.[0];
|
|
262
|
+
const apiPort = apiBinding ? parseInt(apiBinding.HostPort) : 3001;
|
|
263
|
+
|
|
264
|
+
// Extract VNC_PASSWORD from container env
|
|
265
|
+
const envStr = run(`docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' ${name}`, { allowFail: true });
|
|
266
|
+
let apiToken;
|
|
267
|
+
if (envStr) {
|
|
268
|
+
const match = envStr.match(/^VNC_PASSWORD=(.+)$/m);
|
|
269
|
+
if (match) apiToken = match[1];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return { name, apiPort, apiToken, containerUrl: `http://localhost:${apiPort}` };
|
|
273
|
+
} catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
159
278
|
async function cmdStart(args) {
|
|
160
279
|
if (!hasDocker()) {
|
|
161
280
|
console.error(`${c.red}Docker is not available.${c.reset} Install Docker first: https://docs.docker.com/get-docker/`);
|
|
@@ -163,79 +282,224 @@ async function cmdStart(args) {
|
|
|
163
282
|
}
|
|
164
283
|
|
|
165
284
|
const flags = parseFlags(args);
|
|
285
|
+
const os = flags.os || 'linux';
|
|
286
|
+
if (!['linux', 'windows', 'macos'].includes(os)) {
|
|
287
|
+
console.error(`${c.red}Invalid --os value: ${os}${c.reset}. Use: linux, windows, or macos`);
|
|
288
|
+
process.exit(1);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Validate KVM requirements for Windows/macOS
|
|
292
|
+
if (os !== 'linux') {
|
|
293
|
+
validateOsRequirements(os);
|
|
294
|
+
}
|
|
295
|
+
|
|
166
296
|
const name = flags.name || findNextName();
|
|
167
297
|
const { apiPort: defaultApi, vncPort: defaultVnc } = findNextPorts();
|
|
168
298
|
const apiPort = parseInt(flags['api-port']) || defaultApi;
|
|
169
299
|
const vncPort = parseInt(flags['vnc-port']) || defaultVnc;
|
|
170
|
-
const mode = flags.mode || 'full';
|
|
171
|
-
const vncPassword = flags.password || crypto.randomBytes(
|
|
172
|
-
const
|
|
300
|
+
const mode = os === 'linux' ? (flags.mode || 'full') : 'sidecar';
|
|
301
|
+
const vncPassword = flags.password || crypto.randomBytes(24).toString('base64url').slice(0, 24);
|
|
302
|
+
const osLabel = os === 'windows' ? 'Windows' : os === 'macos' ? 'macOS' : 'Linux';
|
|
303
|
+
const sessionName = flags.name
|
|
304
|
+
? name.replace(CONTAINER_PREFIX, '')
|
|
305
|
+
: name.replace(CONTAINER_PREFIX, '').replace(/^(\d+)$/, `${osLabel} $1`);
|
|
306
|
+
|
|
307
|
+
// Tunnel provider: cloudflare by default for Linux, disabled for VMs in v1
|
|
308
|
+
const tunnelProvider = os !== 'linux' ? 'none'
|
|
309
|
+
: flags['no-tunnel'] ? 'none'
|
|
310
|
+
: typeof flags.tunnel === 'string' ? flags.tunnel
|
|
311
|
+
: flags.tunnel === true ? 'cloudflare'
|
|
312
|
+
: 'cloudflare';
|
|
173
313
|
|
|
174
314
|
console.log('');
|
|
175
315
|
console.log(` ${c.bold}${c.cyan}compeek${c.reset}`);
|
|
176
316
|
console.log('');
|
|
177
317
|
|
|
178
|
-
if (
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
318
|
+
if (os === 'linux') {
|
|
319
|
+
// ── Linux: single container (existing behavior) ──
|
|
320
|
+
if (!flags['no-pull']) {
|
|
321
|
+
console.log(` ${c.dim}Pulling image...${c.reset}`);
|
|
322
|
+
try {
|
|
323
|
+
run(`docker pull ${IMAGE}`, { stdio: 'inherit' });
|
|
324
|
+
} catch {
|
|
325
|
+
console.log(` ${c.yellow}Pull failed, using cached image.${c.reset}`);
|
|
326
|
+
}
|
|
327
|
+
console.log('');
|
|
184
328
|
}
|
|
185
|
-
console.log('');
|
|
186
|
-
}
|
|
187
329
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
330
|
+
const info = [
|
|
331
|
+
`mode:${c.white}${mode}${c.reset}`,
|
|
332
|
+
`api:${c.white}${apiPort}${c.reset}`,
|
|
333
|
+
`vnc:${c.white}${vncPort}${c.reset}`,
|
|
334
|
+
];
|
|
335
|
+
if (flags.persist) info.push(`${c.green}persist${c.reset}`);
|
|
336
|
+
if (tunnelProvider !== 'none') info.push(`${c.yellow}${tunnelProvider}${c.reset}`);
|
|
337
|
+
|
|
338
|
+
console.log(` ${c.cyan}▸${c.reset} Starting ${c.bold}${name}${c.reset} ${c.dim}${info.join(' · ')}${c.reset}`);
|
|
339
|
+
|
|
340
|
+
run(`docker rm -f ${name}`, { allowFail: true });
|
|
341
|
+
|
|
342
|
+
run([
|
|
343
|
+
'docker run -d',
|
|
344
|
+
`--name ${name}`,
|
|
345
|
+
`-p ${apiPort}:3000`,
|
|
346
|
+
`-p ${vncPort}:6080`,
|
|
347
|
+
`--shm-size=512m`,
|
|
348
|
+
`-e DISPLAY=:1`,
|
|
349
|
+
`-e DESKTOP_MODE=${mode}`,
|
|
350
|
+
`-e COMPEEK_SESSION_NAME="${sessionName}"`,
|
|
351
|
+
`-e VNC_PASSWORD="${vncPassword}"`,
|
|
352
|
+
`-e TUNNEL_PROVIDER=${tunnelProvider}`,
|
|
353
|
+
flags.persist ? `-v ${name}-data:/home/compeek/data` : '',
|
|
354
|
+
`--security-opt seccomp=unconfined`,
|
|
355
|
+
IMAGE,
|
|
356
|
+
].filter(Boolean).join(' '));
|
|
357
|
+
} else {
|
|
358
|
+
// ── Windows/macOS: VM + sidecar containers ──
|
|
359
|
+
const vmImage = VM_IMAGES[os];
|
|
360
|
+
const vmName = `${name}-vm`;
|
|
361
|
+
const netName = `${name}-net`;
|
|
362
|
+
|
|
363
|
+
if (!flags['no-pull']) {
|
|
364
|
+
console.log(` ${c.dim}Pulling images...${c.reset}`);
|
|
365
|
+
try {
|
|
366
|
+
run(`docker pull ${vmImage}`, { stdio: 'inherit' });
|
|
367
|
+
} catch {
|
|
368
|
+
console.log(` ${c.yellow}VM image pull failed, using cached.${c.reset}`);
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
run(`docker pull ${SIDECAR_IMAGE}`, { stdio: 'inherit' });
|
|
372
|
+
} catch {
|
|
373
|
+
console.log(` ${c.yellow}Sidecar image pull failed, using cached.${c.reset}`);
|
|
374
|
+
}
|
|
375
|
+
console.log('');
|
|
376
|
+
}
|
|
216
377
|
|
|
378
|
+
const vmVersion = flags.version || (os === 'windows' ? '11' : '15');
|
|
379
|
+
const vmRam = flags.ram || '4G';
|
|
380
|
+
const vmCpus = flags.cpus || '2';
|
|
381
|
+
const vmDisk = flags.disk || '64G';
|
|
382
|
+
|
|
383
|
+
const info = [
|
|
384
|
+
`os:${c.white}${osLabel}${c.reset}`,
|
|
385
|
+
`ver:${c.white}${vmVersion}${c.reset}`,
|
|
386
|
+
`ram:${c.white}${vmRam}${c.reset}`,
|
|
387
|
+
`api:${c.white}${apiPort}${c.reset}`,
|
|
388
|
+
`vnc:${c.white}${vncPort}${c.reset}`,
|
|
389
|
+
];
|
|
390
|
+
if (flags.persist) info.push(`${c.green}persist${c.reset}`);
|
|
391
|
+
|
|
392
|
+
console.log(` ${c.cyan}▸${c.reset} Starting ${c.bold}${name}${c.reset} ${c.dim}${info.join(' · ')}${c.reset}`);
|
|
393
|
+
console.log(` ${c.yellow}Note:${c.reset} ${osLabel} VM boot is slow. Tunneling not available for VMs.`);
|
|
394
|
+
|
|
395
|
+
// Clean up any existing containers/network
|
|
396
|
+
run(`docker rm -f ${name}`, { allowFail: true });
|
|
397
|
+
run(`docker rm -f ${vmName}`, { allowFail: true });
|
|
398
|
+
run(`docker network rm ${netName}`, { allowFail: true });
|
|
399
|
+
|
|
400
|
+
// Create dedicated network
|
|
401
|
+
run(`docker network create ${netName}`);
|
|
402
|
+
|
|
403
|
+
// Start dockur VM container
|
|
404
|
+
run([
|
|
405
|
+
'docker run -d',
|
|
406
|
+
`--name ${vmName}`,
|
|
407
|
+
`--network ${netName}`,
|
|
408
|
+
`--device /dev/kvm`,
|
|
409
|
+
`-e VERSION=${vmVersion}`,
|
|
410
|
+
`-e RAM_SIZE=${vmRam}`,
|
|
411
|
+
`-e CPU_CORES=${vmCpus}`,
|
|
412
|
+
`-e DISK_SIZE=${vmDisk}`,
|
|
413
|
+
os === 'windows' ? '-e USERNAME=User -e PASSWORD=password' : '',
|
|
414
|
+
flags.persist ? `-v ${name}-data:/storage` : '',
|
|
415
|
+
`--cap-add NET_ADMIN`,
|
|
416
|
+
vmImage,
|
|
417
|
+
].filter(Boolean).join(' '));
|
|
418
|
+
|
|
419
|
+
// Start compeek sidecar container
|
|
420
|
+
run([
|
|
421
|
+
'docker run -d',
|
|
422
|
+
`--name ${name}`,
|
|
423
|
+
`--network ${netName}`,
|
|
424
|
+
`-p ${apiPort}:3000`,
|
|
425
|
+
`-p ${vncPort}:6080`,
|
|
426
|
+
`-e DESKTOP_MODE=sidecar`,
|
|
427
|
+
`-e SIDECAR_TARGET=${vmName}`,
|
|
428
|
+
`-e SIDECAR_VNC_PORT=5900`,
|
|
429
|
+
`-e SIDECAR_VIEWER_PORT=8006`,
|
|
430
|
+
`-e SIDECAR_OS=${os}`,
|
|
431
|
+
`-e COMPEEK_SESSION_NAME="${sessionName}"`,
|
|
432
|
+
`-e VNC_PASSWORD="${vncPassword}"`,
|
|
433
|
+
SIDECAR_IMAGE,
|
|
434
|
+
].filter(Boolean).join(' '));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const healthTimeout = os === 'linux' ? HEALTH_TIMEOUT : VM_HEALTH_TIMEOUT;
|
|
217
438
|
try {
|
|
218
|
-
await waitForHealth('localhost', apiPort,
|
|
439
|
+
await waitForHealth('localhost', apiPort, healthTimeout);
|
|
219
440
|
} catch {
|
|
220
441
|
console.error(`\n ${c.red}Container did not start.${c.reset} Check logs: npx compeek logs`);
|
|
221
442
|
process.exit(1);
|
|
222
443
|
}
|
|
223
444
|
|
|
224
|
-
|
|
225
|
-
|
|
445
|
+
// Wait for tunnel URLs if tunneling is enabled (Linux only)
|
|
446
|
+
let tunnel = null;
|
|
447
|
+
if (tunnelProvider !== 'none' && mode !== 'headless') {
|
|
448
|
+
tunnel = await waitForTunnelUrls('localhost', apiPort, 30_000);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Build connection strings
|
|
452
|
+
const localConnStr = buildConnectionString(sessionName, 'localhost', apiPort, 'localhost', vncPort, vncPassword, os);
|
|
453
|
+
let tunnelConnStr = null;
|
|
454
|
+
let dashboardLink;
|
|
455
|
+
|
|
456
|
+
if (tunnel) {
|
|
457
|
+
const apiUrl = new URL(tunnel.apiUrl);
|
|
458
|
+
const vncUrl = new URL(tunnel.vncUrl);
|
|
459
|
+
tunnelConnStr = buildConnectionString(
|
|
460
|
+
sessionName,
|
|
461
|
+
apiUrl.hostname, 443,
|
|
462
|
+
vncUrl.hostname, 443,
|
|
463
|
+
vncPassword,
|
|
464
|
+
os,
|
|
465
|
+
);
|
|
466
|
+
dashboardLink = `${DASHBOARD_URL}/#config=${tunnelConnStr}`;
|
|
467
|
+
} else {
|
|
468
|
+
dashboardLink = `${DASHBOARD_URL}/#config=${localConnStr}`;
|
|
469
|
+
}
|
|
226
470
|
|
|
227
471
|
console.log('');
|
|
228
472
|
console.log(` ${c.dim}──── Links ─────────────────────────────────────${c.reset}`);
|
|
229
473
|
console.log('');
|
|
230
474
|
console.log(` ${c.bold}Dashboard${c.reset} ${c.cyan}${dashboardLink}${c.reset}`);
|
|
231
|
-
|
|
475
|
+
if (tunnel) {
|
|
476
|
+
console.log(` ${c.dim}API${c.reset} ${tunnel.apiUrl}`);
|
|
477
|
+
console.log(` ${c.dim}noVNC${c.reset} ${tunnel.vncUrl}`);
|
|
478
|
+
}
|
|
479
|
+
console.log(` ${c.dim}Local API${c.reset} http://localhost:${apiPort}`);
|
|
232
480
|
if (mode !== 'headless') {
|
|
233
|
-
console.log(` ${c.dim}
|
|
481
|
+
console.log(` ${c.dim}Local VNC${c.reset} http://localhost:${vncPort}`);
|
|
234
482
|
console.log(` ${c.dim}Password${c.reset} ${vncPassword}`);
|
|
235
483
|
}
|
|
484
|
+
if (os !== 'linux') {
|
|
485
|
+
console.log('');
|
|
486
|
+
console.log(` ${c.yellow}Limitations:${c.reset} No bash/shell access in ${osLabel} VMs.`);
|
|
487
|
+
console.log(` ${c.dim}The agent uses mouse, keyboard, and screenshots only.${c.reset}`);
|
|
488
|
+
}
|
|
236
489
|
console.log('');
|
|
237
490
|
console.log(` ${c.dim}──── Connection string ──────────────────────────${c.reset}`);
|
|
238
|
-
console.log(` ${c.dim}${
|
|
491
|
+
console.log(` ${c.dim}${tunnelConnStr || localConnStr}${c.reset}`);
|
|
492
|
+
console.log('');
|
|
493
|
+
console.log(` ${c.dim}──── MCP ───────────────────────────────────────${c.reset}`);
|
|
494
|
+
console.log('');
|
|
495
|
+
if (tunnel) {
|
|
496
|
+
console.log(` ${c.dim}Streamable HTTP${c.reset} ${tunnel.apiUrl}/mcp`);
|
|
497
|
+
}
|
|
498
|
+
console.log(` ${c.dim}Local MCP${c.reset} http://localhost:${apiPort}/mcp`);
|
|
499
|
+
console.log(` ${c.dim}stdio proxy${c.reset} npx @rmbk/compeek mcp`);
|
|
500
|
+
console.log('');
|
|
501
|
+
console.log(` ${c.dim}Claude Code config (~/.claude/settings.json):${c.reset}`);
|
|
502
|
+
console.log(` ${c.dim}{ "mcpServers": { "compeek": { "command": "npx", "args": ["-y", "@rmbk/compeek", "mcp"] } } }${c.reset}`);
|
|
239
503
|
console.log('');
|
|
240
504
|
|
|
241
505
|
if (flags.open) {
|
|
@@ -249,6 +513,9 @@ function cmdStop(args) {
|
|
|
249
513
|
const name = target.startsWith(CONTAINER_PREFIX) ? target : `${CONTAINER_PREFIX}${target}`;
|
|
250
514
|
console.log(` ${c.cyan}▸${c.reset} Stopping ${c.bold}${name}${c.reset}...`);
|
|
251
515
|
run(`docker rm -f ${name}`, { allowFail: true, stdio: 'inherit' });
|
|
516
|
+
// Clean up companion VM container and network if they exist
|
|
517
|
+
run(`docker rm -f ${name}-vm`, { allowFail: true });
|
|
518
|
+
run(`docker network rm ${name}-net`, { allowFail: true });
|
|
252
519
|
console.log(` ${c.green}✓${c.reset} Stopped`);
|
|
253
520
|
} else {
|
|
254
521
|
const containers = listContainers();
|
|
@@ -259,6 +526,9 @@ function cmdStop(args) {
|
|
|
259
526
|
for (const ctr of containers) {
|
|
260
527
|
console.log(` ${c.cyan}▸${c.reset} Stopping ${c.bold}${ctr.name}${c.reset}...`);
|
|
261
528
|
run(`docker rm -f ${ctr.name}`, { allowFail: true });
|
|
529
|
+
// Clean up companion VM container and network
|
|
530
|
+
run(`docker rm -f ${ctr.name}-vm`, { allowFail: true });
|
|
531
|
+
run(`docker network rm ${ctr.name}-net`, { allowFail: true });
|
|
262
532
|
}
|
|
263
533
|
console.log(` ${c.green}✓${c.reset} Stopped ${containers.length} container(s)`);
|
|
264
534
|
}
|
|
@@ -301,6 +571,94 @@ function cmdLogs(args) {
|
|
|
301
571
|
child.on('exit', code => process.exit(code || 0));
|
|
302
572
|
}
|
|
303
573
|
|
|
574
|
+
async function cmdMcp(args) {
|
|
575
|
+
const flags = parseFlags(args);
|
|
576
|
+
let containerUrl = flags['container-url'];
|
|
577
|
+
let apiToken = flags['api-token'];
|
|
578
|
+
let startedContainerName = null;
|
|
579
|
+
|
|
580
|
+
if (!containerUrl) {
|
|
581
|
+
// Auto-detect running container
|
|
582
|
+
const detected = detectRunningContainer();
|
|
583
|
+
if (detected) {
|
|
584
|
+
containerUrl = detected.containerUrl;
|
|
585
|
+
apiToken = apiToken || detected.apiToken;
|
|
586
|
+
process.stderr.write(`Using container ${detected.name} at ${containerUrl}\n`);
|
|
587
|
+
} else if (flags.start) {
|
|
588
|
+
// Auto-start a container
|
|
589
|
+
if (!hasDocker()) {
|
|
590
|
+
process.stderr.write('Docker is not available. Specify --container-url or install Docker.\n');
|
|
591
|
+
process.exit(1);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const name = flags.name || findNextName();
|
|
595
|
+
const { apiPort } = findNextPorts();
|
|
596
|
+
const vncPassword = flags.password || crypto.randomBytes(24).toString('base64url').slice(0, 24);
|
|
597
|
+
const mode = flags.mode || 'full';
|
|
598
|
+
|
|
599
|
+
run(`docker rm -f ${name}`, { allowFail: true });
|
|
600
|
+
run([
|
|
601
|
+
'docker run -d',
|
|
602
|
+
`--name ${name}`,
|
|
603
|
+
`-p ${apiPort}:3000`,
|
|
604
|
+
`--shm-size=512m`,
|
|
605
|
+
`-e DISPLAY=:1`,
|
|
606
|
+
`-e DESKTOP_MODE=${mode}`,
|
|
607
|
+
`-e COMPEEK_SESSION_NAME="${name}"`,
|
|
608
|
+
`-e VNC_PASSWORD="${vncPassword}"`,
|
|
609
|
+
`-e TUNNEL_PROVIDER=none`,
|
|
610
|
+
flags.persist ? `-v ${name}-data:/home/compeek/data` : '',
|
|
611
|
+
`--security-opt seccomp=unconfined`,
|
|
612
|
+
IMAGE,
|
|
613
|
+
].filter(Boolean).join(' '));
|
|
614
|
+
|
|
615
|
+
process.stderr.write(`Starting container ${name}...\n`);
|
|
616
|
+
try {
|
|
617
|
+
await waitForHealth('localhost', apiPort, HEALTH_TIMEOUT);
|
|
618
|
+
} catch {
|
|
619
|
+
process.stderr.write('Container did not start. Check logs: npx compeek logs\n');
|
|
620
|
+
process.exit(1);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
containerUrl = `http://localhost:${apiPort}`;
|
|
624
|
+
apiToken = vncPassword;
|
|
625
|
+
startedContainerName = name;
|
|
626
|
+
process.stderr.write(`Container ${name} ready at ${containerUrl}\n`);
|
|
627
|
+
} else {
|
|
628
|
+
process.stderr.write('No running compeek container found.\n');
|
|
629
|
+
process.stderr.write('Start one with: npx @rmbk/compeek start\n');
|
|
630
|
+
process.stderr.write('Or use: npx @rmbk/compeek mcp --start\n');
|
|
631
|
+
process.exit(1);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Resolve path to the compiled stdio entrypoint
|
|
636
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
637
|
+
const stdioPath = join(__dirname, '..', 'dist', 'mcp', 'stdio.js');
|
|
638
|
+
|
|
639
|
+
const mcpArgs = ['--container-url', containerUrl];
|
|
640
|
+
if (apiToken) mcpArgs.push('--api-token', apiToken);
|
|
641
|
+
|
|
642
|
+
const child = spawn('node', [stdioPath, ...mcpArgs], {
|
|
643
|
+
stdio: ['inherit', 'inherit', 'inherit'],
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// Clean up auto-started container on exit
|
|
647
|
+
if (startedContainerName && !flags.persist) {
|
|
648
|
+
const cleanup = () => {
|
|
649
|
+
try {
|
|
650
|
+
run(`docker rm -f ${startedContainerName}`, { allowFail: true });
|
|
651
|
+
process.stderr.write(`Container ${startedContainerName} removed.\n`);
|
|
652
|
+
} catch { /* ignore */ }
|
|
653
|
+
};
|
|
654
|
+
process.on('exit', cleanup);
|
|
655
|
+
process.on('SIGINT', () => { child.kill('SIGINT'); });
|
|
656
|
+
process.on('SIGTERM', () => { child.kill('SIGTERM'); });
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
child.on('exit', code => process.exit(code || 0));
|
|
660
|
+
}
|
|
661
|
+
|
|
304
662
|
function cmdOpen(args) {
|
|
305
663
|
const target = args[0];
|
|
306
664
|
let name;
|
|
@@ -348,8 +706,15 @@ function parseFlags(args) {
|
|
|
348
706
|
const arg = args[i];
|
|
349
707
|
if (arg.startsWith('--')) {
|
|
350
708
|
const key = arg.slice(2);
|
|
351
|
-
if (key === 'open' || key === 'no-pull' || key === 'persist' || key === 'tunnel') {
|
|
709
|
+
if (key === 'open' || key === 'no-pull' || key === 'persist' || key === 'no-tunnel' || key === 'start') {
|
|
352
710
|
flags[key] = true;
|
|
711
|
+
} else if (key === 'tunnel') {
|
|
712
|
+
// --tunnel (default provider) or --tunnel cloudflare / --tunnel localtunnel
|
|
713
|
+
if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
|
|
714
|
+
flags[key] = args[++i];
|
|
715
|
+
} else {
|
|
716
|
+
flags[key] = true;
|
|
717
|
+
}
|
|
353
718
|
} else if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
|
|
354
719
|
flags[key] = args[++i];
|
|
355
720
|
}
|
|
@@ -378,6 +743,9 @@ switch (command) {
|
|
|
378
743
|
case 'open':
|
|
379
744
|
cmdOpen(rest);
|
|
380
745
|
break;
|
|
746
|
+
case 'mcp':
|
|
747
|
+
cmdMcp(rest);
|
|
748
|
+
break;
|
|
381
749
|
case '--help':
|
|
382
750
|
case '-h':
|
|
383
751
|
case 'help':
|
|
@@ -392,17 +760,31 @@ switch (command) {
|
|
|
392
760
|
status ${c.dim}...........${c.reset} List running containers
|
|
393
761
|
logs ${c.dim}[name]${c.reset} ${c.dim}....${c.reset} Follow container logs
|
|
394
762
|
open ${c.dim}[name]${c.reset} ${c.dim}....${c.reset} Open dashboard in browser
|
|
763
|
+
mcp ${c.dim}..............${c.reset} Start MCP stdio server ${c.dim}(for Claude Code, Cursor, etc.)${c.reset}
|
|
395
764
|
|
|
396
765
|
${c.bold}Options${c.reset}
|
|
397
766
|
--open ${c.dim}..........${c.reset} Open dashboard after start
|
|
767
|
+
--os ${c.dim}<os>${c.reset} ${c.dim}........${c.reset} linux ${c.dim}(default)${c.reset} ${c.dim}|${c.reset} windows ${c.dim}|${c.reset} macos
|
|
398
768
|
--mode ${c.dim}<m>${c.reset} ${c.dim}......${c.reset} full ${c.dim}|${c.reset} browser ${c.dim}|${c.reset} minimal ${c.dim}|${c.reset} headless
|
|
399
769
|
--persist ${c.dim}.......${c.reset} Mount volume for persistent data
|
|
400
770
|
--password ${c.dim}<pw>${c.reset} ${c.dim}.${c.reset} Custom VNC password ${c.dim}(auto-generated if omitted)${c.reset}
|
|
401
|
-
--tunnel ${c.dim}
|
|
771
|
+
--no-tunnel ${c.dim}.....${c.reset} Disable tunneling ${c.dim}(local-only mode)${c.reset}
|
|
772
|
+
--tunnel ${c.dim}<p>${c.reset} ${c.dim}.....${c.reset} cloudflare ${c.dim}(default)${c.reset} ${c.dim}|${c.reset} localtunnel
|
|
402
773
|
--no-pull ${c.dim}.......${c.reset} Skip pulling latest Docker image
|
|
403
774
|
--name ${c.dim}<n>${c.reset} ${c.dim}......${c.reset} Custom container name
|
|
404
775
|
--api-port ${c.dim}<p>${c.reset} ${c.dim}.${c.reset} Host port for tool API
|
|
405
776
|
--vnc-port ${c.dim}<p>${c.reset} ${c.dim}.${c.reset} Host port for noVNC
|
|
777
|
+
|
|
778
|
+
${c.bold}VM Options${c.reset} ${c.dim}(Windows/macOS only, requires KVM)${c.reset}
|
|
779
|
+
--version ${c.dim}<v>${c.reset} ${c.dim}..${c.reset} OS version ${c.dim}(Win: 11/10/8; macOS: 11-15)${c.reset}
|
|
780
|
+
--ram ${c.dim}<size>${c.reset} ${c.dim}....${c.reset} VM RAM ${c.dim}(default: 4G)${c.reset}
|
|
781
|
+
--cpus ${c.dim}<n>${c.reset} ${c.dim}......${c.reset} VM CPU cores ${c.dim}(default: 2)${c.reset}
|
|
782
|
+
--disk ${c.dim}<size>${c.reset} ${c.dim}...${c.reset} VM disk size ${c.dim}(default: 64G)${c.reset}
|
|
783
|
+
|
|
784
|
+
${c.bold}MCP Options${c.reset}
|
|
785
|
+
--container-url ${c.dim}<u>${c.reset} Container API URL ${c.dim}(default: auto-detect)${c.reset}
|
|
786
|
+
--api-token ${c.dim}<t>${c.reset} ${c.dim}....${c.reset} Bearer token for container auth
|
|
787
|
+
--start ${c.dim}.........${c.reset} Auto-launch container if none running
|
|
406
788
|
`);
|
|
407
789
|
break;
|
|
408
790
|
default:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmbk/compeek",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "AI eyes and hands for any desktop application — a general-purpose computer use agent framework powered by Claude Opus 4.6",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "rmbk",
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"compeek": "bin/compeek.mjs"
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
|
-
"bin/"
|
|
29
|
+
"bin/",
|
|
30
|
+
"dist/"
|
|
30
31
|
],
|
|
31
32
|
"scripts": {
|
|
32
33
|
"dev:client": "vite",
|
|
@@ -37,15 +38,22 @@
|
|
|
37
38
|
"docker:build": "docker build -f docker/Dockerfile -t compeek .",
|
|
38
39
|
"docker:run": "docker run -p 3000:3000 -p 6080:6080 -p 5900:5900 --shm-size=512m -it compeek",
|
|
39
40
|
"test": "vitest run",
|
|
40
|
-
"test:watch": "vitest"
|
|
41
|
+
"test:watch": "vitest",
|
|
42
|
+
"test:e2e": "playwright test --project=ui",
|
|
43
|
+
"test:e2e:integration": "COMPEEK_E2E_INTEGRATION=1 playwright test --project=integration",
|
|
44
|
+
"test:e2e:all": "COMPEEK_E2E_INTEGRATION=1 playwright test",
|
|
45
|
+
"test:e2e:ui": "playwright test --project=ui --ui"
|
|
41
46
|
},
|
|
42
47
|
"dependencies": {
|
|
43
48
|
"@anthropic-ai/sdk": "^0.74.0",
|
|
49
|
+
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
44
50
|
"cors": "^2.8.5",
|
|
45
51
|
"express": "^4.21.0",
|
|
46
|
-
"sharp": "^0.34.5"
|
|
52
|
+
"sharp": "^0.34.5",
|
|
53
|
+
"zod": "^4.3.6"
|
|
47
54
|
},
|
|
48
55
|
"devDependencies": {
|
|
56
|
+
"@playwright/test": "^1.58.2",
|
|
49
57
|
"@testing-library/jest-dom": "^6.9.1",
|
|
50
58
|
"@testing-library/react": "^16.3.2",
|
|
51
59
|
"@types/cors": "^2.8.17",
|