A Docker-like CLI and HTTP API for managing headless VMs
1import _ from "@es-toolkit/es-toolkit/compat";
2import { createId } from "@paralleldrive/cuid2";
3import { dirname } from "@std/path";
4import chalk from "chalk";
5import { Effect, pipe } from "effect";
6import Moniker from "moniker";
7import {
8 ALMA_LINUX_IMG_URL,
9 ALPINE_DEFAULT_VERSION,
10 ALPINE_ISO_URL,
11 DEBIAN_CLOUD_IMG_URL,
12 DEBIAN_DEFAULT_VERSION,
13 DEBIAN_ISO_URL,
14 EMPTY_DISK_THRESHOLD_KB,
15 FEDORA_CLOUD_IMG_URL,
16 FEDORA_COREOS_DEFAULT_VERSION,
17 FEDORA_COREOS_IMG_URL,
18 FEDORA_IMG_URL,
19 GENTOO_IMG_URL,
20 LOGS_DIR,
21 NIXOS_DEFAULT_VERSION,
22 NIXOS_ISO_URL,
23 ROCKY_LINUX_IMG_URL,
24 UBUNTU_CLOUD_IMG_URL,
25 UBUNTU_ISO_URL,
26} from "./constants.ts";
27import type { Image } from "./db.ts";
28import {
29 InvalidImageNameError,
30 LogCommandError,
31 NoSuchFileError,
32 NoSuchImageError,
33} from "./errors.ts";
34import { generateRandomMacAddress } from "./network.ts";
35import { saveInstanceState, updateInstanceState } from "./state.ts";
36
37export interface Options {
38 output?: string;
39 cpu: string;
40 cpus: number;
41 memory: string;
42 image?: string;
43 diskFormat?: string;
44 size?: string;
45 bridge?: string;
46 portForward?: string;
47 detach?: boolean;
48 install?: boolean;
49 volume?: string;
50 cloud?: boolean;
51 seed?: string;
52}
53
54export const getCurrentArch = (): string => {
55 switch (Deno.build.arch) {
56 case "x86_64":
57 return "amd64";
58 case "aarch64":
59 return "arm64";
60 default:
61 return Deno.build.arch;
62 }
63};
64
65export const isValidISOurl = (url?: string): boolean => {
66 return Boolean(
67 (url?.startsWith("http://") || url?.startsWith("https://")) &&
68 url?.endsWith(".iso"),
69 );
70};
71
72export const humanFileSize = (blocks: number) =>
73 Effect.sync(() => {
74 const blockSize = 512; // bytes per block
75 let bytes = blocks * blockSize;
76 const thresh = 1024;
77
78 if (Math.abs(bytes) < thresh) {
79 return `${bytes}B`;
80 }
81
82 const units = ["KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
83 let u = -1;
84
85 do {
86 bytes /= thresh;
87 ++u;
88 } while (Math.abs(bytes) >= thresh && u < units.length - 1);
89
90 return `${bytes.toFixed(1)}${units[u]}`;
91 });
92
93export const validateImage = (
94 image: string,
95): Effect.Effect<string, InvalidImageNameError, never> => {
96 const regex =
97 /^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/;
98
99 if (!regex.test(image)) {
100 return Effect.fail(
101 new InvalidImageNameError({
102 image,
103 cause:
104 "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.",
105 }),
106 );
107 }
108 return Effect.succeed(image);
109};
110
111export const extractTag = (name: string) =>
112 pipe(
113 validateImage(name),
114 Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")),
115 );
116
117export const failOnMissingImage = (
118 image: Image | undefined,
119): Effect.Effect<Image, Error, never> =>
120 image
121 ? Effect.succeed(image)
122 : Effect.fail(new NoSuchImageError({ cause: "No such image" }));
123
124export const du = (
125 path: string,
126): Effect.Effect<number, LogCommandError, never> =>
127 Effect.tryPromise({
128 try: async () => {
129 const cmd = new Deno.Command("du", {
130 args: [path],
131 stdout: "piped",
132 stderr: "inherit",
133 });
134
135 const { stdout } = await cmd.spawn().output();
136 const output = new TextDecoder().decode(stdout).trim();
137 const size = parseInt(output.split("\t")[0], 10);
138 return size;
139 },
140 catch: (error) => new LogCommandError({ cause: error }),
141 });
142
143export const emptyDiskImage = (path: string) =>
144 Effect.tryPromise({
145 try: async () => {
146 if (!(await Deno.stat(path).catch(() => false))) {
147 return true;
148 }
149 return false;
150 },
151 catch: (error) => new LogCommandError({ cause: error }),
152 }).pipe(
153 Effect.flatMap((exists) =>
154 exists
155 ? Effect.succeed(true)
156 : du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB))
157 ),
158 );
159
160export const downloadIso = (url: string, options: Options) =>
161 Effect.gen(function* () {
162 const filename = url.split("/").pop()!;
163 const outputPath = options.output ?? filename;
164
165 if (options.image) {
166 const imageExists = yield* Effect.tryPromise({
167 try: () =>
168 Deno.stat(options.image!)
169 .then(() => true)
170 .catch(() => false),
171 catch: (error) => new LogCommandError({ cause: error }),
172 });
173
174 if (imageExists) {
175 const driveSize = yield* du(options.image);
176 if (driveSize > EMPTY_DISK_THRESHOLD_KB) {
177 console.log(
178 chalk.yellowBright(
179 `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`,
180 ),
181 );
182 return null;
183 }
184 }
185 }
186
187 const outputExists = yield* Effect.tryPromise({
188 try: () =>
189 Deno.stat(outputPath)
190 .then(() => true)
191 .catch(() => false),
192 catch: (error) => new LogCommandError({ cause: error }),
193 });
194
195 if (outputExists) {
196 console.log(
197 chalk.yellowBright(
198 `File ${outputPath} already exists, skipping download.`,
199 ),
200 );
201 return outputPath;
202 }
203
204 yield* Effect.tryPromise({
205 try: async () => {
206 console.log(
207 chalk.blueBright(
208 `Downloading ${
209 url.endsWith(".iso") ? "ISO" : "image"
210 } from ${url}...`,
211 ),
212 );
213 const cmd = new Deno.Command("curl", {
214 args: ["-L", "-o", outputPath, url],
215 stdin: "inherit",
216 stdout: "inherit",
217 stderr: "inherit",
218 });
219
220 const status = await cmd.spawn().status;
221 if (!status.success) {
222 console.error(chalk.redBright("Failed to download ISO image."));
223 Deno.exit(status.code);
224 }
225 },
226 catch: (error) => new LogCommandError({ cause: error }),
227 });
228
229 console.log(chalk.greenBright(`Downloaded ISO to ${outputPath}`));
230 return outputPath;
231 });
232
233export const setupFirmwareFilesIfNeeded = () =>
234 Effect.gen(function* () {
235 if (Deno.build.arch !== "aarch64") {
236 return [];
237 }
238
239 const { stdout, success } = yield* Effect.tryPromise({
240 try: async () => {
241 const brewCmd = new Deno.Command("brew", {
242 args: ["--prefix", "qemu"],
243 stdout: "piped",
244 stderr: "inherit",
245 });
246 return await brewCmd.spawn().output();
247 },
248 catch: (error) => new LogCommandError({ cause: error }),
249 });
250
251 if (!success) {
252 console.error(
253 chalk.redBright(
254 "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.",
255 ),
256 );
257 Deno.exit(1);
258 }
259
260 const brewPrefix = new TextDecoder().decode(stdout).trim();
261 const edk2Aarch64 = `${brewPrefix}/share/qemu/edk2-aarch64-code.fd`;
262 const edk2VarsAarch64 = "./edk2-arm-vars.fd";
263
264 yield* Effect.tryPromise({
265 try: () =>
266 Deno.copyFile(
267 `${brewPrefix}/share/qemu/edk2-arm-vars.fd`,
268 edk2VarsAarch64,
269 ),
270 catch: (error) => new LogCommandError({ cause: error }),
271 });
272
273 return [
274 "-drive",
275 `if=pflash,format=raw,file=${edk2Aarch64},readonly=on`,
276 "-drive",
277 `if=pflash,format=raw,file=${edk2VarsAarch64}`,
278 ];
279 });
280
281export function setupPortForwardingArgs(portForward?: string): string {
282 if (!portForward) {
283 return "";
284 }
285
286 const forwards = portForward.split(",").map((pair) => {
287 const [hostPort, guestPort] = pair.split(":");
288 return `hostfwd=tcp::${hostPort}-:${guestPort}`;
289 });
290
291 return forwards.join(",");
292}
293
294export function setupNATNetworkArgs(portForward?: string): string {
295 if (!portForward) {
296 return "user,id=net0";
297 }
298
299 const portForwarding = setupPortForwardingArgs(portForward);
300 return `user,id=net0,${portForwarding}`;
301}
302
303export const setupCoreOSArgs = (imagePath?: string | null) =>
304 Effect.gen(function* () {
305 if (
306 imagePath &&
307 imagePath.endsWith(".qcow2") &&
308 imagePath.includes("coreos")
309 ) {
310 const configOK = yield* pipe(
311 fileExists("config.ign"),
312 Effect.flatMap(() => Effect.succeed(true)),
313 Effect.catchAll(() => Effect.succeed(false)),
314 );
315 if (!configOK) {
316 console.error(
317 chalk.redBright(
318 "CoreOS image requires a config.ign file in the current directory.",
319 ),
320 );
321 Deno.exit(1);
322 }
323
324 return [
325 "-drive",
326 `file=${imagePath},format=qcow2,if=virtio`,
327 "-fw_cfg",
328 "name=opt/com.coreos/config,file=config.ign",
329 ];
330 }
331
332 return [];
333 });
334
335export const setupFedoraArgs = (imagePath?: string | null, seed?: string) =>
336 Effect.sync(() => {
337 if (
338 imagePath &&
339 imagePath.endsWith(".qcow2") &&
340 (imagePath.includes("Fedora-Server") ||
341 imagePath.includes("Fedora-Cloud"))
342 ) {
343 return [
344 "-drive",
345 `file=${imagePath},format=qcow2,if=virtio`,
346 ...(seed ? ["-drive", `if=virtio,file=${seed},media=cdrom`] : []),
347 ];
348 }
349
350 return [];
351 });
352
353export const setupGentooArgs = (imagePath?: string | null, seed?: string) =>
354 Effect.sync(() => {
355 if (
356 imagePath &&
357 imagePath.endsWith(".qcow2") &&
358 imagePath.startsWith(
359 `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-`,
360 )
361 ) {
362 return [
363 "-drive",
364 `file=${imagePath},format=qcow2,if=virtio`,
365 ...(seed ? ["-drive", `if=virtio,file=${seed},media=cdrom`] : []),
366 ];
367 }
368
369 return [];
370 });
371
372export const setupAlpineArgs = (
373 imagePath?: string | null,
374 seed: string = "seed.iso",
375) =>
376 Effect.sync(() => {
377 if (
378 imagePath &&
379 imagePath.endsWith(".qcow2") &&
380 imagePath.includes("alpine")
381 ) {
382 return [
383 "-drive",
384 `file=${imagePath},format=qcow2,if=virtio`,
385 "-drive",
386 `if=virtio,file=${seed},media=cdrom`,
387 ];
388 }
389
390 return [];
391 });
392
393export const setupDebianArgs = (
394 imagePath?: string | null,
395 seed: string = "seed.iso",
396) =>
397 Effect.sync(() => {
398 if (
399 imagePath &&
400 imagePath.endsWith(".qcow2") &&
401 imagePath.includes("debian")
402 ) {
403 return [
404 "-drive",
405 `file=${imagePath},format=qcow2,if=virtio`,
406 "-drive",
407 `if=virtio,file=${seed},media=cdrom`,
408 ];
409 }
410
411 return [];
412 });
413
414export const setupUbuntuArgs = (
415 imagePath?: string | null,
416 seed: string = "seed.iso",
417) =>
418 Effect.sync(() => {
419 if (
420 imagePath &&
421 imagePath.endsWith(".img") &&
422 imagePath.includes("server-cloudimg")
423 ) {
424 return [
425 "-drive",
426 `file=${imagePath},format=qcow2,if=virtio`,
427 "-drive",
428 `if=virtio,file=${seed},media=cdrom`,
429 ];
430 }
431
432 return [];
433 });
434
435export const setupAlmaLinuxArgs = (
436 imagePath?: string | null,
437 seed: string = "seed.iso",
438) =>
439 Effect.sync(() => {
440 if (
441 imagePath &&
442 imagePath.endsWith(".qcow2") &&
443 imagePath.includes("AlmaLinux")
444 ) {
445 return [
446 "-drive",
447 `file=${imagePath},format=qcow2,if=virtio`,
448 "-drive",
449 `if=virtio,file=${seed},media=cdrom`,
450 ];
451 }
452
453 return [];
454 });
455
456export const setupRockyLinuxArgs = (
457 imagePath?: string | null,
458 seed: string = "seed.iso",
459) =>
460 Effect.sync(() => {
461 if (
462 imagePath &&
463 imagePath.endsWith(".qcow2") &&
464 imagePath.includes("Rocky")
465 ) {
466 return [
467 "-drive",
468 `file=${imagePath},format=qcow2,if=virtio`,
469 "-drive",
470 `if=virtio,file=${seed},media=cdrom`,
471 ];
472 }
473
474 return [];
475 });
476
477export const runQemu = (isoPath: string | null, options: Options) =>
478 Effect.gen(function* () {
479 const macAddress = yield* generateRandomMacAddress();
480
481 const qemu = Deno.build.arch === "aarch64"
482 ? "qemu-system-aarch64"
483 : "qemu-system-x86_64";
484
485 const firmwareFiles = yield* setupFirmwareFilesIfNeeded();
486 let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image);
487 let fedoraArgs: string[] = yield* setupFedoraArgs(
488 isoPath || options.image,
489 options.seed,
490 );
491 let gentooArgs: string[] = yield* setupGentooArgs(
492 isoPath || options.image,
493 options.seed,
494 );
495 let alpineArgs: string[] = yield* setupAlpineArgs(
496 isoPath || options.image,
497 options.seed,
498 );
499 let debianArgs: string[] = yield* setupDebianArgs(
500 isoPath || options.image,
501 options.seed,
502 );
503 let ubuntuArgs: string[] = yield* setupUbuntuArgs(
504 isoPath || options.image,
505 options.seed,
506 );
507 let almalinuxArgs: string[] = yield* setupAlmaLinuxArgs(
508 isoPath || options.image,
509 options.seed,
510 );
511 let rockylinuxArgs: string[] = yield* setupRockyLinuxArgs(
512 isoPath || options.image,
513 options.seed,
514 );
515
516 if (coreosArgs.length > 0 && !isoPath) {
517 coreosArgs = coreosArgs.slice(2);
518 }
519
520 if (fedoraArgs.length > 0 && !isoPath) {
521 fedoraArgs = [];
522 }
523
524 if (gentooArgs.length > 0 && !isoPath) {
525 gentooArgs = [];
526 }
527
528 if (alpineArgs.length > 0 && !isoPath) {
529 alpineArgs = alpineArgs.slice(2);
530 }
531
532 if (debianArgs.length > 0 && !isoPath) {
533 debianArgs = [];
534 }
535
536 if (ubuntuArgs.length > 0 && !isoPath) {
537 ubuntuArgs = [];
538 }
539
540 if (almalinuxArgs.length > 0 && !isoPath) {
541 almalinuxArgs = [];
542 }
543
544 if (rockylinuxArgs.length > 0 && !isoPath) {
545 rockylinuxArgs = [];
546 }
547
548 const qemuArgs = [
549 ..._.compact([options.bridge && qemu]),
550 ...(Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"]),
551 ...(Deno.build.arch === "aarch64" ? ["-machine", "virt,highmem=on"] : []),
552 "-cpu",
553 options.cpu,
554 "-m",
555 options.memory,
556 "-smp",
557 options.cpus.toString(),
558 ...(isoPath && isoPath.endsWith(".iso") ? ["-cdrom", isoPath] : []),
559 "-netdev",
560 options.bridge
561 ? `bridge,id=net0,br=${options.bridge}`
562 : setupNATNetworkArgs(options.portForward),
563 "-device",
564 `e1000,netdev=net0,mac=${macAddress}`,
565 ...(options.install ? [] : ["-snapshot"]),
566 "-nographic",
567 "-monitor",
568 "none",
569 "-chardev",
570 "stdio,id=con0,signal=off",
571 "-serial",
572 "chardev:con0",
573 ...firmwareFiles,
574 ...coreosArgs,
575 ...fedoraArgs,
576 ...gentooArgs,
577 ...alpineArgs,
578 ...debianArgs,
579 ...ubuntuArgs,
580 ...almalinuxArgs,
581 ...rockylinuxArgs,
582 ..._.compact(
583 options.image && [
584 "-drive",
585 `file=${options.image},format=${options.diskFormat},if=virtio`,
586 ],
587 ),
588 ];
589
590 const name = Moniker.choose();
591
592 if (options.detach) {
593 yield* Effect.tryPromise({
594 try: () => Deno.mkdir(LOGS_DIR, { recursive: true }),
595 catch: (error) => new LogCommandError({ cause: error }),
596 });
597
598 const logPath = `${LOGS_DIR}/${name}.log`;
599
600 const fullCommand = options.bridge
601 ? `sudo ${qemu} ${
602 qemuArgs
603 .slice(1)
604 .join(" ")
605 } >> "${logPath}" 2>&1 & echo $!`
606 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`;
607
608 const { stdout } = yield* Effect.tryPromise({
609 try: async () => {
610 const cmd = new Deno.Command("sh", {
611 args: ["-c", fullCommand],
612 stdin: "null",
613 stdout: "piped",
614 });
615 return await cmd.spawn().output();
616 },
617 catch: (error) => new LogCommandError({ cause: error }),
618 });
619
620 const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10);
621
622 yield* saveInstanceState({
623 id: createId(),
624 name,
625 bridge: options.bridge,
626 macAddress,
627 memory: options.memory,
628 cpus: options.cpus,
629 cpu: options.cpu,
630 diskSize: options.size || "20G",
631 diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
632 options.diskFormat ||
633 "raw",
634 portForward: options.portForward,
635 isoPath:
636 isoPath && !isoPath.includes("coreos") && isoPath.endsWith(".iso")
637 ? Deno.realPathSync(isoPath)
638 : undefined,
639 drivePath: options.image
640 ? Deno.realPathSync(options.image)
641 : isoPath?.endsWith("qcow2")
642 ? Deno.realPathSync(isoPath)
643 : undefined,
644 status: "RUNNING",
645 pid: qemuPid,
646 seed: options.seed,
647 });
648
649 console.log(
650 `Virtual machine ${name} started in background (PID: ${qemuPid})`,
651 );
652 console.log(`Logs will be written to: ${logPath}`);
653
654 // Exit successfully while keeping VM running in background
655 Deno.exit(0);
656 } else {
657 const cmd = new Deno.Command(options.bridge ? "sudo" : qemu, {
658 args: qemuArgs,
659 stdin: "inherit",
660 stdout: "inherit",
661 stderr: "inherit",
662 }).spawn();
663
664 yield* saveInstanceState({
665 id: createId(),
666 name,
667 bridge: options.bridge,
668 macAddress,
669 memory: options.memory,
670 cpus: options.cpus,
671 cpu: options.cpu,
672 diskSize: options.size || "20G",
673 diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
674 options.diskFormat ||
675 "raw",
676 portForward: options.portForward,
677 isoPath:
678 isoPath && !isoPath.includes("coreos") && isoPath.endsWith(".iso")
679 ? Deno.realPathSync(isoPath)
680 : undefined,
681 drivePath: options.image
682 ? Deno.realPathSync(options.image)
683 : isoPath?.endsWith("qcow2")
684 ? Deno.realPathSync(isoPath)
685 : undefined,
686 status: "RUNNING",
687 pid: cmd.pid,
688 seed: options.seed ? Deno.realPathSync(options.seed) : undefined,
689 });
690
691 const status = yield* Effect.tryPromise({
692 try: () => cmd.status,
693 catch: (error) => new LogCommandError({ cause: error }),
694 });
695
696 yield* updateInstanceState(name, "STOPPED");
697
698 if (!status.success) {
699 Deno.exit(status.code);
700 }
701 }
702 });
703
704export const safeKillQemu = (pid: number, useSudo: boolean = false) =>
705 Effect.gen(function* () {
706 const killArgs = useSudo
707 ? ["sudo", "kill", "-TERM", pid.toString()]
708 : ["kill", "-TERM", pid.toString()];
709
710 const termStatus = yield* Effect.tryPromise({
711 try: async () => {
712 const termCmd = new Deno.Command(killArgs[0], {
713 args: killArgs.slice(1),
714 stdout: "null",
715 stderr: "null",
716 });
717 return await termCmd.spawn().status;
718 },
719 catch: (error) => new LogCommandError({ cause: error }),
720 });
721
722 if (termStatus.success) {
723 yield* Effect.tryPromise({
724 try: () => new Promise((resolve) => setTimeout(resolve, 3000)),
725 catch: (error) => new LogCommandError({ cause: error }),
726 });
727
728 const checkStatus = yield* Effect.tryPromise({
729 try: async () => {
730 const checkCmd = new Deno.Command("kill", {
731 args: ["-0", pid.toString()],
732 stdout: "null",
733 stderr: "null",
734 });
735 return await checkCmd.spawn().status;
736 },
737 catch: (error) => new LogCommandError({ cause: error }),
738 });
739
740 if (!checkStatus.success) {
741 return true;
742 }
743 }
744
745 const killKillArgs = useSudo
746 ? ["sudo", "kill", "-KILL", pid.toString()]
747 : ["kill", "-KILL", pid.toString()];
748
749 const killStatus = yield* Effect.tryPromise({
750 try: async () => {
751 const killCmd = new Deno.Command(killKillArgs[0], {
752 args: killKillArgs.slice(1),
753 stdout: "null",
754 stderr: "null",
755 });
756 return await killCmd.spawn().status;
757 },
758 catch: (error) => new LogCommandError({ cause: error }),
759 });
760
761 return killStatus.success;
762 });
763
764export const createDriveImageIfNeeded = ({
765 image: path,
766 diskFormat: format,
767 size,
768}: Options) =>
769 Effect.gen(function* () {
770 const pathExists = yield* Effect.tryPromise({
771 try: () =>
772 Deno.stat(path!)
773 .then(() => true)
774 .catch(() => false),
775 catch: (error) => new LogCommandError({ cause: error }),
776 });
777
778 if (pathExists) {
779 console.log(
780 chalk.yellowBright(
781 `Drive image ${path} already exists, skipping creation.`,
782 ),
783 );
784 return;
785 }
786
787 const status = yield* Effect.tryPromise({
788 try: async () => {
789 const cmd = new Deno.Command("qemu-img", {
790 args: ["create", "-f", format || "raw", path!, size!],
791 stdin: "inherit",
792 stdout: "inherit",
793 stderr: "inherit",
794 });
795 return await cmd.spawn().status;
796 },
797 catch: (error) => new LogCommandError({ cause: error }),
798 });
799
800 if (!status.success) {
801 console.error(chalk.redBright("Failed to create drive image."));
802 Deno.exit(status.code);
803 }
804
805 console.log(chalk.greenBright(`Created drive image at ${path}`));
806 });
807
808export const fileExists = (
809 path: string,
810): Effect.Effect<void, NoSuchFileError, never> =>
811 Effect.try({
812 try: () => Deno.statSync(path),
813 catch: (error) => new NoSuchFileError({ cause: String(error) }),
814 });
815
816export const constructCoreOSImageURL = (
817 image: string,
818): Effect.Effect<string, InvalidImageNameError, never> => {
819 // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version>
820 const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/;
821 const match = image.match(coreosRegex);
822 if (match) {
823 const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION;
824 return Effect.succeed(
825 FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version),
826 );
827 }
828
829 return Effect.fail(
830 new InvalidImageNameError({
831 image,
832 cause: "Image name does not match CoreOS naming conventions.",
833 }),
834 );
835};
836
837export const extractXz = (path: string | null) =>
838 Effect.tryPromise({
839 try: async () => {
840 if (!path) {
841 return null;
842 }
843 const cmd = new Deno.Command("xz", {
844 args: ["-d", path],
845 stdin: "inherit",
846 stdout: "inherit",
847 stderr: "inherit",
848 cwd: dirname(path),
849 }).spawn();
850
851 const status = await cmd.status;
852 if (!status.success) {
853 console.error(chalk.redBright("Failed to extract xz file."));
854 Deno.exit(status.code);
855 }
856 return path.replace(/\.xz$/, "");
857 },
858 catch: (error) => new LogCommandError({ cause: error }),
859 });
860
861export const constructNixOSImageURL = (
862 image: string,
863): Effect.Effect<string, InvalidImageNameError, never> => {
864 // detect with regex if image matches NixOS pattern: nixos or nixos-<version>
865 const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/;
866 const match = image.match(nixosRegex);
867 if (match) {
868 const version = match[3] || NIXOS_DEFAULT_VERSION;
869 return Effect.succeed(
870 NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version),
871 );
872 }
873
874 return Effect.fail(
875 new InvalidImageNameError({
876 image,
877 cause: "Image name does not match NixOS naming conventions.",
878 }),
879 );
880};
881
882export const constructFedoraImageURL = (
883 image: string,
884 cloud: boolean = false,
885): Effect.Effect<string, InvalidImageNameError, never> => {
886 // detect with regex if image matches Fedora pattern: fedora
887 const fedoraRegex = /^(fedora)$/;
888 const match = image.match(fedoraRegex);
889 if (match) {
890 return Effect.succeed(cloud ? FEDORA_CLOUD_IMG_URL : FEDORA_IMG_URL);
891 }
892
893 return Effect.fail(
894 new InvalidImageNameError({
895 image,
896 cause: "Image name does not match Fedora naming conventions.",
897 }),
898 );
899};
900
901export const constructGentooImageURL = (
902 image: string,
903): Effect.Effect<string, InvalidImageNameError, never> => {
904 // detect with regex if image matches genroo pattern: gentoo-20251116T161545Z or gentoo
905 const gentooRegex = /^(gentoo)(-(\d{8}T\d{6}Z))?$/;
906 const match = image.match(gentooRegex);
907 if (match?.[3]) {
908 return Effect.succeed(
909 GENTOO_IMG_URL.replaceAll("20251116T161545Z", match[3]).replaceAll(
910 "20251116T233105Z",
911 match[3],
912 ),
913 );
914 }
915
916 if (match) {
917 return Effect.succeed(GENTOO_IMG_URL);
918 }
919
920 return Effect.fail(
921 new InvalidImageNameError({
922 image,
923 cause: "Image name does not match Gentoo naming conventions.",
924 }),
925 );
926};
927
928export const constructDebianImageURL = (
929 image: string,
930 cloud: boolean = false,
931): Effect.Effect<string, InvalidImageNameError, never> => {
932 if (cloud && image === "debian") {
933 return Effect.succeed(DEBIAN_CLOUD_IMG_URL);
934 }
935
936 // detect with regex if image matches debian pattern: debian-<version> or debian
937 const debianRegex = /^(debian)(-(\d+\.\d+\.\d+))?$/;
938 const match = image.match(debianRegex);
939 if (match?.[3]) {
940 return Effect.succeed(
941 DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]),
942 );
943 }
944
945 if (match) {
946 return Effect.succeed(DEBIAN_ISO_URL);
947 }
948
949 return Effect.fail(
950 new InvalidImageNameError({
951 image,
952 cause: "Image name does not match Debian naming conventions.",
953 }),
954 );
955};
956
957export const constructAlpineImageURL = (
958 image: string,
959): Effect.Effect<string, InvalidImageNameError, never> => {
960 // detect with regex if image matches alpine pattern: alpine-<version> or alpine
961 const alpineRegex = /^(alpine)(-(\d+\.\d+(\.\d+)?))?$/;
962 const match = image.match(alpineRegex);
963 if (match?.[3]) {
964 return Effect.succeed(
965 ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3]),
966 );
967 }
968
969 if (match) {
970 return Effect.succeed(ALPINE_ISO_URL);
971 }
972
973 return Effect.fail(
974 new InvalidImageNameError({
975 image,
976 cause: "Image name does not match Alpine naming conventions.",
977 }),
978 );
979};
980
981export const constructUbuntuImageURL = (
982 image: string,
983 cloud: boolean = false,
984): Effect.Effect<string, InvalidImageNameError, never> => {
985 // detect with regex if image matches ubuntu pattern: ubuntu
986 const ubuntuRegex = /^(ubuntu)$/;
987 const match = image.match(ubuntuRegex);
988 if (match) {
989 if (cloud) {
990 return Effect.succeed(UBUNTU_CLOUD_IMG_URL);
991 }
992 return Effect.succeed(UBUNTU_ISO_URL);
993 }
994
995 return Effect.fail(
996 new InvalidImageNameError({
997 image,
998 cause: "Image name does not match Ubuntu naming conventions.",
999 }),
1000 );
1001};
1002
1003export const constructAlmaLinuxImageURL = (
1004 image: string,
1005 cloud: boolean = false,
1006): Effect.Effect<string, InvalidImageNameError, never> => {
1007 // detect with regex if image matches almalinux pattern: almalinux, almalinux
1008 const almaLinuxRegex = /^(almalinux|alma)$/;
1009 const match = image.match(almaLinuxRegex);
1010 if (match) {
1011 if (cloud) {
1012 return Effect.succeed(ALMA_LINUX_IMG_URL);
1013 }
1014 return Effect.succeed(ALMA_LINUX_IMG_URL);
1015 }
1016
1017 return Effect.fail(
1018 new InvalidImageNameError({
1019 image,
1020 cause: "Image name does not match AlmaLinux naming conventions.",
1021 }),
1022 );
1023};
1024
1025export const constructRockyLinuxImageURL = (
1026 image: string,
1027 cloud: boolean = false,
1028): Effect.Effect<string, InvalidImageNameError, never> => {
1029 // detect with regex if image matches rockylinux pattern: rocky. rockylinux
1030 const rockyLinuxRegex = /^(rockylinux|rocky)$/;
1031 const match = image.match(rockyLinuxRegex);
1032 if (match) {
1033 if (cloud) {
1034 return Effect.succeed(ROCKY_LINUX_IMG_URL);
1035 }
1036 return Effect.succeed(ROCKY_LINUX_IMG_URL);
1037 }
1038
1039 return Effect.fail(
1040 new InvalidImageNameError({
1041 image,
1042 cause: "Image name does not match RockyLinux naming conventions.",
1043 }),
1044 );
1045};