A simple CLI tool to spin up OpenBSD virtual machines using QEMU with minimal fuss.

Merge pull request #4 from tsirysndr/feat/config-file

Add VM configuration management with TOML support and enhance main command functionality

authored by tsiry-sandratraina.com and committed by

GitHub b8c8efe6 c0cc0a42

+182 -12
+2 -1
.gitignore
··· 1 1 *.iso 2 - *.img 2 + *.img 3 + vmconfig.toml
+2
deno.json
··· 15 15 "@paralleldrive/cuid2": "npm:@paralleldrive/cuid2@^3.0.4", 16 16 "@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", 17 17 "@std/assert": "jsr:@std/assert@1", 18 + "@std/toml": "jsr:@std/toml@^1.0.11", 19 + "@zod/zod": "jsr:@zod/zod@^4.1.12", 18 20 "chalk": "npm:chalk@^5.6.2", 19 21 "dayjs": "npm:dayjs@^1.11.19", 20 22 "effect": "npm:effect@^3.19.2",
+17 -5
deno.lock
··· 13 13 "jsr:@soapbox/kysely-deno-sqlite@^2.2.0": "2.2.0", 14 14 "jsr:@std/assert@0.217": "0.217.0", 15 15 "jsr:@std/assert@1": "1.0.15", 16 + "jsr:@std/collections@^1.1.3": "1.1.3", 16 17 "jsr:@std/encoding@1": "1.0.10", 17 18 "jsr:@std/fmt@1": "1.0.8", 18 19 "jsr:@std/fmt@~1.0.2": "1.0.8", ··· 24 25 "jsr:@std/path@1": "1.1.2", 25 26 "jsr:@std/path@^1.1.1": "1.1.2", 26 27 "jsr:@std/text@~1.0.7": "1.0.16", 28 + "jsr:@std/toml@^1.0.11": "1.0.11", 29 + "jsr:@zod/zod@^4.1.12": "4.1.12", 27 30 "npm:@paralleldrive/cuid2@^3.0.4": "3.0.4", 28 31 "npm:chalk@^5.6.2": "5.6.2", 29 32 "npm:dayjs@^1.11.19": "1.11.19", 30 33 "npm:effect@^3.19.2": "3.19.2", 31 34 "npm:kysely@0.27.6": "0.27.6", 32 35 "npm:kysely@~0.27.2": "0.27.6", 33 - "npm:lodash@^4.17.21": "4.17.21", 34 36 "npm:moniker@~0.1.2": "0.1.2" 35 37 }, 36 38 "jsr": { ··· 93 95 "jsr:@std/internal@^1.0.12" 94 96 ] 95 97 }, 98 + "@std/collections@1.1.3": { 99 + "integrity": "bf8b0818886df6a32b64c7d3b037a425111f28278d69fd0995aeb62777c986b0" 100 + }, 96 101 "@std/encoding@1.0.10": { 97 102 "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" 98 103 }, ··· 123 128 }, 124 129 "@std/text@1.0.16": { 125 130 "integrity": "ddb9853b75119a2473857d691cf1ec02ad90793a2e8b4a4ac49d7354281a0cf8" 131 + }, 132 + "@std/toml@1.0.11": { 133 + "integrity": "e084988b872ca4bad6aedfb7350f6eeed0e8ba88e9ee5e1590621c5b5bb8f715", 134 + "dependencies": [ 135 + "jsr:@std/collections" 136 + ] 137 + }, 138 + "@zod/zod@4.1.12": { 139 + "integrity": "5876ed4c6d44673faf5120f0a461a2ada2eb6c735329d3ebaf5ba1fc08387695" 126 140 } 127 141 }, 128 142 "npm": { ··· 169 183 "kysely@0.27.6": { 170 184 "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==" 171 185 }, 172 - "lodash@4.17.21": { 173 - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" 174 - }, 175 186 "moniker@0.1.2": { 176 187 "integrity": "sha512-Uj9iV0QYr6281G+o0TvqhKwHHWB2Q/qUTT4LPQ3qDGc0r8cbMuqQjRXPZuVZ+gcL7APx+iQgE8lcfWPrj1LsLA==" 177 188 }, ··· 188 199 "jsr:@es-toolkit/es-toolkit@^1.41.0", 189 200 "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", 190 201 "jsr:@std/assert@1", 202 + "jsr:@std/toml@^1.0.11", 203 + "jsr:@zod/zod@^4.1.12", 191 204 "npm:@paralleldrive/cuid2@^3.0.4", 192 205 "npm:chalk@^5.6.2", 193 206 "npm:dayjs@^1.11.19", 194 207 "npm:effect@^3.19.2", 195 208 "npm:kysely@0.27.6", 196 - "npm:lodash@^4.17.21", 197 209 "npm:moniker@~0.1.2" 198 210 ] 199 211 }
+36 -5
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 { Effect } from "effect"; 4 + import chalk from "chalk"; 5 + import { Effect, pipe } from "effect"; 5 6 import pkg from "./deno.json" with { type: "json" }; 7 + import { initVmFile, mergeConfig, parseVmFile } from "./src/config.ts"; 8 + import { CONFIG_FILE_NAME } from "./src/constants.ts"; 6 9 import { createBridgeNetworkIfNeeded } from "./src/network.ts"; 7 10 import inspect from "./src/subcommands/inspect.ts"; 8 11 import logs from "./src/subcommands/logs.ts"; ··· 16 19 downloadIso, 17 20 emptyDiskImage, 18 21 handleInput, 22 + isValidISOurl, 19 23 type Options, 20 24 runQemu, 21 25 } from "./src/utils.ts"; ··· 112 116 const resolvedInput = handleInput(input); 113 117 let isoPath: string | null = resolvedInput; 114 118 115 - if ( 116 - resolvedInput.startsWith("https://") || 117 - resolvedInput.startsWith("http://") 118 - ) { 119 + const config = yield* pipe( 120 + parseVmFile(CONFIG_FILE_NAME), 121 + Effect.tap(() => Effect.log("Parsed VM configuration file.")), 122 + Effect.catchAll(() => Effect.succeed(null)), 123 + ); 124 + 125 + if (!input && (isValidISOurl(config?.vm?.iso))) { 126 + isoPath = yield* downloadIso(config!.vm!.iso!, options); 127 + } 128 + 129 + options = yield* mergeConfig(config, options); 130 + 131 + if (input && isValidISOurl(resolvedInput)) { 119 132 isoPath = yield* downloadIso(resolvedInput, options); 120 133 } 121 134 ··· 132 145 133 146 if (options.bridge) { 134 147 yield* createBridgeNetworkIfNeeded(options.bridge); 148 + } 149 + 150 + if (!input && !config?.vm?.iso && isValidISOurl(isoPath!)) { 151 + isoPath = null; 135 152 } 136 153 137 154 yield* runQemu(isoPath, options); ··· 210 227 .arguments("<vm-name:string>") 211 228 .action(async (_options: unknown, vmName: string) => { 212 229 await restart(vmName); 230 + }) 231 + .command("init", "Initialize a default VM configuration file") 232 + .action(async () => { 233 + await Effect.runPromise(initVmFile(CONFIG_FILE_NAME)); 234 + console.log( 235 + `New VM configuration file created at ${ 236 + chalk.greenBright("./") + 237 + chalk.greenBright(CONFIG_FILE_NAME) 238 + }`, 239 + ); 240 + console.log( 241 + `You can edit this file to customize your VM settings and then start the VM with:`, 242 + ); 243 + console.log(` ${chalk.greenBright(`freebsd-up`)}`); 213 244 }) 214 245 .parse(Deno.args); 215 246 }
+116
src/config.ts
··· 1 + import { parseFlags } from "@cliffy/flags"; 2 + import _ from "@es-toolkit/es-toolkit/compat"; 3 + import * as toml from "@std/toml"; 4 + import z from "@zod/zod"; 5 + import { Data, Effect } from "effect"; 6 + import { 7 + constructDownloadUrl, 8 + DEFAULT_VERSION, 9 + type Options, 10 + } from "./utils.ts"; 11 + 12 + export const VmConfigSchema = z.object({ 13 + vm: z.object({ 14 + iso: z.string(), 15 + output: z.string(), 16 + cpu: z.string(), 17 + cpus: z.number(), 18 + memory: z.string(), 19 + image: z.string(), 20 + disk_format: z.enum(["qcow2", "raw"]), 21 + size: z.string(), 22 + }).partial(), 23 + network: z.object({ 24 + bridge: z.string(), 25 + port_forward: z.string(), 26 + }).partial(), 27 + options: z.object({ 28 + detach: z.boolean(), 29 + }).partial(), 30 + }); 31 + 32 + export type VmConfig = z.infer<typeof VmConfigSchema>; 33 + 34 + class VmConfigError extends Data.TaggedError("VmConfigError")<{ 35 + cause?: string; 36 + }> {} 37 + 38 + export const initVmFile = ( 39 + path: string, 40 + ): Effect.Effect<void, VmConfigError, never> => 41 + Effect.tryPromise({ 42 + try: async () => { 43 + const defaultConfig: VmConfig = { 44 + vm: { 45 + iso: constructDownloadUrl(DEFAULT_VERSION), 46 + cpu: "host", 47 + cpus: 2, 48 + memory: "2G", 49 + }, 50 + network: { 51 + port_forward: "2222:22", 52 + }, 53 + options: { 54 + detach: false, 55 + }, 56 + }; 57 + const tomlString = toml.stringify(defaultConfig); 58 + await Deno.writeTextFile(path, tomlString); 59 + }, 60 + catch: (error) => new VmConfigError({ cause: String(error) }), 61 + }); 62 + 63 + export const parseVmFile = ( 64 + path: string, 65 + ): Effect.Effect<VmConfig, VmConfigError, never> => 66 + Effect.tryPromise({ 67 + try: async () => { 68 + const fileContent = await Deno.readTextFile(path); 69 + const parsedToml = toml.parse(fileContent); 70 + return VmConfigSchema.parse(parsedToml); 71 + }, 72 + catch: (error) => new VmConfigError({ cause: String(error) }), 73 + }); 74 + 75 + export const mergeConfig = ( 76 + config: VmConfig | null, 77 + options: Options, 78 + ): Effect.Effect<Options, never, never> => { 79 + const { flags } = parseFlags(Deno.args); 80 + const defaultConfig: VmConfig = { 81 + vm: { 82 + iso: _.get(config, "vm.iso"), 83 + cpu: _.get(config, "vm.cpu", "host"), 84 + cpus: _.get(config, "vm.cpus", 2), 85 + memory: _.get(config, "vm.memory", "2G"), 86 + image: _.get(config, "vm.image", options.image), 87 + disk_format: _.get(config, "vm.disk_format", "raw"), 88 + size: _.get(config, "vm.size", "20G"), 89 + }, 90 + network: { 91 + bridge: _.get(config, "network.bridge"), 92 + port_forward: _.get(config, "network.port_forward", "2222:22"), 93 + }, 94 + options: { 95 + detach: _.get(config, "options.detach", false), 96 + }, 97 + }; 98 + return Effect.succeed({ 99 + memory: _.get(flags, "memory", defaultConfig.vm.memory!) as string, 100 + cpus: _.get(flags, "cpus", defaultConfig.vm.cpus!) as number, 101 + cpu: _.get(flags, "cpu", defaultConfig.vm.cpu!) as string, 102 + diskFormat: _.get( 103 + flags, 104 + "diskFormat", 105 + defaultConfig.vm.disk_format!, 106 + ) as string, 107 + portForward: _.get( 108 + flags, 109 + "portForward", 110 + defaultConfig.network.port_forward!, 111 + ) as string, 112 + image: _.get(flags, "image", defaultConfig.vm.image!) as string, 113 + bridge: _.get(flags, "bridge", defaultConfig.network.bridge!) as string, 114 + size: _.get(flags, "size", defaultConfig.vm.size!) as string, 115 + }); 116 + };
+1
src/constants.ts
··· 2 2 export const DB_PATH: string = `${CONFIG_DIR}/state.sqlite`; 3 3 export const LOGS_DIR: string = `${CONFIG_DIR}/logs`; 4 4 export const EMPTY_DISK_THRESHOLD_KB: number = 100; 5 + export const CONFIG_FILE_NAME: string = "vmconfig.toml";
+8 -1
src/utils.ts
··· 11 11 updateInstanceState, 12 12 } from "./state.ts"; 13 13 14 - const DEFAULT_VERSION = "7.8"; 14 + export const DEFAULT_VERSION = "7.8"; 15 15 16 16 export interface Options { 17 17 output?: string; ··· 29 29 class LogCommandError extends Data.TaggedError("LogCommandError")<{ 30 30 cause?: unknown; 31 31 }> {} 32 + 33 + export const isValidISOurl = (url?: string): boolean => { 34 + return Boolean( 35 + (url?.startsWith("http://") || url?.startsWith("https://")) && 36 + url?.endsWith(".iso"), 37 + ); 38 + }; 32 39 33 40 const du = (path: string): Effect.Effect<number, LogCommandError, never> => 34 41 Effect.tryPromise({