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

run format

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