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

Compare changes

Choose any two refs to compare.

+7 -1
deno.json
··· 1 1 { 2 + "name": "@tsiry/openindiana-up", 3 + "version": "0.1.0", 4 + "exports": "./main.ts", 5 + "license": "MPL-2.0", 2 6 "tasks": { 3 7 "dev": "deno run --watch main.ts" 4 8 }, 5 9 "imports": { 6 10 "@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.8", 11 + "@cliffy/flags": "jsr:@cliffy/flags@^1.0.0-rc.8", 7 12 "@cliffy/table": "jsr:@cliffy/table@^1.0.0-rc.8", 8 13 "@db/sqlite": "jsr:@db/sqlite@^0.12.0", 14 + "@es-toolkit/es-toolkit": "jsr:@es-toolkit/es-toolkit@^1.41.0", 9 15 "@paralleldrive/cuid2": "npm:@paralleldrive/cuid2@^3.0.4", 10 16 "@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", 11 17 "@std/assert": "jsr:@std/assert@1", 12 18 "chalk": "npm:chalk@^5.6.2", 13 19 "dayjs": "npm:dayjs@^1.11.19", 20 + "effect": "npm:effect@^3.19.2", 14 21 "kysely": "npm:kysely@0.27.6", 15 - "lodash": "npm:lodash@^4.17.21", 16 22 "moniker": "npm:moniker@^0.1.2" 17 23 } 18 24 }
+29 -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", 9 10 "jsr:@db/sqlite@0.12": "0.12.0", 10 11 "jsr:@denosaurs/plug@1": "1.1.0", 12 + "jsr:@es-toolkit/es-toolkit@^1.41.0": "1.41.0", 11 13 "jsr:@soapbox/kysely-deno-sqlite@^2.2.0": "2.2.0", 12 14 "jsr:@std/assert@0.217": "0.217.0", 13 15 "jsr:@std/assert@1": "1.0.15", ··· 25 27 "npm:@paralleldrive/cuid2@^3.0.4": "3.0.4", 26 28 "npm:chalk@^5.6.2": "5.6.2", 27 29 "npm:dayjs@^1.11.19": "1.11.19", 30 + "npm:effect@^3.19.2": "3.19.2", 28 31 "npm:kysely@0.27.6": "0.27.6", 29 32 "npm:kysely@~0.27.2": "0.27.6", 30 - "npm:lodash@^4.17.21": "4.17.21", 31 33 "npm:moniker@~0.1.2": "0.1.2" 32 34 }, 33 35 "jsr": { 34 36 "@cliffy/command@1.0.0-rc.8": { 35 37 "integrity": "758147790797c74a707e5294cc7285df665422a13d2a483437092ffce40b5557", 36 38 "dependencies": [ 37 - "jsr:@cliffy/flags", 39 + "jsr:@cliffy/flags@1.0.0-rc.8", 38 40 "jsr:@cliffy/internal", 39 41 "jsr:@cliffy/table@1.0.0-rc.8", 40 42 "jsr:@std/fmt@~1.0.2", ··· 71 73 "jsr:@std/fs", 72 74 "jsr:@std/path@1" 73 75 ] 76 + }, 77 + "@es-toolkit/es-toolkit@1.41.0": { 78 + "integrity": "4df54a18e80b869880cee8a8a9ff7a5e1c424a9fd0916dccd38d34686f110071" 74 79 }, 75 80 "@soapbox/kysely-deno-sqlite@2.2.0": { 76 81 "integrity": "668ec94600bc4b4d7bd618dd7ca65d4ef30ee61c46ffcb379b6f45203c08517a", ··· 132 137 ], 133 138 "bin": true 134 139 }, 140 + "@standard-schema/spec@1.0.0": { 141 + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" 142 + }, 135 143 "bignumber.js@9.3.1": { 136 144 "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==" 137 145 }, ··· 141 149 "dayjs@1.11.19": { 142 150 "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==" 143 151 }, 152 + "effect@3.19.2": { 153 + "integrity": "sha512-AHkxfzl5RbWfHO9HOdLE4oZ0c3nxqkXKHc69t83GWYoAquZmSeoCjmLP5rPgbHvwv4DcfLr8WW8PWbtNIQI+vw==", 154 + "dependencies": [ 155 + "@standard-schema/spec", 156 + "fast-check" 157 + ] 158 + }, 144 159 "error-causes@3.0.2": { 145 160 "integrity": "sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw==" 146 161 }, 162 + "fast-check@3.23.2": { 163 + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", 164 + "dependencies": [ 165 + "pure-rand" 166 + ] 167 + }, 147 168 "kysely@0.27.6": { 148 169 "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==" 149 170 }, 150 - "lodash@4.17.21": { 151 - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" 152 - }, 153 171 "moniker@0.1.2": { 154 172 "integrity": "sha512-Uj9iV0QYr6281G+o0TvqhKwHHWB2Q/qUTT4LPQ3qDGc0r8cbMuqQjRXPZuVZ+gcL7APx+iQgE8lcfWPrj1LsLA==" 173 + }, 174 + "pure-rand@6.1.0": { 175 + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==" 155 176 } 156 177 }, 157 178 "workspace": { 158 179 "dependencies": [ 159 180 "jsr:@cliffy/command@^1.0.0-rc.8", 181 + "jsr:@cliffy/flags@^1.0.0-rc.8", 160 182 "jsr:@cliffy/table@^1.0.0-rc.8", 161 183 "jsr:@db/sqlite@0.12", 184 + "jsr:@es-toolkit/es-toolkit@^1.41.0", 162 185 "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", 163 186 "jsr:@std/assert@1", 164 187 "npm:@paralleldrive/cuid2@^3.0.4", 165 188 "npm:chalk@^5.6.2", 166 189 "npm:dayjs@^1.11.19", 190 + "npm:effect@^3.19.2", 167 191 "npm:kysely@0.27.6", 168 - "npm:lodash@^4.17.21", 169 192 "npm:moniker@~0.1.2" 170 193 ] 171 194 }
+61 -3
main.ts
··· 1 1 #!/usr/bin/env -S deno run --allow-run --allow-read --allow-env 2 2 3 3 import { Command } from "@cliffy/command"; 4 + import pkg from "./deno.json" with { type: "json" }; 4 5 import { createBridgeNetworkIfNeeded } from "./src/network.ts"; 5 6 import inspect from "./src/subcommands/inspect.ts"; 7 + import logs from "./src/subcommands/logs.ts"; 6 8 import ps from "./src/subcommands/ps.ts"; 9 + import restart from "./src/subcommands/restart.ts"; 7 10 import rm from "./src/subcommands/rm.ts"; 8 11 import start from "./src/subcommands/start.ts"; 9 12 import stop from "./src/subcommands/stop.ts"; ··· 19 22 if (import.meta.main) { 20 23 await new Command() 21 24 .name("openindiana-up") 22 - .version("0.1.0") 25 + .version(pkg.version) 23 26 .description("Start a OpenIndiana virtual machine using QEMU") 24 27 .arguments( 25 28 "[path-or-url-to-iso-or-version:string]", ··· 52 55 .option( 53 56 "-b, --bridge <name:string>", 54 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)", 55 66 ) 56 67 .example( 57 68 "Default usage", ··· 125 136 }) 126 137 .command("start", "Start a virtual machine") 127 138 .arguments("<vm-name:string>") 128 - .action(async (_options: unknown, vmName: string) => { 129 - 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)); 130 177 }) 131 178 .command("stop", "Stop a virtual machine") 132 179 .arguments("<vm-name:string>") ··· 142 189 .arguments("<vm-name:string>") 143 190 .action(async (_options: unknown, vmName: string) => { 144 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); 145 203 }) 146 204 .parse(Deno.args); 147 205 }
+2
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`; 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 + }
+102 -44
src/subcommands/start.ts
··· 1 - import _ from "lodash"; 1 + import { parseFlags } from "@cliffy/flags"; 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 - "-nographic", 33 - "-monitor", 34 - "none", 35 - "-chardev", 36 - "stdio,id=con0,signal=off", 37 - "-serial", 38 - "chardev:con0", 39 - ..._.compact( 40 - vm.drivePath && [ 41 - "-drive", 42 - `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 43 - ], 44 - ), 45 - ], 46 - stdin: "inherit", 47 - stdout: "inherit", 48 - stderr: "inherit", 49 - }) 50 - .spawn(); 18 + vm = mergeFlags(vm); 51 19 52 - 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 + ]; 53 54 54 - 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 $!`; 66 + 67 + const cmd = new Deno.Command("sh", { 68 + args: ["-c", fullCommand], 69 + stdin: "null", 70 + stdout: "piped", 71 + }); 72 + 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 + }); 55 92 56 - await updateInstanceState(name, "STOPPED", cmd.pid); 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); 57 99 58 - if (!status.success) { 59 - Deno.exit(status.code); 100 + if (!status.success) { 101 + Deno.exit(status.code); 102 + } 60 103 } 61 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 + }
+1 -1
src/subcommands/stop.ts
··· 1 + import _ from "@es-toolkit/es-toolkit/compat"; 1 2 import chalk from "chalk"; 2 - import _ from "lodash"; 3 3 import { getInstanceState, updateInstanceState } from "../state.ts"; 4 4 5 5 export default async function (name: string) {
+192 -58
src/utils.ts
··· 1 + import _ from "@es-toolkit/es-toolkit/compat"; 1 2 import { createId } from "@paralleldrive/cuid2"; 2 3 import chalk from "chalk"; 3 - import _ from "lodash"; 4 4 import Moniker from "moniker"; 5 + import { EMPTY_DISK_THRESHOLD_KB, 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 = "20251026"; 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> { ··· 37 40 } 38 41 39 42 const size = await du(path); 40 - return size < 10; 43 + return size < EMPTY_DISK_THRESHOLD_KB; 41 44 } 42 45 43 46 export async function downloadIso( ··· 49 52 50 53 if (options.image && await Deno.stat(options.image).catch(() => false)) { 51 54 const driveSize = await du(options.image); 52 - if (driveSize > 10) { 55 + if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 53 56 console.log( 54 57 chalk.yellowBright( 55 58 `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, ··· 89 92 return `https://dlc.openindiana.org/isos/hipster/${version}/OI-hipster-text-${version}.iso`; 90 93 } 91 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 + 92 117 export async function runQemu( 93 118 isoPath: string | null, 94 119 options: Options, 95 120 ): Promise<void> { 96 121 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 - "-nographic", 115 - "-monitor", 116 - "none", 117 - "-chardev", 118 - "stdio,id=con0,signal=off", 119 - "-serial", 120 - "chardev:con0", 121 - ..._.compact( 122 - options.image && [ 123 - "-drive", 124 - `file=${options.image},format=${options.diskFormat},if=virtio`, 125 - ], 126 - ), 127 - ], 128 - stdin: "inherit", 129 - stdout: "inherit", 130 - stderr: "inherit", 131 - }).spawn(); 132 122 133 - await saveInstanceState({ 134 - id: createId(), 135 - name: Moniker.choose(), 136 - bridge: options.bridge, 137 - macAddress, 138 - memory: options.memory, 139 - cpus: options.cpus, 140 - cpu: options.cpu, 141 - diskSize: options.size, 142 - diskFormat: options.diskFormat, 143 - isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 144 - drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 145 - version: DEFAULT_VERSION, 146 - status: "RUNNING", 147 - pid: cmd.pid, 148 - }); 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 + ]; 149 157 150 - const status = await cmd.status; 158 + const name = Moniker.choose(); 151 159 152 - if (!status.success) { 153 - Deno.exit(status.code); 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 + }); 235 + 236 + const status = await cmd.status; 237 + 238 + await updateInstanceState(name, "STOPPED"); 239 + 240 + if (!status.success) { 241 + Deno.exit(status.code); 242 + } 154 243 } 155 244 } 156 245 ··· 174 263 } 175 264 176 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; 177 311 } 178 312 179 313 export async function createDriveImageIfNeeded(