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

run format

+67 -68
+44 -45
src/utils.ts
··· 66 66 export const isValidISOurl = (url?: string): boolean => { 67 67 return Boolean( 68 68 (url?.startsWith("http://") || url?.startsWith("https://")) && 69 - url?.endsWith(".iso") 69 + url?.endsWith(".iso"), 70 70 ); 71 71 }; 72 72 ··· 92 92 }); 93 93 94 94 export const validateImage = ( 95 - image: string 95 + image: string, 96 96 ): Effect.Effect<string, InvalidImageNameError, never> => { 97 97 const regex = 98 98 /^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/; ··· 103 103 image, 104 104 cause: 105 105 "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.", 106 - }) 106 + }), 107 107 ); 108 108 } 109 109 return Effect.succeed(image); ··· 112 112 export const extractTag = (name: string) => 113 113 pipe( 114 114 validateImage(name), 115 - Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")) 115 + Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")), 116 116 ); 117 117 118 118 export const failOnMissingImage = ( 119 - image: Image | undefined 119 + image: Image | undefined, 120 120 ): Effect.Effect<Image, Error, never> => 121 121 image 122 122 ? Effect.succeed(image) 123 123 : Effect.fail(new NoSuchImageError({ cause: "No such image" })); 124 124 125 125 export const du = ( 126 - path: string 126 + path: string, 127 127 ): Effect.Effect<number, LogCommandError, never> => 128 128 Effect.tryPromise({ 129 129 try: async () => { ··· 155 155 exists 156 156 ? Effect.succeed(true) 157 157 : du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB)) 158 - ) 158 + ), 159 159 ); 160 160 161 161 export const downloadIso = (url: string, options: Options) => ··· 177 177 if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 178 178 console.log( 179 179 chalk.yellowBright( 180 - `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.` 181 - ) 180 + `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, 181 + ), 182 182 ); 183 183 return null; 184 184 } ··· 196 196 if (outputExists) { 197 197 console.log( 198 198 chalk.yellowBright( 199 - `File ${outputPath} already exists, skipping download.` 200 - ) 199 + `File ${outputPath} already exists, skipping download.`, 200 + ), 201 201 ); 202 202 return outputPath; 203 203 } ··· 246 246 if (!success) { 247 247 console.error( 248 248 chalk.redBright( 249 - "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew." 250 - ) 249 + "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", 250 + ), 251 251 ); 252 252 Deno.exit(1); 253 253 } ··· 260 260 try: () => 261 261 Deno.copyFile( 262 262 `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 263 - edk2VarsAarch64 263 + edk2VarsAarch64, 264 264 ), 265 265 catch: (error) => new LogCommandError({ cause: error }), 266 266 }); ··· 305 305 const configOK = yield* pipe( 306 306 fileExists("config.ign"), 307 307 Effect.flatMap(() => Effect.succeed(true)), 308 - Effect.catchAll(() => Effect.succeed(false)) 308 + Effect.catchAll(() => Effect.succeed(false)), 309 309 ); 310 310 if (!configOK) { 311 311 console.error( 312 312 chalk.redBright( 313 - "CoreOS image requires a config.ign file in the current directory." 314 - ) 313 + "CoreOS image requires a config.ign file in the current directory.", 314 + ), 315 315 ); 316 316 Deno.exit(1); 317 317 } ··· 346 346 imagePath && 347 347 imagePath.endsWith(".qcow2") && 348 348 imagePath.startsWith( 349 - `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-` 349 + `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-`, 350 350 ) 351 351 ) { 352 352 return ["-drive", `file=${imagePath},format=qcow2,if=virtio`]; ··· 359 359 Effect.gen(function* () { 360 360 const macAddress = yield* generateRandomMacAddress(); 361 361 362 - const qemu = 363 - Deno.build.arch === "aarch64" 364 - ? "qemu-system-aarch64" 365 - : "qemu-system-x86_64"; 362 + const qemu = Deno.build.arch === "aarch64" 363 + ? "qemu-system-aarch64" 364 + : "qemu-system-x86_64"; 366 365 367 366 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 368 367 let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image); ··· 414 413 options.image && [ 415 414 "-drive", 416 415 `file=${options.image},format=${options.diskFormat},if=virtio`, 417 - ] 416 + ], 418 417 ), 419 418 ]; 420 419 ··· 429 428 const logPath = `${LOGS_DIR}/${name}.log`; 430 429 431 430 const fullCommand = options.bridge 432 - ? `sudo ${qemu} ${qemuArgs 431 + ? `sudo ${qemu} ${ 432 + qemuArgs 433 433 .slice(1) 434 - .join(" ")} >> "${logPath}" 2>&1 & echo $!` 434 + .join(" ") 435 + } >> "${logPath}" 2>&1 & echo $!` 435 436 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 436 437 437 438 const { stdout } = yield* Effect.tryPromise({ ··· 457 458 cpus: options.cpus, 458 459 cpu: options.cpu, 459 460 diskSize: options.size || "20G", 460 - diskFormat: 461 - (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 461 + diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 462 462 options.diskFormat || 463 463 "raw", 464 464 portForward: options.portForward, ··· 477 477 }); 478 478 479 479 console.log( 480 - `Virtual machine ${name} started in background (PID: ${qemuPid})` 480 + `Virtual machine ${name} started in background (PID: ${qemuPid})`, 481 481 ); 482 482 console.log(`Logs will be written to: ${logPath}`); 483 483 ··· 500 500 cpus: options.cpus, 501 501 cpu: options.cpu, 502 502 diskSize: options.size || "20G", 503 - diskFormat: 504 - (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 503 + diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 505 504 options.diskFormat || 506 505 "raw", 507 506 portForward: options.portForward, ··· 609 608 if (pathExists) { 610 609 console.log( 611 610 chalk.yellowBright( 612 - `Drive image ${path} already exists, skipping creation.` 613 - ) 611 + `Drive image ${path} already exists, skipping creation.`, 612 + ), 614 613 ); 615 614 return; 616 615 } ··· 637 636 }); 638 637 639 638 export const fileExists = ( 640 - path: string 639 + path: string, 641 640 ): Effect.Effect<void, NoSuchFileError, never> => 642 641 Effect.try({ 643 642 try: () => Deno.statSync(path), ··· 645 644 }); 646 645 647 646 export const constructCoreOSImageURL = ( 648 - image: string 647 + image: string, 649 648 ): Effect.Effect<string, InvalidImageNameError, never> => { 650 649 // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version> 651 650 const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/; ··· 653 652 if (match) { 654 653 const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION; 655 654 return Effect.succeed( 656 - FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version) 655 + FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version), 657 656 ); 658 657 } 659 658 ··· 661 660 new InvalidImageNameError({ 662 661 image, 663 662 cause: "Image name does not match CoreOS naming conventions.", 664 - }) 663 + }), 665 664 ); 666 665 }; 667 666 ··· 690 689 }); 691 690 692 691 export const constructNixOSImageURL = ( 693 - image: string 692 + image: string, 694 693 ): Effect.Effect<string, InvalidImageNameError, never> => { 695 694 // detect with regex if image matches NixOS pattern: nixos or nixos-<version> 696 695 const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/; ··· 698 697 if (match) { 699 698 const version = match[3] || NIXOS_DEFAULT_VERSION; 700 699 return Effect.succeed( 701 - NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version) 700 + NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version), 702 701 ); 703 702 } 704 703 ··· 706 705 new InvalidImageNameError({ 707 706 image, 708 707 cause: "Image name does not match NixOS naming conventions.", 709 - }) 708 + }), 710 709 ); 711 710 }; 712 711 713 712 export const constructFedoraImageURL = ( 714 - image: string 713 + image: string, 715 714 ): Effect.Effect<string, InvalidImageNameError, never> => { 716 715 // detect with regex if image matches Fedora pattern: fedora 717 716 const fedoraRegex = /^(fedora)$/; ··· 724 723 new InvalidImageNameError({ 725 724 image, 726 725 cause: "Image name does not match Fedora naming conventions.", 727 - }) 726 + }), 728 727 ); 729 728 }; 730 729 731 730 export const constructGentooImageURL = ( 732 - image: string 731 + image: string, 733 732 ): Effect.Effect<string, InvalidImageNameError, never> => { 734 733 // detect with regex if image matches genroo pattern: gentoo-20251116T161545Z or gentoo 735 734 const gentooRegex = /^(gentoo)(-(\d{8}T\d{6}Z))?$/; ··· 738 737 return Effect.succeed( 739 738 GENTOO_IMG_URL.replaceAll("20251116T161545Z", match[3]).replaceAll( 740 739 "20251116T233105Z", 741 - match[3] 742 - ) 740 + match[3], 741 + ), 743 742 ); 744 743 } 745 744 ··· 751 750 new InvalidImageNameError({ 752 751 image, 753 752 cause: "Image name does not match Gentoo naming conventions.", 754 - }) 753 + }), 755 754 ); 756 755 };
+23 -23
src/utils_test.ts
··· 15 15 const url = Effect.runSync( 16 16 pipe( 17 17 constructCoreOSImageURL("fedora-coreos"), 18 - Effect.catchAll((_error) => Effect.succeed(null as string | null)) 19 - ) 18 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 19 + ), 20 20 ); 21 21 22 22 assertEquals(url, FEDORA_COREOS_IMG_URL); ··· 26 26 const url = Effect.runSync( 27 27 pipe( 28 28 constructCoreOSImageURL("coreos"), 29 - Effect.catchAll((_error) => Effect.succeed(null as string | null)) 30 - ) 29 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 30 + ), 31 31 ); 32 32 33 33 assertEquals(url, FEDORA_COREOS_IMG_URL); ··· 37 37 const url = Effect.runSync( 38 38 pipe( 39 39 constructCoreOSImageURL("fedora-coreos-43.20251024.2.0"), 40 - Effect.catchAll((_error) => Effect.succeed(null as string | null)) 41 - ) 40 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 41 + ), 42 42 ); 43 43 44 44 assertEquals( 45 45 url, 46 46 "https://builds.coreos.fedoraproject.org/prod/streams/stable/builds/43.20251024.2.0/" + 47 - `${Deno.build.arch}/fedora-coreos-43.20251024.2.0-qemu.${Deno.build.arch}.qcow2.xz` 47 + `${Deno.build.arch}/fedora-coreos-43.20251024.2.0-qemu.${Deno.build.arch}.qcow2.xz`, 48 48 ); 49 49 }); 50 50 ··· 52 52 const url = Effect.runSync( 53 53 pipe( 54 54 constructCoreOSImageURL("fedora-coreos-latest"), 55 - Effect.catchAll((_error) => Effect.succeed(null as string | null)) 56 - ) 55 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 56 + ), 57 57 ); 58 58 59 59 assertEquals(url, null); ··· 63 63 const url = Effect.runSync( 64 64 pipe( 65 65 constructNixOSImageURL("nixos"), 66 - Effect.catchAll((_error) => Effect.succeed(null as string | null)) 67 - ) 66 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 67 + ), 68 68 ); 69 69 70 70 assertEquals(url, NIXOS_ISO_URL); ··· 74 74 const url = Effect.runSync( 75 75 pipe( 76 76 constructNixOSImageURL("nixos-24.05"), 77 - Effect.catchAll((_error) => Effect.succeed(null as string | null)) 78 - ) 77 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 78 + ), 79 79 ); 80 80 81 81 assertEquals( 82 82 url, 83 - `https://channels.nixos.org/nixos-24.05/latest-nixos-minimal-${Deno.build.arch}-linux.iso` 83 + `https://channels.nixos.org/nixos-24.05/latest-nixos-minimal-${Deno.build.arch}-linux.iso`, 84 84 ); 85 85 }); 86 86 ··· 88 88 const url = Effect.runSync( 89 89 pipe( 90 90 constructNixOSImageURL("nixos-latest"), 91 - Effect.catchAll((_error) => Effect.succeed(null as string | null)) 92 - ) 91 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 92 + ), 93 93 ); 94 94 95 95 assertEquals(url, null); ··· 99 99 const url = Effect.runSync( 100 100 pipe( 101 101 constructGentooImageURL("gentoo-20251116T161545Z"), 102 - Effect.catchAll((_error) => Effect.succeed(null as string | null)) 103 - ) 102 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 103 + ), 104 104 ); 105 105 106 106 const arch = Deno.build.arch === "aarch64" ? "arm64" : "amd64"; 107 107 assertEquals( 108 108 url, 109 - `https://distfiles.gentoo.org/releases/${arch}/autobuilds/20251116T161545Z/di-${arch}-console-20251116T161545Z.qcow2` 109 + `https://distfiles.gentoo.org/releases/${arch}/autobuilds/20251116T161545Z/di-${arch}-console-20251116T161545Z.qcow2`, 110 110 ); 111 111 }); 112 112 ··· 114 114 const url = Effect.runSync( 115 115 pipe( 116 116 constructGentooImageURL("gentoo"), 117 - Effect.catchAll((_error) => Effect.succeed(null as string | null)) 118 - ) 117 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 118 + ), 119 119 ); 120 120 121 121 assertEquals(url, GENTOO_IMG_URL); ··· 125 125 const url = Effect.runSync( 126 126 pipe( 127 127 constructGentooImageURL("gentoo-latest"), 128 - Effect.catchAll((_error) => Effect.succeed(null as string | null)) 129 - ) 128 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 129 + ), 130 130 ); 131 131 132 132 assertEquals(url, null);