A Docker-like CLI and HTTP API for managing headless VMs
at main 28 kB view raw
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};