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

run format

+58 -59
+58 -59
src/utils.ts
··· 76 76 export const isValidISOurl = (url?: string): boolean => { 77 77 return Boolean( 78 78 (url?.startsWith("http://") || url?.startsWith("https://")) && 79 - url?.endsWith(".iso") 79 + url?.endsWith(".iso"), 80 80 ); 81 81 }; 82 82 ··· 102 102 }); 103 103 104 104 export const validateImage = ( 105 - image: string 105 + image: string, 106 106 ): Effect.Effect<string, InvalidImageNameError, never> => { 107 107 const regex = 108 108 /^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/; ··· 113 113 image, 114 114 cause: 115 115 "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.", 116 - }) 116 + }), 117 117 ); 118 118 } 119 119 return Effect.succeed(image); ··· 122 122 export const extractTag = (name: string) => 123 123 pipe( 124 124 validateImage(name), 125 - Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")) 125 + Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")), 126 126 ); 127 127 128 128 export const failOnMissingImage = ( 129 - image: Image | undefined 129 + image: Image | undefined, 130 130 ): Effect.Effect<Image, Error, never> => 131 131 image 132 132 ? Effect.succeed(image) 133 133 : Effect.fail(new NoSuchImageError({ cause: "No such image" })); 134 134 135 135 export const du = ( 136 - path: string 136 + path: string, 137 137 ): Effect.Effect<number, LogCommandError, never> => 138 138 Effect.tryPromise({ 139 139 try: async () => { ··· 165 165 exists 166 166 ? Effect.succeed(true) 167 167 : du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB)) 168 - ) 168 + ), 169 169 ); 170 170 171 171 export const downloadIso = (url: string, options: Options) => ··· 187 187 if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 188 188 console.log( 189 189 chalk.yellowBright( 190 - `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.` 191 - ) 190 + `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, 191 + ), 192 192 ); 193 193 return null; 194 194 } ··· 206 206 if (outputExists) { 207 207 console.log( 208 208 chalk.yellowBright( 209 - `File ${outputPath} already exists, skipping download.` 210 - ) 209 + `File ${outputPath} already exists, skipping download.`, 210 + ), 211 211 ); 212 212 return outputPath; 213 213 } ··· 256 256 if (!success) { 257 257 console.error( 258 258 chalk.redBright( 259 - "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew." 260 - ) 259 + "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", 260 + ), 261 261 ); 262 262 Deno.exit(1); 263 263 } ··· 270 270 try: () => 271 271 Deno.copyFile( 272 272 `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 273 - edk2VarsAarch64 273 + edk2VarsAarch64, 274 274 ), 275 275 catch: (error) => new LogCommandError({ cause: error }), 276 276 }); ··· 315 315 const configOK = yield* pipe( 316 316 fileExists("config.ign"), 317 317 Effect.flatMap(() => Effect.succeed(true)), 318 - Effect.catchAll(() => Effect.succeed(false)) 318 + Effect.catchAll(() => Effect.succeed(false)), 319 319 ); 320 320 if (!configOK) { 321 321 console.error( 322 322 chalk.redBright( 323 - "CoreOS image requires a config.ign file in the current directory." 324 - ) 323 + "CoreOS image requires a config.ign file in the current directory.", 324 + ), 325 325 ); 326 326 Deno.exit(1); 327 327 } ··· 356 356 imagePath && 357 357 imagePath.endsWith(".qcow2") && 358 358 imagePath.startsWith( 359 - `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-` 359 + `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-`, 360 360 ) 361 361 ) { 362 362 return ["-drive", `file=${imagePath},format=qcow2,if=virtio`]; ··· 459 459 Effect.gen(function* () { 460 460 const macAddress = yield* generateRandomMacAddress(); 461 461 462 - const qemu = 463 - Deno.build.arch === "aarch64" 464 - ? "qemu-system-aarch64" 465 - : "qemu-system-x86_64"; 462 + const qemu = Deno.build.arch === "aarch64" 463 + ? "qemu-system-aarch64" 464 + : "qemu-system-x86_64"; 466 465 467 466 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 468 467 let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image); ··· 472 471 let debianArgs: string[] = yield* setupDebianArgs(isoPath || options.image); 473 472 let ubuntuArgs: string[] = yield* setupUbuntuArgs(isoPath || options.image); 474 473 let almalinuxArgs: string[] = yield* setupAlmaLinuxArgs( 475 - isoPath || options.image 474 + isoPath || options.image, 476 475 ); 477 476 let rockylinuxArgs: string[] = yield* setupRockyLinuxArgs( 478 - isoPath || options.image 477 + isoPath || options.image, 479 478 ); 480 479 481 480 if (coreosArgs.length > 0 && !isoPath) { ··· 548 547 options.image && [ 549 548 "-drive", 550 549 `file=${options.image},format=${options.diskFormat},if=virtio`, 551 - ] 550 + ], 552 551 ), 553 552 ]; 554 553 ··· 563 562 const logPath = `${LOGS_DIR}/${name}.log`; 564 563 565 564 const fullCommand = options.bridge 566 - ? `sudo ${qemu} ${qemuArgs 565 + ? `sudo ${qemu} ${ 566 + qemuArgs 567 567 .slice(1) 568 - .join(" ")} >> "${logPath}" 2>&1 & echo $!` 568 + .join(" ") 569 + } >> "${logPath}" 2>&1 & echo $!` 569 570 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 570 571 571 572 const { stdout } = yield* Effect.tryPromise({ ··· 591 592 cpus: options.cpus, 592 593 cpu: options.cpu, 593 594 diskSize: options.size || "20G", 594 - diskFormat: 595 - (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 595 + diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 596 596 options.diskFormat || 597 597 "raw", 598 598 portForward: options.portForward, ··· 611 611 }); 612 612 613 613 console.log( 614 - `Virtual machine ${name} started in background (PID: ${qemuPid})` 614 + `Virtual machine ${name} started in background (PID: ${qemuPid})`, 615 615 ); 616 616 console.log(`Logs will be written to: ${logPath}`); 617 617 ··· 634 634 cpus: options.cpus, 635 635 cpu: options.cpu, 636 636 diskSize: options.size || "20G", 637 - diskFormat: 638 - (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 637 + diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 639 638 options.diskFormat || 640 639 "raw", 641 640 portForward: options.portForward, ··· 743 742 if (pathExists) { 744 743 console.log( 745 744 chalk.yellowBright( 746 - `Drive image ${path} already exists, skipping creation.` 747 - ) 745 + `Drive image ${path} already exists, skipping creation.`, 746 + ), 748 747 ); 749 748 return; 750 749 } ··· 771 770 }); 772 771 773 772 export const fileExists = ( 774 - path: string 773 + path: string, 775 774 ): Effect.Effect<void, NoSuchFileError, never> => 776 775 Effect.try({ 777 776 try: () => Deno.statSync(path), ··· 779 778 }); 780 779 781 780 export const constructCoreOSImageURL = ( 782 - image: string 781 + image: string, 783 782 ): Effect.Effect<string, InvalidImageNameError, never> => { 784 783 // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version> 785 784 const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/; ··· 787 786 if (match) { 788 787 const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION; 789 788 return Effect.succeed( 790 - FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version) 789 + FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version), 791 790 ); 792 791 } 793 792 ··· 795 794 new InvalidImageNameError({ 796 795 image, 797 796 cause: "Image name does not match CoreOS naming conventions.", 798 - }) 797 + }), 799 798 ); 800 799 }; 801 800 ··· 824 823 }); 825 824 826 825 export const constructNixOSImageURL = ( 827 - image: string 826 + image: string, 828 827 ): Effect.Effect<string, InvalidImageNameError, never> => { 829 828 // detect with regex if image matches NixOS pattern: nixos or nixos-<version> 830 829 const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/; ··· 832 831 if (match) { 833 832 const version = match[3] || NIXOS_DEFAULT_VERSION; 834 833 return Effect.succeed( 835 - NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version) 834 + NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version), 836 835 ); 837 836 } 838 837 ··· 840 839 new InvalidImageNameError({ 841 840 image, 842 841 cause: "Image name does not match NixOS naming conventions.", 843 - }) 842 + }), 844 843 ); 845 844 }; 846 845 847 846 export const constructFedoraImageURL = ( 848 - image: string 847 + image: string, 849 848 ): Effect.Effect<string, InvalidImageNameError, never> => { 850 849 // detect with regex if image matches Fedora pattern: fedora 851 850 const fedoraRegex = /^(fedora)$/; ··· 858 857 new InvalidImageNameError({ 859 858 image, 860 859 cause: "Image name does not match Fedora naming conventions.", 861 - }) 860 + }), 862 861 ); 863 862 }; 864 863 865 864 export const constructGentooImageURL = ( 866 - image: string 865 + image: string, 867 866 ): Effect.Effect<string, InvalidImageNameError, never> => { 868 867 // detect with regex if image matches genroo pattern: gentoo-20251116T161545Z or gentoo 869 868 const gentooRegex = /^(gentoo)(-(\d{8}T\d{6}Z))?$/; ··· 872 871 return Effect.succeed( 873 872 GENTOO_IMG_URL.replaceAll("20251116T161545Z", match[3]).replaceAll( 874 873 "20251116T233105Z", 875 - match[3] 876 - ) 874 + match[3], 875 + ), 877 876 ); 878 877 } 879 878 ··· 885 884 new InvalidImageNameError({ 886 885 image, 887 886 cause: "Image name does not match Gentoo naming conventions.", 888 - }) 887 + }), 889 888 ); 890 889 }; 891 890 892 891 export const constructDebianImageURL = ( 893 892 image: string, 894 - cloud: boolean = false 893 + cloud: boolean = false, 895 894 ): Effect.Effect<string, InvalidImageNameError, never> => { 896 895 if (cloud && image === "debian") { 897 896 return Effect.succeed(DEBIAN_CLOUD_IMG_URL); ··· 902 901 const match = image.match(debianRegex); 903 902 if (match?.[3]) { 904 903 return Effect.succeed( 905 - DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]) 904 + DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]), 906 905 ); 907 906 } 908 907 ··· 914 913 new InvalidImageNameError({ 915 914 image, 916 915 cause: "Image name does not match Debian naming conventions.", 917 - }) 916 + }), 918 917 ); 919 918 }; 920 919 921 920 export const constructAlpineImageURL = ( 922 - image: string 921 + image: string, 923 922 ): Effect.Effect<string, InvalidImageNameError, never> => { 924 923 // detect with regex if image matches alpine pattern: alpine-<version> or alpine 925 924 const alpineRegex = /^(alpine)(-(\d+\.\d+(\.\d+)?))?$/; 926 925 const match = image.match(alpineRegex); 927 926 if (match?.[3]) { 928 927 return Effect.succeed( 929 - ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3]) 928 + ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3]), 930 929 ); 931 930 } 932 931 ··· 938 937 new InvalidImageNameError({ 939 938 image, 940 939 cause: "Image name does not match Alpine naming conventions.", 941 - }) 940 + }), 942 941 ); 943 942 }; 944 943 945 944 export const constructUbuntuImageURL = ( 946 945 image: string, 947 - cloud: boolean = false 946 + cloud: boolean = false, 948 947 ): Effect.Effect<string, InvalidImageNameError, never> => { 949 948 // detect with regex if image matches ubuntu pattern: ubuntu 950 949 const ubuntuRegex = /^(ubuntu)$/; ··· 960 959 new InvalidImageNameError({ 961 960 image, 962 961 cause: "Image name does not match Ubuntu naming conventions.", 963 - }) 962 + }), 964 963 ); 965 964 }; 966 965 967 966 export const constructAlmaLinuxImageURL = ( 968 967 image: string, 969 - cloud: boolean = false 968 + cloud: boolean = false, 970 969 ): Effect.Effect<string, InvalidImageNameError, never> => { 971 970 // detect with regex if image matches almalinux pattern: ubuntu 972 971 const almaLinuxRegex = /^(almalinux)$/; ··· 982 981 new InvalidImageNameError({ 983 982 image, 984 983 cause: "Image name does not match AlmaLinux naming conventions.", 985 - }) 984 + }), 986 985 ); 987 986 }; 988 987 989 988 export const constructRockyLinuxImageURL = ( 990 989 image: string, 991 - cloud: boolean = false 990 + cloud: boolean = false, 992 991 ): Effect.Effect<string, InvalidImageNameError, never> => { 993 992 // detect with regex if image matches rockylinux pattern: ubuntu 994 993 const rockyLinuxRegex = /^(rockylinux)$/; ··· 1004 1003 new InvalidImageNameError({ 1005 1004 image, 1006 1005 cause: "Image name does not match RockyLinux naming conventions.", 1007 - }) 1006 + }), 1008 1007 ); 1009 1008 };