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

Refactor drive image creation and ISO download logic; add utility functions for better modularity

Changed files
+207 -156
+2 -1
deno.json
··· 5 5 "imports": { 6 6 "@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.8", 7 7 "@std/assert": "jsr:@std/assert@1", 8 - "chalk": "npm:chalk@^5.6.2" 8 + "chalk": "npm:chalk@^5.6.2", 9 + "lodash": "npm:lodash@^4.17.21" 9 10 } 10 11 }
+7 -2
deno.lock
··· 9 9 "jsr:@std/fmt@~1.0.2": "1.0.8", 10 10 "jsr:@std/internal@^1.0.12": "1.0.12", 11 11 "jsr:@std/text@~1.0.7": "1.0.16", 12 - "npm:chalk@^5.6.2": "5.6.2" 12 + "npm:chalk@^5.6.2": "5.6.2", 13 + "npm:lodash@^4.17.21": "4.17.21" 13 14 }, 14 15 "jsr": { 15 16 "@cliffy/command@1.0.0-rc.8": { ··· 56 57 "npm": { 57 58 "chalk@5.6.2": { 58 59 "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==" 60 + }, 61 + "lodash@4.17.21": { 62 + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" 59 63 } 60 64 }, 61 65 "workspace": { 62 66 "dependencies": [ 63 67 "jsr:@cliffy/command@^1.0.0-rc.8", 64 68 "jsr:@std/assert@1", 65 - "npm:chalk@^5.6.2" 69 + "npm:chalk@^5.6.2", 70 + "npm:lodash@^4.17.21" 66 71 ] 67 72 } 68 73 }
+15 -153
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 chalk from "chalk"; 5 - 6 - const DEFAULT_VERSION = "20251026"; 7 - 8 - interface Options { 9 - output?: string; 10 - cpu: string; 11 - cpus: number; 12 - memory: string; 13 - drive?: string; 14 - diskFormat: string; 15 - size: string; 16 - } 17 - 18 - async function downloadIso(url: string, outputPath?: string): Promise<string> { 19 - const filename = url.split("/").pop()!; 20 - outputPath = outputPath ?? filename; 21 - 22 - if (await Deno.stat(outputPath).catch(() => false)) { 23 - console.log( 24 - chalk.yellowBright( 25 - `File ${outputPath} already exists, skipping download.`, 26 - ), 27 - ); 28 - return outputPath; 29 - } 30 - 31 - const cmd = new Deno.Command("curl", { 32 - args: ["-L", "-o", outputPath, url], 33 - stdin: "inherit", 34 - stdout: "inherit", 35 - stderr: "inherit", 36 - }); 37 - 38 - const status = await cmd.spawn().status; 39 - if (!status.success) { 40 - console.error(chalk.redBright("Failed to download ISO image.")); 41 - Deno.exit(status.code); 42 - } 43 - 44 - console.log(chalk.greenBright(`Downloaded ISO to ${outputPath}`)); 45 - return outputPath; 46 - } 47 - 48 - function constructDownloadUrl(version: string): string { 49 - return `https://dlc.openindiana.org/isos/hipster/${version}/OI-hipster-text-${version}.iso`; 50 - } 51 - 52 - async function runQemu(isoPath: string, options: Options): Promise<void> { 53 - const cmd = new Deno.Command("qemu-system-x86_64", { 54 - args: [ 55 - "-enable-kvm", 56 - "-cpu", 57 - options.cpu, 58 - "-m", 59 - options.memory, 60 - "-smp", 61 - options.cpus.toString(), 62 - "-cdrom", 63 - isoPath, 64 - "-netdev", 65 - "user,id=net0,hostfwd=tcp::2222-:22", 66 - "-device", 67 - "e1000,netdev=net0", 68 - "-nographic", 69 - "-monitor", 70 - "none", 71 - "-chardev", 72 - "stdio,id=con0,signal=off", 73 - "-serial", 74 - "chardev:con0", 75 - ...(options.drive 76 - ? [ 77 - "-drive", 78 - `file=${options.drive},format=${options.diskFormat},if=virtio`, 79 - ] 80 - : []), 81 - ], 82 - stdin: "inherit", 83 - stdout: "inherit", 84 - stderr: "inherit", 85 - }); 86 - 87 - const status = await cmd.spawn().status; 88 - 89 - if (!status.success) { 90 - Deno.exit(status.code); 91 - } 92 - } 93 - 94 - function handleInput(input?: string): string { 95 - if (!input) { 96 - console.log( 97 - `No ISO path provided, defaulting to ${chalk.cyan("OpenIndiana")} ${ 98 - chalk.cyan(DEFAULT_VERSION) 99 - }...`, 100 - ); 101 - return constructDownloadUrl(DEFAULT_VERSION); 102 - } 103 - 104 - const versionRegex = /^\d{8}$/; 105 - 106 - if (versionRegex.test(input)) { 107 - console.log( 108 - `Detected version ${chalk.cyan(input)}, constructing download URL...`, 109 - ); 110 - return constructDownloadUrl(input); 111 - } 112 - 113 - return input; 114 - } 115 - 116 - async function createDriveImageIfNeeded( 117 - { 118 - drive: path, 119 - diskFormat: format, 120 - size, 121 - }: Options, 122 - ): Promise<void> { 123 - if (await Deno.stat(path!).catch(() => false)) { 124 - console.log( 125 - chalk.yellowBright( 126 - `Drive image ${path} already exists, skipping creation.`, 127 - ), 128 - ); 129 - return; 130 - } 131 - 132 - const cmd = new Deno.Command("qemu-img", { 133 - args: ["create", "-f", format, path!, size], 134 - stdin: "inherit", 135 - stdout: "inherit", 136 - stderr: "inherit", 137 - }); 138 - 139 - const status = await cmd.spawn().status; 140 - if (!status.success) { 141 - console.error(chalk.redBright("Failed to create drive image.")); 142 - Deno.exit(status.code); 143 - } 144 - 145 - console.log(chalk.greenBright(`Created drive image at ${path}`)); 146 - } 4 + import { 5 + createDriveImageIfNeeded, 6 + downloadIso, 7 + emptyDiskImage, 8 + handleInput, 9 + Options, 10 + runQemu, 11 + } from "./utils.ts"; 147 12 148 13 if (import.meta.main) { 149 14 await new Command() ··· 196 61 ) 197 62 .action(async (options: Options, input?: string) => { 198 63 const resolvedInput = handleInput(input); 199 - let isoPath = resolvedInput; 64 + let isoPath: string | null = resolvedInput; 200 65 201 66 if ( 202 67 resolvedInput.startsWith("https://") || 203 68 resolvedInput.startsWith("http://") 204 69 ) { 205 - isoPath = await downloadIso(resolvedInput, options.output); 70 + isoPath = await downloadIso(resolvedInput, options); 206 71 } 207 72 208 73 if (options.drive) { 209 74 await createDriveImageIfNeeded(options); 210 75 } 211 76 212 - await runQemu(isoPath, { 213 - cpu: options.cpu, 214 - memory: options.memory, 215 - cpus: options.cpus, 216 - drive: options.drive, 217 - diskFormat: options.diskFormat, 218 - size: options.size, 219 - }); 77 + if (!input && options.drive && !await emptyDiskImage(options.drive)) { 78 + isoPath = null; 79 + } 80 + 81 + await runQemu(isoPath, options); 220 82 }) 221 83 .parse(Deno.args); 222 84 }
+183
utils.ts
··· 1 + import chalk from "chalk"; 2 + import _ from "lodash"; 3 + 4 + const DEFAULT_VERSION = "20251026"; 5 + 6 + export interface Options { 7 + output?: string; 8 + cpu: string; 9 + cpus: number; 10 + memory: string; 11 + drive?: string; 12 + diskFormat: string; 13 + size: string; 14 + } 15 + 16 + async function du(path: string): Promise<number> { 17 + const cmd = new Deno.Command("du", { 18 + args: [path], 19 + stdout: "piped", 20 + stderr: "inherit", 21 + }); 22 + 23 + const { stdout } = await cmd.spawn().output(); 24 + const output = new TextDecoder().decode(stdout).trim(); 25 + const size = parseInt(output.split("\t")[0], 10); 26 + return size; 27 + } 28 + 29 + export async function emptyDiskImage(path: string): Promise<boolean> { 30 + if (!await Deno.stat(path).catch(() => false)) { 31 + return true; 32 + } 33 + 34 + const size = await du(path); 35 + return size < 10; 36 + } 37 + 38 + export async function downloadIso( 39 + url: string, 40 + options: Options, 41 + ): Promise<string | null> { 42 + const filename = url.split("/").pop()!; 43 + const outputPath = options.output ?? filename; 44 + 45 + if (options.drive && await Deno.stat(options.drive).catch(() => false)) { 46 + const driveSize = await du(options.drive); 47 + if (driveSize > 10) { 48 + console.log( 49 + chalk.yellowBright( 50 + `Drive image ${options.drive} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, 51 + ), 52 + ); 53 + return null; 54 + } 55 + } 56 + 57 + if (await Deno.stat(outputPath).catch(() => false)) { 58 + console.log( 59 + chalk.yellowBright( 60 + `File ${outputPath} already exists, skipping download.`, 61 + ), 62 + ); 63 + return outputPath; 64 + } 65 + 66 + const cmd = new Deno.Command("curl", { 67 + args: ["-L", "-o", outputPath, url], 68 + stdin: "inherit", 69 + stdout: "inherit", 70 + stderr: "inherit", 71 + }); 72 + 73 + const status = await cmd.spawn().status; 74 + if (!status.success) { 75 + console.error(chalk.redBright("Failed to download ISO image.")); 76 + Deno.exit(status.code); 77 + } 78 + 79 + console.log(chalk.greenBright(`Downloaded ISO to ${outputPath}`)); 80 + return outputPath; 81 + } 82 + 83 + export function constructDownloadUrl(version: string): string { 84 + return `https://dlc.openindiana.org/isos/hipster/${version}/OI-hipster-text-${version}.iso`; 85 + } 86 + 87 + export async function runQemu( 88 + isoPath: string | null, 89 + options: Options, 90 + ): Promise<void> { 91 + const cmd = new Deno.Command("qemu-system-x86_64", { 92 + args: [ 93 + "-enable-kvm", 94 + "-cpu", 95 + options.cpu, 96 + "-m", 97 + options.memory, 98 + "-smp", 99 + options.cpus.toString(), 100 + ..._.compact([isoPath && "-cdrom", isoPath]), 101 + "-netdev", 102 + "user,id=net0,hostfwd=tcp::2222-:22", 103 + "-device", 104 + "e1000,netdev=net0", 105 + "-nographic", 106 + "-monitor", 107 + "none", 108 + "-chardev", 109 + "stdio,id=con0,signal=off", 110 + "-serial", 111 + "chardev:con0", 112 + ..._.compact( 113 + options.drive && [ 114 + "-drive", 115 + `file=${options.drive},format=${options.diskFormat},if=virtio`, 116 + ], 117 + ), 118 + ], 119 + stdin: "inherit", 120 + stdout: "inherit", 121 + stderr: "inherit", 122 + }); 123 + 124 + const status = await cmd.spawn().status; 125 + 126 + if (!status.success) { 127 + Deno.exit(status.code); 128 + } 129 + } 130 + 131 + export function handleInput(input?: string): string { 132 + if (!input) { 133 + console.log( 134 + `No ISO path provided, defaulting to ${chalk.cyan("OpenIndiana")} ${ 135 + chalk.cyan(DEFAULT_VERSION) 136 + }...`, 137 + ); 138 + return constructDownloadUrl(DEFAULT_VERSION); 139 + } 140 + 141 + const versionRegex = /^\d{8}$/; 142 + 143 + if (versionRegex.test(input)) { 144 + console.log( 145 + `Detected version ${chalk.cyan(input)}, constructing download URL...`, 146 + ); 147 + return constructDownloadUrl(input); 148 + } 149 + 150 + return input; 151 + } 152 + 153 + export async function createDriveImageIfNeeded( 154 + { 155 + drive: path, 156 + diskFormat: format, 157 + size, 158 + }: Options, 159 + ): Promise<void> { 160 + if (await Deno.stat(path!).catch(() => false)) { 161 + console.log( 162 + chalk.yellowBright( 163 + `Drive image ${path} already exists, skipping creation.`, 164 + ), 165 + ); 166 + return; 167 + } 168 + 169 + const cmd = new Deno.Command("qemu-img", { 170 + args: ["create", "-f", format, path!, size], 171 + stdin: "inherit", 172 + stdout: "inherit", 173 + stderr: "inherit", 174 + }); 175 + 176 + const status = await cmd.spawn().status; 177 + if (!status.success) { 178 + console.error(chalk.redBright("Failed to create drive image.")); 179 + Deno.exit(status.code); 180 + } 181 + 182 + console.log(chalk.greenBright(`Created drive image at ${path}`)); 183 + }