A Docker-like CLI and HTTP API for managing headless VMs

fix: correct formatting and improve consistency in utils.ts

+75 -74
+75 -74
src/utils.ts
··· 67 67 export const isValidISOurl = (url?: string): boolean => { 68 68 return Boolean( 69 69 (url?.startsWith("http://") || url?.startsWith("https://")) && 70 - url?.endsWith(".iso"), 70 + url?.endsWith(".iso") 71 71 ); 72 72 }; 73 73 ··· 93 93 }); 94 94 95 95 export const validateImage = ( 96 - image: string, 96 + image: string 97 97 ): Effect.Effect<string, InvalidImageNameError, never> => { 98 98 const regex = 99 99 /^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/; ··· 104 104 image, 105 105 cause: 106 106 "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.", 107 - }), 107 + }) 108 108 ); 109 109 } 110 110 return Effect.succeed(image); ··· 113 113 export const extractTag = (name: string) => 114 114 pipe( 115 115 validateImage(name), 116 - Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")), 116 + Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")) 117 117 ); 118 118 119 119 export const failOnMissingImage = ( 120 - image: Image | undefined, 120 + image: Image | undefined 121 121 ): Effect.Effect<Image, Error, never> => 122 122 image 123 123 ? Effect.succeed(image) 124 124 : Effect.fail(new NoSuchImageError({ cause: "No such image" })); 125 125 126 126 export const du = ( 127 - path: string, 127 + path: string 128 128 ): Effect.Effect<number, LogCommandError, never> => 129 129 Effect.tryPromise({ 130 130 try: async () => { ··· 156 156 exists 157 157 ? Effect.succeed(true) 158 158 : du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB)) 159 - ), 159 + ) 160 160 ); 161 161 162 162 export const downloadIso = (url: string, options: Options) => ··· 178 178 if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 179 179 console.log( 180 180 chalk.yellowBright( 181 - `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, 182 - ), 181 + `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.` 182 + ) 183 183 ); 184 184 return null; 185 185 } ··· 197 197 if (outputExists) { 198 198 console.log( 199 199 chalk.yellowBright( 200 - `File ${outputPath} already exists, skipping download.`, 201 - ), 200 + `File ${outputPath} already exists, skipping download.` 201 + ) 202 202 ); 203 203 return outputPath; 204 204 } ··· 209 209 chalk.blueBright( 210 210 `Downloading ${ 211 211 url.endsWith(".iso") ? "ISO" : "image" 212 - } from ${url}...`, 213 - ), 212 + } from ${url}...` 213 + ) 214 214 ); 215 215 const cmd = new Deno.Command("curl", { 216 216 args: ["-L", "-o", outputPath, url], ··· 253 253 if (!success) { 254 254 console.error( 255 255 chalk.redBright( 256 - "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", 257 - ), 256 + "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew." 257 + ) 258 258 ); 259 259 Deno.exit(1); 260 260 } ··· 267 267 try: () => 268 268 Deno.copyFile( 269 269 `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 270 - edk2VarsAarch64, 270 + edk2VarsAarch64 271 271 ), 272 272 catch: (error) => new LogCommandError({ cause: error }), 273 273 }); ··· 312 312 const configOK = yield* pipe( 313 313 fileExists("config.ign"), 314 314 Effect.flatMap(() => Effect.succeed(true)), 315 - Effect.catchAll(() => Effect.succeed(false)), 315 + Effect.catchAll(() => Effect.succeed(false)) 316 316 ); 317 317 if (!configOK) { 318 318 console.error( 319 319 chalk.redBright( 320 - "CoreOS image requires a config.ign file in the current directory.", 321 - ), 320 + "CoreOS image requires a config.ign file in the current directory." 321 + ) 322 322 ); 323 323 Deno.exit(1); 324 324 } ··· 358 358 imagePath && 359 359 imagePath.endsWith(".qcow2") && 360 360 imagePath.startsWith( 361 - `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-`, 361 + `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-` 362 362 ) 363 363 ) { 364 364 return [ ··· 373 373 374 374 export const setupAlpineArgs = ( 375 375 imagePath?: string | null, 376 - seed: string = "seed.iso", 376 + seed: string = "seed.iso" 377 377 ) => 378 378 Effect.sync(() => { 379 379 if ( ··· 394 394 395 395 export const setupDebianArgs = ( 396 396 imagePath?: string | null, 397 - seed: string = "seed.iso", 397 + seed: string = "seed.iso" 398 398 ) => 399 399 Effect.sync(() => { 400 400 if ( ··· 415 415 416 416 export const setupUbuntuArgs = ( 417 417 imagePath?: string | null, 418 - seed: string = "seed.iso", 418 + seed: string = "seed.iso" 419 419 ) => 420 420 Effect.sync(() => { 421 421 if ( ··· 436 436 437 437 export const setupAlmaLinuxArgs = ( 438 438 imagePath?: string | null, 439 - seed: string = "seed.iso", 439 + seed: string = "seed.iso" 440 440 ) => 441 441 Effect.sync(() => { 442 442 if ( ··· 457 457 458 458 export const setupRockyLinuxArgs = ( 459 459 imagePath?: string | null, 460 - seed: string = "seed.iso", 460 + seed: string = "seed.iso" 461 461 ) => 462 462 Effect.sync(() => { 463 463 if ( ··· 480 480 Effect.gen(function* () { 481 481 const macAddress = yield* generateRandomMacAddress(); 482 482 483 - const qemu = Deno.build.arch === "aarch64" 484 - ? "qemu-system-aarch64" 485 - : "qemu-system-x86_64"; 483 + const qemu = 484 + Deno.build.arch === "aarch64" 485 + ? "qemu-system-aarch64" 486 + : "qemu-system-x86_64"; 486 487 487 488 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 488 489 let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image); 489 490 let fedoraArgs: string[] = yield* setupFedoraArgs( 490 491 isoPath || options.image, 491 - options.seed, 492 + options.seed 492 493 ); 493 494 let gentooArgs: string[] = yield* setupGentooArgs( 494 495 isoPath || options.image, 495 - options.seed, 496 + options.seed 496 497 ); 497 498 let alpineArgs: string[] = yield* setupAlpineArgs( 498 499 isoPath || options.image, 499 - options.seed, 500 + options.seed 500 501 ); 501 502 let debianArgs: string[] = yield* setupDebianArgs( 502 503 isoPath || options.image, 503 - options.seed, 504 + options.seed 504 505 ); 505 506 let ubuntuArgs: string[] = yield* setupUbuntuArgs( 506 507 isoPath || options.image, 507 - options.seed, 508 + options.seed 508 509 ); 509 510 let almalinuxArgs: string[] = yield* setupAlmaLinuxArgs( 510 511 isoPath || options.image, 511 - options.seed, 512 + options.seed 512 513 ); 513 514 let rockylinuxArgs: string[] = yield* setupRockyLinuxArgs( 514 515 isoPath || options.image, 515 - options.seed, 516 + options.seed 516 517 ); 517 518 518 519 if (coreosArgs.length > 0 && !isoPath) { ··· 585 586 options.image && [ 586 587 "-drive", 587 588 `file=${options.image},format=${options.diskFormat},if=virtio`, 588 - ], 589 + ] 589 590 ), 590 591 ]; 591 592 ··· 600 601 const logPath = `${LOGS_DIR}/${name}.log`; 601 602 602 603 const fullCommand = options.bridge 603 - ? `sudo ${qemu} ${ 604 - qemuArgs 604 + ? `sudo ${qemu} ${qemuArgs 605 605 .slice(1) 606 - .join(" ") 607 - } >> "${logPath}" 2>&1 & echo $!` 606 + .join(" ")} >> "${logPath}" 2>&1 & echo $!` 608 607 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 609 608 610 609 const { stdout } = yield* Effect.tryPromise({ ··· 630 629 cpus: options.cpus, 631 630 cpu: options.cpu, 632 631 diskSize: options.size || "20G", 633 - diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 632 + diskFormat: 633 + (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 634 634 options.diskFormat || 635 635 "raw", 636 636 portForward: options.portForward, ··· 650 650 }); 651 651 652 652 console.log( 653 - `Virtual machine ${name} started in background (PID: ${qemuPid})`, 653 + `Virtual machine ${name} started in background (PID: ${qemuPid})` 654 654 ); 655 655 console.log(`Logs will be written to: ${logPath}`); 656 656 ··· 673 673 cpus: options.cpus, 674 674 cpu: options.cpu, 675 675 diskSize: options.size || "20G", 676 - diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 676 + diskFormat: 677 + (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 677 678 options.diskFormat || 678 679 "raw", 679 680 portForward: options.portForward, ··· 782 783 if (pathExists) { 783 784 console.log( 784 785 chalk.yellowBright( 785 - `Drive image ${path} already exists, skipping creation.`, 786 - ), 786 + `Drive image ${path} already exists, skipping creation.` 787 + ) 787 788 ); 788 789 return; 789 790 } ··· 810 811 }); 811 812 812 813 export const fileExists = ( 813 - path: string, 814 + path: string 814 815 ): Effect.Effect<void, NoSuchFileError, never> => 815 816 Effect.try({ 816 817 try: () => Deno.statSync(path), ··· 818 819 }); 819 820 820 821 export const constructCoreOSImageURL = ( 821 - image: string, 822 + image: string 822 823 ): Effect.Effect<string, InvalidImageNameError, never> => { 823 824 // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version> 824 825 const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/; ··· 826 827 if (match) { 827 828 const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION; 828 829 return Effect.succeed( 829 - FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version), 830 + FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version) 830 831 ); 831 832 } 832 833 ··· 834 835 new InvalidImageNameError({ 835 836 image, 836 837 cause: "Image name does not match CoreOS naming conventions.", 837 - }), 838 + }) 838 839 ); 839 840 }; 840 841 ··· 863 864 }); 864 865 865 866 export const constructNixOSImageURL = ( 866 - image: string, 867 + image: string 867 868 ): Effect.Effect<string, InvalidImageNameError, never> => { 868 869 // detect with regex if image matches NixOS pattern: nixos or nixos-<version> 869 870 const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/; ··· 871 872 if (match) { 872 873 const version = match[3] || NIXOS_DEFAULT_VERSION; 873 874 return Effect.succeed( 874 - NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version), 875 + NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version) 875 876 ); 876 877 } 877 878 ··· 879 880 new InvalidImageNameError({ 880 881 image, 881 882 cause: "Image name does not match NixOS naming conventions.", 882 - }), 883 + }) 883 884 ); 884 885 }; 885 886 886 887 export const constructFedoraImageURL = ( 887 888 image: string, 888 - cloud: boolean = false, 889 + cloud: boolean = false 889 890 ): Effect.Effect<string, InvalidImageNameError, never> => { 890 891 // detect with regex if image matches Fedora pattern: fedora 891 892 const fedoraRegex = /^(fedora)$/; ··· 898 899 new InvalidImageNameError({ 899 900 image, 900 901 cause: "Image name does not match Fedora naming conventions.", 901 - }), 902 + }) 902 903 ); 903 904 }; 904 905 905 906 export const constructGentooImageURL = ( 906 - image: string, 907 + image: string 907 908 ): Effect.Effect<string, InvalidImageNameError, never> => { 908 909 // detect with regex if image matches genroo pattern: gentoo-20251116T161545Z or gentoo 909 910 const gentooRegex = /^(gentoo)(-(\d{8}T\d{6}Z))?$/; ··· 912 913 return Effect.succeed( 913 914 GENTOO_IMG_URL.replaceAll("20251116T161545Z", match[3]).replaceAll( 914 915 "20251116T233105Z", 915 - match[3], 916 - ), 916 + match[3] 917 + ) 917 918 ); 918 919 } 919 920 ··· 925 926 new InvalidImageNameError({ 926 927 image, 927 928 cause: "Image name does not match Gentoo naming conventions.", 928 - }), 929 + }) 929 930 ); 930 931 }; 931 932 932 933 export const constructDebianImageURL = ( 933 934 image: string, 934 - cloud: boolean = false, 935 + cloud: boolean = false 935 936 ): Effect.Effect<string, InvalidImageNameError, never> => { 936 937 if (cloud && image === "debian") { 937 938 return Effect.succeed(DEBIAN_CLOUD_IMG_URL); ··· 942 943 const match = image.match(debianRegex); 943 944 if (match?.[3]) { 944 945 return Effect.succeed( 945 - DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]), 946 + DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]) 946 947 ); 947 948 } 948 949 ··· 954 955 new InvalidImageNameError({ 955 956 image, 956 957 cause: "Image name does not match Debian naming conventions.", 957 - }), 958 + }) 958 959 ); 959 960 }; 960 961 961 962 export const constructAlpineImageURL = ( 962 - image: string, 963 + image: string 963 964 ): Effect.Effect<string, InvalidImageNameError, never> => { 964 965 // detect with regex if image matches alpine pattern: alpine-<version> or alpine 965 966 const alpineRegex = /^(alpine)(-(\d+\.\d+(\.\d+)?))?$/; 966 967 const match = image.match(alpineRegex); 967 968 if (match?.[3]) { 968 969 return Effect.succeed( 969 - ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3]), 970 + ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3]) 970 971 ); 971 972 } 972 973 ··· 978 979 new InvalidImageNameError({ 979 980 image, 980 981 cause: "Image name does not match Alpine naming conventions.", 981 - }), 982 + }) 982 983 ); 983 984 }; 984 985 985 986 export const constructUbuntuImageURL = ( 986 987 image: string, 987 - cloud: boolean = false, 988 + cloud: boolean = false 988 989 ): Effect.Effect<string, InvalidImageNameError, never> => { 989 990 // detect with regex if image matches ubuntu pattern: ubuntu 990 991 const ubuntuRegex = /^(ubuntu)$/; ··· 1000 1001 new InvalidImageNameError({ 1001 1002 image, 1002 1003 cause: "Image name does not match Ubuntu naming conventions.", 1003 - }), 1004 + }) 1004 1005 ); 1005 1006 }; 1006 1007 1007 1008 export const constructAlmaLinuxImageURL = ( 1008 1009 image: string, 1009 - cloud: boolean = false, 1010 + cloud: boolean = false 1010 1011 ): Effect.Effect<string, InvalidImageNameError, never> => { 1011 - // detect with regex if image matches almalinux pattern: ubuntu 1012 - const almaLinuxRegex = /^(almalinux)$/; 1012 + // detect with regex if image matches almalinux pattern: almalinux, almalinux 1013 + const almaLinuxRegex = /^(almalinux|alma)$/; 1013 1014 const match = image.match(almaLinuxRegex); 1014 1015 if (match) { 1015 1016 if (cloud) { ··· 1022 1023 new InvalidImageNameError({ 1023 1024 image, 1024 1025 cause: "Image name does not match AlmaLinux naming conventions.", 1025 - }), 1026 + }) 1026 1027 ); 1027 1028 }; 1028 1029 1029 1030 export const constructRockyLinuxImageURL = ( 1030 1031 image: string, 1031 - cloud: boolean = false, 1032 + cloud: boolean = false 1032 1033 ): Effect.Effect<string, InvalidImageNameError, never> => { 1033 - // detect with regex if image matches rockylinux pattern: ubuntu 1034 - const rockyLinuxRegex = /^(rockylinux)$/; 1034 + // detect with regex if image matches rockylinux pattern: rocky. rockylinux 1035 + const rockyLinuxRegex = /^(rockylinux|rocky)$/; 1035 1036 const match = image.match(rockyLinuxRegex); 1036 1037 if (match) { 1037 1038 if (cloud) { ··· 1044 1045 new InvalidImageNameError({ 1045 1046 image, 1046 1047 cause: "Image name does not match RockyLinux naming conventions.", 1047 - }), 1048 + }) 1048 1049 ); 1049 1050 };