chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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(' '));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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'))
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'), []);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user