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

run format

+94 -91
+9 -7
src/constants.ts
··· 6 export const IMAGE_DIR: string = `${CONFIG_DIR}/images`; 7 export const VOLUME_DIR: string = `${CONFIG_DIR}/volumes`; 8 9 - export const UBUNTU_ISO_URL: string = 10 - Deno.build.arch === "aarch64" 11 - ? "https://cdimage.ubuntu.com/releases/24.04/release/ubuntu-24.04.3-live-server-arm64.iso" 12 - : "https://releases.ubuntu.com/24.04.3/ubuntu-24.04.3-live-server-amd64.iso"; 13 14 export const FEDORA_COREOS_DEFAULT_VERSION: string = "43.20251024.3.0"; 15 - export const FEDORA_COREOS_IMG_URL: string = `https://builds.coreos.fedoraproject.org/prod/streams/stable/builds/${FEDORA_COREOS_DEFAULT_VERSION}/${Deno.build.arch}/fedora-coreos-${FEDORA_COREOS_DEFAULT_VERSION}-qemu.${Deno.build.arch}.qcow2.xz`; 16 17 export const NIXOS_DEFAULT_VERSION: string = "25.05"; 18 - export const NIXOS_ISO_URL: string = `https://channels.nixos.org/nixos-${NIXOS_DEFAULT_VERSION}/latest-nixos-minimal-${Deno.build.arch}-linux.iso`; 19 20 - export const FEDORA_IMG_URL: string = `https://download.fedoraproject.org/pub/fedora/linux/releases/43/Server/${Deno.build.arch}/images/Fedora-Server-Guest-Generic-43-1.6.${Deno.build.arch}.qcow2`;
··· 6 export const IMAGE_DIR: string = `${CONFIG_DIR}/images`; 7 export const VOLUME_DIR: string = `${CONFIG_DIR}/volumes`; 8 9 + export const UBUNTU_ISO_URL: string = Deno.build.arch === "aarch64" 10 + ? "https://cdimage.ubuntu.com/releases/24.04/release/ubuntu-24.04.3-live-server-arm64.iso" 11 + : "https://releases.ubuntu.com/24.04.3/ubuntu-24.04.3-live-server-amd64.iso"; 12 13 export const FEDORA_COREOS_DEFAULT_VERSION: string = "43.20251024.3.0"; 14 + export const FEDORA_COREOS_IMG_URL: string = 15 + `https://builds.coreos.fedoraproject.org/prod/streams/stable/builds/${FEDORA_COREOS_DEFAULT_VERSION}/${Deno.build.arch}/fedora-coreos-${FEDORA_COREOS_DEFAULT_VERSION}-qemu.${Deno.build.arch}.qcow2.xz`; 16 17 export const NIXOS_DEFAULT_VERSION: string = "25.05"; 18 + export const NIXOS_ISO_URL: string = 19 + `https://channels.nixos.org/nixos-${NIXOS_DEFAULT_VERSION}/latest-nixos-minimal-${Deno.build.arch}-linux.iso`; 20 21 + export const FEDORA_IMG_URL: string = 22 + `https://download.fedoraproject.org/pub/fedora/linux/releases/43/Server/${Deno.build.arch}/images/Fedora-Server-Guest-Generic-43-1.6.${Deno.build.arch}.qcow2`;
+46 -44
src/subcommands/start.ts
··· 17 }> {} 18 19 export class VmAlreadyRunningError extends Data.TaggedError( 20 - "VmAlreadyRunningError" 21 )<{ 22 name: string; 23 }> {} ··· 31 getInstanceState(name), 32 Effect.flatMap((vm) => 33 vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 34 - ) 35 ); 36 37 const logStarting = (vm: VirtualMachine) => ··· 44 export const setupFirmware = () => setupFirmwareFilesIfNeeded(); 45 46 export const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 47 - const qemu = 48 - Deno.build.arch === "aarch64" 49 - ? "qemu-system-aarch64" 50 - : "qemu-system-x86_64"; 51 52 let coreosArgs: string[] = Effect.runSync(setupCoreOSArgs(vm.drivePath)); 53 ··· 84 vm.drivePath && [ 85 "-drive", 86 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 87 - ] 88 ), 89 ...coreosArgs, 90 ...(vm.volume ? [] : ["-snapshot"]), ··· 100 export const startDetachedQemu = ( 101 name: string, 102 vm: VirtualMachine, 103 - qemuArgs: string[] 104 ) => { 105 - const qemu = 106 - Deno.build.arch === "aarch64" 107 - ? "qemu-system-aarch64" 108 - : "qemu-system-x86_64"; 109 110 const logPath = `${LOGS_DIR}/${vm.name}.log`; 111 112 const fullCommand = vm.bridge 113 - ? `sudo ${qemu} ${qemuArgs 114 .slice(1) 115 - .join(" ")} >> "${logPath}" 2>&1 & echo $!` 116 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 117 118 return Effect.tryPromise({ ··· 143 Effect.flatMap(({ qemuPid, logPath }) => 144 pipe( 145 updateInstanceState(name, "RUNNING", qemuPid), 146 - Effect.map(() => ({ vm, qemuPid, logPath })) 147 ) 148 - ) 149 ); 150 }; 151 ··· 160 }) => 161 Effect.sync(() => { 162 console.log( 163 - `Virtual machine ${vm.name} started in background (PID: ${qemuPid})` 164 ); 165 console.log(`Logs will be written to: ${logPath}`); 166 }); ··· 168 const startInteractiveQemu = ( 169 name: string, 170 vm: VirtualMachine, 171 - qemuArgs: string[] 172 ) => { 173 - const qemu = 174 - Deno.build.arch === "aarch64" 175 - ? "qemu-system-aarch64" 176 - : "qemu-system-x86_64"; 177 178 return Effect.tryPromise({ 179 try: async () => { ··· 209 }); 210 211 export const createVolumeIfNeeded = ( 212 - vm: VirtualMachine 213 ): Effect.Effect<[VirtualMachine, Volume?], Error, never> => 214 Effect.gen(function* () { 215 const { flags } = parseFlags(Deno.args); ··· 224 225 if (!vm.drivePath) { 226 throw new Error( 227 - `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.` 228 ); 229 } 230 ··· 267 diskFormat: volume ? "qcow2" : vm.diskFormat, 268 volume: volume?.path, 269 }, 270 - firmwareArgs 271 ) 272 ), 273 Effect.flatMap((qemuArgs) => ··· 277 startDetachedQemu(name, { ...vm, volume: volume?.path }, qemuArgs) 278 ), 279 Effect.tap(logDetachedSuccess), 280 - Effect.map(() => 0) // Exit code 0 281 ) 282 - ) 283 ) 284 ), 285 - Effect.catchAll(handleError) 286 ); 287 288 const startInteractiveEffect = (name: string) => ··· 303 diskFormat: volume ? "qcow2" : vm.diskFormat, 304 volume: volume?.path, 305 }, 306 - firmwareArgs 307 ) 308 ), 309 Effect.flatMap((qemuArgs) => 310 startInteractiveQemu(name, { ...vm, volume: volume?.path }, qemuArgs) 311 ), 312 - Effect.map((status) => (status.success ? 0 : status.code || 1)) 313 ) 314 ), 315 - Effect.catchAll(handleError) 316 ); 317 318 export default async function (name: string, detach: boolean = false) { 319 const exitCode = await Effect.runPromise( 320 - detach ? startDetachedEffect(name) : startInteractiveEffect(name) 321 ); 322 323 if (detach) { ··· 331 const { flags } = parseFlags(Deno.args); 332 return { 333 ...vm, 334 - memory: 335 - flags.memory || flags.m ? String(flags.memory || flags.m) : vm.memory, 336 cpus: flags.cpus || flags.C ? Number(flags.cpus || flags.C) : vm.cpus, 337 cpu: flags.cpu || flags.c ? String(flags.cpu || flags.c) : vm.cpu, 338 diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat, 339 - portForward: 340 - flags.portForward || flags.p 341 - ? String(flags.portForward || flags.p) 342 - : vm.portForward, 343 - drivePath: 344 - flags.image || flags.i ? String(flags.image || flags.i) : vm.drivePath, 345 - bridge: 346 - flags.bridge || flags.b ? String(flags.bridge || flags.b) : vm.bridge, 347 - diskSize: 348 - flags.size || flags.s ? String(flags.size || flags.s) : vm.diskSize, 349 }; 350 }
··· 17 }> {} 18 19 export class VmAlreadyRunningError extends Data.TaggedError( 20 + "VmAlreadyRunningError", 21 )<{ 22 name: string; 23 }> {} ··· 31 getInstanceState(name), 32 Effect.flatMap((vm) => 33 vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 34 + ), 35 ); 36 37 const logStarting = (vm: VirtualMachine) => ··· 44 export const setupFirmware = () => setupFirmwareFilesIfNeeded(); 45 46 export const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 47 + const qemu = Deno.build.arch === "aarch64" 48 + ? "qemu-system-aarch64" 49 + : "qemu-system-x86_64"; 50 51 let coreosArgs: string[] = Effect.runSync(setupCoreOSArgs(vm.drivePath)); 52 ··· 83 vm.drivePath && [ 84 "-drive", 85 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 86 + ], 87 ), 88 ...coreosArgs, 89 ...(vm.volume ? [] : ["-snapshot"]), ··· 99 export const startDetachedQemu = ( 100 name: string, 101 vm: VirtualMachine, 102 + qemuArgs: string[], 103 ) => { 104 + const qemu = Deno.build.arch === "aarch64" 105 + ? "qemu-system-aarch64" 106 + : "qemu-system-x86_64"; 107 108 const logPath = `${LOGS_DIR}/${vm.name}.log`; 109 110 const fullCommand = vm.bridge 111 + ? `sudo ${qemu} ${ 112 + qemuArgs 113 .slice(1) 114 + .join(" ") 115 + } >> "${logPath}" 2>&1 & echo $!` 116 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 117 118 return Effect.tryPromise({ ··· 143 Effect.flatMap(({ qemuPid, logPath }) => 144 pipe( 145 updateInstanceState(name, "RUNNING", qemuPid), 146 + Effect.map(() => ({ vm, qemuPid, logPath })), 147 ) 148 + ), 149 ); 150 }; 151 ··· 160 }) => 161 Effect.sync(() => { 162 console.log( 163 + `Virtual machine ${vm.name} started in background (PID: ${qemuPid})`, 164 ); 165 console.log(`Logs will be written to: ${logPath}`); 166 }); ··· 168 const startInteractiveQemu = ( 169 name: string, 170 vm: VirtualMachine, 171 + qemuArgs: string[], 172 ) => { 173 + const qemu = Deno.build.arch === "aarch64" 174 + ? "qemu-system-aarch64" 175 + : "qemu-system-x86_64"; 176 177 return Effect.tryPromise({ 178 try: async () => { ··· 208 }); 209 210 export const createVolumeIfNeeded = ( 211 + vm: VirtualMachine, 212 ): Effect.Effect<[VirtualMachine, Volume?], Error, never> => 213 Effect.gen(function* () { 214 const { flags } = parseFlags(Deno.args); ··· 223 224 if (!vm.drivePath) { 225 throw new Error( 226 + `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.`, 227 ); 228 } 229 ··· 266 diskFormat: volume ? "qcow2" : vm.diskFormat, 267 volume: volume?.path, 268 }, 269 + firmwareArgs, 270 ) 271 ), 272 Effect.flatMap((qemuArgs) => ··· 276 startDetachedQemu(name, { ...vm, volume: volume?.path }, qemuArgs) 277 ), 278 Effect.tap(logDetachedSuccess), 279 + Effect.map(() => 0), // Exit code 0 280 ) 281 + ), 282 ) 283 ), 284 + Effect.catchAll(handleError), 285 ); 286 287 const startInteractiveEffect = (name: string) => ··· 302 diskFormat: volume ? "qcow2" : vm.diskFormat, 303 volume: volume?.path, 304 }, 305 + firmwareArgs, 306 ) 307 ), 308 Effect.flatMap((qemuArgs) => 309 startInteractiveQemu(name, { ...vm, volume: volume?.path }, qemuArgs) 310 ), 311 + Effect.map((status) => (status.success ? 0 : status.code || 1)), 312 ) 313 ), 314 + Effect.catchAll(handleError), 315 ); 316 317 export default async function (name: string, detach: boolean = false) { 318 const exitCode = await Effect.runPromise( 319 + detach ? startDetachedEffect(name) : startInteractiveEffect(name), 320 ); 321 322 if (detach) { ··· 330 const { flags } = parseFlags(Deno.args); 331 return { 332 ...vm, 333 + memory: flags.memory || flags.m 334 + ? String(flags.memory || flags.m) 335 + : vm.memory, 336 cpus: flags.cpus || flags.C ? Number(flags.cpus || flags.C) : vm.cpus, 337 cpu: flags.cpu || flags.c ? String(flags.cpu || flags.c) : vm.cpu, 338 diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat, 339 + portForward: flags.portForward || flags.p 340 + ? String(flags.portForward || flags.p) 341 + : vm.portForward, 342 + drivePath: flags.image || flags.i 343 + ? String(flags.image || flags.i) 344 + : vm.drivePath, 345 + bridge: flags.bridge || flags.b 346 + ? String(flags.bridge || flags.b) 347 + : vm.bridge, 348 + diskSize: flags.size || flags.s 349 + ? String(flags.size || flags.s) 350 + : vm.diskSize, 351 }; 352 }
+39 -40
src/utils.ts
··· 65 export const isValidISOurl = (url?: string): boolean => { 66 return Boolean( 67 (url?.startsWith("http://") || url?.startsWith("https://")) && 68 - url?.endsWith(".iso") 69 ); 70 }; 71 ··· 91 }); 92 93 export 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._-]+)?$/; ··· 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); ··· 111 export const extractTag = (name: string) => 112 pipe( 113 validateImage(name), 114 - Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")) 115 ); 116 117 export 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 124 export const du = ( 125 - path: string 126 ): Effect.Effect<number, LogCommandError, never> => 127 Effect.tryPromise({ 128 try: async () => { ··· 154 exists 155 ? Effect.succeed(true) 156 : du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB)) 157 - ) 158 ); 159 160 export const downloadIso = (url: string, options: Options) => ··· 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 } ··· 195 if (outputExists) { 196 console.log( 197 chalk.yellowBright( 198 - `File ${outputPath} already exists, skipping download.` 199 - ) 200 ); 201 return outputPath; 202 } ··· 245 if (!success) { 246 console.error( 247 chalk.redBright( 248 - "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew." 249 - ) 250 ); 251 Deno.exit(1); 252 } ··· 259 try: () => 260 Deno.copyFile( 261 `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 262 - edk2VarsAarch64 263 ), 264 catch: (error) => new LogCommandError({ cause: error }), 265 }); ··· 304 const configOK = yield* pipe( 305 fileExists("config.ign"), 306 Effect.flatMap(() => Effect.succeed(true)), 307 - Effect.catchAll(() => Effect.succeed(false)) 308 ); 309 if (!configOK) { 310 console.error( 311 chalk.redBright( 312 - "CoreOS image requires a config.ign file in the current directory." 313 - ) 314 ); 315 Deno.exit(1); 316 } ··· 343 Effect.gen(function* () { 344 const macAddress = yield* generateRandomMacAddress(); 345 346 - const qemu = 347 - Deno.build.arch === "aarch64" 348 - ? "qemu-system-aarch64" 349 - : "qemu-system-x86_64"; 350 351 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 352 let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image); ··· 392 options.image && [ 393 "-drive", 394 `file=${options.image},format=${options.diskFormat},if=virtio`, 395 - ] 396 ), 397 ]; 398 ··· 407 const logPath = `${LOGS_DIR}/${name}.log`; 408 409 const fullCommand = options.bridge 410 - ? `sudo ${qemu} ${qemuArgs 411 .slice(1) 412 - .join(" ")} >> "${logPath}" 2>&1 & echo $!` 413 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 414 415 const { stdout } = yield* Effect.tryPromise({ ··· 435 cpus: options.cpus, 436 cpu: options.cpu, 437 diskSize: options.size || "20G", 438 - diskFormat: 439 - (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 440 options.diskFormat || 441 "raw", 442 portForward: options.portForward, ··· 455 }); 456 457 console.log( 458 - `Virtual machine ${name} started in background (PID: ${qemuPid})` 459 ); 460 console.log(`Logs will be written to: ${logPath}`); 461 ··· 478 cpus: options.cpus, 479 cpu: options.cpu, 480 diskSize: options.size || "20G", 481 - diskFormat: 482 - (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 483 options.diskFormat || 484 "raw", 485 portForward: options.portForward, ··· 587 if (pathExists) { 588 console.log( 589 chalk.yellowBright( 590 - `Drive image ${path} already exists, skipping creation.` 591 - ) 592 ); 593 return; 594 } ··· 615 }); 616 617 export const fileExists = ( 618 - path: string 619 ): Effect.Effect<void, NoSuchFileError, never> => 620 Effect.try({ 621 try: () => Deno.statSync(path), ··· 623 }); 624 625 export const constructCoreOSImageURL = ( 626 - image: string 627 ): Effect.Effect<string, InvalidImageNameError, never> => { 628 // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version> 629 const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/; ··· 631 if (match) { 632 const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION; 633 return Effect.succeed( 634 - FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version) 635 ); 636 } 637 ··· 639 new InvalidImageNameError({ 640 image, 641 cause: "Image name does not match CoreOS naming conventions.", 642 - }) 643 ); 644 }; 645 ··· 668 }); 669 670 export const constructNixOSImageURL = ( 671 - image: string 672 ): Effect.Effect<string, InvalidImageNameError, never> => { 673 // detect with regex if image matches NixOS pattern: nixos or nixos-<version> 674 const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/; ··· 676 if (match) { 677 const version = match[3] || NIXOS_DEFAULT_VERSION; 678 return Effect.succeed( 679 - NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version) 680 ); 681 } 682 ··· 684 new InvalidImageNameError({ 685 image, 686 cause: "Image name does not match NixOS naming conventions.", 687 - }) 688 ); 689 }; 690 691 export const constructFedoraImageURL = ( 692 - image: string 693 ): Effect.Effect<string, InvalidImageNameError, never> => { 694 // detect with regex if image matches Fedora pattern: fedora 695 const fedoraRegex = /^(fedora)$/; ··· 702 new InvalidImageNameError({ 703 image, 704 cause: "Image name does not match Fedora naming conventions.", 705 - }) 706 ); 707 };
··· 65 export const isValidISOurl = (url?: string): boolean => { 66 return Boolean( 67 (url?.startsWith("http://") || url?.startsWith("https://")) && 68 + url?.endsWith(".iso"), 69 ); 70 }; 71 ··· 91 }); 92 93 export 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._-]+)?$/; ··· 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); ··· 111 export const extractTag = (name: string) => 112 pipe( 113 validateImage(name), 114 + Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")), 115 ); 116 117 export 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 124 export const du = ( 125 + path: string, 126 ): Effect.Effect<number, LogCommandError, never> => 127 Effect.tryPromise({ 128 try: async () => { ··· 154 exists 155 ? Effect.succeed(true) 156 : du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB)) 157 + ), 158 ); 159 160 export const downloadIso = (url: string, options: Options) => ··· 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 } ··· 195 if (outputExists) { 196 console.log( 197 chalk.yellowBright( 198 + `File ${outputPath} already exists, skipping download.`, 199 + ), 200 ); 201 return outputPath; 202 } ··· 245 if (!success) { 246 console.error( 247 chalk.redBright( 248 + "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", 249 + ), 250 ); 251 Deno.exit(1); 252 } ··· 259 try: () => 260 Deno.copyFile( 261 `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 262 + edk2VarsAarch64, 263 ), 264 catch: (error) => new LogCommandError({ cause: error }), 265 }); ··· 304 const configOK = yield* pipe( 305 fileExists("config.ign"), 306 Effect.flatMap(() => Effect.succeed(true)), 307 + Effect.catchAll(() => Effect.succeed(false)), 308 ); 309 if (!configOK) { 310 console.error( 311 chalk.redBright( 312 + "CoreOS image requires a config.ign file in the current directory.", 313 + ), 314 ); 315 Deno.exit(1); 316 } ··· 343 Effect.gen(function* () { 344 const macAddress = yield* generateRandomMacAddress(); 345 346 + const qemu = Deno.build.arch === "aarch64" 347 + ? "qemu-system-aarch64" 348 + : "qemu-system-x86_64"; 349 350 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 351 let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image); ··· 391 options.image && [ 392 "-drive", 393 `file=${options.image},format=${options.diskFormat},if=virtio`, 394 + ], 395 ), 396 ]; 397 ··· 406 const logPath = `${LOGS_DIR}/${name}.log`; 407 408 const fullCommand = options.bridge 409 + ? `sudo ${qemu} ${ 410 + qemuArgs 411 .slice(1) 412 + .join(" ") 413 + } >> "${logPath}" 2>&1 & echo $!` 414 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 415 416 const { stdout } = yield* Effect.tryPromise({ ··· 436 cpus: options.cpus, 437 cpu: options.cpu, 438 diskSize: options.size || "20G", 439 + diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 440 options.diskFormat || 441 "raw", 442 portForward: options.portForward, ··· 455 }); 456 457 console.log( 458 + `Virtual machine ${name} started in background (PID: ${qemuPid})`, 459 ); 460 console.log(`Logs will be written to: ${logPath}`); 461 ··· 478 cpus: options.cpus, 479 cpu: options.cpu, 480 diskSize: options.size || "20G", 481 + diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 482 options.diskFormat || 483 "raw", 484 portForward: options.portForward, ··· 586 if (pathExists) { 587 console.log( 588 chalk.yellowBright( 589 + `Drive image ${path} already exists, skipping creation.`, 590 + ), 591 ); 592 return; 593 } ··· 614 }); 615 616 export const fileExists = ( 617 + path: string, 618 ): Effect.Effect<void, NoSuchFileError, never> => 619 Effect.try({ 620 try: () => Deno.statSync(path), ··· 622 }); 623 624 export const constructCoreOSImageURL = ( 625 + image: string, 626 ): Effect.Effect<string, InvalidImageNameError, never> => { 627 // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version> 628 const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/; ··· 630 if (match) { 631 const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION; 632 return Effect.succeed( 633 + FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version), 634 ); 635 } 636 ··· 638 new InvalidImageNameError({ 639 image, 640 cause: "Image name does not match CoreOS naming conventions.", 641 + }), 642 ); 643 }; 644 ··· 667 }); 668 669 export const constructNixOSImageURL = ( 670 + image: string, 671 ): Effect.Effect<string, InvalidImageNameError, never> => { 672 // detect with regex if image matches NixOS pattern: nixos or nixos-<version> 673 const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/; ··· 675 if (match) { 676 const version = match[3] || NIXOS_DEFAULT_VERSION; 677 return Effect.succeed( 678 + NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version), 679 ); 680 } 681 ··· 683 new InvalidImageNameError({ 684 image, 685 cause: "Image name does not match NixOS naming conventions.", 686 + }), 687 ); 688 }; 689 690 export const constructFedoraImageURL = ( 691 + image: string, 692 ): Effect.Effect<string, InvalidImageNameError, never> => { 693 // detect with regex if image matches Fedora pattern: fedora 694 const fedoraRegex = /^(fedora)$/; ··· 701 new InvalidImageNameError({ 702 image, 703 cause: "Image name does not match Fedora naming conventions.", 704 + }), 705 ); 706 };