A simple, powerful CLI tool to spin up OpenIndiana virtual machines with QEMU

Add logging and port forwarding options for virtual machines; refactor start and runQemu functions

+1
deno.json
··· 8 8 }, 9 9 "imports": { 10 10 "@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.8", 11 + "@cliffy/flags": "jsr:@cliffy/flags@^1.0.0-rc.8", 11 12 "@cliffy/table": "jsr:@cliffy/table@^1.0.0-rc.8", 12 13 "@db/sqlite": "jsr:@db/sqlite@^0.12.0", 13 14 "@es-toolkit/es-toolkit": "jsr:@es-toolkit/es-toolkit@^1.41.0",
+3 -6
deno.lock
··· 3 3 "specifiers": { 4 4 "jsr:@cliffy/command@^1.0.0-rc.8": "1.0.0-rc.8", 5 5 "jsr:@cliffy/flags@1.0.0-rc.8": "1.0.0-rc.8", 6 + "jsr:@cliffy/flags@^1.0.0-rc.8": "1.0.0-rc.8", 6 7 "jsr:@cliffy/internal@1.0.0-rc.8": "1.0.0-rc.8", 7 8 "jsr:@cliffy/table@1.0.0-rc.8": "1.0.0-rc.8", 8 9 "jsr:@cliffy/table@^1.0.0-rc.8": "1.0.0-rc.8", ··· 29 30 "npm:effect@^3.19.2": "3.19.2", 30 31 "npm:kysely@0.27.6": "0.27.6", 31 32 "npm:kysely@~0.27.2": "0.27.6", 32 - "npm:lodash@^4.17.21": "4.17.21", 33 33 "npm:moniker@~0.1.2": "0.1.2" 34 34 }, 35 35 "jsr": { 36 36 "@cliffy/command@1.0.0-rc.8": { 37 37 "integrity": "758147790797c74a707e5294cc7285df665422a13d2a483437092ffce40b5557", 38 38 "dependencies": [ 39 - "jsr:@cliffy/flags", 39 + "jsr:@cliffy/flags@1.0.0-rc.8", 40 40 "jsr:@cliffy/internal", 41 41 "jsr:@cliffy/table@1.0.0-rc.8", 42 42 "jsr:@std/fmt@~1.0.2", ··· 168 168 "kysely@0.27.6": { 169 169 "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==" 170 170 }, 171 - "lodash@4.17.21": { 172 - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" 173 - }, 174 171 "moniker@0.1.2": { 175 172 "integrity": "sha512-Uj9iV0QYr6281G+o0TvqhKwHHWB2Q/qUTT4LPQ3qDGc0r8cbMuqQjRXPZuVZ+gcL7APx+iQgE8lcfWPrj1LsLA==" 176 173 }, ··· 181 178 "workspace": { 182 179 "dependencies": [ 183 180 "jsr:@cliffy/command@^1.0.0-rc.8", 181 + "jsr:@cliffy/flags@^1.0.0-rc.8", 184 182 "jsr:@cliffy/table@^1.0.0-rc.8", 185 183 "jsr:@db/sqlite@0.12", 186 184 "jsr:@es-toolkit/es-toolkit@^1.41.0", ··· 191 189 "npm:dayjs@^1.11.19", 192 190 "npm:effect@^3.19.2", 193 191 "npm:kysely@0.27.6", 194 - "npm:lodash@^4.17.21", 195 192 "npm:moniker@~0.1.2" 196 193 ] 197 194 }
+59 -2
main.ts
··· 4 4 import pkg from "./deno.json" with { type: "json" }; 5 5 import { createBridgeNetworkIfNeeded } from "./src/network.ts"; 6 6 import inspect from "./src/subcommands/inspect.ts"; 7 + import logs from "./src/subcommands/logs.ts"; 7 8 import ps from "./src/subcommands/ps.ts"; 9 + import restart from "./src/subcommands/restart.ts"; 8 10 import rm from "./src/subcommands/rm.ts"; 9 11 import start from "./src/subcommands/start.ts"; 10 12 import stop from "./src/subcommands/stop.ts"; ··· 53 55 .option( 54 56 "-b, --bridge <name:string>", 55 57 "Name of the network bridge to use for networking (e.g., br0)", 58 + ) 59 + .option( 60 + "-d, --detach", 61 + "Run VM in the background and print VM name", 62 + ) 63 + .option( 64 + "-p, --port-forward <mappings:string>", 65 + "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 56 66 ) 57 67 .example( 58 68 "Default usage", ··· 126 136 }) 127 137 .command("start", "Start a virtual machine") 128 138 .arguments("<vm-name:string>") 129 - .action(async (_options: unknown, vmName: string) => { 130 - await start(vmName); 139 + .option("-c, --cpu <type:string>", "Type of CPU to emulate", { 140 + default: "host", 141 + }) 142 + .option("-C, --cpus <number:number>", "Number of CPU cores", { 143 + default: 2, 144 + }) 145 + .option("-m, --memory <size:string>", "Amount of memory for the VM", { 146 + default: "2G", 147 + }) 148 + .option("-i, --image <path:string>", "Path to VM disk image") 149 + .option( 150 + "--disk-format <format:string>", 151 + "Disk image format (e.g., qcow2, raw)", 152 + { 153 + default: "raw", 154 + }, 155 + ) 156 + .option( 157 + "--size <size:string>", 158 + "Size of the VM disk image to create if it doesn't exist (e.g., 20G)", 159 + { 160 + default: "20G", 161 + }, 162 + ) 163 + .option( 164 + "-b, --bridge <name:string>", 165 + "Name of the network bridge to use for networking (e.g., br0)", 166 + ) 167 + .option( 168 + "-d, --detach", 169 + "Run VM in the background and print VM name", 170 + ) 171 + .option( 172 + "-p, --port-forward <mappings:string>", 173 + "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 174 + ) 175 + .action(async (options: unknown, vmName: string) => { 176 + await start(vmName, Boolean((options as { detach: boolean }).detach)); 131 177 }) 132 178 .command("stop", "Stop a virtual machine") 133 179 .arguments("<vm-name:string>") ··· 143 189 .arguments("<vm-name:string>") 144 190 .action(async (_options: unknown, vmName: string) => { 145 191 await rm(vmName); 192 + }) 193 + .command("logs", "View logs of a virtual machine") 194 + .option("--follow, -f", "Follow log output") 195 + .arguments("<vm-name:string>") 196 + .action(async (options: unknown, vmName: string) => { 197 + await logs(vmName, Boolean((options as { follow: boolean }).follow)); 198 + }) 199 + .command("restart", "Restart a virtual machine") 200 + .arguments("<vm-name:string>") 201 + .action(async (_options: unknown, vmName: string) => { 202 + await restart(vmName); 146 203 }) 147 204 .parse(Deno.args); 148 205 }
+1
src/constants.ts
··· 1 1 export const CONFIG_DIR = `${Deno.env.get("HOME")}/.openindiana-up`; 2 2 export const DB_PATH = `${CONFIG_DIR}/state.sqlite`; 3 + export const LOGS_DIR: string = `${CONFIG_DIR}/logs`; 3 4 export const EMPTY_DISK_THRESHOLD_KB: number = 100;
+19 -2
src/db.ts
··· 35 35 drivePath?: string; 36 36 diskFormat: string; 37 37 isoPath?: string; 38 + portForward?: string; 38 39 version: string; 39 40 status: STATUS; 40 41 pid: number; ··· 68 69 .addColumn("diskFormat", "varchar") 69 70 .addColumn("isoPath", "varchar") 70 71 .addColumn("status", "varchar", (col) => col.notNull()) 71 - .addColumn("pid", "integer", (col) => col.notNull().unique()) 72 + .addColumn("pid", "integer") 72 73 .addColumn( 73 74 "createdAt", 74 75 "varchar", ··· 87 88 }, 88 89 }; 89 90 90 - export const migrateToLatest = async (db: Database) => { 91 + migrations["002"] = { 92 + async up(db: Kysely<unknown>): Promise<void> { 93 + await db.schema 94 + .alterTable("virtual_machines") 95 + .addColumn("portForward", "varchar") 96 + .execute(); 97 + }, 98 + 99 + async down(db: Kysely<unknown>): Promise<void> { 100 + await db.schema 101 + .alterTable("virtual_machines") 102 + .dropColumn("portForward") 103 + .execute(); 104 + }, 105 + }; 106 + 107 + export const migrateToLatest = async (db: Database): Promise<void> => { 91 108 const migrator = new Migrator({ db, provider: migrationProvider }); 92 109 const { error } = await migrator.migrateToLatest(); 93 110 if (error) throw error;
+23
src/subcommands/logs.ts
··· 1 + import { LOGS_DIR } from "../constants.ts"; 2 + 3 + export default async function (name: string, follow: boolean) { 4 + await Deno.mkdir(LOGS_DIR, { recursive: true }); 5 + const logPath = `${LOGS_DIR}/${name}.log`; 6 + 7 + const cmd = new Deno.Command(follow ? "tail" : "cat", { 8 + args: [ 9 + ...(follow ? ["-n", "100", "-f"] : []), 10 + logPath, 11 + ], 12 + stdin: "inherit", 13 + stdout: "inherit", 14 + stderr: "inherit", 15 + }); 16 + 17 + const status = await cmd.spawn().status; 18 + 19 + if (!status.success) { 20 + console.error(`Failed to view logs for virtual machine ${name}.`); 21 + Deno.exit(status.code); 22 + } 23 + }
+97
src/subcommands/restart.ts
··· 1 + import _ from "@es-toolkit/es-toolkit/compat"; 2 + import chalk from "chalk"; 3 + import { LOGS_DIR } from "../constants.ts"; 4 + import { getInstanceState, updateInstanceState } from "../state.ts"; 5 + import { safeKillQemu } from "../utils.ts"; 6 + 7 + export default async function (name: string) { 8 + const vm = await getInstanceState(name); 9 + if (!vm) { 10 + console.error( 11 + `Virtual machine with name or ID ${chalk.greenBright(name)} not found.`, 12 + ); 13 + Deno.exit(1); 14 + } 15 + 16 + const success = await safeKillQemu(vm.pid, Boolean(vm.bridge)); 17 + 18 + if (!success) { 19 + console.error( 20 + `Failed to stop virtual machine ${chalk.greenBright(vm.name)}.`, 21 + ); 22 + Deno.exit(1); 23 + } 24 + await updateInstanceState(vm.id, "STOPPED"); 25 + 26 + await new Promise((resolve) => setTimeout(resolve, 2000)); 27 + 28 + await Deno.mkdir(LOGS_DIR, { recursive: true }); 29 + const logPath = `${LOGS_DIR}/${vm.name}.log`; 30 + 31 + const qemuArgs = [ 32 + ..._.compact([vm.bridge && "qemu-system-x86_64"]), 33 + ...Deno.build.os === "linux" ? ["-enable-kvm"] : [], 34 + "-cpu", 35 + vm.cpu, 36 + "-m", 37 + vm.memory, 38 + "-smp", 39 + vm.cpus.toString(), 40 + ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]), 41 + "-netdev", 42 + vm.bridge 43 + ? `bridge,id=net0,br=${vm.bridge}` 44 + : "user,id=net0,hostfwd=tcp::2222-:22", 45 + "-device", 46 + `e1000,netdev=net0,mac=${vm.macAddress}`, 47 + "-device", 48 + "ahci,id=ahci0", 49 + "-nographic", 50 + "-monitor", 51 + "none", 52 + "-chardev", 53 + "stdio,id=con0,signal=off", 54 + "-serial", 55 + "chardev:con0", 56 + ..._.compact( 57 + vm.drivePath && [ 58 + "-drive", 59 + `file=${vm.drivePath},format=${vm.diskFormat},if=none,id=disk0`, 60 + "-device", 61 + "ide-hd,drive=disk0,bus=ahci0.0", 62 + ], 63 + ), 64 + ]; 65 + 66 + const fullCommand = vm.bridge 67 + ? `sudo qemu-system-x86_64 ${ 68 + qemuArgs.slice(1).join(" ") 69 + } >> "${logPath}" 2>&1 & echo $!` 70 + : `qemu-system-x86_64 ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 71 + 72 + const cmd = new Deno.Command("sh", { 73 + args: ["-c", fullCommand], 74 + stdin: "null", 75 + stdout: "piped", 76 + }); 77 + 78 + const { stdout } = await cmd.spawn().output(); 79 + const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 80 + 81 + await new Promise((resolve) => setTimeout(resolve, 2000)); 82 + 83 + await updateInstanceState(vm.id, "RUNNING", qemuPid); 84 + 85 + console.log( 86 + `${chalk.greenBright(vm.name)} restarted with PID ${ 87 + chalk.greenBright(qemuPid) 88 + }.`, 89 + ); 90 + console.log( 91 + `Logs are being written to ${chalk.blueBright(logPath)}`, 92 + ); 93 + 94 + await new Promise((resolve) => setTimeout(resolve, 2000)); 95 + 96 + Deno.exit(0); 97 + }
+101 -47
src/subcommands/start.ts
··· 1 + import { parseFlags } from "@cliffy/flags"; 1 2 import _ from "@es-toolkit/es-toolkit/compat"; 3 + import { LOGS_DIR } from "../constants.ts"; 4 + import type { VirtualMachine } from "../db.ts"; 2 5 import { getInstanceState, updateInstanceState } from "../state.ts"; 3 6 4 - export default async function (name: string) { 5 - const vm = await getInstanceState(name); 7 + export default async function (name: string, detach: boolean = false) { 8 + let vm = await getInstanceState(name); 6 9 if (!vm) { 7 10 console.error( 8 11 `Virtual machine with name or ID ${name} not found.`, ··· 12 15 13 16 console.log(`Starting virtual machine ${vm.name} (ID: ${vm.id})...`); 14 17 15 - const cmd = new Deno.Command(vm.bridge ? "sudo" : "qemu-system-x86_64", { 16 - args: [ 17 - ..._.compact([vm.bridge && "qemu-system-x86_64"]), 18 - "-enable-kvm", 19 - "-cpu", 20 - vm.cpu, 21 - "-m", 22 - vm.memory, 23 - "-smp", 24 - vm.cpus.toString(), 25 - ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]), 26 - "-netdev", 27 - vm.bridge 28 - ? `bridge,id=net0,br=${vm.bridge}` 29 - : "user,id=net0,hostfwd=tcp::2222-:22", 30 - "-device", 31 - `e1000,netdev=net0,mac=${vm.macAddress}`, 32 - "-device", 33 - "ahci,id=ahci0", 34 - "-nographic", 35 - "-monitor", 36 - "none", 37 - "-chardev", 38 - "stdio,id=con0,signal=off", 39 - "-serial", 40 - "chardev:con0", 41 - ..._.compact( 42 - vm.drivePath && [ 43 - "-drive", 44 - `file=${vm.drivePath},format=${vm.diskFormat},if=none,id=disk0`, 45 - "-device", 46 - "ide-hd,drive=disk0,bus=ahci0.0", 47 - ], 48 - ), 49 - ], 50 - stdin: "inherit", 51 - stdout: "inherit", 52 - stderr: "inherit", 53 - }) 54 - .spawn(); 18 + vm = mergeFlags(vm); 55 19 56 - await updateInstanceState(name, "RUNNING", cmd.pid); 20 + const qemuArgs = [ 21 + ..._.compact([vm.bridge && "qemu-system-x86_64"]), 22 + ...Deno.build.os === "linux" ? ["-enable-kvm"] : [], 23 + "-cpu", 24 + vm.cpu, 25 + "-m", 26 + vm.memory, 27 + "-smp", 28 + vm.cpus.toString(), 29 + ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]), 30 + "-netdev", 31 + vm.bridge 32 + ? `bridge,id=net0,br=${vm.bridge}` 33 + : "user,id=net0,hostfwd=tcp::2222-:22", 34 + "-device", 35 + `e1000,netdev=net0,mac=${vm.macAddress}`, 36 + "-device", 37 + "ahci,id=ahci0", 38 + "-nographic", 39 + "-monitor", 40 + "none", 41 + "-chardev", 42 + "stdio,id=con0,signal=off", 43 + "-serial", 44 + "chardev:con0", 45 + ..._.compact( 46 + vm.drivePath && [ 47 + "-drive", 48 + `file=${vm.drivePath},format=${vm.diskFormat},if=none,id=disk0`, 49 + "-device", 50 + "ide-hd,drive=disk0,bus=ahci0.0", 51 + ], 52 + ), 53 + ]; 57 54 58 - const status = await cmd.status; 55 + if (detach) { 56 + await Deno.mkdir(LOGS_DIR, { recursive: true }); 57 + const logPath = `${LOGS_DIR}/${vm.name}.log`; 58 + 59 + const fullCommand = vm.bridge 60 + ? `sudo qemu-system-x86_64 ${ 61 + qemuArgs.slice(1).join(" ") 62 + } >> "${logPath}" 2>&1 & echo $!` 63 + : `qemu-system-x86_64 ${ 64 + qemuArgs.join(" ") 65 + } >> "${logPath}" 2>&1 & echo $!`; 59 66 60 - await updateInstanceState(name, "STOPPED", cmd.pid); 67 + const cmd = new Deno.Command("sh", { 68 + args: ["-c", fullCommand], 69 + stdin: "null", 70 + stdout: "piped", 71 + }); 61 72 62 - if (!status.success) { 63 - Deno.exit(status.code); 73 + const { stdout } = await cmd.spawn().output(); 74 + const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 75 + 76 + await updateInstanceState(name, "RUNNING", qemuPid); 77 + 78 + console.log( 79 + `Virtual machine ${vm.name} started in background (PID: ${qemuPid})`, 80 + ); 81 + console.log(`Logs will be written to: ${logPath}`); 82 + 83 + // Exit successfully while keeping VM running in background 84 + Deno.exit(0); 85 + } else { 86 + const cmd = new Deno.Command(vm.bridge ? "sudo" : "qemu-system-x86_64", { 87 + args: qemuArgs, 88 + stdin: "inherit", 89 + stdout: "inherit", 90 + stderr: "inherit", 91 + }); 92 + 93 + const child = cmd.spawn(); 94 + await updateInstanceState(name, "RUNNING", child.pid); 95 + 96 + const status = await child.status; 97 + 98 + await updateInstanceState(name, "STOPPED", child.pid); 99 + 100 + if (!status.success) { 101 + Deno.exit(status.code); 102 + } 64 103 } 65 104 } 105 + 106 + function mergeFlags(vm: VirtualMachine): VirtualMachine { 107 + const { flags } = parseFlags(Deno.args); 108 + return { 109 + ...vm, 110 + memory: flags.memory ? String(flags.memory) : vm.memory, 111 + cpus: flags.cpus ? Number(flags.cpus) : vm.cpus, 112 + cpu: flags.cpu ? String(flags.cpu) : vm.cpu, 113 + diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat, 114 + portForward: flags.portForward ? String(flags.portForward) : vm.portForward, 115 + drivePath: flags.image ? String(flags.image) : vm.drivePath, 116 + bridge: flags.bridge ? String(flags.bridge) : vm.bridge, 117 + diskSize: flags.size ? String(flags.size) : vm.diskSize, 118 + }; 119 + }
+189 -60
src/utils.ts
··· 2 2 import { createId } from "@paralleldrive/cuid2"; 3 3 import chalk from "chalk"; 4 4 import Moniker from "moniker"; 5 - import { EMPTY_DISK_THRESHOLD_KB } from "./constants.ts"; 5 + import { EMPTY_DISK_THRESHOLD_KB, LOGS_DIR } from "./constants.ts"; 6 6 import { generateRandomMacAddress } from "./network.ts"; 7 - import { saveInstanceState } from "./state.ts"; 7 + import { saveInstanceState, updateInstanceState } from "./state.ts"; 8 8 9 9 const DEFAULT_VERSION = "20251026"; 10 10 ··· 17 17 diskFormat: string; 18 18 size: string; 19 19 bridge?: string; 20 + portForward?: string; 21 + detach?: boolean; 20 22 } 21 23 22 24 async function du(path: string): Promise<number> { ··· 90 92 return `https://dlc.openindiana.org/isos/hipster/${version}/OI-hipster-text-${version}.iso`; 91 93 } 92 94 95 + export function setupPortForwardingArgs(portForward?: string): string { 96 + if (!portForward) { 97 + return ""; 98 + } 99 + 100 + const forwards = portForward.split(",").map((pair) => { 101 + const [hostPort, guestPort] = pair.split(":"); 102 + return `hostfwd=tcp::${hostPort}-:${guestPort}`; 103 + }); 104 + 105 + return forwards.join(","); 106 + } 107 + 108 + export function setupNATNetworkArgs(portForward?: string): string { 109 + if (!portForward) { 110 + return "user,id=net0"; 111 + } 112 + 113 + const portForwarding = setupPortForwardingArgs(portForward); 114 + return `user,id=net0,${portForwarding}`; 115 + } 116 + 93 117 export async function runQemu( 94 118 isoPath: string | null, 95 119 options: Options, 96 120 ): Promise<void> { 97 121 const macAddress = generateRandomMacAddress(); 98 - const cmd = new Deno.Command(options.bridge ? "sudo" : "qemu-system-x86_64", { 99 - args: [ 100 - ..._.compact([options.bridge && "qemu-system-x86_64"]), 101 - "-enable-kvm", 102 - "-cpu", 103 - options.cpu, 104 - "-m", 105 - options.memory, 106 - "-smp", 107 - options.cpus.toString(), 108 - ..._.compact([isoPath && "-cdrom", isoPath]), 109 - "-netdev", 110 - options.bridge 111 - ? `bridge,id=net0,br=${options.bridge}` 112 - : "user,id=net0,hostfwd=tcp::2222-:22", 113 - "-device", 114 - `e1000,netdev=net0,mac=${macAddress}`, 115 - "-device", 116 - "ahci,id=ahci0", 117 - "-nographic", 118 - "-monitor", 119 - "none", 120 - "-chardev", 121 - "stdio,id=con0,signal=off", 122 - "-serial", 123 - "chardev:con0", 124 - ..._.compact( 125 - options.image && [ 126 - "-drive", 127 - `file=${options.image},format=${options.diskFormat},if=none,id=disk0`, 128 - "-device", 129 - "ide-hd,drive=disk0,bus=ahci0.0", 130 - ], 131 - ), 132 - ], 133 - stdin: "inherit", 134 - stdout: "inherit", 135 - stderr: "inherit", 136 - }).spawn(); 122 + 123 + const qemuArgs = [ 124 + ..._.compact([options.bridge && "qemu-system-x86_64"]), 125 + ...Deno.build.os === "linux" ? ["-enable-kvm"] : [], 126 + "-cpu", 127 + options.cpu, 128 + "-m", 129 + options.memory, 130 + "-smp", 131 + options.cpus.toString(), 132 + ..._.compact([isoPath && "-cdrom", isoPath]), 133 + "-netdev", 134 + options.bridge 135 + ? `bridge,id=net0,br=${options.bridge}` 136 + : "user,id=net0,hostfwd=tcp::2222-:22", 137 + "-device", 138 + `e1000,netdev=net0,mac=${macAddress}`, 139 + "-device", 140 + "ahci,id=ahci0", 141 + "-nographic", 142 + "-monitor", 143 + "none", 144 + "-chardev", 145 + "stdio,id=con0,signal=off", 146 + "-serial", 147 + "chardev:con0", 148 + ..._.compact( 149 + options.image && [ 150 + "-drive", 151 + `file=${options.image},format=${options.diskFormat},if=none,id=disk0`, 152 + "-device", 153 + "ide-hd,drive=disk0,bus=ahci0.0", 154 + ], 155 + ), 156 + ]; 157 + 158 + const name = Moniker.choose(); 159 + 160 + if (options.detach) { 161 + await Deno.mkdir(LOGS_DIR, { recursive: true }); 162 + const logPath = `${LOGS_DIR}/${name}.log`; 163 + 164 + const fullCommand = options.bridge 165 + ? `sudo qemu-system-x86_64 ${ 166 + qemuArgs.slice(1).join(" ") 167 + } >> "${logPath}" 2>&1 & echo $!` 168 + : `qemu-system-x86_64 ${ 169 + qemuArgs.join(" ") 170 + } >> "${logPath}" 2>&1 & echo $!`; 171 + 172 + const cmd = new Deno.Command("sh", { 173 + args: ["-c", fullCommand], 174 + stdin: "null", 175 + stdout: "piped", 176 + }); 177 + 178 + const { stdout } = await cmd.spawn().output(); 179 + const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 180 + 181 + await saveInstanceState({ 182 + id: createId(), 183 + name, 184 + bridge: options.bridge, 185 + macAddress, 186 + memory: options.memory, 187 + cpus: options.cpus, 188 + cpu: options.cpu, 189 + diskSize: options.size, 190 + diskFormat: options.diskFormat, 191 + portForward: options.portForward, 192 + isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 193 + drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 194 + version: DEFAULT_VERSION, 195 + status: "RUNNING", 196 + pid: qemuPid, 197 + }); 198 + 199 + console.log( 200 + `Virtual machine ${name} started in background (PID: ${qemuPid})`, 201 + ); 202 + console.log(`Logs will be written to: ${logPath}`); 203 + 204 + // Exit successfully while keeping VM running in background 205 + Deno.exit(0); 206 + } else { 207 + const cmd = new Deno.Command( 208 + options.bridge ? "sudo" : "qemu-system-x86_64", 209 + { 210 + args: qemuArgs, 211 + stdin: "inherit", 212 + stdout: "inherit", 213 + stderr: "inherit", 214 + }, 215 + ) 216 + .spawn(); 217 + 218 + await saveInstanceState({ 219 + id: createId(), 220 + name, 221 + bridge: options.bridge, 222 + macAddress, 223 + memory: options.memory, 224 + cpus: options.cpus, 225 + cpu: options.cpu, 226 + diskSize: options.size, 227 + diskFormat: options.diskFormat, 228 + portForward: options.portForward, 229 + isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 230 + drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 231 + version: DEFAULT_VERSION, 232 + status: "RUNNING", 233 + pid: cmd.pid, 234 + }); 137 235 138 - await saveInstanceState({ 139 - id: createId(), 140 - name: Moniker.choose(), 141 - bridge: options.bridge, 142 - macAddress, 143 - memory: options.memory, 144 - cpus: options.cpus, 145 - cpu: options.cpu, 146 - diskSize: options.size, 147 - diskFormat: options.diskFormat, 148 - isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 149 - drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 150 - version: DEFAULT_VERSION, 151 - status: "RUNNING", 152 - pid: cmd.pid, 153 - }); 236 + const status = await cmd.status; 154 237 155 - const status = await cmd.status; 238 + await updateInstanceState(name, "STOPPED"); 156 239 157 - if (!status.success) { 158 - Deno.exit(status.code); 240 + if (!status.success) { 241 + Deno.exit(status.code); 242 + } 159 243 } 160 244 } 161 245 ··· 179 263 } 180 264 181 265 return input; 266 + } 267 + 268 + export async function safeKillQemu( 269 + pid: number, 270 + useSudo: boolean = false, 271 + ): Promise<boolean> { 272 + const killArgs = useSudo 273 + ? ["sudo", "kill", "-TERM", pid.toString()] 274 + : ["kill", "-TERM", pid.toString()]; 275 + 276 + const termCmd = new Deno.Command(killArgs[0], { 277 + args: killArgs.slice(1), 278 + stdout: "null", 279 + stderr: "null", 280 + }); 281 + 282 + const termStatus = await termCmd.spawn().status; 283 + 284 + if (termStatus.success) { 285 + await new Promise((resolve) => setTimeout(resolve, 3000)); 286 + 287 + const checkCmd = new Deno.Command("kill", { 288 + args: ["-0", pid.toString()], 289 + stdout: "null", 290 + stderr: "null", 291 + }); 292 + 293 + const checkStatus = await checkCmd.spawn().status; 294 + if (!checkStatus.success) { 295 + return true; 296 + } 297 + } 298 + 299 + const killKillArgs = useSudo 300 + ? ["sudo", "kill", "-KILL", pid.toString()] 301 + : ["kill", "-KILL", pid.toString()]; 302 + 303 + const killCmd = new Deno.Command(killKillArgs[0], { 304 + args: killKillArgs.slice(1), 305 + stdout: "null", 306 + stderr: "null", 307 + }); 308 + 309 + const killStatus = await killCmd.spawn().status; 310 + return killStatus.success; 182 311 } 183 312 184 313 export async function createDriveImageIfNeeded(