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

Compare changes

Choose any two refs to compare.

+68 -9
README.md
··· 16 16 - ๐ŸŒ **SSH Ready** - Pre-configured port forwarding (host:2222 โ†’ guest:22) 17 17 - ๐ŸŽ›๏ธ **Customizable** - Configure CPU, memory, cores, and disk options 18 18 - ๐Ÿ“ฅ **Smart Caching** - Skips re-downloading existing ISO files 19 + - ๐ŸŽฎ **VM Management** - Start, stop, list, and inspect virtual machines 20 + - ๐Ÿ“Š **State Persistence** - SQLite database tracks VM state and configuration 21 + - ๐Ÿท๏ธ **Auto-naming** - Generates unique names for VMs automatically 22 + - ๐ŸŒ‰ **Bridge Networking** - Support for bridge networking with custom network 23 + interfaces 24 + - ๐Ÿ†” **MAC Address Management** - Automatic MAC address generation for network 25 + devices 19 26 20 27 ## ๐Ÿ“‹ Requirements 21 28 ··· 55 62 56 63 ### Options 57 64 58 - | Option | Description | Default | 59 - | ------------------------ | ------------------------------ | ------------ | 60 - | `-o, --output <path>` | Output path for downloaded ISO | ISO filename | 61 - | `-c, --cpu <type>` | CPU type to emulate | `host` | 62 - | `-C, --cpus <number>` | Number of CPU cores | `2` | 63 - | `-m, --memory <size>` | RAM allocation | `2G` | 64 - | `-d, --drive <path>` | Path to virtual disk image | None | 65 - | `--disk-format <format>` | Disk format (qcow2, raw, etc.) | `raw` | 65 + | Option | Description | Default | 66 + | ------------------------ | ------------------------------------------------------------ | ------------ | 67 + | `-o, --output <path>` | Output path for downloaded ISO | ISO filename | 68 + | `-c, --cpu <type>` | CPU type to emulate | `host` | 69 + | `-C, --cpus <number>` | Number of CPU cores | `2` | 70 + | `-m, --memory <size>` | RAM allocation | `2G` | 71 + | `-i, --image <path>` | Path to virtual disk image | None | 72 + | `--disk-format <format>` | Disk format (qcow2, raw, etc.) | `raw` | 73 + | `--size <size>` | Size of the VM disk image to create if it does not exist | `20G` | 74 + | `-b, --bridge <name>` | Name of the network bridge to use for networking (e.g., br0) | None | 75 + 76 + ### VM Management Commands 77 + 78 + | Command | Description | 79 + | ---------------------------------- | --------------------------------------------- | 80 + | `openindiana-up ps` | List all running virtual machines | 81 + | `openindiana-up ps --all` | List all virtual machines (including stopped) | 82 + | `openindiana-up start <vm-name>` | Start a stopped virtual machine | 83 + | `openindiana-up stop <vm-name>` | Stop a running virtual machine | 84 + | `openindiana-up inspect <vm-name>` | Inspect virtual machine configuration | 66 85 67 86 ## ๐Ÿ’ก Examples 68 87 ··· 94 113 openindiana-up -o ~/isos/openindiana.iso 95 114 ``` 96 115 116 + ### VM Management Examples 117 + 118 + ```bash 119 + # List all running VMs 120 + openindiana-up ps 121 + 122 + # List all VMs (including stopped ones) 123 + openindiana-up ps --all 124 + 125 + # Start a specific VM 126 + openindiana-up start my-vm-name 127 + 128 + # Stop a running VM 129 + openindiana-up stop my-vm-name 130 + 131 + # Inspect VM configuration 132 + openindiana-up inspect my-vm-name 133 + ``` 134 + 135 + ### Bridge Networking 136 + 137 + ```bash 138 + # Use bridge networking (requires bridge setup) 139 + openindiana-up --bridge br0 140 + ``` 141 + 142 + ### Automatic Disk Creation 143 + 144 + ```bash 145 + # Automatically create a 50GB disk if it doesn't exist 146 + openindiana-up --image my-disk.qcow2 --disk-format qcow2 --size 50G 147 + ``` 148 + 97 149 ## ๐Ÿ–ฅ๏ธ Console Setup 98 150 99 151 When OpenIndiana boots, you'll see the boot menu. For the best experience with ··· 118 170 - **Memory**: 2GB RAM (configurable with `--memory`) 119 171 - **Cores**: 2 virtual CPUs (configurable with `--cpus`) 120 172 - **Storage**: ISO-only by default; optional persistent disk (configurable with 121 - `--drive`) 173 + `--image`) 122 174 - **Network**: User mode networking with SSH forwarding 123 175 - **Console**: Enhanced serial console via stdio with proper signal handling 124 176 - **Default Version**: OpenIndiana 20251026 (when no arguments provided) ··· 157 209 - Downloaded ISOs are cached and won't be re-downloaded if they exist 158 210 - KVM acceleration requires `/dev/kvm` access on your host system 159 211 - Serial console is connected to stdio for direct interaction 212 + - VM state is automatically persisted in a SQLite database at 213 + `~/.openindiana-up/state.sqlite` 214 + - Each VM gets a unique randomly generated name using the Moniker library 215 + - MAC addresses are automatically generated for network devices 216 + - Bridge networking requires proper bridge configuration and may need sudo 217 + privileges 218 + - VMs can be managed independently with start/stop/inspect commands 160 219 161 220 ## ๐Ÿ“œ License 162 221
+15 -2
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", 12 + "@cliffy/table": "jsr:@cliffy/table@^1.0.0-rc.8", 13 + "@db/sqlite": "jsr:@db/sqlite@^0.12.0", 14 + "@es-toolkit/es-toolkit": "jsr:@es-toolkit/es-toolkit@^1.41.0", 15 + "@paralleldrive/cuid2": "npm:@paralleldrive/cuid2@^3.0.4", 16 + "@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", 7 17 "@std/assert": "jsr:@std/assert@1", 8 18 "chalk": "npm:chalk@^5.6.2", 9 - "lodash": "npm:lodash@^4.17.21" 19 + "dayjs": "npm:dayjs@^1.11.19", 20 + "effect": "npm:effect@^3.19.2", 21 + "kysely": "npm:kysely@0.27.6", 22 + "moniker": "npm:moniker@^0.1.2" 10 23 } 11 - } 24 + }
+131 -9
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", 9 + "jsr:@cliffy/table@^1.0.0-rc.8": "1.0.0-rc.8", 10 + "jsr:@db/sqlite@0.12": "0.12.0", 11 + "jsr:@denosaurs/plug@1": "1.1.0", 12 + "jsr:@es-toolkit/es-toolkit@^1.41.0": "1.41.0", 13 + "jsr:@soapbox/kysely-deno-sqlite@^2.2.0": "2.2.0", 14 + "jsr:@std/assert@0.217": "0.217.0", 8 15 "jsr:@std/assert@1": "1.0.15", 16 + "jsr:@std/encoding@1": "1.0.10", 17 + "jsr:@std/fmt@1": "1.0.8", 9 18 "jsr:@std/fmt@~1.0.2": "1.0.8", 19 + "jsr:@std/fs@1": "1.0.19", 20 + "jsr:@std/internal@^1.0.10": "1.0.12", 10 21 "jsr:@std/internal@^1.0.12": "1.0.12", 22 + "jsr:@std/internal@^1.0.9": "1.0.12", 23 + "jsr:@std/path@0.217": "0.217.0", 24 + "jsr:@std/path@1": "1.1.2", 25 + "jsr:@std/path@^1.1.1": "1.1.2", 11 26 "jsr:@std/text@~1.0.7": "1.0.16", 27 + "npm:@paralleldrive/cuid2@^3.0.4": "3.0.4", 12 28 "npm:chalk@^5.6.2": "5.6.2", 13 - "npm:lodash@^4.17.21": "4.17.21" 29 + "npm:dayjs@^1.11.19": "1.11.19", 30 + "npm:effect@^3.19.2": "3.19.2", 31 + "npm:kysely@0.27.6": "0.27.6", 32 + "npm:kysely@~0.27.2": "0.27.6", 33 + "npm:moniker@~0.1.2": "0.1.2" 14 34 }, 15 35 "jsr": { 16 36 "@cliffy/command@1.0.0-rc.8": { 17 37 "integrity": "758147790797c74a707e5294cc7285df665422a13d2a483437092ffce40b5557", 18 38 "dependencies": [ 19 - "jsr:@cliffy/flags", 39 + "jsr:@cliffy/flags@1.0.0-rc.8", 20 40 "jsr:@cliffy/internal", 21 - "jsr:@cliffy/table", 22 - "jsr:@std/fmt", 41 + "jsr:@cliffy/table@1.0.0-rc.8", 42 + "jsr:@std/fmt@~1.0.2", 23 43 "jsr:@std/text" 24 44 ] 25 45 }, ··· 35 55 "@cliffy/table@1.0.0-rc.8": { 36 56 "integrity": "8bbcdc2ba5e0061b4b13810a24e6f5c6ab19c09f0cce9eb691ccd76c7c6c9db5", 37 57 "dependencies": [ 38 - "jsr:@std/fmt" 58 + "jsr:@std/fmt@~1.0.2" 39 59 ] 40 60 }, 61 + "@db/sqlite@0.12.0": { 62 + "integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f", 63 + "dependencies": [ 64 + "jsr:@denosaurs/plug", 65 + "jsr:@std/path@0.217" 66 + ] 67 + }, 68 + "@denosaurs/plug@1.1.0": { 69 + "integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044", 70 + "dependencies": [ 71 + "jsr:@std/encoding", 72 + "jsr:@std/fmt@1", 73 + "jsr:@std/fs", 74 + "jsr:@std/path@1" 75 + ] 76 + }, 77 + "@es-toolkit/es-toolkit@1.41.0": { 78 + "integrity": "4df54a18e80b869880cee8a8a9ff7a5e1c424a9fd0916dccd38d34686f110071" 79 + }, 80 + "@soapbox/kysely-deno-sqlite@2.2.0": { 81 + "integrity": "668ec94600bc4b4d7bd618dd7ca65d4ef30ee61c46ffcb379b6f45203c08517a", 82 + "dependencies": [ 83 + "npm:kysely@~0.27.2" 84 + ] 85 + }, 86 + "@std/assert@0.217.0": { 87 + "integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642" 88 + }, 41 89 "@std/assert@1.0.15": { 42 90 "integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b", 43 91 "dependencies": [ 44 - "jsr:@std/internal" 92 + "jsr:@std/internal@^1.0.12" 45 93 ] 46 94 }, 95 + "@std/encoding@1.0.10": { 96 + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" 97 + }, 47 98 "@std/fmt@1.0.8": { 48 99 "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" 100 + }, 101 + "@std/fs@1.0.19": { 102 + "integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06", 103 + "dependencies": [ 104 + "jsr:@std/internal@^1.0.9", 105 + "jsr:@std/path@^1.1.1" 106 + ] 49 107 }, 50 108 "@std/internal@1.0.12": { 51 109 "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 52 110 }, 111 + "@std/path@0.217.0": { 112 + "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11", 113 + "dependencies": [ 114 + "jsr:@std/assert@0.217" 115 + ] 116 + }, 117 + "@std/path@1.1.2": { 118 + "integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038", 119 + "dependencies": [ 120 + "jsr:@std/internal@^1.0.10" 121 + ] 122 + }, 53 123 "@std/text@1.0.16": { 54 124 "integrity": "ddb9853b75119a2473857d691cf1ec02ad90793a2e8b4a4ac49d7354281a0cf8" 55 125 } 56 126 }, 57 127 "npm": { 128 + "@noble/hashes@2.0.1": { 129 + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==" 130 + }, 131 + "@paralleldrive/cuid2@3.0.4": { 132 + "integrity": "sha512-sM6M2PWrByOEpN2QYAdulhEbSZmChwj0e52u4hpwB7u4PznFiNAavtE6m7O8tWUlzX+jT2eKKtc5/ZgX+IHrtg==", 133 + "dependencies": [ 134 + "@noble/hashes", 135 + "bignumber.js", 136 + "error-causes" 137 + ], 138 + "bin": true 139 + }, 140 + "@standard-schema/spec@1.0.0": { 141 + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" 142 + }, 143 + "bignumber.js@9.3.1": { 144 + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==" 145 + }, 58 146 "chalk@5.6.2": { 59 147 "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==" 60 148 }, 61 - "lodash@4.17.21": { 62 - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" 149 + "dayjs@1.11.19": { 150 + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==" 151 + }, 152 + "effect@3.19.2": { 153 + "integrity": "sha512-AHkxfzl5RbWfHO9HOdLE4oZ0c3nxqkXKHc69t83GWYoAquZmSeoCjmLP5rPgbHvwv4DcfLr8WW8PWbtNIQI+vw==", 154 + "dependencies": [ 155 + "@standard-schema/spec", 156 + "fast-check" 157 + ] 158 + }, 159 + "error-causes@3.0.2": { 160 + "integrity": "sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw==" 161 + }, 162 + "fast-check@3.23.2": { 163 + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", 164 + "dependencies": [ 165 + "pure-rand" 166 + ] 167 + }, 168 + "kysely@0.27.6": { 169 + "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==" 170 + }, 171 + "moniker@0.1.2": { 172 + "integrity": "sha512-Uj9iV0QYr6281G+o0TvqhKwHHWB2Q/qUTT4LPQ3qDGc0r8cbMuqQjRXPZuVZ+gcL7APx+iQgE8lcfWPrj1LsLA==" 173 + }, 174 + "pure-rand@6.1.0": { 175 + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==" 63 176 } 64 177 }, 65 178 "workspace": { 66 179 "dependencies": [ 67 180 "jsr:@cliffy/command@^1.0.0-rc.8", 181 + "jsr:@cliffy/flags@^1.0.0-rc.8", 182 + "jsr:@cliffy/table@^1.0.0-rc.8", 183 + "jsr:@db/sqlite@0.12", 184 + "jsr:@es-toolkit/es-toolkit@^1.41.0", 185 + "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", 68 186 "jsr:@std/assert@1", 187 + "npm:@paralleldrive/cuid2@^3.0.4", 69 188 "npm:chalk@^5.6.2", 70 - "npm:lodash@^4.17.21" 189 + "npm:dayjs@^1.11.19", 190 + "npm:effect@^3.19.2", 191 + "npm:kysely@0.27.6", 192 + "npm:moniker@~0.1.2" 71 193 ] 72 194 } 73 195 }
+127 -6
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" }; 5 + import { createBridgeNetworkIfNeeded } from "./src/network.ts"; 6 + import inspect from "./src/subcommands/inspect.ts"; 7 + import logs from "./src/subcommands/logs.ts"; 8 + import ps from "./src/subcommands/ps.ts"; 9 + import restart from "./src/subcommands/restart.ts"; 10 + import rm from "./src/subcommands/rm.ts"; 11 + import start from "./src/subcommands/start.ts"; 12 + import stop from "./src/subcommands/stop.ts"; 4 13 import { 5 14 createDriveImageIfNeeded, 6 15 downloadIso, 7 16 emptyDiskImage, 8 17 handleInput, 9 - Options, 18 + type Options, 10 19 runQemu, 11 - } from "./utils.ts"; 20 + } from "./src/utils.ts"; 12 21 13 22 if (import.meta.main) { 14 23 await new Command() 15 24 .name("openindiana-up") 16 - .version("0.1.0") 25 + .version(pkg.version) 17 26 .description("Start a OpenIndiana virtual machine using QEMU") 18 27 .arguments( 19 28 "[path-or-url-to-iso-or-version:string]", ··· 28 37 .option("-m, --memory <size:string>", "Amount of memory for the VM", { 29 38 default: "2G", 30 39 }) 31 - .option("-d, --drive <path:string>", "Path to VM disk image") 40 + .option("-i, --image <path:string>", "Path to VM disk image") 32 41 .option( 33 42 "--disk-format <format:string>", 34 43 "Disk image format (e.g., qcow2, raw)", ··· 43 52 default: "20G", 44 53 }, 45 54 ) 55 + .option( 56 + "-b, --bridge <name:string>", 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)", 66 + ) 46 67 .example( 47 68 "Default usage", 48 69 "openindiana-up", ··· 59 80 "Download URL", 60 81 "openindiana-up https://dlc.openindiana.org/isos/hipster/20251026/OI-hipster-text-20251026.iso", 61 82 ) 83 + .example( 84 + "List running VMs", 85 + "openindiana-up ps", 86 + ) 87 + .example( 88 + "List all VMs", 89 + "openindiana-up ps --all", 90 + ) 91 + .example( 92 + "Start a VM", 93 + "openindiana-up start my-vm", 94 + ) 95 + .example( 96 + "Stop a VM", 97 + "openindiana-up stop my-vm", 98 + ) 99 + .example( 100 + "Inspect a VM", 101 + "openindiana-up inspect my-vm", 102 + ) 103 + .example( 104 + "Remove a VM", 105 + "openindiana-up rm my-vm", 106 + ) 62 107 .action(async (options: Options, input?: string) => { 63 108 const resolvedInput = handleInput(input); 64 109 let isoPath: string | null = resolvedInput; ··· 70 115 isoPath = await downloadIso(resolvedInput, options); 71 116 } 72 117 73 - if (options.drive) { 118 + if (options.image) { 74 119 await createDriveImageIfNeeded(options); 75 120 } 76 121 77 - if (!input && options.drive && !await emptyDiskImage(options.drive)) { 122 + if (!input && options.image && !await emptyDiskImage(options.image)) { 78 123 isoPath = null; 79 124 } 80 125 126 + if (options.bridge) { 127 + await createBridgeNetworkIfNeeded(options.bridge); 128 + } 129 + 81 130 await runQemu(isoPath, options); 131 + }) 132 + .command("ps", "List all virtual machines") 133 + .option("--all, -a", "Show all virtual machines, including stopped ones") 134 + .action(async (options: { all?: unknown }) => { 135 + await ps(Boolean(options.all)); 136 + }) 137 + .command("start", "Start a virtual machine") 138 + .arguments("<vm-name:string>") 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)); 177 + }) 178 + .command("stop", "Stop a virtual machine") 179 + .arguments("<vm-name:string>") 180 + .action(async (_options: unknown, vmName: string) => { 181 + await stop(vmName); 182 + }) 183 + .command("inspect", "Inspect a virtual machine") 184 + .arguments("<vm-name:string>") 185 + .action(async (_options: unknown, vmName: string) => { 186 + await inspect(vmName); 187 + }) 188 + .command("rm", "Remove a virtual machine") 189 + .arguments("<vm-name:string>") 190 + .action(async (_options: unknown, vmName: string) => { 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); 82 203 }) 83 204 .parse(Deno.args); 84 205 }
+4
src/constants.ts
··· 1 + export const CONFIG_DIR = `${Deno.env.get("HOME")}/.openindiana-up`; 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;
+11
src/context.ts
··· 1 + import { DB_PATH } from "./constants.ts"; 2 + import { createDb, migrateToLatest } from "./db.ts"; 3 + 4 + export const db = createDb(DB_PATH); 5 + await migrateToLatest(db); 6 + 7 + export const ctx = { 8 + db, 9 + }; 10 + 11 + export type Context = typeof ctx;
+113
src/db.ts
··· 1 + import { Database as Sqlite } from "@db/sqlite"; 2 + import { DenoSqlite3Dialect } from "@soapbox/kysely-deno-sqlite"; 3 + import { 4 + Kysely, 5 + type Migration, 6 + type MigrationProvider, 7 + Migrator, 8 + sql, 9 + } from "kysely"; 10 + import { CONFIG_DIR } from "./constants.ts"; 11 + import type { STATUS } from "./types.ts"; 12 + 13 + export const createDb = (location: string): Database => { 14 + Deno.mkdirSync(CONFIG_DIR, { recursive: true }); 15 + return new Kysely<DatabaseSchema>({ 16 + dialect: new DenoSqlite3Dialect({ 17 + database: new Sqlite(location), 18 + }), 19 + }); 20 + }; 21 + 22 + export type DatabaseSchema = { 23 + virtual_machines: VirtualMachine; 24 + }; 25 + 26 + export type VirtualMachine = { 27 + id: string; 28 + name: string; 29 + bridge?: string; 30 + macAddress: string; 31 + memory: string; 32 + cpus: number; 33 + cpu: string; 34 + diskSize: string; 35 + drivePath?: string; 36 + diskFormat: string; 37 + isoPath?: string; 38 + portForward?: string; 39 + version: string; 40 + status: STATUS; 41 + pid: number; 42 + createdAt?: string; 43 + updatedAt?: string; 44 + }; 45 + 46 + const migrations: Record<string, Migration> = {}; 47 + 48 + const migrationProvider: MigrationProvider = { 49 + // deno-lint-ignore require-await 50 + async getMigrations() { 51 + return migrations; 52 + }, 53 + }; 54 + 55 + migrations["001"] = { 56 + async up(db: Kysely<unknown>): Promise<void> { 57 + await db.schema 58 + .createTable("virtual_machines") 59 + .addColumn("id", "varchar", (col) => col.primaryKey()) 60 + .addColumn("name", "varchar", (col) => col.notNull().unique()) 61 + .addColumn("bridge", "varchar") 62 + .addColumn("macAddress", "varchar", (col) => col.notNull().unique()) 63 + .addColumn("memory", "varchar", (col) => col.notNull()) 64 + .addColumn("cpus", "integer", (col) => col.notNull()) 65 + .addColumn("cpu", "varchar", (col) => col.notNull()) 66 + .addColumn("diskSize", "varchar", (col) => col.notNull()) 67 + .addColumn("drivePath", "varchar") 68 + .addColumn("version", "varchar", (col) => col.notNull()) 69 + .addColumn("diskFormat", "varchar") 70 + .addColumn("isoPath", "varchar") 71 + .addColumn("status", "varchar", (col) => col.notNull()) 72 + .addColumn("pid", "integer") 73 + .addColumn( 74 + "createdAt", 75 + "varchar", 76 + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 77 + ) 78 + .addColumn( 79 + "updatedAt", 80 + "varchar", 81 + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 82 + ) 83 + .execute(); 84 + }, 85 + 86 + async down(db: Kysely<unknown>): Promise<void> { 87 + await db.schema.dropTable("virtual_machines").execute(); 88 + }, 89 + }; 90 + 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> => { 108 + const migrator = new Migrator({ db, provider: migrationProvider }); 109 + const { error } = await migrator.migrateToLatest(); 110 + if (error) throw error; 111 + }; 112 + 113 + export type Database = Kysely<DatabaseSchema>;
+120
src/network.ts
··· 1 + import chalk from "chalk"; 2 + 3 + export async function setupQemuBridge(bridgeName: string): Promise<void> { 4 + const bridgeConfPath = "/etc/qemu/bridge.conf"; 5 + const bridgeConfContent = await Deno.readTextFile(bridgeConfPath).catch( 6 + () => "", 7 + ); 8 + if (bridgeConfContent.includes(`allow ${bridgeName}`)) { 9 + console.log( 10 + chalk.greenBright( 11 + `QEMU bridge configuration for ${bridgeName} already exists.`, 12 + ), 13 + ); 14 + return; 15 + } 16 + 17 + console.log( 18 + chalk.blueBright( 19 + `Adding QEMU bridge configuration for ${bridgeName}...`, 20 + ), 21 + ); 22 + 23 + const cmd = new Deno.Command("sudo", { 24 + args: [ 25 + "sh", 26 + "-c", 27 + `mkdir -p /etc/qemu && echo "allow ${bridgeName}" >> ${bridgeConfPath}`, 28 + ], 29 + stdin: "inherit", 30 + stdout: "inherit", 31 + stderr: "inherit", 32 + }); 33 + const status = await cmd.spawn().status; 34 + 35 + if (!status.success) { 36 + console.error( 37 + chalk.redBright( 38 + `Failed to add QEMU bridge configuration for ${bridgeName}.`, 39 + ), 40 + ); 41 + Deno.exit(status.code); 42 + } 43 + 44 + console.log( 45 + chalk.greenBright( 46 + `QEMU bridge configuration for ${bridgeName} added successfully.`, 47 + ), 48 + ); 49 + } 50 + 51 + export async function createBridgeNetworkIfNeeded( 52 + bridgeName: string, 53 + ): Promise<void> { 54 + const bridgeExistsCmd = new Deno.Command("ip", { 55 + args: ["link", "show", bridgeName], 56 + stdout: "null", 57 + stderr: "null", 58 + }); 59 + 60 + const bridgeExistsStatus = await bridgeExistsCmd.spawn().status; 61 + if (bridgeExistsStatus.success) { 62 + console.log( 63 + chalk.greenBright(`Network bridge ${bridgeName} already exists.`), 64 + ); 65 + await setupQemuBridge(bridgeName); 66 + return; 67 + } 68 + 69 + console.log(chalk.blueBright(`Creating network bridge ${bridgeName}...`)); 70 + const createBridgeCmd = new Deno.Command("sudo", { 71 + args: ["ip", "link", "add", bridgeName, "type", "bridge"], 72 + stdin: "inherit", 73 + stdout: "inherit", 74 + stderr: "inherit", 75 + }); 76 + 77 + let status = await createBridgeCmd.spawn().status; 78 + if (!status.success) { 79 + console.error( 80 + chalk.redBright(`Failed to create network bridge ${bridgeName}.`), 81 + ); 82 + Deno.exit(status.code); 83 + } 84 + 85 + const bringUpBridgeCmd = new Deno.Command("sudo", { 86 + args: ["ip", "link", "set", "dev", bridgeName, "up"], 87 + stdin: "inherit", 88 + stdout: "inherit", 89 + stderr: "inherit", 90 + }); 91 + status = await bringUpBridgeCmd.spawn().status; 92 + if (!status.success) { 93 + console.error( 94 + chalk.redBright(`Failed to bring up network bridge ${bridgeName}.`), 95 + ); 96 + Deno.exit(status.code); 97 + } 98 + 99 + console.log( 100 + chalk.greenBright(`Network bridge ${bridgeName} created and up.`), 101 + ); 102 + 103 + await setupQemuBridge(bridgeName); 104 + } 105 + 106 + export function generateRandomMacAddress(): string { 107 + const hexDigits = "0123456789ABCDEF"; 108 + let macAddress = "52:54:00"; 109 + 110 + for (let i = 0; i < 3; i++) { 111 + macAddress += ":"; 112 + for (let j = 0; j < 2; j++) { 113 + macAddress += hexDigits.charAt( 114 + Math.floor(Math.random() * hexDigits.length), 115 + ); 116 + } 117 + } 118 + 119 + return macAddress; 120 + }
+52
src/state.ts
··· 1 + import { ctx } from "./context.ts"; 2 + import type { VirtualMachine } from "./db.ts"; 3 + import type { STATUS } from "./types.ts"; 4 + 5 + export async function saveInstanceState(vm: VirtualMachine) { 6 + await ctx.db.insertInto("virtual_machines") 7 + .values(vm) 8 + .execute(); 9 + } 10 + 11 + export async function updateInstanceState( 12 + name: string, 13 + status: STATUS, 14 + pid?: number, 15 + ) { 16 + await ctx.db.updateTable("virtual_machines") 17 + .set({ status, pid }) 18 + .where((eb) => 19 + eb.or([ 20 + eb("name", "=", name), 21 + eb("id", "=", name), 22 + ]) 23 + ) 24 + .execute(); 25 + } 26 + 27 + export async function removeInstanceState(name: string) { 28 + await ctx.db.deleteFrom("virtual_machines") 29 + .where((eb) => 30 + eb.or([ 31 + eb("name", "=", name), 32 + eb("id", "=", name), 33 + ]) 34 + ) 35 + .execute(); 36 + } 37 + 38 + export async function getInstanceState( 39 + name: string, 40 + ): Promise<VirtualMachine | undefined> { 41 + const vm = await ctx.db.selectFrom("virtual_machines") 42 + .selectAll() 43 + .where((eb) => 44 + eb.or([ 45 + eb("name", "=", name), 46 + eb("id", "=", name), 47 + ]) 48 + ) 49 + .executeTakeFirst(); 50 + 51 + return vm; 52 + }
+13
src/subcommands/inspect.ts
··· 1 + import { getInstanceState } 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(vm); 13 + }
+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 + }
+39
src/subcommands/ps.ts
··· 1 + import { Table } from "@cliffy/table"; 2 + import dayjs from "dayjs"; 3 + import relativeTime from "dayjs/plugin/relativeTime.js"; 4 + import utc from "dayjs/plugin/utc.js"; 5 + import { ctx } from "../context.ts"; 6 + 7 + dayjs.extend(relativeTime); 8 + dayjs.extend(utc); 9 + 10 + export default async function (all: boolean) { 11 + const results = await ctx.db.selectFrom("virtual_machines") 12 + .selectAll() 13 + .where((eb) => { 14 + if (all) { 15 + return eb("id", "!=", ""); 16 + } 17 + return eb("status", "=", "RUNNING"); 18 + }) 19 + .execute(); 20 + 21 + const table: Table = new Table( 22 + ["NAME", "VCPU", "MEMORY", "STATUS", "PID", "BRIDGE", "MAC", "CREATED"], 23 + ); 24 + 25 + for (const vm of results) { 26 + table.push([ 27 + vm.name, 28 + vm.cpus.toString(), 29 + vm.memory, 30 + vm.status, 31 + vm.pid?.toString() ?? "-", 32 + vm.bridge ?? "-", 33 + vm.macAddress, 34 + dayjs.utc(vm.createdAt).local().fromNow(), 35 + ]); 36 + } 37 + 38 + console.log(table.padding(2).toString()); 39 + }
+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 + }
+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 + }
+119
src/subcommands/start.ts
··· 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"; 5 + import { getInstanceState, updateInstanceState } from "../state.ts"; 6 + 7 + export default async function (name: string, detach: boolean = false) { 8 + let vm = await getInstanceState(name); 9 + if (!vm) { 10 + console.error( 11 + `Virtual machine with name or ID ${name} not found.`, 12 + ); 13 + Deno.exit(1); 14 + } 15 + 16 + console.log(`Starting virtual machine ${vm.name} (ID: ${vm.id})...`); 17 + 18 + vm = mergeFlags(vm); 19 + 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 + ]; 54 + 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 + }); 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 + } 103 + } 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 + }
+43
src/subcommands/stop.ts
··· 1 + import _ from "@es-toolkit/es-toolkit/compat"; 2 + import chalk from "chalk"; 3 + import { getInstanceState, updateInstanceState } from "../state.ts"; 4 + 5 + export default async function (name: string) { 6 + const vm = await getInstanceState(name); 7 + if (!vm) { 8 + console.error( 9 + `Virtual machine with name or ID ${chalk.greenBright(name)} not found.`, 10 + ); 11 + Deno.exit(1); 12 + } 13 + 14 + console.log( 15 + `Stopping virtual machine ${chalk.greenBright(vm.name)} (ID: ${ 16 + chalk.greenBright(vm.id) 17 + })...`, 18 + ); 19 + 20 + const cmd = new Deno.Command(vm.bridge ? "sudo" : "kill", { 21 + args: [ 22 + ..._.compact([vm.bridge && "kill"]), 23 + "-TERM", 24 + vm.pid.toString(), 25 + ], 26 + stdin: "inherit", 27 + stdout: "inherit", 28 + stderr: "inherit", 29 + }); 30 + 31 + const status = await cmd.spawn().status; 32 + 33 + if (!status.success) { 34 + console.error( 35 + `Failed to stop virtual machine ${chalk.greenBright(vm.name)}.`, 36 + ); 37 + Deno.exit(status.code); 38 + } 39 + 40 + await updateInstanceState(vm.name, "STOPPED"); 41 + 42 + console.log(`Virtual machine ${chalk.greenBright(vm.name)} stopped.`); 43 + }
+1
src/types.ts
··· 1 + export type STATUS = "RUNNING" | "STOPPED";
+343
src/utils.ts
··· 1 + import _ from "@es-toolkit/es-toolkit/compat"; 2 + import { createId } from "@paralleldrive/cuid2"; 3 + import chalk from "chalk"; 4 + import Moniker from "moniker"; 5 + import { EMPTY_DISK_THRESHOLD_KB, LOGS_DIR } from "./constants.ts"; 6 + import { generateRandomMacAddress } from "./network.ts"; 7 + import { saveInstanceState, updateInstanceState } from "./state.ts"; 8 + 9 + const DEFAULT_VERSION = "20251026"; 10 + 11 + export interface Options { 12 + output?: string; 13 + cpu: string; 14 + cpus: number; 15 + memory: string; 16 + image?: string; 17 + diskFormat: string; 18 + size: string; 19 + bridge?: string; 20 + portForward?: string; 21 + detach?: boolean; 22 + } 23 + 24 + async function du(path: string): Promise<number> { 25 + const cmd = new Deno.Command("du", { 26 + args: [path], 27 + stdout: "piped", 28 + stderr: "inherit", 29 + }); 30 + 31 + const { stdout } = await cmd.spawn().output(); 32 + const output = new TextDecoder().decode(stdout).trim(); 33 + const size = parseInt(output.split("\t")[0], 10); 34 + return size; 35 + } 36 + 37 + export async function emptyDiskImage(path: string): Promise<boolean> { 38 + if (!await Deno.stat(path).catch(() => false)) { 39 + return true; 40 + } 41 + 42 + const size = await du(path); 43 + return size < EMPTY_DISK_THRESHOLD_KB; 44 + } 45 + 46 + export async function downloadIso( 47 + url: string, 48 + options: Options, 49 + ): Promise<string | null> { 50 + const filename = url.split("/").pop()!; 51 + const outputPath = options.output ?? filename; 52 + 53 + if (options.image && await Deno.stat(options.image).catch(() => false)) { 54 + const driveSize = await du(options.image); 55 + if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 56 + console.log( 57 + chalk.yellowBright( 58 + `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, 59 + ), 60 + ); 61 + return null; 62 + } 63 + } 64 + 65 + if (await Deno.stat(outputPath).catch(() => false)) { 66 + console.log( 67 + chalk.yellowBright( 68 + `File ${outputPath} already exists, skipping download.`, 69 + ), 70 + ); 71 + return outputPath; 72 + } 73 + 74 + const cmd = new Deno.Command("curl", { 75 + args: ["-L", "-o", outputPath, url], 76 + stdin: "inherit", 77 + stdout: "inherit", 78 + stderr: "inherit", 79 + }); 80 + 81 + const status = await cmd.spawn().status; 82 + if (!status.success) { 83 + console.error(chalk.redBright("Failed to download ISO image.")); 84 + Deno.exit(status.code); 85 + } 86 + 87 + console.log(chalk.greenBright(`Downloaded ISO to ${outputPath}`)); 88 + return outputPath; 89 + } 90 + 91 + export function constructDownloadUrl(version: string): string { 92 + return `https://dlc.openindiana.org/isos/hipster/${version}/OI-hipster-text-${version}.iso`; 93 + } 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 + 117 + export async function runQemu( 118 + isoPath: string | null, 119 + options: Options, 120 + ): Promise<void> { 121 + const macAddress = generateRandomMacAddress(); 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 + }); 235 + 236 + const status = await cmd.status; 237 + 238 + await updateInstanceState(name, "STOPPED"); 239 + 240 + if (!status.success) { 241 + Deno.exit(status.code); 242 + } 243 + } 244 + } 245 + 246 + export function handleInput(input?: string): string { 247 + if (!input) { 248 + console.log( 249 + `No ISO path provided, defaulting to ${chalk.cyan("OpenIndiana")} ${ 250 + chalk.cyan(DEFAULT_VERSION) 251 + }...`, 252 + ); 253 + return constructDownloadUrl(DEFAULT_VERSION); 254 + } 255 + 256 + const versionRegex = /^\d{8}$/; 257 + 258 + if (versionRegex.test(input)) { 259 + console.log( 260 + `Detected version ${chalk.cyan(input)}, constructing download URL...`, 261 + ); 262 + return constructDownloadUrl(input); 263 + } 264 + 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; 311 + } 312 + 313 + export async function createDriveImageIfNeeded( 314 + { 315 + image: path, 316 + diskFormat: format, 317 + size, 318 + }: Options, 319 + ): Promise<void> { 320 + if (await Deno.stat(path!).catch(() => false)) { 321 + console.log( 322 + chalk.yellowBright( 323 + `Drive image ${path} already exists, skipping creation.`, 324 + ), 325 + ); 326 + return; 327 + } 328 + 329 + const cmd = new Deno.Command("qemu-img", { 330 + args: ["create", "-f", format, path!, size], 331 + stdin: "inherit", 332 + stdout: "inherit", 333 + stderr: "inherit", 334 + }); 335 + 336 + const status = await cmd.spawn().status; 337 + if (!status.success) { 338 + console.error(chalk.redBright("Failed to create drive image.")); 339 + Deno.exit(status.code); 340 + } 341 + 342 + console.log(chalk.greenBright(`Created drive image at ${path}`)); 343 + }
-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 - }