A convenient CLI tool to quickly spin up DragonflyBSD virtual machines using QEMU with sensible defaults.

Add VM management commands: start, stop, restart, remove, and logs; enhance options for port forwarding and background execution

+530 -111
+32 -2
main.ts
··· 3 3 import { Command } from "@cliffy/command"; 4 4 import { createBridgeNetworkIfNeeded } from "./src/network.ts"; 5 5 import inspect from "./src/subcommands/inspect.ts"; 6 + import logs from "./src/subcommands/logs.ts"; 6 7 import ps from "./src/subcommands/ps.ts"; 8 + import restart from "./src/subcommands/restart.ts"; 9 + import rm from "./src/subcommands/rm.ts"; 7 10 import start from "./src/subcommands/start.ts"; 8 11 import stop from "./src/subcommands/stop.ts"; 9 12 import { ··· 14 17 type Options, 15 18 runQemu, 16 19 } from "./src/utils.ts"; 20 + 21 + export * from "./src/mod.ts"; 17 22 18 23 if (import.meta.main) { 19 24 await new Command() ··· 51 56 .option( 52 57 "-b, --bridge <name:string>", 53 58 "Name of the network bridge to use for networking (e.g., br0)", 59 + ) 60 + .option( 61 + "-d, --detach", 62 + "Run VM in the background and print VM name", 63 + ) 64 + .option( 65 + "-p, --port-forward <mappings:string>", 66 + "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 54 67 ) 55 68 .example( 56 69 "Default usage", ··· 120 133 }) 121 134 .command("start", "Start a virtual machine") 122 135 .arguments("<vm-name:string>") 123 - .action(async (_options: unknown, vmName: string) => { 124 - await start(vmName); 136 + .option("--detach, -d", "Run VM in the background and print VM name") 137 + .action(async (options: unknown, vmName: string) => { 138 + await start(vmName, Boolean((options as { detach: boolean }).detach)); 125 139 }) 126 140 .command("stop", "Stop a virtual machine") 127 141 .arguments("<vm-name:string>") ··· 132 146 .arguments("<vm-name:string>") 133 147 .action(async (_options: unknown, vmName: string) => { 134 148 await inspect(vmName); 149 + }) 150 + .command("rm", "Remove a virtual machine") 151 + .arguments("<vm-name:string>") 152 + .action(async (_options: unknown, vmName: string) => { 153 + await rm(vmName); 154 + }) 155 + .command("logs", "View logs of a virtual machine") 156 + .option("--follow, -f", "Follow log output") 157 + .arguments("<vm-name:string>") 158 + .action(async (options: unknown, vmName: string) => { 159 + await logs(vmName, Boolean((options as { follow: boolean }).follow)); 160 + }) 161 + .command("restart", "Restart a virtual machine") 162 + .arguments("<vm-name:string>") 163 + .action(async (_options: unknown, vmName: string) => { 164 + await restart(vmName); 135 165 }) 136 166 .parse(Deno.args); 137 167 }
+3 -2
src/constants.ts
··· 1 - export const CONFIG_DIR = `${Deno.env.get("HOME")}/.dflybsd-up`; 2 - export const DB_PATH = `${CONFIG_DIR}/state.sqlite`; 1 + export const CONFIG_DIR: string = `${Deno.env.get("HOME")}/.dflybsd-up`; 2 + export const DB_PATH: string = `${CONFIG_DIR}/state.sqlite`; 3 + export const LOGS_DIR: string = `${CONFIG_DIR}/logs`;
+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;
+7
src/mod.ts
··· 1 + export * from "./constants.ts"; 2 + export * from "./context.ts"; 3 + export * from "./db.ts"; 4 + export * from "./network.ts"; 5 + export * from "./state.ts"; 6 + export * from "./types.ts"; 7 + export * from "./utils.ts";
+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 + }
+29 -3
src/subcommands/ps.ts
··· 3 3 import relativeTime from "dayjs/plugin/relativeTime.js"; 4 4 import utc from "dayjs/plugin/utc.js"; 5 5 import { ctx } from "../context.ts"; 6 + import type { VirtualMachine } from "../db.ts"; 6 7 7 8 dayjs.extend(relativeTime); 8 9 dayjs.extend(utc); ··· 19 20 .execute(); 20 21 21 22 const table: Table = new Table( 22 - ["NAME", "VCPU", "MEMORY", "STATUS", "PID", "BRIDGE", "MAC", "CREATED"], 23 + ["NAME", "VCPU", "MEMORY", "STATUS", "PID", "BRIDGE", "PORTS", "CREATED"], 23 24 ); 24 25 25 26 for (const vm of results) { ··· 27 28 vm.name, 28 29 vm.cpus.toString(), 29 30 vm.memory, 30 - vm.status, 31 + formatStatus(vm), 31 32 vm.pid?.toString() ?? "-", 32 33 vm.bridge ?? "-", 33 - vm.macAddress, 34 + formatPorts(vm.portForward), 34 35 dayjs.utc(vm.createdAt).local().fromNow(), 35 36 ]); 36 37 } 37 38 38 39 console.log(table.padding(2).toString()); 39 40 } 41 + 42 + function formatStatus(vm: VirtualMachine) { 43 + switch (vm.status) { 44 + case "RUNNING": 45 + return `Up ${ 46 + dayjs.utc(vm.updatedAt).local().fromNow().replace("ago", "") 47 + }`; 48 + case "STOPPED": 49 + return `Exited ${dayjs.utc(vm.updatedAt).local().fromNow()}`; 50 + default: 51 + return vm.status; 52 + } 53 + } 54 + 55 + function formatPorts(portForward?: string) { 56 + if (!portForward) { 57 + return "-"; 58 + } 59 + 60 + const mappings = portForward.split(","); 61 + return mappings.map((mapping) => { 62 + const [hostPort, guestPort] = mapping.split(":"); 63 + return `${hostPort}->${guestPort}`; 64 + }).join(", "); 65 + }
+96
src/subcommands/restart.ts
··· 1 + import chalk from "chalk"; 2 + import _ from "lodash"; 3 + import { LOGS_DIR } from "../constants.ts"; 4 + import { getInstanceState, updateInstanceState } from "../state.ts"; 5 + import { safeKillQemu, setupNATNetworkArgs } 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 + "-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 + : setupNATNetworkArgs(vm.portForward), 45 + "-device", 46 + `e1000,netdev=net0,mac=${vm.macAddress}`, 47 + "-display", 48 + "none", 49 + "-vga", 50 + "none", 51 + "-monitor", 52 + "none", 53 + "-chardev", 54 + "stdio,id=con0,signal=off", 55 + "-serial", 56 + "chardev:con0", 57 + ..._.compact( 58 + vm.drivePath && [ 59 + "-drive", 60 + `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 61 + ], 62 + ), 63 + ]; 64 + 65 + const fullCommand = vm.bridge 66 + ? `sudo qemu-system-x86_64 ${ 67 + qemuArgs.slice(1).join(" ") 68 + } >> "${logPath}" 2>&1 & echo $!` 69 + : `qemu-system-x86_64 ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 70 + 71 + const cmd = new Deno.Command("sh", { 72 + args: ["-c", fullCommand], 73 + stdin: "null", 74 + stdout: "piped", 75 + }); 76 + 77 + const { stdout } = await cmd.spawn().output(); 78 + const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 79 + 80 + await new Promise((resolve) => setTimeout(resolve, 2000)); 81 + 82 + await updateInstanceState(vm.id, "RUNNING", qemuPid); 83 + 84 + console.log( 85 + `${chalk.greenBright(vm.name)} restarted with PID ${ 86 + chalk.greenBright(qemuPid) 87 + }.`, 88 + ); 89 + console.log( 90 + `Logs are being written to ${chalk.blueBright(logPath)}`, 91 + ); 92 + 93 + await new Promise((resolve) => setTimeout(resolve, 2000)); 94 + 95 + Deno.exit(0); 96 + }
+14
src/subcommands/rm.ts
··· 1 + import { getInstanceState, removeInstanceState } from "../state.ts"; 2 + 3 + export default async function (name: string) { 4 + const vm = await getInstanceState(name); 5 + if (!vm) { 6 + console.error( 7 + `Virtual machine with name or ID ${name} not found.`, 8 + ); 9 + Deno.exit(1); 10 + } 11 + 12 + console.log(`Removing virtual machine ${vm.name} (ID: ${vm.id})...`); 13 + await removeInstanceState(name); 14 + }
+81 -44
src/subcommands/start.ts
··· 1 1 import _ from "lodash"; 2 + import { LOGS_DIR } from "../constants.ts"; 2 3 import { getInstanceState, updateInstanceState } from "../state.ts"; 4 + import { setupNATNetworkArgs } from "../utils.ts"; 3 5 4 - export default async function (name: string) { 6 + export default async function (name: string, detach: boolean = false) { 5 7 const vm = await getInstanceState(name); 6 8 if (!vm) { 7 9 console.error( ··· 12 14 13 15 console.log(`Starting virtual machine ${vm.name} (ID: ${vm.id})...`); 14 16 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 - "-display", 33 - "none", 34 - "-vga", 35 - "none", 36 - "-monitor", 37 - "none", 38 - "-chardev", 39 - "stdio,id=con0,signal=off", 40 - "-serial", 41 - "chardev:con0", 42 - ..._.compact( 43 - vm.drivePath && [ 44 - "-drive", 45 - `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 46 - ], 47 - ), 48 - ], 49 - stdin: "inherit", 50 - stdout: "inherit", 51 - stderr: "inherit", 52 - }).spawn(); 17 + const qemuArgs = [ 18 + ..._.compact([vm.bridge && "qemu-system-x86_64"]), 19 + "-enable-kvm", 20 + "-cpu", 21 + vm.cpu, 22 + "-m", 23 + vm.memory, 24 + "-smp", 25 + vm.cpus.toString(), 26 + ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]), 27 + "-netdev", 28 + vm.bridge 29 + ? `bridge,id=net0,br=${vm.bridge}` 30 + : setupNATNetworkArgs(vm.portForward), 31 + "-device", 32 + `e1000,netdev=net0,mac=${vm.macAddress}`, 33 + "-display", 34 + "none", 35 + "-vga", 36 + "none", 37 + "-monitor", 38 + "none", 39 + "-chardev", 40 + "stdio,id=con0,signal=off", 41 + "-serial", 42 + "chardev:con0", 43 + ..._.compact( 44 + vm.drivePath && [ 45 + "-drive", 46 + `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 47 + ], 48 + ), 49 + ]; 50 + 51 + if (detach) { 52 + await Deno.mkdir(LOGS_DIR, { recursive: true }); 53 + const logPath = `${LOGS_DIR}/${vm.name}.log`; 54 + 55 + const fullCommand = vm.bridge 56 + ? `sudo qemu-system-x86_64 ${ 57 + qemuArgs.slice(1).join(" ") 58 + } >> "${logPath}" 2>&1 & echo $!` 59 + : `qemu-system-x86_64 ${ 60 + qemuArgs.join(" ") 61 + } >> "${logPath}" 2>&1 & echo $!`; 62 + 63 + const cmd = new Deno.Command("sh", { 64 + args: ["-c", fullCommand], 65 + stdin: "null", 66 + stdout: "piped", 67 + }); 68 + 69 + const { stdout } = await cmd.spawn().output(); 70 + const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 71 + 72 + await updateInstanceState(name, "RUNNING", qemuPid); 73 + 74 + console.log( 75 + `Virtual machine ${vm.name} started in background (PID: ${qemuPid})`, 76 + ); 77 + console.log(`Logs will be written to: ${logPath}`); 78 + 79 + // Exit successfully while keeping VM running in background 80 + Deno.exit(0); 81 + } else { 82 + const cmd = new Deno.Command(vm.bridge ? "sudo" : "qemu-system-x86_64", { 83 + args: qemuArgs, 84 + stdin: "inherit", 85 + stdout: "inherit", 86 + stderr: "inherit", 87 + }); 53 88 54 - await updateInstanceState(name, "RUNNING", cmd.pid); 89 + const child = cmd.spawn(); 90 + await updateInstanceState(name, "RUNNING", child.pid); 55 91 56 - const status = await cmd.status; 92 + const status = await child.status; 57 93 58 - await updateInstanceState(name, "STOPPED", cmd.pid); 94 + await updateInstanceState(name, "STOPPED", child.pid); 59 95 60 - if (!status.success) { 61 - Deno.exit(status.code); 96 + if (!status.success) { 97 + Deno.exit(status.code); 98 + } 62 99 } 63 100 }
+226 -58
src/utils.ts
··· 2 2 import chalk from "chalk"; 3 3 import _ from "lodash"; 4 4 import Moniker from "moniker"; 5 + import { LOGS_DIR } from "./constants.ts"; 5 6 import { generateRandomMacAddress } from "./network.ts"; 6 - import { saveInstanceState } from "./state.ts"; 7 + import { saveInstanceState, updateInstanceState } from "./state.ts"; 7 8 8 9 const DEFAULT_VERSION = "6.4.2"; 9 10 ··· 16 17 diskFormat: string; 17 18 size: string; 18 19 bridge?: string; 20 + portForward?: string; 21 + detach?: boolean; 19 22 } 20 23 21 24 async function du(path: string): Promise<number> { ··· 89 92 return `https://mirror-master.dragonflybsd.org/iso-images/dfly-x86_64-${version}_REL.iso`; 90 93 } 91 94 95 + export async function setupFirmwareFilesIfNeeded(): Promise<string[]> { 96 + if (Deno.build.arch !== "aarch64") { 97 + return []; 98 + } 99 + 100 + const brewCmd = new Deno.Command("brew", { 101 + args: ["--prefix", "qemu"], 102 + stdout: "piped", 103 + stderr: "inherit", 104 + }); 105 + const { stdout, success } = await brewCmd.spawn().output(); 106 + 107 + if (!success) { 108 + console.error( 109 + chalk.redBright( 110 + "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", 111 + ), 112 + ); 113 + Deno.exit(1); 114 + } 115 + 116 + const brewPrefix = new TextDecoder().decode(stdout).trim(); 117 + const edk2Aarch64 = `${brewPrefix}/share/qemu/edk2-aarch64-code.fd`; 118 + const edk2VarsAarch64 = "./edk2-arm-vars.fd"; 119 + 120 + await Deno.copyFile( 121 + `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 122 + edk2VarsAarch64, 123 + ); 124 + 125 + return [ 126 + "-drive", 127 + `if=pflash,format=raw,file=${edk2Aarch64},readonly=on`, 128 + "-drive", 129 + `if=pflash,format=raw,file=${edk2VarsAarch64}`, 130 + ]; 131 + } 132 + 133 + export function setupPortForwardingArgs(portForward?: string): string { 134 + if (!portForward) { 135 + return ""; 136 + } 137 + 138 + const forwards = portForward.split(",").map((pair) => { 139 + const [hostPort, guestPort] = pair.split(":"); 140 + return `hostfwd=tcp::${hostPort}-:${guestPort}`; 141 + }); 142 + 143 + return forwards.join(","); 144 + } 145 + 146 + export function setupNATNetworkArgs(portForward?: string): string { 147 + if (!portForward) { 148 + return "user,id=net0"; 149 + } 150 + 151 + const portForwarding = setupPortForwardingArgs(portForward); 152 + return `user,id=net0,${portForwarding}`; 153 + } 154 + 92 155 export async function runQemu( 93 156 isoPath: string | null, 94 157 options: Options, 95 158 ): Promise<void> { 96 159 const macAddress = generateRandomMacAddress(); 97 - const cmd = new Deno.Command(options.bridge ? "sudo" : "qemu-system-x86_64", { 98 - args: [ 99 - ..._.compact([options.bridge && "qemu-system-x86_64"]), 100 - "-enable-kvm", 101 - "-cpu", 102 - options.cpu, 103 - "-m", 104 - options.memory, 105 - "-smp", 106 - options.cpus.toString(), 107 - ..._.compact([isoPath && "-cdrom", isoPath]), 108 - "-netdev", 109 - options.bridge 110 - ? `bridge,id=net0,br=${options.bridge}` 111 - : "user,id=net0,hostfwd=tcp::2222-:22", 112 - "-device", 113 - `e1000,netdev=net0,mac=${macAddress}`, 114 - "-display", 115 - "none", 116 - "-vga", 117 - "none", 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=virtio`, 128 - ], 129 - ), 130 - ], 131 - stdin: "inherit", 132 - stdout: "inherit", 133 - stderr: "inherit", 134 - }).spawn(); 135 160 136 - await saveInstanceState({ 137 - id: createId(), 138 - name: Moniker.choose(), 139 - bridge: options.bridge, 140 - macAddress, 141 - memory: options.memory, 142 - cpus: options.cpus, 143 - cpu: options.cpu, 144 - diskSize: options.size, 145 - diskFormat: options.diskFormat, 146 - isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 147 - drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 148 - version: DEFAULT_VERSION, 149 - status: "RUNNING", 150 - pid: cmd.pid, 151 - }); 161 + const qemuArgs = [ 162 + ..._.compact([options.bridge && "qemu-system-x86_64"]), 163 + "-enable-kvm", 164 + "-cpu", 165 + options.cpu, 166 + "-m", 167 + options.memory, 168 + "-smp", 169 + options.cpus.toString(), 170 + ..._.compact([isoPath && "-cdrom", isoPath]), 171 + "-netdev", 172 + options.bridge 173 + ? `bridge,id=net0,br=${options.bridge}` 174 + : setupNATNetworkArgs(options.portForward), 175 + "-device", 176 + `e1000,netdev=net0,mac=${macAddress}`, 177 + "-display", 178 + "none", 179 + "-vga", 180 + "none", 181 + "-monitor", 182 + "none", 183 + "-chardev", 184 + "stdio,id=con0,signal=off", 185 + "-serial", 186 + "chardev:con0", 187 + ..._.compact( 188 + options.image && [ 189 + "-drive", 190 + `file=${options.image},format=${options.diskFormat},if=virtio`, 191 + ], 192 + ), 193 + ]; 152 194 153 - const status = await cmd.status; 195 + const name = Moniker.choose(); 196 + 197 + if (options.detach) { 198 + await Deno.mkdir(LOGS_DIR, { recursive: true }); 199 + const logPath = `${LOGS_DIR}/${name}.log`; 200 + 201 + const fullCommand = options.bridge 202 + ? `sudo qemu-system-x86_64 ${ 203 + qemuArgs.slice(1).join(" ") 204 + } >> "${logPath}" 2>&1 & echo $!` 205 + : `qemu-system-x86_64 ${ 206 + qemuArgs.join(" ") 207 + } >> "${logPath}" 2>&1 & echo $!`; 208 + 209 + const cmd = new Deno.Command("sh", { 210 + args: ["-c", fullCommand], 211 + stdin: "null", 212 + stdout: "piped", 213 + }); 214 + 215 + const { stdout } = await cmd.spawn().output(); 216 + const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 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: qemuPid, 234 + }); 235 + 236 + console.log( 237 + `Virtual machine ${name} started in background (PID: ${qemuPid})`, 238 + ); 239 + console.log(`Logs will be written to: ${logPath}`); 154 240 155 - if (!status.success) { 156 - Deno.exit(status.code); 241 + // Exit successfully while keeping VM running in background 242 + Deno.exit(0); 243 + } else { 244 + const cmd = new Deno.Command( 245 + options.bridge ? "sudo" : "qemu-system-x86_64", 246 + { 247 + args: qemuArgs, 248 + stdin: "inherit", 249 + stdout: "inherit", 250 + stderr: "inherit", 251 + }, 252 + ) 253 + .spawn(); 254 + 255 + await saveInstanceState({ 256 + id: createId(), 257 + name, 258 + bridge: options.bridge, 259 + macAddress, 260 + memory: options.memory, 261 + cpus: options.cpus, 262 + cpu: options.cpu, 263 + diskSize: options.size, 264 + diskFormat: options.diskFormat, 265 + portForward: options.portForward, 266 + isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 267 + drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 268 + version: DEFAULT_VERSION, 269 + status: "RUNNING", 270 + pid: cmd.pid, 271 + }); 272 + 273 + const status = await cmd.status; 274 + 275 + await updateInstanceState(name, "STOPPED"); 276 + 277 + if (!status.success) { 278 + Deno.exit(status.code); 279 + } 157 280 } 158 281 } 159 282 ··· 179 302 } 180 303 181 304 return input; 305 + } 306 + 307 + export async function safeKillQemu( 308 + pid: number, 309 + useSudo: boolean = false, 310 + ): Promise<boolean> { 311 + const killArgs = useSudo 312 + ? ["sudo", "kill", "-TERM", pid.toString()] 313 + : ["kill", "-TERM", pid.toString()]; 314 + 315 + const termCmd = new Deno.Command(killArgs[0], { 316 + args: killArgs.slice(1), 317 + stdout: "null", 318 + stderr: "null", 319 + }); 320 + 321 + const termStatus = await termCmd.spawn().status; 322 + 323 + if (termStatus.success) { 324 + await new Promise((resolve) => setTimeout(resolve, 3000)); 325 + 326 + const checkCmd = new Deno.Command("kill", { 327 + args: ["-0", pid.toString()], 328 + stdout: "null", 329 + stderr: "null", 330 + }); 331 + 332 + const checkStatus = await checkCmd.spawn().status; 333 + if (!checkStatus.success) { 334 + return true; 335 + } 336 + } 337 + 338 + const killKillArgs = useSudo 339 + ? ["sudo", "kill", "-KILL", pid.toString()] 340 + : ["kill", "-KILL", pid.toString()]; 341 + 342 + const killCmd = new Deno.Command(killKillArgs[0], { 343 + args: killKillArgs.slice(1), 344 + stdout: "null", 345 + stderr: "null", 346 + }); 347 + 348 + const killStatus = await killCmd.spawn().status; 349 + return killStatus.success; 182 350 } 183 351 184 352 export async function createDriveImageIfNeeded(