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

Add VM configuration management and improve command handling

- Introduced `src/config.ts` for VM configuration schema and file handling.
- Updated `src/constants.ts` to define `CONFIG_FILE_NAME`.
- Enhanced `main.ts` to support VM initialization and configuration merging.
- Improved ISO URL validation in `src/utils.ts`.
- Refactored `inspect.ts` to log VM state more effectively.
- Updated `.gitignore` to include `vmconfig.toml`.
- Added new dependencies in `deno.json` for TOML parsing and validation.

+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",
+27
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", 31 + "npm:@types/node@*": "24.2.0", 28 32 "npm:chalk@^5.6.2": "5.6.2", 29 33 "npm:dayjs@^1.11.19": "1.11.19", 30 34 "npm:effect@^3.19.2": "3.19.2", ··· 92 96 "jsr:@std/internal@^1.0.12" 93 97 ] 94 98 }, 99 + "@std/collections@1.1.3": { 100 + "integrity": "bf8b0818886df6a32b64c7d3b037a425111f28278d69fd0995aeb62777c986b0" 101 + }, 95 102 "@std/encoding@1.0.10": { 96 103 "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" 97 104 }, ··· 122 129 }, 123 130 "@std/text@1.0.16": { 124 131 "integrity": "ddb9853b75119a2473857d691cf1ec02ad90793a2e8b4a4ac49d7354281a0cf8" 132 + }, 133 + "@std/toml@1.0.11": { 134 + "integrity": "e084988b872ca4bad6aedfb7350f6eeed0e8ba88e9ee5e1590621c5b5bb8f715", 135 + "dependencies": [ 136 + "jsr:@std/collections" 137 + ] 138 + }, 139 + "@zod/zod@4.1.12": { 140 + "integrity": "5876ed4c6d44673faf5120f0a461a2ada2eb6c735329d3ebaf5ba1fc08387695" 125 141 } 126 142 }, 127 143 "npm": { ··· 140 156 "@standard-schema/spec@1.0.0": { 141 157 "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" 142 158 }, 159 + "@types/node@24.2.0": { 160 + "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", 161 + "dependencies": [ 162 + "undici-types" 163 + ] 164 + }, 143 165 "bignumber.js@9.3.1": { 144 166 "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==" 145 167 }, ··· 173 195 }, 174 196 "pure-rand@6.1.0": { 175 197 "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==" 198 + }, 199 + "undici-types@7.10.0": { 200 + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==" 176 201 } 177 202 }, 178 203 "workspace": { ··· 184 209 "jsr:@es-toolkit/es-toolkit@^1.41.0", 185 210 "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", 186 211 "jsr:@std/assert@1", 212 + "jsr:@std/toml@^1.0.11", 213 + "jsr:@zod/zod@^4.1.12", 187 214 "npm:@paralleldrive/cuid2@^3.0.4", 188 215 "npm:chalk@^5.6.2", 189 216 "npm:dayjs@^1.11.19",
+52 -12
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"; ··· 32 36 ) 33 37 .option("-o, --output <path:string>", "Output path for downloaded ISO") 34 38 .option("-c, --cpu <type:string>", "Type of CPU to emulate", { 35 - default: "host", 39 + default: Deno.build.os === "darwin" && Deno.build.arch === "aarch64" 40 + ? "max" 41 + : "host", 36 42 }) 37 43 .option("-C, --cpus <number:number>", "Number of CPU cores", { 38 44 default: 2, ··· 66 72 .option( 67 73 "-p, --port-forward <mappings:string>", 68 74 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 75 + ) 76 + .example( 77 + "Create a default VM configuration file", 78 + "dflybsd-up init", 69 79 ) 70 80 .example( 71 81 "Default usage", ··· 108 118 const resolvedInput = handleInput(input); 109 119 let isoPath: string | null = resolvedInput; 110 120 111 - if ( 112 - resolvedInput.startsWith("https://") || 113 - resolvedInput.startsWith("http://") 114 - ) { 121 + const config = yield* pipe( 122 + parseVmFile(CONFIG_FILE_NAME), 123 + Effect.tap(() => Effect.log("Parsed VM configuration file.")), 124 + Effect.catchAll(() => Effect.succeed(null)), 125 + ); 126 + 127 + if (!input && (isValidISOurl(config?.vm?.iso))) { 128 + isoPath = yield* downloadIso(config!.vm!.iso!, options); 129 + } 130 + 131 + options = yield* mergeConfig(config, options); 132 + 133 + if (input && isValidISOurl(resolvedInput)) { 115 134 isoPath = yield* downloadIso(resolvedInput, options); 116 135 } 117 136 ··· 119 138 yield* createDriveImageIfNeeded(options); 120 139 } 121 140 122 - if ( 123 - !input && options.image && 124 - !(yield* emptyDiskImage(options.image)) 125 - ) { 126 - isoPath = null; 141 + if (!input && options.image) { 142 + const isEmpty = yield* emptyDiskImage(options.image); 143 + if (!isEmpty) { 144 + isoPath = null; 145 + } 127 146 } 128 147 129 148 if (options.bridge) { 130 149 yield* createBridgeNetworkIfNeeded(options.bridge); 131 150 } 151 + 152 + if (!input && !config?.vm?.iso && isValidISOurl(isoPath!)) { 153 + isoPath = null; 154 + } 155 + 132 156 yield* runQemu(isoPath, options); 133 157 }); 134 158 ··· 142 166 .command("start", "Start a virtual machine") 143 167 .arguments("<vm-name:string>") 144 168 .option("-c, --cpu <type:string>", "Type of CPU to emulate", { 145 - default: "host", 169 + default: Deno.build.os === "darwin" && Deno.build.arch === "aarch64" 170 + ? "max" 171 + : "host", 146 172 }) 147 173 .option("-C, --cpus <number:number>", "Number of CPU cores", { 148 174 default: 2, ··· 205 231 .arguments("<vm-name:string>") 206 232 .action(async (_options: unknown, vmName: string) => { 207 233 await restart(vmName); 234 + }) 235 + .command("init", "Initialize a default VM configuration file") 236 + .action(async () => { 237 + await Effect.runPromise(initVmFile(CONFIG_FILE_NAME)); 238 + console.log( 239 + `New VM configuration file created at ${ 240 + chalk.greenBright("./") + 241 + chalk.greenBright(CONFIG_FILE_NAME) 242 + }`, 243 + ); 244 + console.log( 245 + `You can edit this file to customize your VM settings and then start the VM with:`, 246 + ); 247 + console.log(` ${chalk.greenBright(`dflybsd-up`)}`); 208 248 }) 209 249 .parse(Deno.args); 210 250 }
+124
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: Deno.build.os === "darwin" && Deno.build.arch === "aarch64" 47 + ? "max" 48 + : "host", 49 + cpus: 2, 50 + memory: "2G", 51 + }, 52 + network: { 53 + port_forward: "2222:22", 54 + }, 55 + options: { 56 + detach: false, 57 + }, 58 + }; 59 + const tomlString = toml.stringify(defaultConfig); 60 + await Deno.writeTextFile(path, tomlString); 61 + }, 62 + catch: (error) => new VmConfigError({ cause: String(error) }), 63 + }); 64 + 65 + export const parseVmFile = ( 66 + path: string, 67 + ): Effect.Effect<VmConfig, VmConfigError, never> => 68 + Effect.tryPromise({ 69 + try: async () => { 70 + const fileContent = await Deno.readTextFile(path); 71 + const parsedToml = toml.parse(fileContent); 72 + return VmConfigSchema.parse(parsedToml); 73 + }, 74 + catch: (error) => new VmConfigError({ cause: String(error) }), 75 + }); 76 + 77 + export const mergeConfig = ( 78 + config: VmConfig | null, 79 + options: Options, 80 + ): Effect.Effect<Options, never, never> => { 81 + const { flags } = parseFlags(Deno.args); 82 + const defaultConfig: VmConfig = { 83 + vm: { 84 + iso: _.get(config, "vm.iso"), 85 + cpu: _.get( 86 + config, 87 + "vm.cpu", 88 + Deno.build.os === "darwin" && Deno.build.arch === "aarch64" 89 + ? "max" 90 + : "host", 91 + ), 92 + cpus: _.get(config, "vm.cpus", 2), 93 + memory: _.get(config, "vm.memory", "2G"), 94 + image: _.get(config, "vm.image", options.image), 95 + disk_format: _.get(config, "vm.disk_format", "raw"), 96 + size: _.get(config, "vm.size", "20G"), 97 + }, 98 + network: { 99 + bridge: _.get(config, "network.bridge"), 100 + port_forward: _.get(config, "network.port_forward", "2222:22"), 101 + }, 102 + options: { 103 + detach: _.get(config, "options.detach", false), 104 + }, 105 + }; 106 + return Effect.succeed({ 107 + memory: _.get(flags, "memory", defaultConfig.vm.memory!) as string, 108 + cpus: _.get(flags, "cpus", defaultConfig.vm.cpus!) as number, 109 + cpu: _.get(flags, "cpu", defaultConfig.vm.cpu!) as string, 110 + diskFormat: _.get( 111 + flags, 112 + "diskFormat", 113 + defaultConfig.vm.disk_format!, 114 + ) as string, 115 + portForward: _.get( 116 + flags, 117 + "portForward", 118 + defaultConfig.network.port_forward!, 119 + ) as string, 120 + image: _.get(flags, "image", defaultConfig.vm.image!) as string, 121 + bridge: _.get(flags, "bridge", defaultConfig.network.bridge!) as string, 122 + size: _.get(flags, "size", defaultConfig.vm.size!) as string, 123 + }); 124 + };
+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 = 100; 5 + export const CONFIG_FILE_NAME: string = "vmconfig.toml";
+1
src/mod.ts
··· 1 + export * from "./config.ts"; 1 2 export * from "./constants.ts"; 2 3 export * from "./context.ts"; 3 4 export * from "./db.ts";
+5 -1
src/subcommands/inspect.ts
··· 4 4 const inspectVirtualMachine = (name: string) => 5 5 pipe( 6 6 getInstanceStateOrFail(name), 7 - Effect.flatMap(Effect.log), 7 + Effect.tap((vm) => 8 + Effect.sync(() => { 9 + console.log(vm); 10 + }) 11 + ), 8 12 ); 9 13 10 14 export default async function (name: string) {
+9 -2
src/utils.ts
··· 25 25 pid: number; 26 26 }> {} 27 27 28 - const DEFAULT_VERSION = "6.4.2"; 28 + export const DEFAULT_VERSION = "6.4.2"; 29 29 30 30 export interface Options { 31 31 output?: string; ··· 39 39 portForward?: string; 40 40 detach?: boolean; 41 41 } 42 + 43 + export const isValidISOurl = (url?: string): boolean => { 44 + return Boolean( 45 + (url?.startsWith("http://") || url?.startsWith("https://")) && 46 + url?.endsWith(".iso"), 47 + ); 48 + }; 42 49 43 50 const du = (path: string) => 44 51 Effect.tryPromise({ ··· 273 280 catch: (cause) => 274 281 new CommandExecutionError({ 275 282 cause, 276 - message: "Failed to start detached QEMU process", 283 + message: `Failed to start detached QEMU process: ${cause}`, 277 284 }), 278 285 }), 279 286 Effect.flatMap((qemuPid) =>