diff --git a/.gitignore b/.gitignore index 3fc43c2..e6e7771 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ *.iso -*.img \ No newline at end of file +*.img +vmconfig.toml \ No newline at end of file diff --git a/deno.json b/deno.json index acf9c4d..677f31d 100644 --- a/deno.json +++ b/deno.json @@ -15,6 +15,8 @@ "@paralleldrive/cuid2": "npm:@paralleldrive/cuid2@^3.0.4", "@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", "@std/assert": "jsr:@std/assert@1", + "@std/toml": "jsr:@std/toml@^1.0.11", + "@zod/zod": "jsr:@zod/zod@^4.1.12", "chalk": "npm:chalk@^5.6.2", "dayjs": "npm:dayjs@^1.11.19", "effect": "npm:effect@^3.19.2", diff --git a/deno.lock b/deno.lock index 8ad63bb..0c0fa36 100644 --- a/deno.lock +++ b/deno.lock @@ -13,6 +13,7 @@ "jsr:@soapbox/kysely-deno-sqlite@^2.2.0": "2.2.0", "jsr:@std/assert@0.217": "0.217.0", "jsr:@std/assert@1": "1.0.15", + "jsr:@std/collections@^1.1.3": "1.1.3", "jsr:@std/encoding@1": "1.0.10", "jsr:@std/fmt@1": "1.0.8", "jsr:@std/fmt@~1.0.2": "1.0.8", @@ -24,13 +25,14 @@ "jsr:@std/path@1": "1.1.2", "jsr:@std/path@^1.1.1": "1.1.2", "jsr:@std/text@~1.0.7": "1.0.16", + "jsr:@std/toml@^1.0.11": "1.0.11", + "jsr:@zod/zod@^4.1.12": "4.1.12", "npm:@paralleldrive/cuid2@^3.0.4": "3.0.4", "npm:chalk@^5.6.2": "5.6.2", "npm:dayjs@^1.11.19": "1.11.19", "npm:effect@^3.19.2": "3.19.2", "npm:kysely@0.27.6": "0.27.6", "npm:kysely@~0.27.2": "0.27.6", - "npm:lodash@^4.17.21": "4.17.21", "npm:moniker@~0.1.2": "0.1.2" }, "jsr": { @@ -93,6 +95,9 @@ "jsr:@std/internal@^1.0.12" ] }, + "@std/collections@1.1.3": { + "integrity": "bf8b0818886df6a32b64c7d3b037a425111f28278d69fd0995aeb62777c986b0" + }, "@std/encoding@1.0.10": { "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" }, @@ -123,6 +128,15 @@ }, "@std/text@1.0.16": { "integrity": "ddb9853b75119a2473857d691cf1ec02ad90793a2e8b4a4ac49d7354281a0cf8" + }, + "@std/toml@1.0.11": { + "integrity": "e084988b872ca4bad6aedfb7350f6eeed0e8ba88e9ee5e1590621c5b5bb8f715", + "dependencies": [ + "jsr:@std/collections" + ] + }, + "@zod/zod@4.1.12": { + "integrity": "5876ed4c6d44673faf5120f0a461a2ada2eb6c735329d3ebaf5ba1fc08387695" } }, "npm": { @@ -169,9 +183,6 @@ "kysely@0.27.6": { "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==" }, - "lodash@4.17.21": { - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, "moniker@0.1.2": { "integrity": "sha512-Uj9iV0QYr6281G+o0TvqhKwHHWB2Q/qUTT4LPQ3qDGc0r8cbMuqQjRXPZuVZ+gcL7APx+iQgE8lcfWPrj1LsLA==" }, @@ -188,12 +199,13 @@ "jsr:@es-toolkit/es-toolkit@^1.41.0", "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", "jsr:@std/assert@1", + "jsr:@std/toml@^1.0.11", + "jsr:@zod/zod@^4.1.12", "npm:@paralleldrive/cuid2@^3.0.4", "npm:chalk@^5.6.2", "npm:dayjs@^1.11.19", "npm:effect@^3.19.2", "npm:kysely@0.27.6", - "npm:lodash@^4.17.21", "npm:moniker@~0.1.2" ] } diff --git a/main.ts b/main.ts index f6b31c7..c6201fc 100755 --- a/main.ts +++ b/main.ts @@ -1,8 +1,11 @@ #!/usr/bin/env -S deno run --allow-run --allow-read --allow-env import { Command } from "@cliffy/command"; -import { Effect } from "effect"; +import chalk from "chalk"; +import { Effect, pipe } from "effect"; import pkg from "./deno.json" with { type: "json" }; +import { initVmFile, mergeConfig, parseVmFile } from "./src/config.ts"; +import { CONFIG_FILE_NAME } from "./src/constants.ts"; import { createBridgeNetworkIfNeeded } from "./src/network.ts"; import inspect from "./src/subcommands/inspect.ts"; import logs from "./src/subcommands/logs.ts"; @@ -16,6 +19,7 @@ import { downloadIso, emptyDiskImage, handleInput, + isValidISOurl, type Options, runQemu, } from "./src/utils.ts"; @@ -112,10 +116,19 @@ if (import.meta.main) { const resolvedInput = handleInput(input); let isoPath: string | null = resolvedInput; - if ( - resolvedInput.startsWith("https://") || - resolvedInput.startsWith("http://") - ) { + const config = yield* pipe( + parseVmFile(CONFIG_FILE_NAME), + Effect.tap(() => Effect.log("Parsed VM configuration file.")), + Effect.catchAll(() => Effect.succeed(null)), + ); + + if (!input && (isValidISOurl(config?.vm?.iso))) { + isoPath = yield* downloadIso(config!.vm!.iso!, options); + } + + options = yield* mergeConfig(config, options); + + if (input && isValidISOurl(resolvedInput)) { isoPath = yield* downloadIso(resolvedInput, options); } @@ -134,6 +147,10 @@ if (import.meta.main) { yield* createBridgeNetworkIfNeeded(options.bridge); } + if (!input && !config?.vm?.iso && isValidISOurl(isoPath!)) { + isoPath = null; + } + yield* runQemu(isoPath, options); }); @@ -211,5 +228,19 @@ if (import.meta.main) { .action(async (_options: unknown, vmName: string) => { await restart(vmName); }) + .command("init", "Initialize a default VM configuration file") + .action(async () => { + await Effect.runPromise(initVmFile(CONFIG_FILE_NAME)); + console.log( + `New VM configuration file created at ${ + chalk.greenBright("./") + + chalk.greenBright(CONFIG_FILE_NAME) + }`, + ); + console.log( + `You can edit this file to customize your VM settings and then start the VM with:`, + ); + console.log(` ${chalk.greenBright(`freebsd-up`)}`); + }) .parse(Deno.args); } diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..ab8b4f0 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,116 @@ +import { parseFlags } from "@cliffy/flags"; +import _ from "@es-toolkit/es-toolkit/compat"; +import * as toml from "@std/toml"; +import z from "@zod/zod"; +import { Data, Effect } from "effect"; +import { + constructDownloadUrl, + DEFAULT_VERSION, + type Options, +} from "./utils.ts"; + +export const VmConfigSchema = z.object({ + vm: z.object({ + iso: z.string(), + output: z.string(), + cpu: z.string(), + cpus: z.number(), + memory: z.string(), + image: z.string(), + disk_format: z.enum(["qcow2", "raw"]), + size: z.string(), + }).partial(), + network: z.object({ + bridge: z.string(), + port_forward: z.string(), + }).partial(), + options: z.object({ + detach: z.boolean(), + }).partial(), +}); + +export type VmConfig = z.infer; + +class VmConfigError extends Data.TaggedError("VmConfigError")<{ + cause?: string; +}> {} + +export const initVmFile = ( + path: string, +): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const defaultConfig: VmConfig = { + vm: { + iso: constructDownloadUrl(DEFAULT_VERSION), + cpu: "host", + cpus: 2, + memory: "2G", + }, + network: { + port_forward: "2222:22", + }, + options: { + detach: false, + }, + }; + const tomlString = toml.stringify(defaultConfig); + await Deno.writeTextFile(path, tomlString); + }, + catch: (error) => new VmConfigError({ cause: String(error) }), + }); + +export const parseVmFile = ( + path: string, +): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const fileContent = await Deno.readTextFile(path); + const parsedToml = toml.parse(fileContent); + return VmConfigSchema.parse(parsedToml); + }, + catch: (error) => new VmConfigError({ cause: String(error) }), + }); + +export const mergeConfig = ( + config: VmConfig | null, + options: Options, +): Effect.Effect => { + const { flags } = parseFlags(Deno.args); + const defaultConfig: VmConfig = { + vm: { + iso: _.get(config, "vm.iso"), + cpu: _.get(config, "vm.cpu", "host"), + cpus: _.get(config, "vm.cpus", 2), + memory: _.get(config, "vm.memory", "2G"), + image: _.get(config, "vm.image", options.image), + disk_format: _.get(config, "vm.disk_format", "raw"), + size: _.get(config, "vm.size", "20G"), + }, + network: { + bridge: _.get(config, "network.bridge"), + port_forward: _.get(config, "network.port_forward", "2222:22"), + }, + options: { + detach: _.get(config, "options.detach", false), + }, + }; + return Effect.succeed({ + memory: _.get(flags, "memory", defaultConfig.vm.memory!) as string, + cpus: _.get(flags, "cpus", defaultConfig.vm.cpus!) as number, + cpu: _.get(flags, "cpu", defaultConfig.vm.cpu!) as string, + diskFormat: _.get( + flags, + "diskFormat", + defaultConfig.vm.disk_format!, + ) as string, + portForward: _.get( + flags, + "portForward", + defaultConfig.network.port_forward!, + ) as string, + image: _.get(flags, "image", defaultConfig.vm.image!) as string, + bridge: _.get(flags, "bridge", defaultConfig.network.bridge!) as string, + size: _.get(flags, "size", defaultConfig.vm.size!) as string, + }); +}; diff --git a/src/constants.ts b/src/constants.ts index 4149fa7..6b0fba7 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,3 +2,4 @@ export const CONFIG_DIR: string = `${Deno.env.get("HOME")}/.openbsd-up`; export const DB_PATH: string = `${CONFIG_DIR}/state.sqlite`; export const LOGS_DIR: string = `${CONFIG_DIR}/logs`; export const EMPTY_DISK_THRESHOLD_KB: number = 100; +export const CONFIG_FILE_NAME: string = "vmconfig.toml"; diff --git a/src/utils.ts b/src/utils.ts index 359c8ad..0aa9e43 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -11,7 +11,7 @@ import { updateInstanceState, } from "./state.ts"; -const DEFAULT_VERSION = "7.8"; +export const DEFAULT_VERSION = "7.8"; export interface Options { output?: string; @@ -30,6 +30,13 @@ class LogCommandError extends Data.TaggedError("LogCommandError")<{ cause?: unknown; }> {} +export const isValidISOurl = (url?: string): boolean => { + return Boolean( + (url?.startsWith("http://") || url?.startsWith("https://")) && + url?.endsWith(".iso"), + ); +}; + const du = (path: string): Effect.Effect => Effect.tryPromise({ try: async () => {