#!/usr/bin/env -S deno run --allow-run --allow-read --allow-env import { Command } from "@cliffy/command"; import { Secret } from "@cliffy/prompt"; import { readAll } from "@std/io"; import { basename } from "@std/path"; 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 { NoSuchFileError } from "./src/errors.ts"; import { getImage } from "./src/images.ts"; import { constructCoreOSImageURL } from "./src/mod.ts"; import { createBridgeNetworkIfNeeded } from "./src/network.ts"; import { getImageArchivePath } from "./src/oras.ts"; import images from "./src/subcommands/images.ts"; import inspect from "./src/subcommands/inspect.ts"; import login from "./src/subcommands/login.ts"; import logout from "./src/subcommands/logout.ts"; import logs from "./src/subcommands/logs.ts"; import ps from "./src/subcommands/ps.ts"; import pull from "./src/subcommands/pull.ts"; import push from "./src/subcommands/push.ts"; import restart from "./src/subcommands/restart.ts"; import rm from "./src/subcommands/rm.ts"; import rmi from "./src/subcommands/rmi.ts"; import run from "./src/subcommands/run.ts"; import seed from "./src/subcommands/seed.ts"; import serve from "./src/subcommands/serve.ts"; import start from "./src/subcommands/start.ts"; import stop from "./src/subcommands/stop.ts"; import tag from "./src/subcommands/tag.ts"; import * as volumes from "./src/subcommands/volume.ts"; import { constructAlmaLinuxImageURL, constructAlpineImageURL, constructDebianImageURL, constructFedoraImageURL, constructGentooImageURL, constructNixOSImageURL, constructRockyLinuxImageURL, constructUbuntuImageURL, createDriveImageIfNeeded, downloadIso, emptyDiskImage, extractXz, fileExists, isValidISOurl, type Options, runQemu, } from "./src/utils.ts"; export * from "./src/mod.ts"; if (import.meta.main) { await new Command() .name("vmx") .version(pkg.version) .description("Manage and run headless VMs using QEMU") .arguments( "[path-or-url-to-iso:string]", ) .option("-o, --output ", "Output path for downloaded ISO") .option("-c, --cpu ", "Type of CPU to emulate", { default: "host", }) .option("-C, --cpus ", "Number of CPU cores", { default: 2, }) .option("-m, --memory ", "Amount of memory for the VM", { default: "2G", }) .option("-i, --image ", "Path to VM disk image") .option( "--disk-format ", "Disk image format (e.g., qcow2, raw)", { default: "raw", }, ) .option( "-s, --size ", "Size of the disk image to create if it doesn't exist (e.g., 20G)", { default: "20G", }, ) .option( "-b, --bridge ", "Name of the network bridge to use for networking (e.g., br0)", ) .option( "-d, --detach", "Run VM in the background and print VM name", ) .option( "-p, --port-forward ", "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", ) .option( "--install", "Persist changes to the VM disk image", ) .option( "--cloud", "Use cloud-init for initial configuration (only for compatible images)", ) .option( "--seed ", "Path to cloud-init seed image (ISO format)", ) .example( "Create a default VM configuration file", "vmx init", ) .example( "Local ISO file", "vmx /path/to/image.iso", ) .example( "Download URL", "vmx https://cdimage.ubuntu.com/releases/24.04/release/ubuntu-24.04.3-live-server-arm64.iso", ) .example( "From OCI Registry", "vmx ghcr.io/tsirysndr/ubuntu:24.04", ) .example( "List running VMs", "vmx ps", ) .example( "List all VMs", "vmx ps --all", ) .example( "Start a VM", "vmx start my-vm", ) .example( "Stop a VM", "vmx stop my-vm", ) .example( "Inspect a VM", "vmx inspect my-vm", ) .action(async (options: Options, input?: string) => { const program = Effect.gen(function* () { let isoPath: string | null = null; if (options.seed) { const seedExists = yield* pipe( fileExists(options.seed), Effect.map(() => true), Effect.catchAll(() => Effect.succeed(false)), ); if (!seedExists) { console.error(`Seed file ${options.seed} does not exist.`); console.log( `Please run ${ chalk.greenBright(`vmx seed`) } to create a seed image.`, ); Deno.exit(1); } } if (input) { const [image, archivePath] = yield* Effect.all([ getImage(input), pipe( getImageArchivePath(input), Effect.catchAll(() => Effect.succeed(null)), ), ]); if (image || archivePath) { yield* Effect.tryPromise({ try: () => run(input), catch: () => {}, }); return; } if (isValidISOurl(input)) { isoPath = yield* downloadIso(input, options); } if ( yield* pipe( fileExists(input), Effect.map(() => true), Effect.catchAll(() => Effect.succeed(false)), ) ) { if (input.endsWith(".iso")) { isoPath = input; } } const coreOSImageURL = yield* pipe( constructCoreOSImageURL(input), Effect.catchAll(() => Effect.succeed(null)), ); if (coreOSImageURL) { const cached = yield* pipe( basename(coreOSImageURL).replace(".xz", ""), fileExists, Effect.flatMap(() => Effect.succeed(true)), Effect.catchAll(() => Effect.succeed(false)), ); if (!cached) { isoPath = yield* pipe( downloadIso(coreOSImageURL, options), Effect.flatMap((xz) => extractXz(xz)), ); } else { isoPath = basename(coreOSImageURL).replace(".xz", ""); } } const nixOSIsoURL = yield* pipe( constructNixOSImageURL(input), Effect.catchAll(() => Effect.succeed(null)), ); if (nixOSIsoURL) { const cached = yield* pipe( basename(nixOSIsoURL), fileExists, Effect.flatMap(() => Effect.succeed(true)), Effect.catchAll(() => Effect.succeed(false)), ); if (!cached) { isoPath = yield* downloadIso(nixOSIsoURL, options); } else { isoPath = basename(nixOSIsoURL); } } const fedoraImageURL = yield* pipe( constructFedoraImageURL(input, options.cloud), Effect.catchAll(() => Effect.succeed(null)), ); if (fedoraImageURL) { const cached = yield* pipe( basename(fedoraImageURL), fileExists, Effect.flatMap(() => Effect.succeed(true)), Effect.catchAll(() => Effect.succeed(false)), ); if (!cached) { isoPath = yield* downloadIso(fedoraImageURL, options); } else { isoPath = basename(fedoraImageURL); } } const gentooImageURL = yield* pipe( constructGentooImageURL(input), Effect.catchAll(() => Effect.succeed(null)), ); if (gentooImageURL) { const cached = yield* pipe( basename(gentooImageURL), fileExists, Effect.flatMap(() => Effect.succeed(true)), Effect.catchAll(() => Effect.succeed(false)), ); if (!cached) { isoPath = yield* downloadIso(gentooImageURL, options); } else { isoPath = basename(gentooImageURL); } } const debianImageURL = yield* pipe( constructDebianImageURL(input, options.cloud), Effect.catchAll(() => Effect.succeed(null)), ); if (debianImageURL) { const cached = yield* pipe( basename(debianImageURL), fileExists, Effect.flatMap(() => Effect.succeed(true)), Effect.catchAll(() => Effect.succeed(false)), ); if (!cached) { isoPath = yield* downloadIso(debianImageURL, options); } else { isoPath = basename(debianImageURL); } } const ubuntuImageURL = yield* pipe( constructUbuntuImageURL(input, options.cloud), Effect.catchAll(() => Effect.succeed(null)), ); if (ubuntuImageURL) { const cached = yield* pipe( basename(ubuntuImageURL), fileExists, Effect.flatMap(() => Effect.succeed(true)), Effect.catchAll(() => Effect.succeed(false)), ); if (!cached) { isoPath = yield* downloadIso(ubuntuImageURL, options); } else { isoPath = basename(ubuntuImageURL); } } const alpineImageURL = yield* pipe( constructAlpineImageURL(input), Effect.catchAll(() => Effect.succeed(null)), ); if (alpineImageURL) { const cached = yield* pipe( basename(alpineImageURL), fileExists, Effect.flatMap(() => Effect.succeed(true)), Effect.catchAll(() => Effect.succeed(false)), ); if (!cached) { isoPath = yield* downloadIso(alpineImageURL, options); } else { isoPath = basename(alpineImageURL); } } const almalinuxImageURL = yield* pipe( constructAlmaLinuxImageURL(input), Effect.catchAll(() => Effect.succeed(null)), ); if (almalinuxImageURL) { const cached = yield* pipe( basename(almalinuxImageURL), fileExists, Effect.flatMap(() => Effect.succeed(true)), Effect.catchAll(() => Effect.succeed(false)), ); if (!cached) { isoPath = yield* downloadIso(almalinuxImageURL, options); } else { isoPath = basename(almalinuxImageURL); } } const rockylinuxImageURL = yield* pipe( constructRockyLinuxImageURL(input), Effect.catchAll(() => Effect.succeed(null)), ); if (rockylinuxImageURL) { const cached = yield* pipe( basename(rockylinuxImageURL), fileExists, Effect.flatMap(() => Effect.succeed(true)), Effect.catchAll(() => Effect.succeed(false)), ); if (!cached) { isoPath = yield* downloadIso(rockylinuxImageURL, options); } else { isoPath = basename(rockylinuxImageURL); } } } const config = yield* pipe( fileExists(CONFIG_FILE_NAME), Effect.flatMap(() => parseVmFile(CONFIG_FILE_NAME)), Effect.tap(() => Effect.log("Parsed VM configuration file.")), Effect.catchAll((error) => { if (error instanceof NoSuchFileError) { console.log( chalk.yellowBright(`No vmconfig.toml file found, please run:`), chalk.greenBright("vmx init"), ); Deno.exit(1); } return Effect.fail(error); }), ); if (!input && (isValidISOurl(config?.vm?.iso))) { isoPath = yield* downloadIso(config!.vm!.iso!, options); } if (!input && config?.vm?.iso) { const coreOSImageURL = yield* pipe( constructCoreOSImageURL(config.vm.iso), Effect.catchAll(() => Effect.succeed(null)), ); if (coreOSImageURL) { const cached = yield* pipe( basename(coreOSImageURL).replace(".xz", ""), fileExists, Effect.flatMap(() => Effect.succeed(true)), Effect.catchAll(() => Effect.succeed(false)), ); if (!cached) { const xz = yield* downloadIso(coreOSImageURL, options); isoPath = yield* extractXz(xz); } else { isoPath = basename(coreOSImageURL).replace(".xz", ""); } } } options = yield* mergeConfig(config, options); if (options.image) { yield* createDriveImageIfNeeded(options); } if (!input && options.image) { const isEmpty = yield* emptyDiskImage(options.image); if (!isEmpty) { isoPath = null; } } if (options.bridge) { yield* createBridgeNetworkIfNeeded(options.bridge); } if (!input && !config?.vm?.iso && isValidISOurl(isoPath!)) { isoPath = null; } yield* runQemu(isoPath, options); }); await Effect.runPromise(program); }) .command("ps", "List all virtual machines") .option("--all, -a", "Show all virtual machines, including stopped ones") .action(async (options: { all?: unknown }) => { await ps(Boolean(options.all)); }) .command("start", "Start a virtual machine") .arguments("") .option("-c, --cpu ", "Type of CPU to emulate", { default: "host", }) .option("-C, --cpus ", "Number of CPU cores", { default: 2, }) .option("-m, --memory ", "Amount of memory for the VM", { default: "2G", }) .option("-i, --image ", "Path to VM disk image") .option( "--disk-format ", "Disk image format (e.g., qcow2, raw)", { default: "raw", }, ) .option( "--size ", "Size of the VM disk image to create if it doesn't exist (e.g., 20G)", { default: "20G", }, ) .option( "-b, --bridge ", "Name of the network bridge to use for networking (e.g., br0)", ) .option( "-d, --detach", "Run VM in the background and print VM name", ) .option( "-p, --port-forward ", "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", ) .option( "-v, --volume ", "Name of the volume to attach to the VM, will be created if it doesn't exist", ) .option( "--seed ", "Path to cloud-init seed image (ISO format)", ) .action(async (options: unknown, vmName: string) => { await start(vmName, Boolean((options as { detach: boolean }).detach)); }) .command("stop", "Stop a virtual machine") .arguments("") .action(async (_options: unknown, vmName: string) => { await stop(vmName); }) .command("inspect", "Inspect a virtual machine") .arguments("") .action(async (_options: unknown, vmName: string) => { await inspect(vmName); }) .command("rm", "Remove a virtual machine") .arguments("") .action(async (_options: unknown, vmName: string) => { await rm(vmName); }) .command("logs", "View logs of a virtual machine") .option("--follow, -f", "Follow log output") .arguments("") .action(async (options: unknown, vmName: string) => { await logs(vmName, Boolean((options as { follow: boolean }).follow)); }) .command("restart", "Restart a virtual machine") .arguments("") .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(`vmx`)}`); }) .command( "pull", "Pull VM image from an OCI-compliant registry, e.g., ghcr.io, docker hub", ) .arguments("") .action(async (_options: unknown, image: string) => { await pull(image); }) .command( "push", "Push VM image to an OCI-compliant registry, e.g., ghcr.io, docker hub", ) .arguments("") .action(async (_options: unknown, image: string) => { await push(image); }) .command( "tag", "Create a tag 'image' that refers to the VM image of 'vm-name'", ) .arguments(" ") .action(async (_options: unknown, vmName: string, image: string) => { await tag(vmName, image); }) .command( "login", "Authenticate to an OCI-compliant registry, e.g., ghcr.io, docker.io (docker hub), etc.", ) .option("-u, --username ", "Registry username") .arguments("") .action(async (options: unknown, registry: string) => { const username = (options as { username: string }).username; let password: string | undefined; const stdinIsTTY = Deno.stdin.isTerminal(); if (!stdinIsTTY) { const buffer = await readAll(Deno.stdin); password = new TextDecoder().decode(buffer).trim(); } else { password = await Secret.prompt("Registry Password: "); } console.log( `Authenticating to registry ${chalk.greenBright(registry)} as ${ chalk.greenBright(username) }...`, ); await login(username, password, registry); }) .command("logout", "Logout from an OCI-compliant registry") .arguments("") .action(async (_options: unknown, registry: string) => { await logout(registry); }) .command("images", "List all local VM images") .action(async () => { await images(); }) .command("rmi", "Remove a local VM image") .arguments("") .action(async (_options: unknown, image: string) => { await rmi(image); }) .command("run", "Create and run a VM from an image") .arguments("") .option("-c, --cpu ", "Type of CPU to emulate", { default: "host", }) .option("-C, --cpus ", "Number of CPU cores", { default: 2, }) .option("-m, --memory ", "Amount of memory for the VM", { default: "2G", }) .option( "-b, --bridge ", "Name of the network bridge to use for networking (e.g., br0)", ) .option( "-d, --detach", "Run VM in the background and print VM name", ) .option( "-p, --port-forward ", "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", ) .option( "-v, --volume ", "Name of the volume to attach to the VM, will be created if it doesn't exist", ) .option( "-s, --size ", "Size of the volume to create if it doesn't exist (e.g., 20G)", ) .option( "--seed ", "Path to cloud-init seed image (ISO format)", ) .action(async (_options: unknown, image: string) => { await run(image); }) .command("volumes", "List all volumes") .action(async () => { await volumes.list(); }) .command( "volume", new Command() .command("rm", "Remove a volume") .arguments("") .action(async (_options: unknown, volumeName: string) => { await volumes.remove(volumeName); }) .command("inspect", "Inspect a volume") .arguments("") .action(async (_options: unknown, volumeName: string) => { await volumes.inspect(volumeName); }), ) .description("Manage volumes") .command("serve", "Start the HTTP API server") .option("-p, --port ", "Port to listen on", { default: 8889 }) .action(() => { serve(); }) .command( "seed", "Seed initial cloud-init user-data and meta-data files for the VM", ) .arguments("[path:string]") .action(async (_options: unknown, path?: string) => { await seed(path); }) .parse(Deno.args); }