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

Add CI workflow for testing and formatting with Deno

+174 -132
+26
.github/workflows/ci.yml
··· 1 + name: ci 2 + 3 + on: 4 + push: 5 + branches: [main] 6 + pull_request: 7 + branches: [main] 8 + 9 + jobs: 10 + test: 11 + runs-on: ubuntu-latest 12 + 13 + steps: 14 + - name: Checkout code 15 + uses: actions/checkout@v4 16 + 17 + - name: Setup Deno 18 + uses: denoland/setup-deno@v2 19 + with: 20 + deno-version: v2.x 21 + 22 + - name: Run tests 23 + run: deno test -A 24 + 25 + - name: Check formatting 26 + run: deno fmt --check
+21 -10
README.md
··· 1 1 # vmx 2 2 3 - A powerful command-line tool and HTTP API for managing and running headless virtual machines using QEMU. Built with Deno and TypeScript, vmx provides a Docker-like experience for VM management with OCI registry support. 3 + A powerful command-line tool and HTTP API for managing and running headless 4 + virtual machines using QEMU. Built with Deno and TypeScript, vmx provides a 5 + Docker-like experience for VM management with OCI registry support. 4 6 5 7 ## Features 6 8 7 9 ### 🚀 Core Functionality 8 10 9 11 - **Headless VM Management** - Run VMs in the background without GUI overhead 10 - - **QEMU Integration** - Leverages QEMU for robust virtualization on both x86_64 and ARM64 architectures 11 - - **Docker-like CLI** - Familiar commands for VM lifecycle management (run, start, stop, ps, rm, etc.) 12 + - **QEMU Integration** - Leverages QEMU for robust virtualization on both x86_64 13 + and ARM64 architectures 14 + - **Docker-like CLI** - Familiar commands for VM lifecycle management (run, 15 + start, stop, ps, rm, etc.) 12 16 - **Configuration Files** - TOML-based configuration for reproducible VM setups 13 - - **Multiple Input Sources** - Boot from local ISOs, remote URLs, or OCI registry images 17 + - **Multiple Input Sources** - Boot from local ISOs, remote URLs, or OCI 18 + registry images 14 19 15 20 ### 📦 OCI Registry Support 16 21 17 - - **Pull & Push** - Store and retrieve VM images from OCI-compliant registries (GitHub Container Registry, Docker Hub, etc.) 22 + - **Pull & Push** - Store and retrieve VM images from OCI-compliant registries 23 + (GitHub Container Registry, Docker Hub, etc.) 18 24 - **Image Management** - List, tag, and remove local VM images 19 25 - **Authentication** - Secure login/logout for private registries 20 - - **Cross-platform** - Automatic architecture detection and handling (amd64/arm64) 26 + - **Cross-platform** - Automatic architecture detection and handling 27 + (amd64/arm64) 21 28 22 29 ### 🌐 Networking 23 30 24 31 - **Bridge Networking** - Create and manage network bridges for VM connectivity 25 32 - **Port Forwarding** - Easy SSH and service access with flexible port mapping 26 - - **Multiple Network Modes** - Support for various QEMU networking configurations 33 + - **Multiple Network Modes** - Support for various QEMU networking 34 + configurations 27 35 28 36 ### 💾 Storage & Volumes 29 37 30 38 - **Volume Management** - Create, list, inspect, and delete persistent volumes 31 39 - **Multiple Disk Formats** - Support for qcow2 and raw disk images 32 - - **Automatic Provisioning** - Volumes are created automatically from base images 40 + - **Automatic Provisioning** - Volumes are created automatically from base 41 + images 33 42 - **Flexible Sizing** - Configurable disk sizes for different workloads 34 43 35 44 ### 🔧 Advanced Features ··· 38 47 - **Live Logs** - Stream VM output and follow logs in real-time 39 48 - **VM Inspection** - Detailed information about running and stopped VMs 40 49 - **Resource Configuration** - Customizable CPU, memory, and disk settings 41 - - **ARM64 & x86_64 Support** - Native support for both architectures with UEFI firmware 50 + - **ARM64 & x86_64 Support** - Native support for both architectures with UEFI 51 + firmware 42 52 43 53 ### 🌍 HTTP API 44 54 ··· 304 314 vmx automatically detects and adapts to your system architecture: 305 315 306 316 - **x86_64 / amd64** - Full QEMU system emulation 307 - - **ARM64 / aarch64** - Native Apple Silicon and ARM server support with UEFI firmware 317 + - **ARM64 / aarch64** - Native Apple Silicon and ARM server support with UEFI 318 + firmware 308 319 309 320 ## Examples 310 321
+1 -1
deno.json
··· 28 28 "kysely": "npm:kysely@0.27.6", 29 29 "moniker": "npm:moniker@^0.1.2" 30 30 } 31 - } 31 + }
+12 -12
main.ts
··· 154 154 isoPath = yield* downloadIso(input, options); 155 155 } 156 156 157 - if (yield* pipe( 157 + if ( 158 + yield* pipe( 158 159 fileExists(input), 159 160 Effect.map(() => true), 160 - Effect.catchAll(() => Effect.succeed(false))) 161 - ) { 162 - if (input.endsWith(".iso")) { 163 - isoPath = input; 164 - } 161 + Effect.catchAll(() => Effect.succeed(false)), 162 + ) 163 + ) { 164 + if (input.endsWith(".iso")) { 165 + isoPath = input; 165 166 } 167 + } 166 168 167 169 const coreOSImageURL = yield* pipe( 168 170 constructCoreOSImageURL(input), 169 - Effect.catchAll(() => Effect.succeed(null)) 171 + Effect.catchAll(() => Effect.succeed(null)), 170 172 ); 171 173 172 174 if (coreOSImageURL) { ··· 174 176 basename(coreOSImageURL).replace(".xz", ""), 175 177 fileExists, 176 178 Effect.flatMap(() => Effect.succeed(true)), 177 - Effect.catchAll(() => Effect.succeed(false)) 179 + Effect.catchAll(() => Effect.succeed(false)), 178 180 ); 179 181 if (!cached) { 180 182 isoPath = yield* pipe( ··· 185 187 isoPath = basename(coreOSImageURL).replace(".xz", ""); 186 188 } 187 189 } 188 - 189 190 } 190 - 191 191 192 192 const config = yield* pipe( 193 193 fileExists(CONFIG_FILE_NAME), ··· 212 212 if (!input && config?.vm?.iso) { 213 213 const coreOSImageURL = yield* pipe( 214 214 constructCoreOSImageURL(config.vm.iso), 215 - Effect.catchAll(() => Effect.succeed(null)) 215 + Effect.catchAll(() => Effect.succeed(null)), 216 216 ); 217 217 218 218 if (coreOSImageURL) { ··· 220 220 basename(coreOSImageURL).replace(".xz", ""), 221 221 fileExists, 222 222 Effect.flatMap(() => Effect.succeed(true)), 223 - Effect.catchAll(() => Effect.succeed(false)) 223 + Effect.catchAll(() => Effect.succeed(false)), 224 224 ); 225 225 if (!cached) { 226 226 const xz = yield* downloadIso(coreOSImageURL, options);
+2 -2
src/api/mod.ts
··· 17 17 console.log(`Using API token: ${token}`); 18 18 } else { 19 19 console.log( 20 - `Using provided API token from environment variable VMX_API_TOKEN` 20 + `Using provided API token from environment variable VMX_API_TOKEN`, 21 21 ); 22 22 } 23 23 ··· 39 39 flags.p || 40 40 (Deno.env.get("VMX_API_PORT") 41 41 ? Number(Deno.env.get("VMX_API_PORT")) 42 - : 8889) 42 + : 8889), 43 43 ); 44 44 45 45 Deno.serve({ port }, app.fetch);
+5 -5
src/config.ts
··· 39 39 }> {} 40 40 41 41 export const initVmFile = ( 42 - path: string 42 + path: string, 43 43 ): Effect.Effect<void, VmConfigError, never> => 44 44 Effect.tryPromise({ 45 45 try: async () => { ··· 64 64 }); 65 65 66 66 export const parseVmFile = ( 67 - path: string 67 + path: string, 68 68 ): Effect.Effect<VmConfig, VmConfigError, never> => 69 69 Effect.tryPromise({ 70 70 try: async () => { ··· 77 77 78 78 export const mergeConfig = ( 79 79 config: VmConfig | null, 80 - options: Options 80 + options: Options, 81 81 ): Effect.Effect<Options, never, never> => { 82 82 const { flags } = parseFlags(Deno.args); 83 83 flags.image = flags.i || flags.image; ··· 113 113 diskFormat: _.get( 114 114 flags, 115 115 "diskFormat", 116 - defaultConfig.vm.disk_format! 116 + defaultConfig.vm.disk_format!, 117 117 ) as string, 118 118 portForward: _.get( 119 119 flags, 120 120 "portForward", 121 - defaultConfig.network.port_forward! 121 + defaultConfig.network.port_forward!, 122 122 ) as string, 123 123 image: _.get(flags, "image", defaultConfig.vm.image!) as string, 124 124 bridge: _.get(flags, "bridge", defaultConfig.network.bridge!) as string,
+5 -5
src/constants.ts
··· 5 5 export const CONFIG_FILE_NAME: string = "vmconfig.toml"; 6 6 export const IMAGE_DIR: string = `${CONFIG_DIR}/images`; 7 7 export const VOLUME_DIR: string = `${CONFIG_DIR}/volumes`; 8 - export const UBUNTU_ISO_URL: string = 9 - 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"; 8 + export const UBUNTU_ISO_URL: string = Deno.build.arch === "aarch64" 9 + ? "https://cdimage.ubuntu.com/releases/24.04/release/ubuntu-24.04.3-live-server-arm64.iso" 10 + : "https://releases.ubuntu.com/24.04.3/ubuntu-24.04.3-live-server-amd64.iso"; 12 11 export const FEDORA_COREOS_DEFAULT_VERSION: string = "43.20251024.3.0"; 13 - 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`; 12 + export const FEDORA_COREOS_IMG_URL: string = 13 + `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`;
+15 -13
src/oras.ts
··· 18 18 }> {} 19 19 20 20 export class CreateDirectoryError extends Data.TaggedError( 21 - "CreateDirectoryError" 21 + "CreateDirectoryError", 22 22 )<{ 23 23 cause?: unknown; 24 24 }> {} 25 25 26 26 export class ImageAlreadyPulledError extends Data.TaggedError( 27 - "ImageAlreadyPulledError" 27 + "ImageAlreadyPulledError", 28 28 )<{ 29 29 name: string; 30 30 }> {} ··· 60 60 } 61 61 62 62 // https://github.com/oras-project/oras/releases/download/v1.3.0/oras_1.3.0_darwin_amd64.tar.gz 63 - const downloadUrl = `https://github.com/oras-project/oras/releases/download/v${version}/oras_${version}_${os}_${arch}.tar.gz`; 63 + const downloadUrl = 64 + `https://github.com/oras-project/oras/releases/download/v${version}/oras_${version}_${os}_${arch}.tar.gz`; 64 65 65 66 console.log(`Downloading ORAS from ${chalk.greenBright(downloadUrl)}`); 66 67 ··· 100 101 await Deno.chmod(`${CONFIG_DIR}/bin/oras`, 0o755); 101 102 102 103 console.log( 103 - `ORAS binary installed at ${chalk.greenBright(`${CONFIG_DIR}/bin/oras`)}` 104 + `ORAS binary installed at ${chalk.greenBright(`${CONFIG_DIR}/bin/oras`)}`, 104 105 ); 105 106 } 106 107 ··· 199 200 return Effect.fail(new ImageAlreadyPulledError({ name: image })); 200 201 } 201 202 return Effect.succeed(void 0); 202 - }) 203 + }), 203 204 ); 204 205 205 206 export const pullFromRegistry = (image: string) => ··· 211 212 const tag = image.split(":")[1] || "latest"; 212 213 console.log( 213 214 "pull", 214 - `${formatRepository(repository)}:${tag}-${getCurrentArch()}` 215 + `${formatRepository(repository)}:${tag}-${getCurrentArch()}`, 215 216 ); 216 217 217 218 const process = new Deno.Command("oras", { ··· 234 235 new PullImageError({ 235 236 cause: error instanceof Error ? error.message : String(error), 236 237 }), 237 - }) 238 + }), 238 239 ); 239 240 240 241 export const getImageArchivePath = (image: string) => ··· 268 269 !layers[0].annotations["org.opencontainers.image.title"] 269 270 ) { 270 271 throw new Error( 271 - `No title annotation found for layer in image ${image}` 272 + `No title annotation found for layer in image ${image}`, 272 273 ); 273 274 } 274 275 275 - const path = `${IMAGE_DIR}/${layers[0].annotations["org.opencontainers.image.title"]}`; 276 + const path = `${IMAGE_DIR}/${ 277 + layers[0].annotations["org.opencontainers.image.title"] 278 + }`; 276 279 277 280 if (!(await Deno.stat(path).catch(() => false))) { 278 281 throw new Error(`Image archive not found at expected path ${path}`); ··· 368 371 return Effect.succeed(void 0); 369 372 }), 370 373 Effect.flatMap(() => pushToRegistry(img)), 371 - Effect.flatMap(cleanup) 374 + Effect.flatMap(cleanup), 372 375 ) 373 - ) 376 + ), 374 377 ); 375 378 376 379 export const pullImage = (image: string) => ··· 391 394 ), 392 395 Effect.flatMap(cleanup), 393 396 Effect.catchTag("ImageAlreadyPulledError", () => 394 - Effect.sync(() => console.log(`Image ${image} is already pulled.`)) 395 - ) 397 + Effect.sync(() => console.log(`Image ${image} is already pulled.`))), 396 398 );
+46 -44
src/subcommands/start.ts
··· 13 13 }> {} 14 14 15 15 export class VmAlreadyRunningError extends Data.TaggedError( 16 - "VmAlreadyRunningError" 16 + "VmAlreadyRunningError", 17 17 )<{ 18 18 name: string; 19 19 }> {} ··· 27 27 getInstanceState(name), 28 28 Effect.flatMap((vm) => 29 29 vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 30 - ) 30 + ), 31 31 ); 32 32 33 33 const logStarting = (vm: VirtualMachine) => ··· 40 40 export const setupFirmware = () => setupFirmwareFilesIfNeeded(); 41 41 42 42 export const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 43 - const qemu = 44 - Deno.build.arch === "aarch64" 45 - ? "qemu-system-aarch64" 46 - : "qemu-system-x86_64"; 43 + const qemu = Deno.build.arch === "aarch64" 44 + ? "qemu-system-aarch64" 45 + : "qemu-system-x86_64"; 47 46 48 47 return Effect.succeed([ 49 48 ..._.compact([vm.bridge && qemu]), ··· 74 73 vm.drivePath && [ 75 74 "-drive", 76 75 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 77 - ] 76 + ], 78 77 ), 79 78 ]); 80 79 }; ··· 88 87 export const startDetachedQemu = ( 89 88 name: string, 90 89 vm: VirtualMachine, 91 - qemuArgs: string[] 90 + qemuArgs: string[], 92 91 ) => { 93 - const qemu = 94 - Deno.build.arch === "aarch64" 95 - ? "qemu-system-aarch64" 96 - : "qemu-system-x86_64"; 92 + const qemu = Deno.build.arch === "aarch64" 93 + ? "qemu-system-aarch64" 94 + : "qemu-system-x86_64"; 97 95 98 96 const logPath = `${LOGS_DIR}/${vm.name}.log`; 99 97 100 98 const fullCommand = vm.bridge 101 - ? `sudo ${qemu} ${qemuArgs 99 + ? `sudo ${qemu} ${ 100 + qemuArgs 102 101 .slice(1) 103 - .join(" ")} >> "${logPath}" 2>&1 & echo $!` 102 + .join(" ") 103 + } >> "${logPath}" 2>&1 & echo $!` 104 104 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 105 105 106 106 return Effect.tryPromise({ ··· 131 131 Effect.flatMap(({ qemuPid, logPath }) => 132 132 pipe( 133 133 updateInstanceState(name, "RUNNING", qemuPid), 134 - Effect.map(() => ({ vm, qemuPid, logPath })) 134 + Effect.map(() => ({ vm, qemuPid, logPath })), 135 135 ) 136 - ) 136 + ), 137 137 ); 138 138 }; 139 139 ··· 148 148 }) => 149 149 Effect.sync(() => { 150 150 console.log( 151 - `Virtual machine ${vm.name} started in background (PID: ${qemuPid})` 151 + `Virtual machine ${vm.name} started in background (PID: ${qemuPid})`, 152 152 ); 153 153 console.log(`Logs will be written to: ${logPath}`); 154 154 }); ··· 156 156 const startInteractiveQemu = ( 157 157 name: string, 158 158 vm: VirtualMachine, 159 - qemuArgs: string[] 159 + qemuArgs: string[], 160 160 ) => { 161 - const qemu = 162 - Deno.build.arch === "aarch64" 163 - ? "qemu-system-aarch64" 164 - : "qemu-system-x86_64"; 161 + const qemu = Deno.build.arch === "aarch64" 162 + ? "qemu-system-aarch64" 163 + : "qemu-system-x86_64"; 165 164 166 165 return Effect.tryPromise({ 167 166 try: async () => { ··· 197 196 }); 198 197 199 198 export const createVolumeIfNeeded = ( 200 - vm: VirtualMachine 199 + vm: VirtualMachine, 201 200 ): Effect.Effect<[VirtualMachine, Volume?], Error, never> => 202 201 Effect.gen(function* () { 203 202 const { flags } = parseFlags(Deno.args); ··· 211 210 212 211 if (!vm.drivePath) { 213 212 throw new Error( 214 - `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.` 213 + `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.`, 215 214 ); 216 215 } 217 216 ··· 253 252 drivePath: volume ? volume.path : vm.drivePath, 254 253 diskFormat: volume ? "qcow2" : vm.diskFormat, 255 254 }, 256 - firmwareArgs 255 + firmwareArgs, 257 256 ) 258 257 ), 259 258 Effect.flatMap((qemuArgs) => ··· 261 260 createLogsDir(), 262 261 Effect.flatMap(() => startDetachedQemu(name, vm, qemuArgs)), 263 262 Effect.tap(logDetachedSuccess), 264 - Effect.map(() => 0) // Exit code 0 263 + Effect.map(() => 0), // Exit code 0 265 264 ) 266 - ) 265 + ), 267 266 ) 268 267 ), 269 - Effect.catchAll(handleError) 268 + Effect.catchAll(handleError), 270 269 ); 271 270 272 271 const startInteractiveEffect = (name: string) => ··· 286 285 drivePath: volume ? volume.path : vm.drivePath, 287 286 diskFormat: volume ? "qcow2" : vm.diskFormat, 288 287 }, 289 - firmwareArgs 288 + firmwareArgs, 290 289 ) 291 290 ), 292 291 Effect.flatMap((qemuArgs) => startInteractiveQemu(name, vm, qemuArgs)), 293 - Effect.map((status) => (status.success ? 0 : status.code || 1)) 292 + Effect.map((status) => (status.success ? 0 : status.code || 1)), 294 293 ) 295 294 ), 296 - Effect.catchAll(handleError) 295 + Effect.catchAll(handleError), 297 296 ); 298 297 299 298 export default async function (name: string, detach: boolean = false) { 300 299 const exitCode = await Effect.runPromise( 301 - detach ? startDetachedEffect(name) : startInteractiveEffect(name) 300 + detach ? startDetachedEffect(name) : startInteractiveEffect(name), 302 301 ); 303 302 304 303 if (detach) { ··· 312 311 const { flags } = parseFlags(Deno.args); 313 312 return { 314 313 ...vm, 315 - memory: 316 - flags.memory || flags.m ? String(flags.memory || flags.m) : vm.memory, 314 + memory: flags.memory || flags.m 315 + ? String(flags.memory || flags.m) 316 + : vm.memory, 317 317 cpus: flags.cpus || flags.C ? Number(flags.cpus || flags.C) : vm.cpus, 318 318 cpu: flags.cpu || flags.c ? String(flags.cpu || flags.c) : vm.cpu, 319 319 diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat, 320 - portForward: 321 - flags.portForward || flags.p 322 - ? String(flags.portForward || flags.p) 323 - : vm.portForward, 324 - drivePath: 325 - flags.image || flags.i ? String(flags.image || flags.i) : vm.drivePath, 326 - bridge: 327 - flags.bridge || flags.b ? String(flags.bridge || flags.b) : vm.bridge, 328 - diskSize: 329 - flags.size || flags.s ? String(flags.size || flags.s) : vm.diskSize, 320 + portForward: flags.portForward || flags.p 321 + ? String(flags.portForward || flags.p) 322 + : vm.portForward, 323 + drivePath: flags.image || flags.i 324 + ? String(flags.image || flags.i) 325 + : vm.drivePath, 326 + bridge: flags.bridge || flags.b 327 + ? String(flags.bridge || flags.b) 328 + : vm.bridge, 329 + diskSize: flags.size || flags.s 330 + ? String(flags.size || flags.s) 331 + : vm.diskSize, 330 332 }; 331 333 }
+32 -31
src/utils.ts
··· 62 62 export const isValidISOurl = (url?: string): boolean => { 63 63 return Boolean( 64 64 (url?.startsWith("http://") || url?.startsWith("https://")) && 65 - url?.endsWith(".iso") 65 + url?.endsWith(".iso"), 66 66 ); 67 67 }; 68 68 ··· 88 88 }); 89 89 90 90 export const validateImage = ( 91 - image: string 91 + image: string, 92 92 ): Effect.Effect<string, InvalidImageNameError, never> => { 93 93 const regex = 94 94 /^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/; ··· 99 99 image, 100 100 cause: 101 101 "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.", 102 - }) 102 + }), 103 103 ); 104 104 } 105 105 return Effect.succeed(image); ··· 108 108 export const extractTag = (name: string) => 109 109 pipe( 110 110 validateImage(name), 111 - Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")) 111 + Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")), 112 112 ); 113 113 114 114 export const failOnMissingImage = ( 115 - image: Image | undefined 115 + image: Image | undefined, 116 116 ): Effect.Effect<Image, Error, never> => 117 117 image 118 118 ? Effect.succeed(image) 119 119 : Effect.fail(new NoSuchImageError({ cause: "No such image" })); 120 120 121 121 export const du = ( 122 - path: string 122 + path: string, 123 123 ): Effect.Effect<number, LogCommandError, never> => 124 124 Effect.tryPromise({ 125 125 try: async () => { ··· 151 151 exists 152 152 ? Effect.succeed(true) 153 153 : du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB)) 154 - ) 154 + ), 155 155 ); 156 156 157 157 export const downloadIso = (url: string, options: Options) => ··· 173 173 if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 174 174 console.log( 175 175 chalk.yellowBright( 176 - `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.` 177 - ) 176 + `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, 177 + ), 178 178 ); 179 179 return null; 180 180 } ··· 192 192 if (outputExists) { 193 193 console.log( 194 194 chalk.yellowBright( 195 - `File ${outputPath} already exists, skipping download.` 196 - ) 195 + `File ${outputPath} already exists, skipping download.`, 196 + ), 197 197 ); 198 198 return outputPath; 199 199 } ··· 242 242 if (!success) { 243 243 console.error( 244 244 chalk.redBright( 245 - "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew." 246 - ) 245 + "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", 246 + ), 247 247 ); 248 248 Deno.exit(1); 249 249 } ··· 256 256 try: () => 257 257 Deno.copyFile( 258 258 `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 259 - edk2VarsAarch64 259 + edk2VarsAarch64, 260 260 ), 261 261 catch: (error) => new LogCommandError({ cause: error }), 262 262 }); ··· 301 301 const configOK = yield* pipe( 302 302 fileExists("config.ign"), 303 303 Effect.flatMap(() => Effect.succeed(true)), 304 - Effect.catchAll(() => Effect.succeed(false)) 304 + Effect.catchAll(() => Effect.succeed(false)), 305 305 ); 306 306 if (!configOK) { 307 307 console.error( 308 308 chalk.redBright( 309 - "CoreOS image requires a config.ign file in the current directory." 310 - ) 309 + "CoreOS image requires a config.ign file in the current directory.", 310 + ), 311 311 ); 312 312 Deno.exit(1); 313 313 } ··· 327 327 Effect.gen(function* () { 328 328 const macAddress = yield* generateRandomMacAddress(); 329 329 330 - const qemu = 331 - Deno.build.arch === "aarch64" 332 - ? "qemu-system-aarch64" 333 - : "qemu-system-x86_64"; 330 + const qemu = Deno.build.arch === "aarch64" 331 + ? "qemu-system-aarch64" 332 + : "qemu-system-x86_64"; 334 333 335 334 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 336 335 const coreosArgs: string[] = yield* setupCoreOSArgs(isoPath); ··· 366 365 options.image && [ 367 366 "-drive", 368 367 `file=${options.image},format=${options.diskFormat},if=virtio`, 369 - ] 368 + ], 370 369 ), 371 370 ]; 372 371 ··· 381 380 const logPath = `${LOGS_DIR}/${name}.log`; 382 381 383 382 const fullCommand = options.bridge 384 - ? `sudo ${qemu} ${qemuArgs 383 + ? `sudo ${qemu} ${ 384 + qemuArgs 385 385 .slice(1) 386 - .join(" ")} >> "${logPath}" 2>&1 & echo $!` 386 + .join(" ") 387 + } >> "${logPath}" 2>&1 & echo $!` 387 388 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 388 389 389 390 const { stdout } = yield* Effect.tryPromise({ ··· 419 420 }); 420 421 421 422 console.log( 422 - `Virtual machine ${name} started in background (PID: ${qemuPid})` 423 + `Virtual machine ${name} started in background (PID: ${qemuPid})`, 423 424 ); 424 425 console.log(`Logs will be written to: ${logPath}`); 425 426 ··· 541 542 if (pathExists) { 542 543 console.log( 543 544 chalk.yellowBright( 544 - `Drive image ${path} already exists, skipping creation.` 545 - ) 545 + `Drive image ${path} already exists, skipping creation.`, 546 + ), 546 547 ); 547 548 return; 548 549 } ··· 569 570 }); 570 571 571 572 export const fileExists = ( 572 - path: string 573 + path: string, 573 574 ): Effect.Effect<void, NoSuchFileError, never> => 574 575 Effect.tryPromise({ 575 576 try: () => Deno.stat(path), ··· 577 578 }); 578 579 579 580 export const constructCoreOSImageURL = ( 580 - image: string 581 + image: string, 581 582 ): Effect.Effect<string, InvalidImageNameError, never> => { 582 583 // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version> 583 584 const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/; ··· 585 586 if (match) { 586 587 const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION; 587 588 return Effect.succeed( 588 - FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version) 589 + FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version), 589 590 ); 590 591 } 591 592 ··· 593 594 new InvalidImageNameError({ 594 595 image, 595 596 cause: "Image name does not match CoreOS naming conventions.", 596 - }) 597 + }), 597 598 ); 598 599 }; 599 600
+9 -9
src/utils_test.ts
··· 7 7 const url = Effect.runSync( 8 8 pipe( 9 9 constructCoreOSImageURL("fedora-coreos"), 10 - Effect.catchAll((_error) => Effect.succeed(null as string | null)) 11 - ) 10 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 11 + ), 12 12 ); 13 13 14 14 assertEquals(url, FEDORA_COREOS_IMG_URL); ··· 18 18 const url = Effect.runSync( 19 19 pipe( 20 20 constructCoreOSImageURL("coreos"), 21 - Effect.catchAll((_error) => Effect.succeed(null as string | null)) 22 - ) 21 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 22 + ), 23 23 ); 24 24 25 25 assertEquals(url, FEDORA_COREOS_IMG_URL); ··· 29 29 const url = Effect.runSync( 30 30 pipe( 31 31 constructCoreOSImageURL("fedora-coreos-43.20251024.2.0"), 32 - Effect.catchAll((_error) => Effect.succeed(null as string | null)) 33 - ) 32 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 33 + ), 34 34 ); 35 35 36 36 assertEquals( 37 37 url, 38 38 "https://builds.coreos.fedoraproject.org/prod/streams/stable/builds/43.20251024.2.0/" + 39 - `${Deno.build.arch}/fedora-coreos-43.20251024.2.0-qemu.${Deno.build.arch}.qcow2.xz` 39 + `${Deno.build.arch}/fedora-coreos-43.20251024.2.0-qemu.${Deno.build.arch}.qcow2.xz`, 40 40 ); 41 41 }); 42 42 ··· 44 44 const url = Effect.runSync( 45 45 pipe( 46 46 constructCoreOSImageURL("fedora-coreos-latest"), 47 - Effect.catchAll((_error) => Effect.succeed(null as string | null)) 48 - ) 47 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 48 + ), 49 49 ); 50 50 51 51 assertEquals(url, null);