chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:20:32 +08:00
commit d2375abf88
5575 changed files with 274444 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
import * as fs from 'fs';
import * as path from 'path';
import { Command } from 'commander';
import { BASE_DISK, VM_USER, resolveEfiCode } from '../config.js';
import {
agentDir, agentExists, agentEnvPath,
readAgentJson, writeAgentJson, nextIndex, listAgents,
pidfilePath, monitorSockPath, efiVarsPath,
} from '../registry.js';
import { isRunning, readPid, killPid, qemuImgCreate, removeMonitorSock } from '../vm.js';
import { isJsonMode, jsonOk, jsonErr, info } from '../output.js';
import { execUpCommand } from './control.js';
export function registerAgentCommands(program: Command): void {
program
.command('create <name> [flags...]')
.description('Create and start an agent (--no-start to skip boot)')
.option('--no-start', 'Create without starting')
.option('--dev', 'Mount services/ via 9p (dev mode)')
.option('--gui', 'Show QEMU window')
.action((name: string, _flags: string[], opts: { start: boolean; dev?: boolean; gui?: boolean }) => {
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
jsonErr('Invalid name. Use alphanumeric, dash, underscore.');
}
if (agentExists(name)) jsonErr(`Agent '${name}' already exists.`);
if (!fs.existsSync(BASE_DISK)) {
jsonErr("No base image found. Run 'open-computer base install' first.");
}
const dir = agentDir(name);
const index = nextIndex();
fs.mkdirSync(dir, { recursive: true });
// Create COW overlay
qemuImgCreate(`${dir}/disk.qcow2`, BASE_DISK, '40G');
// Copy EFI vars
const efiDest = efiVarsPath(name);
const baseEfi = path.join(path.dirname(BASE_DISK), 'efi-vars.fd');
if (fs.existsSync(baseEfi)) {
fs.copyFileSync(baseEfi, efiDest);
} else {
fs.copyFileSync(resolveEfiCode(), efiDest);
}
const agent = writeAgentJson(name, index);
const { ssh_port, vnc_port, app_port } = agent;
if (!opts.start) {
if (isJsonMode()) {
jsonOk({ name, index, ssh_port, vnc_port, app_port, desktop_url: `http://localhost:${app_port}`, started: false });
} else {
info(`Created agent '${name}':`);
info(` SSH: ssh -p ${ssh_port} ${VM_USER}@localhost`);
info(` VNC: open vnc://localhost:${vnc_port}`);
info(` Desktop: http://localhost:${app_port}`);
info('');
info(`Start with: open-computer up ${name}`);
}
return;
}
const started = execUpCommand(name, { dev: opts.dev ?? false, gui: opts.gui ?? false });
if (isJsonMode()) {
jsonOk({ name, index, ssh_port, vnc_port, app_port, desktop_url: `http://localhost:${app_port}`, started });
} else {
info(`Created agent '${name}':`);
info(` SSH: ssh -p ${ssh_port} ${VM_USER}@localhost`);
info(` VNC: open vnc://localhost:${vnc_port}`);
info(` Desktop: http://localhost:${app_port}`);
}
});
program
.command('destroy <name>')
.description('Delete an agent and its disk')
.action((name: string) => {
if (!agentExists(name)) jsonErr(`Agent '${name}' not found.`);
const pf = pidfilePath(name);
if (isRunning(pf)) {
if (!isJsonMode()) info(`Agent '${name}' is running — killing it first...`);
killPid(pf);
removeMonitorSock(monitorSockPath(name));
}
fs.rmSync(agentDir(name), { recursive: true, force: true });
if (isJsonMode()) {
jsonOk({ name });
} else {
info(`Destroyed agent '${name}'.`);
}
});
program
.command('list')
.description('List all agents and their status')
.action(() => {
const names = listAgents();
if (isJsonMode()) {
const agents = names.map((name) => {
const data = readAgentJson(name);
const pf = pidfilePath(name);
const running = isRunning(pf);
const pid = running ? readPid(pf) : undefined;
return {
name: data.name,
status: running ? 'running' : 'stopped',
ssh_port: data.ssh_port,
vnc_port: data.vnc_port,
app_port: data.app_port,
desktop_url: `http://localhost:${data.app_port}`,
created: data.created,
...(pid !== null && pid !== undefined ? { pid } : {}),
};
});
jsonOk({ agents });
return;
}
if (names.length === 0) {
info('No agents yet. Create one with: open-computer create <name>');
return;
}
const nameW = 16, statusW = 8, sshW = 10, appW = 10, desktopW = 28;
const header = [
'NAME'.padEnd(nameW), 'STATUS'.padEnd(statusW),
'SSH'.padEnd(sshW), 'APP'.padEnd(appW), 'DESKTOP'.padEnd(desktopW), 'CREATED',
].join(' ');
const divider = [
'----'.padEnd(nameW), '------'.padEnd(statusW),
'---'.padEnd(sshW), '---'.padEnd(appW), '-------'.padEnd(desktopW), '-------',
].join(' ');
console.log(header);
console.log(divider);
for (const name of names) {
const data = readAgentJson(name);
const pf = pidfilePath(name);
const status = isRunning(pf) ? 'running' : 'stopped';
const created = data.created.split('T')[0];
console.log([
data.name.padEnd(nameW),
status.padEnd(statusW),
`:${data.ssh_port}`.padEnd(sshW),
`:${data.app_port}`.padEnd(appW),
`http://localhost:${data.app_port}`.padEnd(desktopW),
created,
].join(' '));
}
});
}
+215
View File
@@ -0,0 +1,215 @@
import * as fs from 'fs';
import * as path from 'path';
import { spawnSync } from 'child_process';
import { Command } from 'commander';
import {
BASE_DIR, BASE_DISK, BASE_EFI, SETUP_DIR, VM_USER,
SSH_PORT_BASE, APP_PORT_BASE, PLATFORM,
isoPath, resolveEfiCode, resolveEfiVars, resolveQemuImgBinary,
} from '../config.js';
import {
isRunning, readPid, killPid, startVm, waitForShutdown,
qemuImgConvert, fileSize, formatBytes, removeMonitorSock,
} from '../vm.js';
import { sshRun, sshInteractive, scpTo, waitForSsh } from '../ssh.js';
import { isJsonMode, jsonOk, jsonErr, info } from '../output.js';
import { basePidfile, baseMonitorSock } from '../registry.js';
const BASE_SSH_PORT = SSH_PORT_BASE - 1;
const BASE_APP_PORT = APP_PORT_BASE - 1;
function ensureBaseDir(): void {
fs.mkdirSync(BASE_DIR, { recursive: true });
}
export function registerBaseCommand(program: Command): void {
const base = program
.command('base')
.description('Base image management (one-time setup)');
base
.command('install')
.description('Boot Debian ISO to build the golden base image (opens GUI window)')
.action(() => {
const iso = isoPath();
if (!fs.existsSync(iso)) {
jsonErr(`ISO not found: ${iso}\nRun: scripts/fetch-debian-iso.sh`);
}
ensureBaseDir();
if (!fs.existsSync(BASE_DISK)) {
spawnSync(resolveQemuImgBinary(), ['create', '-f', 'qcow2', BASE_DISK, '40G'], { stdio: 'inherit' });
}
const pf = basePidfile();
const sock = baseMonitorSock();
// Windows needs the OVMF VARS template; other platforms keep the CODE copy
// (original behavior) to avoid any regression.
if (!fs.existsSync(BASE_EFI)) {
fs.copyFileSync(PLATFORM === 'win32' ? resolveEfiVars() : resolveEfiCode(), BASE_EFI);
}
info('=== Installing base image ===');
info('Starting QEMU with VNC display — connect with any VNC viewer:');
info(' macOS built-in: open vnc://localhost:5901');
info(' RealVNC / Tiger: connect to localhost:5901');
info(`Create user '${VM_USER}' during install. Enable SSH. Skip desktop environment.`);
startVm({
disk: BASE_DISK,
efi: BASE_EFI,
iso,
sshPort: BASE_SSH_PORT,
appPort: BASE_APP_PORT,
pidFile: pf,
monitorSock: sock,
gui: true,
daemonize: false,
});
});
base
.command('up')
.description('Start the base image for provisioning/dev')
.option('--console', 'Show QEMU console window')
.action((opts: { console?: boolean }) => {
const pf = basePidfile();
const sock = baseMonitorSock();
if (opts.console) {
if (isRunning(pf)) jsonErr('Base image is already running. Shut it down first.');
info(`=== Starting base image (console, SSH on localhost:${BASE_SSH_PORT}) ===`);
startVm({
disk: BASE_DISK, efi: BASE_EFI,
sshPort: BASE_SSH_PORT, appPort: BASE_APP_PORT,
pidFile: pf, monitorSock: sock,
gui: true, daemonize: false, dev: true,
});
return;
}
info(`=== Starting base image (SSH :${BASE_SSH_PORT}, App :${BASE_APP_PORT}) ===`);
startVm({
disk: BASE_DISK, efi: BASE_EFI,
sshPort: BASE_SSH_PORT, appPort: BASE_APP_PORT,
pidFile: pf, monitorSock: sock,
dev: true,
});
});
base
.command('ssh [cmd...]')
.description('SSH into the base image')
.action((cmd: string[]) => {
if (cmd.length > 0) {
sshInteractive(BASE_SSH_PORT, VM_USER, cmd.join(' '));
} else {
sshInteractive(BASE_SSH_PORT, VM_USER);
}
});
base
.command('provision')
.description('Run master/setup/provision.sh on the base image')
.action(() => {
info('=== Provisioning base image ===');
info(' Waiting for SSH (up to 3 min)...');
if (!waitForSsh(BASE_SSH_PORT, VM_USER, 60, 3)) {
jsonErr('SSH not reachable after 3 minutes. Is the base image running? Try: open-computer base up');
}
info(' SSH ready. Copying files...');
scpTo(BASE_SSH_PORT, VM_USER, path.join(SETUP_DIR, 'provision.sh'), '/tmp/provision.sh');
scpTo(BASE_SSH_PORT, VM_USER, path.join(SETUP_DIR, 'curl-wrapper.sh'), '/tmp/curl-wrapper.sh');
info(' Copying win10 theme...');
scpTo(BASE_SSH_PORT, VM_USER, path.join(SETUP_DIR, 'themes', 'win10'), '/tmp/win10', { recursive: true });
scpTo(BASE_SSH_PORT, VM_USER, path.join(SETUP_DIR, 'favicons'), '/tmp/favicons', { recursive: true });
scpTo(BASE_SSH_PORT, VM_USER, path.join(SETUP_DIR, 'a11y-harvest.py'), '/tmp/a11y-harvest.py');
scpTo(BASE_SSH_PORT, VM_USER, path.join(SETUP_DIR, 'a11y-action.py'), '/tmp/a11y-action.py');
sshInteractive(BASE_SSH_PORT, VM_USER, 'chmod +x /tmp/provision.sh && su -c /tmp/provision.sh');
});
base
.command('compact')
.description('Shrink the base image (zeros free space + recompresses qcow2)')
.action(() => {
const pf = basePidfile();
const sock = baseMonitorSock();
const before = fileSize(BASE_DISK);
if (isRunning(pf)) {
info('=== Zeroing free space inside VM ===');
sshRun(BASE_SSH_PORT, VM_USER,
'sudo fstrim -av 2>/dev/null || (sudo dd if=/dev/zero of=/tmp/zero bs=1M 2>/dev/null; sudo rm -f /tmp/zero)',
{ silent: true }
);
info(' Shutting down base image...');
waitForShutdown(pf, sock, BASE_SSH_PORT);
} else {
info('Base image is not running — compacting offline.');
}
info('=== Compacting base.qcow2 ===');
const tmp = `${BASE_DISK}.tmp`;
qemuImgConvert(BASE_DISK, tmp);
fs.renameSync(tmp, BASE_DISK);
const after = fileSize(BASE_DISK);
if (isJsonMode()) {
jsonOk({ before_bytes: before, after_bytes: after });
} else {
info(` Before: ${formatBytes(before)}`);
info(` After: ${formatBytes(after)}`);
info('=== Compaction complete ===');
}
});
base
.command('down')
.description('Shut down the base image gracefully')
.action(() => {
const pf = basePidfile();
const sock = baseMonitorSock();
if (!isRunning(pf)) {
info('Base image is not running.');
fs.rmSync(pf, { force: true });
return;
}
waitForShutdown(pf, sock, BASE_SSH_PORT);
});
base
.command('kill')
.description('Force-kill the base image QEMU process')
.action(() => {
const pf = basePidfile();
const sock = baseMonitorSock();
if (!isRunning(pf)) {
info('Base image is not running.');
fs.rmSync(pf, { force: true });
return;
}
killPid(pf);
removeMonitorSock(sock);
info('Base image killed.');
});
base
.command('status')
.description('Check if base image is running')
.action(() => {
const pf = basePidfile();
if (isRunning(pf)) {
const pid = readPid(pf);
if (isJsonMode()) {
jsonOk({ status: 'running', pid });
} else {
info(`Base image is running (pid ${pid}).`);
}
} else {
fs.rmSync(pf, { force: true });
if (isJsonMode()) {
jsonOk({ status: 'stopped' });
} else {
info('Base image is not running.');
}
}
});
}
+226
View File
@@ -0,0 +1,226 @@
import * as fs from 'fs';
import * as path from 'path';
import { spawnSync } from 'child_process';
import { Command } from 'commander';
import {
SERVICE_DIR, SETUP_DIR, VM_USER, BASE_DISK, BASE_EFI,
SSH_PORT_BASE, APP_PORT_BASE,
} from '../config.js';
import { basePidfile, baseMonitorSock } from '../registry.js';
import {
isRunning, startVm, waitForShutdown, qemuImgConvert,
fileSize, formatBytes,
} from '../vm.js';
import { sshRun, scpTo, waitForSsh } from '../ssh.js';
import { jsonErr, info } from '../output.js';
const BASE_SSH_PORT = SSH_PORT_BASE - 1;
const BASE_APP_PORT = APP_PORT_BASE - 1;
export function registerBuildCommand(program: Command): void {
program
.command('build')
.description('Bundle services and bake them into the base image (production build)')
.action(() => {
const pf = basePidfile();
if (isRunning(pf)) {
jsonErr('Base image is already running. Shut it down first (open-computer base down).');
}
// ── 1. Bundle ──────────────────────────────────────────────────────────
info('=== Bundling interface-service ===');
// On Windows, `bash` resolves to the WSL launcher which lacks the Windows
// Node toolchain; prefer Git Bash (ships with Git for Windows).
let bashCmd = 'bash';
if (process.platform === 'win32') {
const gitBash = [
'C:\\Program Files\\Git\\bin\\bash.exe',
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
].find((p) => fs.existsSync(p));
if (gitBash) bashCmd = gitBash;
}
const buildResult = spawnSync(bashCmd, [path.join(SERVICE_DIR, 'build.sh')], {
stdio: 'inherit',
cwd: SERVICE_DIR,
});
if (buildResult.status !== 0) jsonErr('Build script failed.');
const bundle = path.join(SERVICE_DIR, 'dist', 'interface-service.cjs');
if (!fs.existsSync(bundle)) jsonErr(`Build failed: ${bundle} not found.`);
// ── 2. Start base image ────────────────────────────────────────────────
info('=== Starting base image ===');
const sock = baseMonitorSock();
startVm({
disk: BASE_DISK, efi: BASE_EFI,
sshPort: BASE_SSH_PORT, appPort: BASE_APP_PORT,
pidFile: pf, monitorSock: sock,
dev: false,
});
info(' Waiting for SSH...');
if (!waitForSsh(BASE_SSH_PORT, VM_USER, 30, 2)) {
jsonErr('Timed out waiting for base image SSH.');
}
// ── 3. Bake into base image ────────────────────────────────────────────
info('=== Baking into base image ===');
sshRun(BASE_SSH_PORT, VM_USER,
'sudo mkdir -p /opt/open-computer/interface-service/utils /opt/open-computer/extensions /opt/open-computer/public /opt/open-computer/memory-manager/public && sudo rm -f /opt/open-computer/interface-service /opt/open-computer/html-to-markdown',
);
info(' Uploading bundles...');
scpTo(BASE_SSH_PORT, VM_USER, bundle, '/tmp/interface-service.cjs');
sshRun(BASE_SSH_PORT, VM_USER, 'sudo mv /tmp/interface-service.cjs /opt/open-computer/interface-service.cjs');
const html2md = path.join(SERVICE_DIR, 'dist', 'html-to-markdown.cjs');
if (fs.existsSync(html2md)) {
scpTo(BASE_SSH_PORT, VM_USER, html2md, '/tmp/html-to-markdown.cjs');
sshRun(BASE_SSH_PORT, VM_USER, 'sudo mv /tmp/html-to-markdown.cjs /opt/open-computer/html-to-markdown.cjs');
}
info(' Uploading supporting files...');
for (const f of ['cdp-eval.js', 'cdp-input.js', 'browser-harvest.js', 'html-to-markdown.js']) {
const src = path.join(SERVICE_DIR, 'dist', 'interface-service', 'utils', f);
if (fs.existsSync(src)) {
scpTo(BASE_SSH_PORT, VM_USER, src, `/tmp/${f}`);
sshRun(BASE_SSH_PORT, VM_USER, `sudo mv /tmp/${f} /opt/open-computer/interface-service/utils/${f}`);
}
}
info(' Uploading extensions...');
for (const f of fs.readdirSync(path.join(SERVICE_DIR, 'extensions'))) {
if (!f.endsWith('.ts')) continue;
const src = path.join(SERVICE_DIR, 'extensions', f);
scpTo(BASE_SSH_PORT, VM_USER, src, `/tmp/${f}`);
sshRun(BASE_SSH_PORT, VM_USER, `sudo mv /tmp/${f} /opt/open-computer/extensions/${f}`);
}
info(' Uploading memory-manager...');
const mmCjs = path.join(SERVICE_DIR, 'dist', 'memory-manager', 'memory-manager.cjs');
const mmStart = path.join(SERVICE_DIR, 'dist', 'memory-manager', 'start.sh');
scpTo(BASE_SSH_PORT, VM_USER, mmCjs, '/tmp/memory-manager.cjs');
scpTo(BASE_SSH_PORT, VM_USER, mmStart, '/tmp/memory-manager-start.sh');
sshRun(BASE_SSH_PORT, VM_USER,
'sudo mv /tmp/memory-manager.cjs /opt/open-computer/memory-manager/memory-manager.cjs && sudo mv /tmp/memory-manager-start.sh /opt/open-computer/memory-manager/start.sh && sudo chmod +x /opt/open-computer/memory-manager/start.sh',
);
const mmPublicDir = path.join(SERVICE_DIR, 'dist', 'memory-manager', 'public');
if (fs.existsSync(mmPublicDir)) {
for (const f of fs.readdirSync(mmPublicDir)) {
const src = path.join(mmPublicDir, f);
if (!fs.statSync(src).isFile()) continue;
scpTo(BASE_SSH_PORT, VM_USER, src, `/tmp/mm-${f}`);
sshRun(BASE_SSH_PORT, VM_USER, `sudo mv /tmp/mm-${f} /opt/open-computer/memory-manager/public/${f}`);
}
}
info(' Uploading public (UI)...');
const publicDir = path.join(SERVICE_DIR, 'dist', 'public');
if (fs.existsSync(publicDir)) {
for (const f of fs.readdirSync(publicDir)) {
const src = path.join(publicDir, f);
if (!fs.statSync(src).isFile()) continue;
scpTo(BASE_SSH_PORT, VM_USER, src, `/tmp/ui-${f}`);
sshRun(BASE_SSH_PORT, VM_USER, `sudo mv /tmp/ui-${f} /opt/open-computer/public/${f}`);
}
}
const wallpaper = path.join(SETUP_DIR, 'themes', 'win10', 'background.png');
if (fs.existsSync(wallpaper)) {
info(' Uploading wallpaper...');
scpTo(BASE_SSH_PORT, VM_USER, wallpaper, '/tmp/background.png');
sshRun(BASE_SSH_PORT, VM_USER,
'sudo mv /tmp/background.png /usr/share/pixmaps/open-computer-background.png && sudo chmod 644 /usr/share/pixmaps/open-computer-background.png',
);
}
info(' Uploading startup script...');
scpTo(BASE_SSH_PORT, VM_USER, path.join(SERVICE_DIR, 'start-service.sh'), '/tmp/start-service.sh');
sshRun(BASE_SSH_PORT, VM_USER,
'sudo mv /tmp/start-service.sh /opt/open-computer/start-service.sh && sudo chmod +x /opt/open-computer/start-service.sh',
);
info(' Updating systemd units...');
const mountUnit = `[Unit]
Description=Mount 9p open-computer service directory (dev mode)
DefaultDependencies=no
After=local-fs.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c 'mount -t 9p -o trans=virtio,version=9p2000.L,msize=104857600 open-computer_service /opt/open-computer 2>/dev/null; true'
[Install]
WantedBy=multi-user.target`;
const openComputerUnit = `[Unit]
Description=open-computer orchestration service
After=novnc.service open-computer-mount.service
Wants=novnc.service open-computer-mount.service
[Service]
Type=simple
User=agent
WorkingDirectory=/opt/open-computer
ExecStart=/opt/open-computer/start-service.sh
Restart=always
RestartSec=3
Environment=HOME=/home/agent
Environment=PORT=8080
[Install]
WantedBy=graphical.target`;
const mmUnit = `[Unit]
Description=Memory Manager Web UI
After=network.target
[Service]
Type=simple
User=agent
WorkingDirectory=/opt/open-computer/memory-manager
ExecStart=/opt/open-computer/memory-manager/start.sh
Restart=always
RestartSec=3
Environment=HOME=/home/agent
Environment=PORT=8090
[Install]
WantedBy=multi-user.target`;
const writeUnit = (content: string, dest: string): void => {
const escaped = content.replace(/'/g, "'\\''");
sshRun(BASE_SSH_PORT, VM_USER, `echo '${escaped}' | sudo tee ${dest} > /dev/null`);
};
writeUnit(mountUnit, '/etc/systemd/system/open-computer-mount.service');
writeUnit(openComputerUnit, '/etc/systemd/system/open-computer.service');
writeUnit(mmUnit, '/etc/systemd/system/memory-manager.service');
sshRun(BASE_SSH_PORT, VM_USER,
'sudo systemctl daemon-reload && sudo systemctl enable open-computer-mount memory-manager && sudo systemctl restart open-computer memory-manager',
);
// ── 4. Shut down ───────────────────────────────────────────────────────
info('');
info('=== Shutting down base image ===');
waitForShutdown(pf, sock, BASE_SSH_PORT);
// ── 5. Compact ─────────────────────────────────────────────────────────
info('=== Compacting base image ===');
const before = fileSize(BASE_DISK);
const tmp = `${BASE_DISK}.tmp`;
qemuImgConvert(BASE_DISK, tmp);
fs.renameSync(tmp, BASE_DISK);
const after = fileSize(BASE_DISK);
info(` Before: ${formatBytes(before)} After: ${formatBytes(after)}`);
info('');
info('=== Build complete ===');
info(` Base image: ${BASE_DISK}`);
info(` EFI vars: ${BASE_EFI}`);
});
}
+347
View File
@@ -0,0 +1,347 @@
import * as fs from 'fs';
import * as path from 'path';
import { spawnSync } from 'child_process';
import { Command } from 'commander';
import { PLATFORM, GUEST_ARCH, VM_USER, SETUP_DIR, SERVICE_DIR, BASE_DISK } from '../config.js';
import {
agentExists, readAgentJson, agentEnvPath,
pidfilePath, monitorSockPath, efiVarsPath,
} from '../registry.js';
import {
isRunning, readPid, killPid, startVm, waitForShutdown,
qemuImgConvert, fileSize, formatBytes, removeMonitorSock,
} from '../vm.js';
import { sshRun, sshInteractive, scpTo, waitForSsh } from '../ssh.js';
import { isJsonMode, jsonOk, jsonErr, info } from '../output.js';
// ── Shared helpers ────────────────────────────────────────────────────────────
function syncAgentEnv(name: string): boolean {
const envFile = agentEnvPath(name);
if (!fs.existsSync(envFile)) return true;
const { ssh_port } = readAgentJson(name);
const ready = waitForSsh(ssh_port, VM_USER, 30, 2);
if (!ready) {
if (!isJsonMode()) process.stderr.write(` Warning: could not reach '${name}' to sync agent .env\n`);
return false;
}
return scpTo(ssh_port, VM_USER, envFile, '/home/agent/agent.env', { silent: true });
}
function restartServiceInVm(name: string): void {
const { ssh_port } = readAgentJson(name);
sshRun(ssh_port, VM_USER, 'sudo systemctl restart open-computer', { silent: true });
}
/**
* On Windows x64 the 9p virtio share is not available, so dev mode syncs
* the services/ directory into the VM over SCP instead.
*/
function syncServiceToVm(sshPort: number): void {
info(' Syncing services/ to VM over SCP...');
sshRun(sshPort, VM_USER, 'sudo mkdir -p /opt/open-computer', { silent: true });
scpTo(sshPort, VM_USER, SERVICE_DIR, '/tmp/open-computer-sync', { recursive: true, silent: true });
sshRun(sshPort, VM_USER, [
'sudo cp -a /tmp/open-computer-sync/* /opt/open-computer/',
'rm -rf /tmp/open-computer-sync',
"sudo find /opt/open-computer -type f -exec sed -i 's/\\r$//' {} +",
'sudo find /opt/open-computer -name "*.sh" -exec chmod +x {} +',
'sudo chown -R agent:agent /opt/open-computer',
].join(' ; '), { silent: true });
info(' Service synced.');
}
export function execUpCommand(name: string, opts: { dev?: boolean; gui?: boolean } = {}): boolean {
if (!agentExists(name)) { jsonErr(`Agent '${name}' not found.`); }
const agent = readAgentJson(name);
const { disk, ssh_port, vnc_display, app_port } = agent;
const efi = efiVarsPath(name);
const pf = pidfilePath(name);
const sock = monitorSockPath(name);
const dev = opts.dev ?? false;
const gui = opts.gui ?? false;
const ok = startVm({ disk, efi, sshPort: ssh_port, appPort: app_port, pidFile: pf, monitorSock: sock, vncDisplay: vnc_display, dev, gui });
if (ok && dev && PLATFORM === 'win32' && GUEST_ARCH === 'x86_64') {
const ready = waitForSsh(ssh_port, VM_USER, 60, 3);
if (ready) {
syncServiceToVm(ssh_port);
sshRun(ssh_port, VM_USER, 'sudo systemctl restart open-computer', { silent: true });
} else {
info(' Warning: SSH not reachable — could not sync services to VM.');
}
}
if (fs.existsSync(agentEnvPath(name))) {
syncAgentEnv(name);
restartServiceInVm(name);
}
return ok;
}
// ── Command registrations ─────────────────────────────────────────────────────
export function registerControlCommands(program: Command): void {
program
.command('up <name>')
.description('Start an agent')
.option('--dev', 'Mount services/ via 9p (dev mode)')
.option('--gui', 'Show QEMU window')
.action((name: string, opts: { dev?: boolean; gui?: boolean }) => {
if (!agentExists(name)) jsonErr(`Agent '${name}' not found.`);
const agent = readAgentJson(name);
const dev = opts.dev ?? false;
const gui = opts.gui ?? false;
const mode = dev
? (PLATFORM === 'win32' && GUEST_ARCH === 'x86_64' ? 'dev (scp sync)' : 'dev (9p mount)')
: 'prod';
if (!isJsonMode()) {
if (gui) {
info(`=== Starting '${name}' [${mode}] (SSH :${agent.ssh_port}, Desktop http://localhost:${agent.app_port}) ===`);
} else {
info(`=== Starting '${name}' [${mode}] (headless, SSH :${agent.ssh_port}, Desktop http://localhost:${agent.app_port}) ===`);
}
}
const ok = execUpCommand(name, { dev, gui });
if (isJsonMode()) {
const pid = readPid(pidfilePath(name));
jsonOk({
name, ssh_port: agent.ssh_port, app_port: agent.app_port,
desktop_url: `http://localhost:${agent.app_port}`,
dev, gui,
...(pid !== null ? { pid } : {}),
});
} else if (!ok) {
process.exit(1);
}
});
program
.command('down <name>')
.description('Graceful shutdown')
.action((name: string) => {
if (!agentExists(name)) jsonErr(`Agent '${name}' not found.`);
const pf = pidfilePath(name);
const sock = monitorSockPath(name);
const { ssh_port } = readAgentJson(name);
if (!isRunning(pf)) {
fs.rmSync(pf, { force: true });
if (isJsonMode()) {
jsonOk({ name, was_running: false });
} else {
info(`Agent '${name}' is not running.`);
}
return;
}
waitForShutdown(pf, sock, ssh_port);
if (isJsonMode()) jsonOk({ name, was_running: true });
});
program
.command('kill <name>')
.description('Force-stop a running agent')
.action((name: string) => {
if (!agentExists(name)) jsonErr(`Agent '${name}' not found.`);
const pf = pidfilePath(name);
const sock = monitorSockPath(name);
if (!isRunning(pf)) {
fs.rmSync(pf, { force: true });
if (isJsonMode()) {
jsonOk({ name, was_running: false });
} else {
info(`Agent '${name}' is not running.`);
}
return;
}
killPid(pf);
removeMonitorSock(sock);
if (isJsonMode()) {
jsonOk({ name, was_running: true });
} else {
info(`Killed agent '${name}'.`);
}
});
program
.command('restart <name>')
.description('Restart the open-computer service inside the agent VM')
.action((name: string) => {
if (!agentExists(name)) jsonErr(`Agent '${name}' not found.`);
const pf = pidfilePath(name);
if (!isRunning(pf)) jsonErr(`Agent '${name}' is not running.`);
if (!isJsonMode()) info(`Restarting open-computer service on '${name}'...`);
syncAgentEnv(name);
restartServiceInVm(name);
if (isJsonMode()) {
jsonOk({ name });
} else {
info('Service restarted.');
}
});
program
.command('compact <name>')
.description('Shrink an agent\'s overlay disk (fstrim + qcow2 recompress)')
.action((name: string) => {
if (!agentExists(name)) jsonErr(`Agent '${name}' not found.`);
const agent = readAgentJson(name);
const pf = pidfilePath(name);
const diskPath = agent.disk;
const before = fileSize(diskPath);
if (isRunning(pf)) {
if (!isJsonMode()) info(`Zeroing free space inside '${name}'...`);
sshRun(agent.ssh_port, VM_USER,
'sudo fstrim -av 2>/dev/null || (sudo dd if=/dev/zero of=/tmp/zero bs=1M 2>/dev/null; sudo rm -f /tmp/zero)',
{ silent: true },
);
if (!isJsonMode()) info(`Shutting down '${name}'...`);
const sock = monitorSockPath(name);
waitForShutdown(pf, sock, agent.ssh_port);
}
if (!isJsonMode()) info('Compacting disk...');
const tmp = `${diskPath}.tmp`;
try {
// Use the base disk as backing when re-creating the overlay
const ok = qemuImgConvert(diskPath, tmp, BASE_DISK);
if (!ok) {
try { fs.unlinkSync(tmp); } catch {}
jsonErr('qemu-img convert failed — disk unchanged');
}
fs.renameSync(tmp, diskPath);
} catch (err: any) {
try { fs.unlinkSync(tmp); } catch {}
throw err;
}
const after = fileSize(diskPath);
if (isJsonMode()) {
jsonOk({ name, before_bytes: before, after_bytes: after });
} else {
info(`Compacted '${name}': ${formatBytes(before)}${formatBytes(after)}`);
}
});
program
.command('status <name>')
.description('Check if an agent is running')
.action((name: string) => {
if (!agentExists(name)) jsonErr(`Agent '${name}' not found.`);
const agent = readAgentJson(name);
const pf = pidfilePath(name);
if (isRunning(pf)) {
const pid = readPid(pf);
if (isJsonMode()) {
jsonOk({
name, status: 'running', pid,
ssh_port: agent.ssh_port, vnc_port: agent.vnc_port, app_port: agent.app_port,
desktop_url: `http://localhost:${agent.app_port}`,
});
} else {
info(`Agent '${name}' is running (pid ${pid}).`);
info(` SSH: localhost:${agent.ssh_port}`);
info(` Desktop: http://localhost:${agent.app_port}`);
}
} else {
fs.rmSync(pf, { force: true });
if (isJsonMode()) {
jsonOk({
name, status: 'stopped',
ssh_port: agent.ssh_port, vnc_port: agent.vnc_port, app_port: agent.app_port,
});
} else {
info(`Agent '${name}' is stopped.`);
}
}
});
program
.command('ssh <name> [cmd...]')
.description('SSH into an agent')
.action((name: string, cmd: string[]) => {
if (!agentExists(name)) jsonErr(`Agent '${name}' not found.`);
const { ssh_port } = readAgentJson(name);
if (isJsonMode()) {
if (cmd.length > 0) {
const { output, ok } = sshRun(ssh_port, VM_USER, cmd.join(' '), { silent: true });
jsonOk({ name, output });
} else {
jsonOk({ name, ssh_port, ssh_user: VM_USER, ssh_host: 'localhost' });
}
return;
}
if (cmd.length > 0) {
sshInteractive(ssh_port, VM_USER, cmd.join(' '));
} else {
sshInteractive(ssh_port, VM_USER);
}
});
program
.command('vnc <name>')
.description('Open VNC viewer for an agent')
.action((name: string) => {
if (!agentExists(name)) jsonErr(`Agent '${name}' not found.`);
const { vnc_port } = readAgentJson(name);
const vncUrl = `vnc://localhost:${vnc_port}`;
if (isJsonMode()) {
jsonOk({ name, vnc_url: vncUrl });
return;
}
if (PLATFORM === 'darwin') {
spawnSync('open', [vncUrl], { stdio: 'inherit' });
} else if (PLATFORM === 'win32') {
spawnSync('start', [vncUrl], { shell: true, stdio: 'inherit' });
} else {
spawnSync('xdg-open', [vncUrl], { stdio: 'inherit' });
}
});
program
.command('provision <name>')
.description('Re-run provision script on a running agent')
.action((name: string) => {
if (!agentExists(name)) jsonErr(`Agent '${name}' not found.`);
const { ssh_port } = readAgentJson(name);
if (!isJsonMode()) info(`=== Provisioning '${name}' ===`);
scpTo(ssh_port, VM_USER, path.join(SETUP_DIR, 'provision.sh'), '/tmp/provision.sh', { silent: true });
scpTo(ssh_port, VM_USER, path.join(SETUP_DIR, 'curl-wrapper.sh'), '/tmp/curl-wrapper.sh', { silent: true });
scpTo(ssh_port, VM_USER, path.join(SETUP_DIR, 'themes', 'win10'), '/tmp/win10', { recursive: true, silent: true });
scpTo(ssh_port, VM_USER, path.join(SETUP_DIR, 'favicons'), '/tmp/favicons', { recursive: true, silent: true });
sshRun(ssh_port, VM_USER, 'chmod +x /tmp/provision.sh && sudo /tmp/provision.sh');
if (isJsonMode()) jsonOk({ name });
});
}
+14
View File
@@ -0,0 +1,14 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { efiVarsFileName } from './config.js';
// The blank-display bug was caused by copying the OVMF CODE firmware into the
// writable vars pflash. These tests pin the correct VARS template per guest arch.
test('efiVarsFileName: x86_64 uses the i386 VARS template (not the CODE firmware)', () => {
assert.equal(efiVarsFileName('x86_64'), 'edk2-i386-vars.fd');
});
test('efiVarsFileName: aarch64 uses the arm VARS template', () => {
assert.equal(efiVarsFileName('aarch64'), 'edk2-arm-vars.fd');
});
+159
View File
@@ -0,0 +1,159 @@
import * as path from 'path';
import * as fs from 'fs';
// Resolve the project root: cli/ lives one level inside the repo root
export const OPEN_COMPUTER_DIR = path.resolve(__dirname, '..', '..');
// ── Platform detection ──────────────────────────────────────────────────────
export type Platform = 'darwin' | 'win32' | 'linux';
export type Arch = 'arm64' | 'x64';
export const PLATFORM = process.platform as Platform;
export const ARCH = process.arch as Arch;
// On macOS/Linux arm64 the guest is aarch64 (HVF/KVM + ARM64 Debian).
// On macOS/Linux x64 the guest is x86_64 (HVF/KVM + amd64 Debian).
// On Windows x64 the guest is x86_64 (WHPX + amd64 Debian).
// On Windows arm64 the guest is aarch64 (WHPX + ARM64 Debian).
export const GUEST_ARCH: 'aarch64' | 'x86_64' = ARCH === 'x64' ? 'x86_64' : 'aarch64';
export const QEMU_BINARY =
GUEST_ARCH === 'aarch64' ? 'qemu-system-aarch64' : 'qemu-system-x86_64';
function defaultQemuDir(): string {
if (PLATFORM === 'darwin') {
const darwinVariant = ARCH === 'x64' ? 'darwin-x64' : 'darwin-arm64';
return path.join(OPEN_COMPUTER_DIR, 'master', 'qemu', darwinVariant);
}
if (PLATFORM === 'win32') {
const variant = ARCH === 'x64' ? 'win-x64' : 'win-arm64';
const dir = path.join(OPEN_COMPUTER_DIR, 'master', 'qemu', variant);
if (!fs.existsSync(dir)) {
const parent = path.join(OPEN_COMPUTER_DIR, 'master', 'qemu');
const contents = fs.existsSync(parent) ? fs.readdirSync(parent).join(', ') : '(directory missing)';
throw new Error(
`QEMU directory not found: ${dir}\n` +
`Contents of ${parent}: ${contents}\n` +
`Make sure the QEMU archive is extracted so that the folder is named "${variant}" ` +
`(not "qemu-${variant}" or a nested subfolder). ` +
`You can also set OPEN_COMPUTER_QEMU_DIR to point to your QEMU installation.`
);
}
return dir;
}
// Linux: assume system QEMU
return '';
}
export const QEMU_DIST = process.env.OPEN_COMPUTER_QEMU_DIR ?? defaultQemuDir();
// Full path to the QEMU binary (with .exe on Windows)
export function resolveQemuBinary(): string {
const binaryName = PLATFORM === 'win32' ? `${QEMU_BINARY}.exe` : QEMU_BINARY;
if (QEMU_DIST) {
const bundled = path.join(QEMU_DIST, 'bin', binaryName);
if (fs.existsSync(bundled)) return bundled;
// Windows QEMU may sit directly in the dist folder
const direct = path.join(QEMU_DIST, binaryName);
if (fs.existsSync(direct)) return direct;
}
// Fall back to system PATH
return binaryName;
}
// EFI firmware — filename depends on the guest architecture
const EFI_FIRMWARE_FILE =
GUEST_ARCH === 'x86_64' ? 'edk2-x86_64-code.fd' : 'edk2-aarch64-code.fd';
export function resolveEfiCode(): string {
if (QEMU_DIST) {
for (const cand of [
path.join(QEMU_DIST, 'share', 'qemu', EFI_FIRMWARE_FILE),
path.join(QEMU_DIST, 'share', EFI_FIRMWARE_FILE),
]) {
if (fs.existsSync(cand)) return cand;
}
}
if (PLATFORM === 'win32') {
throw new Error(
`EFI firmware not found: ${EFI_FIRMWARE_FILE}\n` +
`Searched in: ${QEMU_DIST || '(no QEMU_DIST set)'}\n` +
`Set OPEN_COMPUTER_QEMU_DIR to your QEMU installation directory.`,
);
}
const shareDir = PLATFORM === 'linux' ? '/usr/share/qemu' : '/opt/homebrew/share/qemu';
return `${shareDir}/${EFI_FIRMWARE_FILE}`;
}
// EFI variable store template (the writable pflash). This MUST be the VARS file,
// not the CODE firmware: copying CODE into the vars slot leaves OVMF without a
// variable store, so it never POSTs and the display stays blank.
export function efiVarsFileName(guestArch: 'aarch64' | 'x86_64' = GUEST_ARCH): string {
return guestArch === 'x86_64' ? 'edk2-i386-vars.fd' : 'edk2-arm-vars.fd';
}
export function resolveEfiVars(): string {
const file = efiVarsFileName();
if (QEMU_DIST) {
// The bundled Windows build ships the vars template in share/ (not share/qemu/).
for (const cand of [
path.join(QEMU_DIST, 'share', 'qemu', file),
path.join(QEMU_DIST, 'share', file),
]) {
if (fs.existsSync(cand)) return cand;
}
}
if (PLATFORM === 'win32') {
throw new Error(
`EFI vars template not found: ${file}\n` +
`Searched in: ${QEMU_DIST || '(no QEMU_DIST set)'}\n` +
`Set OPEN_COMPUTER_QEMU_DIR to your QEMU installation directory.`,
);
}
const shareDir = PLATFORM === 'linux' ? '/usr/share/qemu' : '/opt/homebrew/share/qemu';
return `${shareDir}/${file}`;
}
// Full path to the qemu-img binary
export function resolveQemuImgBinary(): string {
const binaryName = PLATFORM === 'win32' ? 'qemu-img.exe' : 'qemu-img';
if (QEMU_DIST) {
const bundled = path.join(QEMU_DIST, 'bin', binaryName);
if (fs.existsSync(bundled)) return bundled;
const direct = path.join(QEMU_DIST, binaryName);
if (fs.existsSync(direct)) return direct;
}
return binaryName;
}
// ── Directory paths ──────────────────────────────────────────────────────────
export const BASE_DIR = process.env.OPEN_COMPUTER_BASE_DIR ?? path.join(OPEN_COMPUTER_DIR, 'master', 'base_image');
export const AGENTS_DIR = process.env.OPEN_COMPUTER_AGENTS_DIR ?? path.join(OPEN_COMPUTER_DIR, 'agents');
export const SETUP_DIR = path.join(OPEN_COMPUTER_DIR, 'master', 'setup');
export const SERVICE_DIR = path.join(OPEN_COMPUTER_DIR, 'services');
export const VM_DIR = process.env.OPEN_COMPUTER_VM_DIR ?? path.join(OPEN_COMPUTER_DIR, 'master', 'iso');
export const BASE_DISK = path.join(BASE_DIR, 'base.qcow2');
export const BASE_EFI = path.join(BASE_DIR, 'efi-vars.fd');
export function isoPath(): string {
const arch = GUEST_ARCH === 'aarch64' ? 'arm64' : 'amd64';
return path.join(VM_DIR, `debian-13.5.0-${arch}-netinst.iso`);
}
// ── VM defaults ──────────────────────────────────────────────────────────────
export const CPUS = 4;
export const RAM = '4G';
export const DISK_SIZE = '40G';
export const VM_USER = 'agent';
// Port allocation bases
export const SSH_PORT_BASE = 2222;
export const VNC_DISPLAY_BASE = 1;
export const APP_PORT_BASE = 9800;
// Path inside the VM where the per-agent .env is staged
export const VM_AGENT_ENV = '/home/agent/agent.env';
+25
View File
@@ -0,0 +1,25 @@
import { Command } from 'commander';
import { setJsonMode } from './output.js';
import { registerBaseCommand } from './commands/base.js';
import { registerAgentCommands } from './commands/agents.js';
import { registerControlCommands } from './commands/control.js';
import { registerBuildCommand } from './commands/build.js';
const program = new Command();
program
.name('open-computer')
.description('Cross-platform CLI for open-computer VM management')
.version('0.1.0')
.option('--json', 'Output machine-readable JSON')
// Parse --json early so subcommands can read it
.hook('preAction', (thisCommand) => {
setJsonMode(thisCommand.opts().json === true);
});
registerBaseCommand(program);
registerAgentCommands(program);
registerControlCommands(program);
registerBuildCommand(program);
program.parse(process.argv);
+38
View File
@@ -0,0 +1,38 @@
// Global flag toggled by --json before command execution
let jsonMode = false;
export function setJsonMode(value: boolean): void {
jsonMode = value;
}
export function isJsonMode(): boolean {
return jsonMode;
}
export function jsonOk(fields: Record<string, unknown> = {}): void {
console.log(JSON.stringify({ ok: true, ...fields }));
}
export function jsonErr(message: string): never {
if (jsonMode) {
process.stderr.write(JSON.stringify({ ok: false, error: message }) + '\n');
} else {
process.stderr.write(`Error: ${message}\n`);
}
process.exit(1);
}
/** Print only in human mode */
export function info(message: string): void {
if (!jsonMode) console.log(message);
}
/** Always print to stderr */
export function warn(message: string): void {
if (!jsonMode) process.stderr.write(`Warning: ${message}\n`);
}
/** Print only in human mode, to stderr */
export function errorMsg(message: string): void {
if (!jsonMode) process.stderr.write(`Error: ${message}\n`);
}
+96
View File
@@ -0,0 +1,96 @@
import * as fs from 'fs';
import * as path from 'path';
import {
AGENTS_DIR, BASE_DIR, SSH_PORT_BASE, VNC_DISPLAY_BASE, APP_PORT_BASE,
} from './config.js';
export interface AgentJson {
name: string;
index: number;
ssh_port: number;
vnc_display: number;
vnc_port: number;
app_port: number;
disk: string;
created: string;
}
export function agentDir(name: string): string {
return path.join(AGENTS_DIR, name);
}
export function agentJsonPath(name: string): string {
return path.join(agentDir(name), 'agent.json');
}
export function agentEnvPath(name: string): string {
return path.join(agentDir(name), '.env');
}
export function pidfilePath(name: string): string {
return path.join(agentDir(name), 'qemu.pid');
}
export function monitorSockPath(name: string): string {
return path.join(agentDir(name), 'qemu-monitor.sock');
}
export function efiVarsPath(name: string): string {
return path.join(agentDir(name), 'efi-vars.fd');
}
export function basePidfile(): string {
return path.join(BASE_DIR, 'qemu.pid');
}
export function baseMonitorSock(): string {
return path.join(BASE_DIR, 'qemu-monitor.sock');
}
export function agentExists(name: string): boolean {
return fs.existsSync(agentJsonPath(name));
}
export function readAgentJson(name: string): AgentJson {
const raw = fs.readFileSync(agentJsonPath(name), 'utf8');
return JSON.parse(raw) as AgentJson;
}
export function nextIndex(): number {
if (!fs.existsSync(AGENTS_DIR)) return 0;
let max = -1;
for (const entry of fs.readdirSync(AGENTS_DIR)) {
const jsonPath = path.join(AGENTS_DIR, entry, 'agent.json');
if (!fs.existsSync(jsonPath)) continue;
try {
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8')) as { index: number };
if (data.index > max) max = data.index;
} catch {
// ignore malformed entries
}
}
return max + 1;
}
export function writeAgentJson(name: string, index: number): AgentJson {
const dir = agentDir(name);
const agent: AgentJson = {
name,
index,
ssh_port: SSH_PORT_BASE + index,
vnc_display: VNC_DISPLAY_BASE + index,
vnc_port: 5900 + VNC_DISPLAY_BASE + index,
app_port: APP_PORT_BASE + index,
disk: path.join(dir, 'disk.qcow2'),
created: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
};
fs.writeFileSync(agentJsonPath(name), JSON.stringify(agent, null, 2) + '\n');
return agent;
}
export function listAgents(): string[] {
if (!fs.existsSync(AGENTS_DIR)) return [];
return fs.readdirSync(AGENTS_DIR).filter((entry) =>
fs.existsSync(path.join(AGENTS_DIR, entry, 'agent.json'))
);
}
+100
View File
@@ -0,0 +1,100 @@
import { spawnSync, spawn } from 'child_process';
export const SSH_OPTS = [
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'LogLevel=ERROR',
];
interface RunOptions {
/** If true, suppress stdio output and return stdout as string */
silent?: boolean;
/** Timeout in milliseconds */
timeout?: number;
}
export function sshRun(
port: number,
user: string,
command: string,
opts: RunOptions = {},
): { ok: boolean; output: string } {
const args = [...SSH_OPTS, '-p', String(port), `${user}@localhost`, command];
const result = spawnSync('ssh', args, {
stdio: opts.silent ? 'pipe' : 'inherit',
timeout: opts.timeout,
encoding: 'utf8',
});
return {
ok: result.status === 0,
output: (result.stdout ?? '') + (result.stderr ?? ''),
};
}
/** Open an interactive SSH shell (inherits stdio). Allocates a PTY (-t) so
* remote programs that require a terminal (e.g. `su` password prompts) work. */
export function sshInteractive(port: number, user: string, command?: string): void {
const args = [...SSH_OPTS, '-t', '-p', String(port), `${user}@localhost`];
if (command) args.push(command);
const child = spawn('ssh', args, { stdio: 'inherit' });
child.on('exit', (code) => process.exit(code ?? 0));
}
export function scpTo(
port: number,
user: string,
localPath: string,
remotePath: string,
opts: { recursive?: boolean; silent?: boolean } = {},
): boolean {
const args = [
...SSH_OPTS,
...(opts.recursive ? ['-r'] : []),
'-P', String(port),
localPath,
`${user}@localhost:${remotePath}`,
];
const result = spawnSync('scp', args, {
stdio: opts.silent ? 'pipe' : 'inherit',
});
return result.status === 0;
}
export function scpFrom(
port: number,
user: string,
remotePath: string,
localPath: string,
opts: { recursive?: boolean; silent?: boolean } = {},
): boolean {
const args = [
...SSH_OPTS,
...(opts.recursive ? ['-r'] : []),
'-P', String(port),
`${user}@localhost:${remotePath}`,
localPath,
];
const result = spawnSync('scp', args, {
stdio: opts.silent ? 'pipe' : 'inherit',
});
return result.status === 0;
}
/** Wait until SSH is reachable, retrying up to maxAttempts times with delaySec between. */
export function waitForSsh(
port: number,
user: string,
maxAttempts = 30,
delaySec = 2,
): boolean {
for (let i = 0; i < maxAttempts; i++) {
const result = spawnSync('ssh', [...SSH_OPTS, '-p', String(port), `${user}@localhost`, 'true'], {
stdio: 'pipe',
timeout: 5000,
});
if (result.status === 0) return true;
// Sleep delaySec
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delaySec * 1000);
}
return false;
}
+62
View File
@@ -0,0 +1,62 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildMachineArgs, gpuDeviceArgs, isoDeviceArgs } from './vm.js';
// These tests lock in the Windows x64 (WHPX) behavior and, just as importantly,
// prove the macOS/Linux paths are unchanged (no VGA, no q35-only ide-cd bus).
test('buildMachineArgs: Windows x86_64 uses WHPX with a Haswell CPU, not host', () => {
// -cpu host exposes APX/MPX on recent AMD CPUs, which WHPX rejects with
// "Unexpected VP exit code 4".
assert.deepEqual(
buildMachineArgs('win32', 'x86_64'),
['-machine', 'q35', '-accel', 'whpx', '-cpu', 'Haswell'],
);
});
test('buildMachineArgs: Windows aarch64 keeps host passthrough', () => {
assert.deepEqual(
buildMachineArgs('win32', 'aarch64'),
['-machine', 'virt,highmem=on', '-accel', 'whpx', '-cpu', 'host'],
);
});
test('buildMachineArgs: macOS uses HVF with host for both guest arches', () => {
assert.deepEqual(
buildMachineArgs('darwin', 'aarch64'),
['-machine', 'virt,highmem=on', '-accel', 'hvf', '-cpu', 'host'],
);
assert.deepEqual(
buildMachineArgs('darwin', 'x86_64'),
['-machine', 'q35', '-accel', 'hvf', '-cpu', 'host'],
);
});
test('buildMachineArgs: Linux uses KVM with host', () => {
assert.deepEqual(
buildMachineArgs('linux', 'x86_64'),
['-machine', 'q35', '-accel', 'kvm', '-cpu', 'host'],
);
});
test('gpuDeviceArgs: Windows uses standard VGA, other platforms use virtio-gpu', () => {
assert.deepEqual(gpuDeviceArgs('win32'), ['-device', 'VGA']);
assert.deepEqual(gpuDeviceArgs('darwin'), ['-device', 'virtio-gpu-pci']);
assert.deepEqual(gpuDeviceArgs('linux'), ['-device', 'virtio-gpu-pci']);
});
test('isoDeviceArgs: attaches a bootable USB CD-ROM only on Windows', () => {
const win = isoDeviceArgs('/tmp/debian.iso', 'win32');
assert.ok(win.includes('usb-storage,drive=install-cdrom'));
assert.ok(win.some((s) => s.includes('media=cdrom')));
assert.ok(win.some((s) => s.includes('/tmp/debian.iso')));
});
test('isoDeviceArgs: no CD-ROM on macOS/Linux', () => {
assert.deepEqual(isoDeviceArgs('/tmp/debian.iso', 'darwin'), []);
assert.deepEqual(isoDeviceArgs('/tmp/debian.iso', 'linux'), []);
});
test('isoDeviceArgs: no CD-ROM when no ISO is provided', () => {
assert.deepEqual(isoDeviceArgs(undefined, 'win32'), []);
});
+346
View File
@@ -0,0 +1,346 @@
import * as fs from 'fs';
import * as path from 'path';
import { spawnSync, spawn } from 'child_process';
import {
PLATFORM, GUEST_ARCH, CPUS, RAM, SERVICE_DIR,
resolveQemuBinary, resolveQemuImgBinary, resolveEfiCode, resolveEfiVars,
type Platform,
} from './config.js';
// ── Process helpers ──────────────────────────────────────────────────────────
export function isRunning(pidFile: string): boolean {
if (!fs.existsSync(pidFile)) return false;
try {
const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
if (isNaN(pid)) return false;
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export function readPid(pidFile: string): number | null {
if (!fs.existsSync(pidFile)) return null;
const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
return isNaN(pid) ? null : pid;
}
export function killPid(pidFile: string): void {
const pid = readPid(pidFile);
if (pid !== null) {
try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ }
}
fs.rmSync(pidFile, { force: true });
}
/**
* Remove a QEMU monitor socket file, tolerating platform quirks.
* On Windows the AF_UNIX socket file created by `-monitor unix:` can make
* fs.rmSync throw EACCES (lstat fails), so fall back to `cmd del`. Never throws.
*/
export function removeMonitorSock(sock: string): void {
try {
fs.rmSync(sock, { force: true });
} catch {
if (PLATFORM === 'win32') {
try { spawnSync('cmd', ['/c', 'del', '/f', '/q', sock], { stdio: 'ignore' }); } catch { /* ignore */ }
}
}
}
// ── QEMU args builder ────────────────────────────────────────────────────────
interface QemuArgsOptions {
disk: string;
efi: string;
sshPort: number;
pidFile: string;
monitorSock: string;
appPort?: number;
dev?: boolean;
gui?: boolean;
vncDisplay?: number;
/** Optional installer ISO to attach as a bootable CD-ROM (used by `base install`). */
iso?: string;
}
// Machine/accelerator/CPU flags. Pure and parameterized so the platform matrix
// can be unit tested without spawning QEMU.
export function buildMachineArgs(
platform: Platform = PLATFORM,
guestArch: 'aarch64' | 'x86_64' = GUEST_ARCH,
): string[] {
// Machine type depends on the guest ISA, not the host OS.
// 'virt' is the ARM64 platform board; 'q35' is the x86_64 platform board.
const machine = guestArch === 'aarch64' ? 'virt,highmem=on' : 'q35';
if (platform === 'win32') {
// WHPX + `-cpu host` crashes on some AMD CPUs (Zen4 exposes APX/MPX features
// WHPX rejects -> "Unexpected VP exit code 4"). Use a compatible named model
// for the x86_64 guest; the arm64 guest still needs host passthrough.
const cpu = guestArch === 'x86_64' ? 'Haswell' : 'host';
return ['-machine', machine, '-accel', 'whpx', '-cpu', cpu];
}
if (platform === 'linux') {
return ['-machine', machine, '-accel', 'kvm', '-cpu', 'host'];
}
// macOS (darwin): HVF for both arm64 (virt) and x64 (q35)
return ['-machine', machine, '-accel', 'hvf', '-cpu', 'host'];
}
// Display adapter flags. OVMF on the bundled Windows QEMU build does not render
// to virtio-gpu over VNC, so Windows uses a standard VGA adapter; other
// platforms keep virtio-gpu. Pure and parameterized for testing.
export function gpuDeviceArgs(platform: Platform = PLATFORM): string[] {
return platform === 'win32' ? ['-device', 'VGA'] : ['-device', 'virtio-gpu-pci'];
}
// Installer ISO CD-ROM flags (base install only). Scoped to Windows: it uses the
// q35 AHCI bus (ide.0), which does not exist on the aarch64 `virt` machine used
// on macOS/Linux arm64 hosts. Returns an empty array when not applicable.
export function isoDeviceArgs(iso: string | undefined, platform: Platform = PLATFORM): string[] {
if (!iso || platform !== 'win32') return [];
return [
'-drive', `file=${iso},media=cdrom,readonly=on,if=none,id=install-cdrom`,
'-device', 'usb-storage,drive=install-cdrom',
];
}
export function buildQemuArgs(opts: QemuArgsOptions): string[] {
const { disk, efi, sshPort, pidFile, monitorSock, appPort, dev, vncDisplay = 1, iso } = opts;
const efiCode = resolveEfiCode();
// Ensure per-VM efi-vars.fd exists. Windows needs the OVMF VARS template
// (copying CODE leaves OVMF without a variable store); other platforms keep
// the original behavior so their setup is unchanged.
if (!fs.existsSync(efi)) {
fs.copyFileSync(PLATFORM === 'win32' ? resolveEfiVars() : efiCode, efi);
}
let netdev = `user,id=net0,hostfwd=tcp::${sshPort}-:22`;
if (appPort !== undefined) {
netdev += `,hostfwd=tcp::${appPort}-:8080`;
}
const args: string[] = [
...buildMachineArgs(),
'-smp', String(CPUS),
'-m', RAM,
'-drive', `if=pflash,format=raw,readonly=on,file=${efiCode}`,
'-drive', `if=pflash,format=raw,file=${efi}`,
'-drive', `if=virtio,format=qcow2,discard=unmap,detect-zeroes=unmap,file=${disk}`,
'-device', 'virtio-net-pci,netdev=net0',
'-netdev', netdev,
...gpuDeviceArgs(),
'-device', 'virtio-rng-pci',
'-device', 'qemu-xhci',
'-device', 'usb-kbd',
'-device', 'usb-tablet',
'-pidfile', pidFile,
'-monitor', `unix:${monitorSock},server,nowait`,
];
// Attach the installer ISO as a bootable CD-ROM (base install only, Windows).
args.push(...isoDeviceArgs(iso));
if (dev) {
// 9p virtio host share (dev mode): supported on macOS and Windows ARM64
// Windows x64 uses SCP sync instead (handled separately)
if (!(PLATFORM === 'win32' && GUEST_ARCH === 'x86_64')) {
args.push(
'-fsdev', `local,id=svc,path=${SERVICE_DIR},security_model=mapped-xattr`,
'-device', `virtio-9p-pci,fsdev=svc,mount_tag=open-computer_service`,
);
}
}
return args;
}
// ── Start/stop helpers ───────────────────────────────────────────────────────
interface StartVmOptions extends QemuArgsOptions {
gui?: boolean;
daemonize?: boolean;
}
/**
* Pick the best available display backend by probing the binary at runtime.
* Preference order: cocoa (native macOS) → gtk → sdl → vnc → none.
* The bundled QEMU is intentionally headless (only 'none'), which is correct
* for all normal agent flows. For base install, users with a display-capable
* QEMU (e.g. Homebrew) get a native window; others fall back to headless and
* can interact via the serial console or a separate VNC setup.
*/
function chooseDisplayArgs(binary: string, vncDisplay: number): string[] | null {
const result = spawnSync(binary, ['--display', 'help'], { stdio: 'pipe', encoding: 'utf8' });
const output = (result.stdout ?? '') + (result.stderr ?? '');
const backends = new Set(
output.split('\n').map((l) => l.trim()).filter((l) => /^[a-z][-a-z0-9]*$/.test(l)),
);
const prefer = PLATFORM === 'darwin'
? ['cocoa', 'sdl', 'gtk', 'vnc']
: ['gtk', 'sdl', 'vnc'];
for (const b of prefer) {
if (backends.has(b)) {
if (b === 'vnc') {
const vncPort = 5900 + vncDisplay;
console.log(`No native display available — VNC server on localhost:${vncPort}.`);
console.log(` macOS: open vnc://localhost:${vncPort}`);
return ['-display', `vnc=:${vncDisplay}`];
}
return ['-display', b];
}
}
return null;
}
export function startVm(opts: StartVmOptions): boolean {
const { pidFile, gui = false, daemonize = true, vncDisplay = 1 } = opts;
if (isRunning(pidFile)) {
const pid = readPid(pidFile);
console.log(`Already running (pid ${pid}).`);
return true;
}
const binary = resolveQemuBinary();
const args = buildQemuArgs(opts);
if (gui) {
const displayArgs = chooseDisplayArgs(binary, vncDisplay);
if (!displayArgs) {
console.error(
'Error: GUI mode requested but the bundled QEMU has no display backends (no gtk, sdl, or vnc).\n' +
'Install a full QEMU build with display support:\n' +
' Windows: choco install qemu OR https://qemu.org/download/#windows\n' +
' Then set OPEN_COMPUTER_QEMU_DIR to the install path, or add it to your PATH.',
);
return false;
}
if (!daemonize) {
// Foreground: keep QEMU attached so the GUI window stays alive and
// errors are visible (used by `base install`).
spawnSync(binary, [...args, ...displayArgs], { stdio: 'inherit' });
return true;
}
const child = spawn(binary, [...args, ...displayArgs], {
stdio: 'ignore',
detached: true,
});
child.unref();
} else if (daemonize && PLATFORM !== 'win32') {
// macOS/Linux: use QEMU's built-in -daemonize
const result = spawnSync(binary, [...args, '-display', 'none', '-daemonize'], {
stdio: 'inherit',
});
if (result.status !== 0) {
console.error('Failed to start QEMU.');
return false;
}
} else {
// Windows or non-daemonize: detach via Node
const child = spawn(binary, [...args, '-display', 'none'], {
stdio: 'ignore',
detached: true,
});
child.unref();
// Give the process a moment to write the pidfile
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000);
}
// Brief pause to let pidfile be written
const started = Date.now();
while (Date.now() - started < 3000) {
if (isRunning(pidFile)) {
const pid = readPid(pidFile);
console.log(`Started (pid ${pid}).`);
return true;
}
// Busy-wait is acceptable here for a 3-second window
}
console.error('Failed to start (pidfile not found after 3s).');
return false;
}
// ── qemu-img wrappers ────────────────────────────────────────────────────────
export function qemuImgCreate(file: string, backingFile: string, size: string): boolean {
const result = spawnSync(resolveQemuImgBinary(), [
'create', '-f', 'qcow2', '-b', backingFile, '-F', 'qcow2', file,
], { stdio: 'pipe' });
return result.status === 0;
}
export function qemuImgConvert(src: string, dst: string, backingFile?: string): boolean {
const args = [
'convert', '-O', 'qcow2', '-c',
...(backingFile ? ['-B', backingFile, '-F', 'qcow2'] : []),
src, dst,
];
const result = spawnSync(resolveQemuImgBinary(), args, { stdio: 'inherit' });
return result.status === 0;
}
export function fileSize(filePath: string): number {
try {
return fs.statSync(filePath).size;
} catch {
return 0;
}
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}
// ── Graceful shutdown ────────────────────────────────────────────────────────
import { sshRun } from './ssh.js';
import { VM_USER } from './config.js';
export function waitForShutdown(
pidFile: string,
monitorSock: string,
sshPort?: number,
): void {
if (sshPort !== undefined) {
process.stdout.write('Shutting down via SSH...');
sshRun(sshPort, VM_USER, 'sudo shutdown -h now', { silent: true });
} else {
// Fallback: ACPI power-down via QEMU monitor (Unix socket)
process.stdout.write('Sending ACPI shutdown...');
try {
// Use nc/socat to send to the monitor socket
spawnSync('sh', ['-c', `echo system_powerdown | socat - UNIX-CONNECT:${monitorSock}`], {
stdio: 'pipe',
});
} catch { /* socat may not be available */ }
}
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
if (!isRunning(pidFile)) {
process.stdout.write(' stopped.\n');
fs.rmSync(pidFile, { force: true });
removeMonitorSock(monitorSock);
return;
}
process.stdout.write('.');
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000);
}
// Force kill after timeout
process.stdout.write(' force-killing.\n');
killPid(pidFile);
removeMonitorSock(monitorSock);
}