A Docker-like CLI and HTTP API for managing headless VMs
1#!/usr/bin/env -S deno run --allow-run --allow-read --allow-env
2
3import { Command } from "@cliffy/command";
4import { Secret } from "@cliffy/prompt";
5import { readAll } from "@std/io";
6import { basename } from "@std/path";
7import chalk from "chalk";
8import { Effect, pipe } from "effect";
9import pkg from "./deno.json" with { type: "json" };
10import { initVmFile, mergeConfig, parseVmFile } from "./src/config.ts";
11import { CONFIG_FILE_NAME } from "./src/constants.ts";
12import { NoSuchFileError } from "./src/errors.ts";
13import { getImage } from "./src/images.ts";
14import { constructCoreOSImageURL } from "./src/mod.ts";
15import { createBridgeNetworkIfNeeded } from "./src/network.ts";
16import { getImageArchivePath } from "./src/oras.ts";
17import images from "./src/subcommands/images.ts";
18import inspect from "./src/subcommands/inspect.ts";
19import login from "./src/subcommands/login.ts";
20import logout from "./src/subcommands/logout.ts";
21import logs from "./src/subcommands/logs.ts";
22import ps from "./src/subcommands/ps.ts";
23import pull from "./src/subcommands/pull.ts";
24import push from "./src/subcommands/push.ts";
25import restart from "./src/subcommands/restart.ts";
26import rm from "./src/subcommands/rm.ts";
27import rmi from "./src/subcommands/rmi.ts";
28import run from "./src/subcommands/run.ts";
29import seed from "./src/subcommands/seed.ts";
30import serve from "./src/subcommands/serve.ts";
31import start from "./src/subcommands/start.ts";
32import stop from "./src/subcommands/stop.ts";
33import tag from "./src/subcommands/tag.ts";
34import * as volumes from "./src/subcommands/volume.ts";
35import {
36 constructAlmaLinuxImageURL,
37 constructAlpineImageURL,
38 constructDebianImageURL,
39 constructFedoraImageURL,
40 constructGentooImageURL,
41 constructNixOSImageURL,
42 constructRockyLinuxImageURL,
43 constructUbuntuImageURL,
44 createDriveImageIfNeeded,
45 downloadIso,
46 emptyDiskImage,
47 extractXz,
48 fileExists,
49 isValidISOurl,
50 type Options,
51 runQemu,
52} from "./src/utils.ts";
53
54export * from "./src/mod.ts";
55
56if (import.meta.main) {
57 await new Command()
58 .name("vmx")
59 .version(pkg.version)
60 .description("Manage and run headless VMs using QEMU")
61 .arguments(
62 "[path-or-url-to-iso:string]",
63 )
64 .option("-o, --output <path:string>", "Output path for downloaded ISO")
65 .option("-c, --cpu <type:string>", "Type of CPU to emulate", {
66 default: "host",
67 })
68 .option("-C, --cpus <number:number>", "Number of CPU cores", {
69 default: 2,
70 })
71 .option("-m, --memory <size:string>", "Amount of memory for the VM", {
72 default: "2G",
73 })
74 .option("-i, --image <path:string>", "Path to VM disk image")
75 .option(
76 "--disk-format <format:string>",
77 "Disk image format (e.g., qcow2, raw)",
78 {
79 default: "raw",
80 },
81 )
82 .option(
83 "-s, --size <size:string>",
84 "Size of the disk image to create if it doesn't exist (e.g., 20G)",
85 {
86 default: "20G",
87 },
88 )
89 .option(
90 "-b, --bridge <name:string>",
91 "Name of the network bridge to use for networking (e.g., br0)",
92 )
93 .option(
94 "-d, --detach",
95 "Run VM in the background and print VM name",
96 )
97 .option(
98 "-p, --port-forward <mappings:string>",
99 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)",
100 )
101 .option(
102 "--install",
103 "Persist changes to the VM disk image",
104 )
105 .option(
106 "--cloud",
107 "Use cloud-init for initial configuration (only for compatible images)",
108 )
109 .option(
110 "--seed <path:string>",
111 "Path to cloud-init seed image (ISO format)",
112 )
113 .example(
114 "Create a default VM configuration file",
115 "vmx init",
116 )
117 .example(
118 "Local ISO file",
119 "vmx /path/to/image.iso",
120 )
121 .example(
122 "Download URL",
123 "vmx https://cdimage.ubuntu.com/releases/24.04/release/ubuntu-24.04.3-live-server-arm64.iso",
124 )
125 .example(
126 "From OCI Registry",
127 "vmx ghcr.io/tsirysndr/ubuntu:24.04",
128 )
129 .example(
130 "List running VMs",
131 "vmx ps",
132 )
133 .example(
134 "List all VMs",
135 "vmx ps --all",
136 )
137 .example(
138 "Start a VM",
139 "vmx start my-vm",
140 )
141 .example(
142 "Stop a VM",
143 "vmx stop my-vm",
144 )
145 .example(
146 "Inspect a VM",
147 "vmx inspect my-vm",
148 )
149 .action(async (options: Options, input?: string) => {
150 const program = Effect.gen(function* () {
151 let isoPath: string | null = null;
152
153 if (options.seed) {
154 const seedExists = yield* pipe(
155 fileExists(options.seed),
156 Effect.map(() => true),
157 Effect.catchAll(() => Effect.succeed(false)),
158 );
159 if (!seedExists) {
160 console.error(`Seed file ${options.seed} does not exist.`);
161 console.log(
162 `Please run ${
163 chalk.greenBright(`vmx seed`)
164 } to create a seed image.`,
165 );
166 Deno.exit(1);
167 }
168 }
169
170 if (input) {
171 const [image, archivePath] = yield* Effect.all([
172 getImage(input),
173 pipe(
174 getImageArchivePath(input),
175 Effect.catchAll(() => Effect.succeed(null)),
176 ),
177 ]);
178
179 if (image || archivePath) {
180 yield* Effect.tryPromise({
181 try: () => run(input),
182 catch: () => {},
183 });
184 return;
185 }
186
187 if (isValidISOurl(input)) {
188 isoPath = yield* downloadIso(input, options);
189 }
190
191 if (
192 yield* pipe(
193 fileExists(input),
194 Effect.map(() => true),
195 Effect.catchAll(() => Effect.succeed(false)),
196 )
197 ) {
198 if (input.endsWith(".iso")) {
199 isoPath = input;
200 }
201 }
202
203 const coreOSImageURL = yield* pipe(
204 constructCoreOSImageURL(input),
205 Effect.catchAll(() => Effect.succeed(null)),
206 );
207
208 if (coreOSImageURL) {
209 const cached = yield* pipe(
210 basename(coreOSImageURL).replace(".xz", ""),
211 fileExists,
212 Effect.flatMap(() => Effect.succeed(true)),
213 Effect.catchAll(() => Effect.succeed(false)),
214 );
215 if (!cached) {
216 isoPath = yield* pipe(
217 downloadIso(coreOSImageURL, options),
218 Effect.flatMap((xz) => extractXz(xz)),
219 );
220 } else {
221 isoPath = basename(coreOSImageURL).replace(".xz", "");
222 }
223 }
224
225 const nixOSIsoURL = yield* pipe(
226 constructNixOSImageURL(input),
227 Effect.catchAll(() => Effect.succeed(null)),
228 );
229
230 if (nixOSIsoURL) {
231 const cached = yield* pipe(
232 basename(nixOSIsoURL),
233 fileExists,
234 Effect.flatMap(() => Effect.succeed(true)),
235 Effect.catchAll(() => Effect.succeed(false)),
236 );
237 if (!cached) {
238 isoPath = yield* downloadIso(nixOSIsoURL, options);
239 } else {
240 isoPath = basename(nixOSIsoURL);
241 }
242 }
243
244 const fedoraImageURL = yield* pipe(
245 constructFedoraImageURL(input, options.cloud),
246 Effect.catchAll(() => Effect.succeed(null)),
247 );
248
249 if (fedoraImageURL) {
250 const cached = yield* pipe(
251 basename(fedoraImageURL),
252 fileExists,
253 Effect.flatMap(() => Effect.succeed(true)),
254 Effect.catchAll(() => Effect.succeed(false)),
255 );
256 if (!cached) {
257 isoPath = yield* downloadIso(fedoraImageURL, options);
258 } else {
259 isoPath = basename(fedoraImageURL);
260 }
261 }
262
263 const gentooImageURL = yield* pipe(
264 constructGentooImageURL(input),
265 Effect.catchAll(() => Effect.succeed(null)),
266 );
267
268 if (gentooImageURL) {
269 const cached = yield* pipe(
270 basename(gentooImageURL),
271 fileExists,
272 Effect.flatMap(() => Effect.succeed(true)),
273 Effect.catchAll(() => Effect.succeed(false)),
274 );
275 if (!cached) {
276 isoPath = yield* downloadIso(gentooImageURL, options);
277 } else {
278 isoPath = basename(gentooImageURL);
279 }
280 }
281
282 const debianImageURL = yield* pipe(
283 constructDebianImageURL(input, options.cloud),
284 Effect.catchAll(() => Effect.succeed(null)),
285 );
286
287 if (debianImageURL) {
288 const cached = yield* pipe(
289 basename(debianImageURL),
290 fileExists,
291 Effect.flatMap(() => Effect.succeed(true)),
292 Effect.catchAll(() => Effect.succeed(false)),
293 );
294 if (!cached) {
295 isoPath = yield* downloadIso(debianImageURL, options);
296 } else {
297 isoPath = basename(debianImageURL);
298 }
299 }
300
301 const ubuntuImageURL = yield* pipe(
302 constructUbuntuImageURL(input, options.cloud),
303 Effect.catchAll(() => Effect.succeed(null)),
304 );
305
306 if (ubuntuImageURL) {
307 const cached = yield* pipe(
308 basename(ubuntuImageURL),
309 fileExists,
310 Effect.flatMap(() => Effect.succeed(true)),
311 Effect.catchAll(() => Effect.succeed(false)),
312 );
313 if (!cached) {
314 isoPath = yield* downloadIso(ubuntuImageURL, options);
315 } else {
316 isoPath = basename(ubuntuImageURL);
317 }
318 }
319
320 const alpineImageURL = yield* pipe(
321 constructAlpineImageURL(input),
322 Effect.catchAll(() => Effect.succeed(null)),
323 );
324
325 if (alpineImageURL) {
326 const cached = yield* pipe(
327 basename(alpineImageURL),
328 fileExists,
329 Effect.flatMap(() => Effect.succeed(true)),
330 Effect.catchAll(() => Effect.succeed(false)),
331 );
332 if (!cached) {
333 isoPath = yield* downloadIso(alpineImageURL, options);
334 } else {
335 isoPath = basename(alpineImageURL);
336 }
337 }
338
339 const almalinuxImageURL = yield* pipe(
340 constructAlmaLinuxImageURL(input),
341 Effect.catchAll(() => Effect.succeed(null)),
342 );
343
344 if (almalinuxImageURL) {
345 const cached = yield* pipe(
346 basename(almalinuxImageURL),
347 fileExists,
348 Effect.flatMap(() => Effect.succeed(true)),
349 Effect.catchAll(() => Effect.succeed(false)),
350 );
351 if (!cached) {
352 isoPath = yield* downloadIso(almalinuxImageURL, options);
353 } else {
354 isoPath = basename(almalinuxImageURL);
355 }
356 }
357
358 const rockylinuxImageURL = yield* pipe(
359 constructRockyLinuxImageURL(input),
360 Effect.catchAll(() => Effect.succeed(null)),
361 );
362
363 if (rockylinuxImageURL) {
364 const cached = yield* pipe(
365 basename(rockylinuxImageURL),
366 fileExists,
367 Effect.flatMap(() => Effect.succeed(true)),
368 Effect.catchAll(() => Effect.succeed(false)),
369 );
370 if (!cached) {
371 isoPath = yield* downloadIso(rockylinuxImageURL, options);
372 } else {
373 isoPath = basename(rockylinuxImageURL);
374 }
375 }
376 }
377
378 const config = yield* pipe(
379 fileExists(CONFIG_FILE_NAME),
380 Effect.flatMap(() => parseVmFile(CONFIG_FILE_NAME)),
381 Effect.tap(() => Effect.log("Parsed VM configuration file.")),
382 Effect.catchAll((error) => {
383 if (error instanceof NoSuchFileError) {
384 console.log(
385 chalk.yellowBright(`No vmconfig.toml file found, please run:`),
386 chalk.greenBright("vmx init"),
387 );
388 Deno.exit(1);
389 }
390 return Effect.fail(error);
391 }),
392 );
393
394 if (!input && (isValidISOurl(config?.vm?.iso))) {
395 isoPath = yield* downloadIso(config!.vm!.iso!, options);
396 }
397
398 if (!input && config?.vm?.iso) {
399 const coreOSImageURL = yield* pipe(
400 constructCoreOSImageURL(config.vm.iso),
401 Effect.catchAll(() => Effect.succeed(null)),
402 );
403
404 if (coreOSImageURL) {
405 const cached = yield* pipe(
406 basename(coreOSImageURL).replace(".xz", ""),
407 fileExists,
408 Effect.flatMap(() => Effect.succeed(true)),
409 Effect.catchAll(() => Effect.succeed(false)),
410 );
411 if (!cached) {
412 const xz = yield* downloadIso(coreOSImageURL, options);
413 isoPath = yield* extractXz(xz);
414 } else {
415 isoPath = basename(coreOSImageURL).replace(".xz", "");
416 }
417 }
418 }
419
420 options = yield* mergeConfig(config, options);
421
422 if (options.image) {
423 yield* createDriveImageIfNeeded(options);
424 }
425
426 if (!input && options.image) {
427 const isEmpty = yield* emptyDiskImage(options.image);
428 if (!isEmpty) {
429 isoPath = null;
430 }
431 }
432
433 if (options.bridge) {
434 yield* createBridgeNetworkIfNeeded(options.bridge);
435 }
436
437 if (!input && !config?.vm?.iso && isValidISOurl(isoPath!)) {
438 isoPath = null;
439 }
440
441 yield* runQemu(isoPath, options);
442 });
443
444 await Effect.runPromise(program);
445 })
446 .command("ps", "List all virtual machines")
447 .option("--all, -a", "Show all virtual machines, including stopped ones")
448 .action(async (options: { all?: unknown }) => {
449 await ps(Boolean(options.all));
450 })
451 .command("start", "Start a virtual machine")
452 .arguments("<vm-name:string>")
453 .option("-c, --cpu <type:string>", "Type of CPU to emulate", {
454 default: "host",
455 })
456 .option("-C, --cpus <number:number>", "Number of CPU cores", {
457 default: 2,
458 })
459 .option("-m, --memory <size:string>", "Amount of memory for the VM", {
460 default: "2G",
461 })
462 .option("-i, --image <path:string>", "Path to VM disk image")
463 .option(
464 "--disk-format <format:string>",
465 "Disk image format (e.g., qcow2, raw)",
466 {
467 default: "raw",
468 },
469 )
470 .option(
471 "--size <size:string>",
472 "Size of the VM disk image to create if it doesn't exist (e.g., 20G)",
473 {
474 default: "20G",
475 },
476 )
477 .option(
478 "-b, --bridge <name:string>",
479 "Name of the network bridge to use for networking (e.g., br0)",
480 )
481 .option(
482 "-d, --detach",
483 "Run VM in the background and print VM name",
484 )
485 .option(
486 "-p, --port-forward <mappings:string>",
487 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)",
488 )
489 .option(
490 "-v, --volume <name:string>",
491 "Name of the volume to attach to the VM, will be created if it doesn't exist",
492 )
493 .option(
494 "--seed <path:string>",
495 "Path to cloud-init seed image (ISO format)",
496 )
497 .action(async (options: unknown, vmName: string) => {
498 await start(vmName, Boolean((options as { detach: boolean }).detach));
499 })
500 .command("stop", "Stop a virtual machine")
501 .arguments("<vm-name:string>")
502 .action(async (_options: unknown, vmName: string) => {
503 await stop(vmName);
504 })
505 .command("inspect", "Inspect a virtual machine")
506 .arguments("<vm-name:string>")
507 .action(async (_options: unknown, vmName: string) => {
508 await inspect(vmName);
509 })
510 .command("rm", "Remove a virtual machine")
511 .arguments("<vm-name:string>")
512 .action(async (_options: unknown, vmName: string) => {
513 await rm(vmName);
514 })
515 .command("logs", "View logs of a virtual machine")
516 .option("--follow, -f", "Follow log output")
517 .arguments("<vm-name:string>")
518 .action(async (options: unknown, vmName: string) => {
519 await logs(vmName, Boolean((options as { follow: boolean }).follow));
520 })
521 .command("restart", "Restart a virtual machine")
522 .arguments("<vm-name:string>")
523 .action(async (_options: unknown, vmName: string) => {
524 await restart(vmName);
525 })
526 .command("init", "Initialize a default VM configuration file")
527 .action(async () => {
528 await Effect.runPromise(initVmFile(CONFIG_FILE_NAME));
529 console.log(
530 `New VM configuration file created at ${
531 chalk.greenBright("./") +
532 chalk.greenBright(CONFIG_FILE_NAME)
533 }`,
534 );
535 console.log(
536 `You can edit this file to customize your VM settings and then start the VM with:`,
537 );
538 console.log(` ${chalk.greenBright(`vmx`)}`);
539 })
540 .command(
541 "pull",
542 "Pull VM image from an OCI-compliant registry, e.g., ghcr.io, docker hub",
543 )
544 .arguments("<image:string>")
545 .action(async (_options: unknown, image: string) => {
546 await pull(image);
547 })
548 .command(
549 "push",
550 "Push VM image to an OCI-compliant registry, e.g., ghcr.io, docker hub",
551 )
552 .arguments("<image:string>")
553 .action(async (_options: unknown, image: string) => {
554 await push(image);
555 })
556 .command(
557 "tag",
558 "Create a tag 'image' that refers to the VM image of 'vm-name'",
559 )
560 .arguments("<vm-name:string> <image:string>")
561 .action(async (_options: unknown, vmName: string, image: string) => {
562 await tag(vmName, image);
563 })
564 .command(
565 "login",
566 "Authenticate to an OCI-compliant registry, e.g., ghcr.io, docker.io (docker hub), etc.",
567 )
568 .option("-u, --username <username:string>", "Registry username")
569 .arguments("<registry:string>")
570 .action(async (options: unknown, registry: string) => {
571 const username = (options as { username: string }).username;
572
573 let password: string | undefined;
574 const stdinIsTTY = Deno.stdin.isTerminal();
575
576 if (!stdinIsTTY) {
577 const buffer = await readAll(Deno.stdin);
578 password = new TextDecoder().decode(buffer).trim();
579 } else {
580 password = await Secret.prompt("Registry Password: ");
581 }
582
583 console.log(
584 `Authenticating to registry ${chalk.greenBright(registry)} as ${
585 chalk.greenBright(username)
586 }...`,
587 );
588 await login(username, password, registry);
589 })
590 .command("logout", "Logout from an OCI-compliant registry")
591 .arguments("<registry:string>")
592 .action(async (_options: unknown, registry: string) => {
593 await logout(registry);
594 })
595 .command("images", "List all local VM images")
596 .action(async () => {
597 await images();
598 })
599 .command("rmi", "Remove a local VM image")
600 .arguments("<image:string>")
601 .action(async (_options: unknown, image: string) => {
602 await rmi(image);
603 })
604 .command("run", "Create and run a VM from an image")
605 .arguments("<image:string>")
606 .option("-c, --cpu <type:string>", "Type of CPU to emulate", {
607 default: "host",
608 })
609 .option("-C, --cpus <number:number>", "Number of CPU cores", {
610 default: 2,
611 })
612 .option("-m, --memory <size:string>", "Amount of memory for the VM", {
613 default: "2G",
614 })
615 .option(
616 "-b, --bridge <name:string>",
617 "Name of the network bridge to use for networking (e.g., br0)",
618 )
619 .option(
620 "-d, --detach",
621 "Run VM in the background and print VM name",
622 )
623 .option(
624 "-p, --port-forward <mappings:string>",
625 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)",
626 )
627 .option(
628 "-v, --volume <name:string>",
629 "Name of the volume to attach to the VM, will be created if it doesn't exist",
630 )
631 .option(
632 "-s, --size <size:string>",
633 "Size of the volume to create if it doesn't exist (e.g., 20G)",
634 )
635 .option(
636 "--seed <path:string>",
637 "Path to cloud-init seed image (ISO format)",
638 )
639 .action(async (_options: unknown, image: string) => {
640 await run(image);
641 })
642 .command("volumes", "List all volumes")
643 .action(async () => {
644 await volumes.list();
645 })
646 .command(
647 "volume",
648 new Command()
649 .command("rm", "Remove a volume")
650 .arguments("<volume-name:string>")
651 .action(async (_options: unknown, volumeName: string) => {
652 await volumes.remove(volumeName);
653 })
654 .command("inspect", "Inspect a volume")
655 .arguments("<volume-name:string>")
656 .action(async (_options: unknown, volumeName: string) => {
657 await volumes.inspect(volumeName);
658 }),
659 )
660 .description("Manage volumes")
661 .command("serve", "Start the HTTP API server")
662 .option("-p, --port <port:number>", "Port to listen on", { default: 8889 })
663 .action(() => {
664 serve();
665 })
666 .command(
667 "seed",
668 "Seed initial cloud-init user-data and meta-data files for the VM",
669 )
670 .arguments("[path:string]")
671 .action(async (_options: unknown, path?: string) => {
672 await seed(path);
673 })
674 .parse(Deno.args);
675}