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

feat: update deno.lock and improve migration scripts for virtual machines

+220 -132
+17 -1
deno.lock
··· 27 27 "jsr:@std/internal@^1.0.10": "1.0.12", 28 28 "jsr:@std/internal@^1.0.12": "1.0.12", 29 29 "jsr:@std/internal@^1.0.9": "1.0.12", 30 + "jsr:@std/io@~0.224.9": "0.224.9", 30 31 "jsr:@std/io@~0.225.2": "0.225.2", 31 32 "jsr:@std/path@0.217": "0.217.0", 32 33 "jsr:@std/path@1": "1.1.2", ··· 39 40 "jsr:@zod/zod@^4.1.12": "4.1.12", 40 41 "npm:@hono/swagger-ui@~0.5.2": "0.5.2_hono@4.10.6", 41 42 "npm:@paralleldrive/cuid2@^3.0.4": "3.0.4", 43 + "npm:@types/node@*": "24.2.0", 42 44 "npm:chalk@^5.6.2": "5.6.2", 43 45 "npm:dayjs@^1.11.19": "1.11.19", 44 46 "npm:effect@^3.19.2": "3.19.3", ··· 52 54 "integrity": "ba37f10ce55bbfbdd8ddd987f91f029b17bce88385b98ba3058870f3b007b80c", 53 55 "dependencies": [ 54 56 "jsr:@cliffy/internal", 55 - "jsr:@std/encoding@~1.0.5" 57 + "jsr:@std/encoding@~1.0.5", 58 + "jsr:@std/io@~0.224.9" 56 59 ] 57 60 }, 58 61 "@cliffy/command@1.0.0-rc.8": { ··· 85 88 "jsr:@cliffy/keycode", 86 89 "jsr:@std/assert@~1.0.6", 87 90 "jsr:@std/fmt@~1.0.2", 91 + "jsr:@std/io@~0.224.9", 88 92 "jsr:@std/path@~1.0.6", 89 93 "jsr:@std/text" 90 94 ] ··· 150 154 }, 151 155 "@std/internal@1.0.12": { 152 156 "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 157 + }, 158 + "@std/io@0.224.9": { 159 + "integrity": "4414664b6926f665102e73c969cfda06d2c4c59bd5d0c603fd4f1b1c840d6ee3" 153 160 }, 154 161 "@std/io@0.225.2": { 155 162 "integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7", ··· 210 217 "@standard-schema/spec@1.0.0": { 211 218 "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" 212 219 }, 220 + "@types/node@24.2.0": { 221 + "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", 222 + "dependencies": [ 223 + "undici-types" 224 + ] 225 + }, 213 226 "bignumber.js@9.3.1": { 214 227 "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==" 215 228 }, ··· 246 259 }, 247 260 "pure-rand@6.1.0": { 248 261 "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==" 262 + }, 263 + "undici-types@7.10.0": { 264 + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==" 249 265 } 250 266 }, 251 267 "workspace": {
+35 -28
src/api/machines.ts
··· 6 6 import { SEED_DIR } from "../constants.ts"; 7 7 import { ImageNotFoundError, RemoveRunningVmError } from "../errors.ts"; 8 8 import { getImage } from "../images.ts"; 9 - import { DEFAULT_VERSION, getInstanceState } from "../mod.ts"; 10 9 import { generateRandomMacAddress } from "../network.ts"; 11 10 import { 11 + getInstanceState, 12 12 listInstances, 13 13 removeInstanceState, 14 14 saveInstanceState, ··· 43 43 Effect.flatMap((params) => 44 44 listInstances(params.all === "true" || params.all === "1") 45 45 ), 46 - presentation(c), 47 - ), 48 - )); 46 + presentation(c) 47 + ) 48 + ) 49 + ); 49 50 50 51 app.post("/", (c) => 51 52 Effect.runPromise( ··· 56 57 const image = yield* getImage(params.image); 57 58 if (!image) { 58 59 return yield* Effect.fail( 59 - new ImageNotFoundError({ id: params.image }), 60 + new ImageNotFoundError({ id: params.image }) 60 61 ); 61 62 } 62 63 ··· 90 91 sshPwauth: false, 91 92 }, 92 93 }, 93 - tempDir, 94 + tempDir 94 95 ); 95 96 } 96 97 ··· 110 111 ? params.portForward.join(",") 111 112 : undefined, 112 113 drivePath: volume ? volume.path : image.path, 113 - version: image.tag ?? DEFAULT_VERSION, 114 + version: image.tag, 114 115 status: "STOPPED", 115 116 seed: _.get( 116 117 params, 117 118 "seed", 118 - params.users ? `${SEED_DIR}/seed-${name}.iso` : undefined, 119 + params.users ? `${SEED_DIR}/seed-${name}.iso` : undefined 119 120 ), 120 121 pid: 0, 121 122 }); ··· 125 126 }) 126 127 ), 127 128 presentation(c), 128 - Effect.catchAll((error) => handleError(error, c)), 129 - ), 130 - )); 129 + Effect.catchAll((error) => handleError(error, c)) 130 + ) 131 + ) 132 + ); 131 133 132 134 app.get("/:id", (c) => 133 135 Effect.runPromise( 134 136 pipe( 135 137 parseParams(c), 136 138 Effect.flatMap(({ id }) => getInstanceState(id)), 137 - presentation(c), 138 - ), 139 - )); 139 + presentation(c) 140 + ) 141 + ) 142 + ); 140 143 141 144 app.delete("/:id", (c) => 142 145 Effect.runPromise( ··· 155 158 }) 156 159 ), 157 160 presentation(c), 158 - Effect.catchAll((error) => handleError(error, c)), 159 - ), 160 - )); 161 + Effect.catchAll((error) => handleError(error, c)) 162 + ) 163 + ) 164 + ); 161 165 162 166 app.post("/:id/start", (c) => 163 167 Effect.runPromise( ··· 182 186 ? startRequest.portForward.join(",") 183 187 : vm.portForward, 184 188 }, 185 - firmwareArgs, 189 + firmwareArgs 186 190 ); 187 191 yield* createLogsDir(); 188 192 yield* startDetachedQemu(vm.id, vm, qemuArgs); ··· 190 194 }) 191 195 ), 192 196 presentation(c), 193 - Effect.catchAll((error) => handleError(error, c)), 194 - ), 195 - )); 197 + Effect.catchAll((error) => handleError(error, c)) 198 + ) 199 + ) 200 + ); 196 201 197 202 app.post("/:id/stop", (c) => 198 203 Effect.runPromise( ··· 202 207 Effect.flatMap(killProcess), 203 208 Effect.flatMap(updateToStopped), 204 209 presentation(c), 205 - Effect.catchAll((error) => handleError(error, c)), 206 - ), 207 - )); 210 + Effect.catchAll((error) => handleError(error, c)) 211 + ) 212 + ) 213 + ); 208 214 209 215 app.post("/:id/restart", (c) => 210 216 Effect.runPromise( ··· 231 237 ? startRequest.portForward.join(",") 232 238 : vm.portForward, 233 239 }, 234 - firmwareArgs, 240 + firmwareArgs 235 241 ); 236 242 yield* createLogsDir(); 237 243 yield* startDetachedQemu(vm.id, vm, qemuArgs); ··· 239 245 }) 240 246 ), 241 247 presentation(c), 242 - Effect.catchAll((error) => handleError(error, c)), 243 - ), 244 - )); 248 + Effect.catchAll((error) => handleError(error, c)) 249 + ) 250 + ) 251 + ); 245 252 246 253 export default app;
+1 -1
src/db.ts
··· 32 32 diskFormat: string; 33 33 isoPath?: string; 34 34 portForward?: string; 35 - version: string; 35 + version?: string; 36 36 status: STATUS; 37 37 pid: number; 38 38 volume?: string;
+96 -28
src/migrations.ts
··· 34 34 .addColumn("isoPath", "varchar") 35 35 .addColumn("status", "varchar", (col) => col.notNull()) 36 36 .addColumn("pid", "integer") 37 - .addColumn( 38 - "createdAt", 39 - "varchar", 40 - (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 37 + .addColumn("createdAt", "varchar", (col) => 38 + col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) 41 39 ) 42 - .addColumn( 43 - "updatedAt", 44 - "varchar", 45 - (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 40 + .addColumn("updatedAt", "varchar", (col) => 41 + col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) 46 42 ) 47 43 .execute(); 48 44 }, ··· 157 153 .addColumn("size", "integer", (col) => col.notNull()) 158 154 .addColumn("path", "varchar", (col) => col.notNull()) 159 155 .addColumn("format", "varchar", (col) => col.notNull().defaultTo("qcow2")) 160 - .addColumn( 161 - "createdAt", 162 - "varchar", 163 - (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 156 + .addColumn("createdAt", "varchar", (col) => 157 + col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) 164 158 ) 165 159 .addUniqueConstraint("images_repository_tag_unique", [ 166 160 "repository", ··· 218 212 .createTable("volumes") 219 213 .addColumn("id", "varchar", (col) => col.primaryKey()) 220 214 .addColumn("name", "varchar", (col) => col.notNull().unique()) 221 - .addColumn( 222 - "baseImageId", 223 - "varchar", 224 - (col) => col.notNull().references("images.id").onDelete("cascade"), 215 + .addColumn("baseImageId", "varchar", (col) => 216 + col.notNull().references("images.id").onDelete("cascade") 225 217 ) 226 218 .addColumn("path", "varchar", (col) => col.notNull()) 227 - .addColumn( 228 - "createdAt", 229 - "varchar", 230 - (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 219 + .addColumn("createdAt", "varchar", (col) => 220 + col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) 231 221 ) 232 222 .execute(); 233 223 }, ··· 243 233 .createTable("volumes_new") 244 234 .addColumn("id", "varchar", (col) => col.primaryKey()) 245 235 .addColumn("name", "varchar", (col) => col.notNull().unique()) 246 - .addColumn( 247 - "baseImageId", 248 - "varchar", 249 - (col) => col.notNull().references("images.id").onDelete("cascade"), 236 + .addColumn("baseImageId", "varchar", (col) => 237 + col.notNull().references("images.id").onDelete("cascade") 250 238 ) 251 239 .addColumn("path", "varchar", (col) => col.notNull()) 252 - .addColumn( 253 - "createdAt", 254 - "varchar", 255 - (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 240 + .addColumn("createdAt", "varchar", (col) => 241 + col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) 256 242 ) 257 243 .execute(); 258 244 ··· 296 282 297 283 async down(db: Kysely<unknown>): Promise<void> { 298 284 await db.schema.alterTable("virtual_machines").dropColumn("seed").execute(); 285 + }, 286 + }; 287 + 288 + migrations["012"] = { 289 + async up(db: Kysely<unknown>): Promise<void> { 290 + // make version nullable 291 + await db.schema 292 + .createTable("virtual_machines_new") 293 + .addColumn("id", "varchar", (col) => col.primaryKey()) 294 + .addColumn("name", "varchar", (col) => col.notNull().unique()) 295 + .addColumn("bridge", "varchar") 296 + .addColumn("macAddress", "varchar", (col) => col.notNull().unique()) 297 + .addColumn("memory", "varchar", (col) => col.notNull()) 298 + .addColumn("cpus", "integer", (col) => col.notNull()) 299 + .addColumn("cpu", "varchar", (col) => col.notNull()) 300 + .addColumn("diskSize", "varchar", (col) => col.notNull()) 301 + .addColumn("drivePath", "varchar") 302 + .addColumn("version", "varchar") 303 + .addColumn("diskFormat", "varchar") 304 + .addColumn("isoPath", "varchar") 305 + .addColumn("portForward", "varchar") 306 + .addColumn("status", "varchar", (col) => col.notNull()) 307 + .addColumn("pid", "integer") 308 + .addColumn("volume", "varchar") 309 + .addColumn("seed", "varchar") 310 + .addColumn("createdAt", "varchar", (col) => 311 + col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) 312 + ) 313 + .addColumn("updatedAt", "varchar", (col) => 314 + col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) 315 + ) 316 + .execute(); 317 + 318 + await sql` 319 + INSERT INTO virtual_machines_new (id, name, bridge, macAddress, memory, cpus, cpu, diskSize, drivePath, version, diskFormat, isoPath, portForward, status, pid, volume, seed, createdAt, updatedAt) 320 + SELECT id, name, bridge, macAddress, memory, cpus, cpu, diskSize, drivePath, version, diskFormat, isoPath, portForward, status, pid, volume, seed, createdAt, updatedAt FROM virtual_machines 321 + `.execute(db); 322 + 323 + await db.schema.dropTable("virtual_machines").execute(); 324 + await sql`ALTER TABLE virtual_machines_new RENAME TO virtual_machines`.execute( 325 + db 326 + ); 327 + }, 328 + 329 + async down(db: Kysely<unknown>): Promise<void> { 330 + // make version not nullable 331 + await db.schema 332 + .createTable("virtual_machines_old") 333 + .addColumn("id", "varchar", (col) => col.primaryKey()) 334 + .addColumn("name", "varchar", (col) => col.notNull().unique()) 335 + .addColumn("bridge", "varchar") 336 + .addColumn("macAddress", "varchar", (col) => col.notNull().unique()) 337 + .addColumn("memory", "varchar", (col) => col.notNull()) 338 + .addColumn("cpus", "integer", (col) => col.notNull()) 339 + .addColumn("cpu", "varchar", (col) => col.notNull()) 340 + .addColumn("diskSize", "varchar", (col) => col.notNull()) 341 + .addColumn("drivePath", "varchar") 342 + .addColumn("version", "varchar", (col) => col.notNull()) 343 + .addColumn("diskFormat", "varchar") 344 + .addColumn("isoPath", "varchar") 345 + .addColumn("portForward", "varchar") 346 + .addColumn("status", "varchar", (col) => col.notNull()) 347 + .addColumn("pid", "integer") 348 + .addColumn("volume", "varchar") 349 + .addColumn("seed", "varchar") 350 + .addColumn("createdAt", "varchar", (col) => 351 + col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) 352 + ) 353 + .addColumn("updatedAt", "varchar", (col) => 354 + col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) 355 + ) 356 + .execute(); 357 + 358 + await sql` 359 + INSERT INTO virtual_machines_old (id, name, bridge, macAddress, memory, cpus, cpu, diskSize, drivePath, version, diskFormat, isoPath, portForward, status, pid, volume, seed, createdAt, updatedAt) 360 + SELECT id, name, bridge, macAddress, memory, cpus, cpu, diskSize, drivePath, version, diskFormat, isoPath, portForward, status, pid, volume, seed, createdAt, updatedAt FROM virtual_machines 361 + `.execute(db); 362 + 363 + await db.schema.dropTable("virtual_machines").execute(); 364 + await sql`ALTER TABLE virtual_machines_old RENAME TO virtual_machines`.execute( 365 + db 366 + ); 299 367 }, 300 368 }; 301 369
+71 -74
src/utils.ts
··· 34 34 import { generateRandomMacAddress } from "./network.ts"; 35 35 import { saveInstanceState, updateInstanceState } from "./state.ts"; 36 36 37 - export const DEFAULT_VERSION = "14.3-RELEASE"; 38 - 39 37 export interface Options { 40 38 output?: string; 41 39 cpu: string; ··· 67 65 export const isValidISOurl = (url?: string): boolean => { 68 66 return Boolean( 69 67 (url?.startsWith("http://") || url?.startsWith("https://")) && 70 - url?.endsWith(".iso"), 68 + url?.endsWith(".iso") 71 69 ); 72 70 }; 73 71 ··· 93 91 }); 94 92 95 93 export const validateImage = ( 96 - image: string, 94 + image: string 97 95 ): Effect.Effect<string, InvalidImageNameError, never> => { 98 96 const regex = 99 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._-]+)?$/; ··· 104 102 image, 105 103 cause: 106 104 "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.", 107 - }), 105 + }) 108 106 ); 109 107 } 110 108 return Effect.succeed(image); ··· 113 111 export const extractTag = (name: string) => 114 112 pipe( 115 113 validateImage(name), 116 - Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")), 114 + Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")) 117 115 ); 118 116 119 117 export const failOnMissingImage = ( 120 - image: Image | undefined, 118 + image: Image | undefined 121 119 ): Effect.Effect<Image, Error, never> => 122 120 image 123 121 ? Effect.succeed(image) 124 122 : Effect.fail(new NoSuchImageError({ cause: "No such image" })); 125 123 126 124 export const du = ( 127 - path: string, 125 + path: string 128 126 ): Effect.Effect<number, LogCommandError, never> => 129 127 Effect.tryPromise({ 130 128 try: async () => { ··· 156 154 exists 157 155 ? Effect.succeed(true) 158 156 : du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB)) 159 - ), 157 + ) 160 158 ); 161 159 162 160 export const downloadIso = (url: string, options: Options) => ··· 178 176 if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 179 177 console.log( 180 178 chalk.yellowBright( 181 - `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, 182 - ), 179 + `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.` 180 + ) 183 181 ); 184 182 return null; 185 183 } ··· 197 195 if (outputExists) { 198 196 console.log( 199 197 chalk.yellowBright( 200 - `File ${outputPath} already exists, skipping download.`, 201 - ), 198 + `File ${outputPath} already exists, skipping download.` 199 + ) 202 200 ); 203 201 return outputPath; 204 202 } ··· 209 207 chalk.blueBright( 210 208 `Downloading ${ 211 209 url.endsWith(".iso") ? "ISO" : "image" 212 - } from ${url}...`, 213 - ), 210 + } from ${url}...` 211 + ) 214 212 ); 215 213 const cmd = new Deno.Command("curl", { 216 214 args: ["-L", "-o", outputPath, url], ··· 253 251 if (!success) { 254 252 console.error( 255 253 chalk.redBright( 256 - "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", 257 - ), 254 + "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew." 255 + ) 258 256 ); 259 257 Deno.exit(1); 260 258 } ··· 267 265 try: () => 268 266 Deno.copyFile( 269 267 `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 270 - edk2VarsAarch64, 268 + edk2VarsAarch64 271 269 ), 272 270 catch: (error) => new LogCommandError({ cause: error }), 273 271 }); ··· 312 310 const configOK = yield* pipe( 313 311 fileExists("config.ign"), 314 312 Effect.flatMap(() => Effect.succeed(true)), 315 - Effect.catchAll(() => Effect.succeed(false)), 313 + Effect.catchAll(() => Effect.succeed(false)) 316 314 ); 317 315 if (!configOK) { 318 316 console.error( 319 317 chalk.redBright( 320 - "CoreOS image requires a config.ign file in the current directory.", 321 - ), 318 + "CoreOS image requires a config.ign file in the current directory." 319 + ) 322 320 ); 323 321 Deno.exit(1); 324 322 } ··· 358 356 imagePath && 359 357 imagePath.endsWith(".qcow2") && 360 358 imagePath.startsWith( 361 - `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-`, 359 + `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-` 362 360 ) 363 361 ) { 364 362 return [ ··· 373 371 374 372 export const setupAlpineArgs = ( 375 373 imagePath?: string | null, 376 - seed: string = "seed.iso", 374 + seed: string = "seed.iso" 377 375 ) => 378 376 Effect.sync(() => { 379 377 if ( ··· 394 392 395 393 export const setupDebianArgs = ( 396 394 imagePath?: string | null, 397 - seed: string = "seed.iso", 395 + seed: string = "seed.iso" 398 396 ) => 399 397 Effect.sync(() => { 400 398 if ( ··· 415 413 416 414 export const setupUbuntuArgs = ( 417 415 imagePath?: string | null, 418 - seed: string = "seed.iso", 416 + seed: string = "seed.iso" 419 417 ) => 420 418 Effect.sync(() => { 421 419 if ( ··· 436 434 437 435 export const setupAlmaLinuxArgs = ( 438 436 imagePath?: string | null, 439 - seed: string = "seed.iso", 437 + seed: string = "seed.iso" 440 438 ) => 441 439 Effect.sync(() => { 442 440 if ( ··· 457 455 458 456 export const setupRockyLinuxArgs = ( 459 457 imagePath?: string | null, 460 - seed: string = "seed.iso", 458 + seed: string = "seed.iso" 461 459 ) => 462 460 Effect.sync(() => { 463 461 if ( ··· 480 478 Effect.gen(function* () { 481 479 const macAddress = yield* generateRandomMacAddress(); 482 480 483 - const qemu = Deno.build.arch === "aarch64" 484 - ? "qemu-system-aarch64" 485 - : "qemu-system-x86_64"; 481 + const qemu = 482 + Deno.build.arch === "aarch64" 483 + ? "qemu-system-aarch64" 484 + : "qemu-system-x86_64"; 486 485 487 486 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 488 487 let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image); 489 488 let fedoraArgs: string[] = yield* setupFedoraArgs( 490 489 isoPath || options.image, 491 - options.seed, 490 + options.seed 492 491 ); 493 492 let gentooArgs: string[] = yield* setupGentooArgs( 494 493 isoPath || options.image, 495 - options.seed, 494 + options.seed 496 495 ); 497 496 let alpineArgs: string[] = yield* setupAlpineArgs( 498 497 isoPath || options.image, 499 - options.seed, 498 + options.seed 500 499 ); 501 500 let debianArgs: string[] = yield* setupDebianArgs( 502 501 isoPath || options.image, 503 - options.seed, 502 + options.seed 504 503 ); 505 504 let ubuntuArgs: string[] = yield* setupUbuntuArgs( 506 505 isoPath || options.image, 507 - options.seed, 506 + options.seed 508 507 ); 509 508 let almalinuxArgs: string[] = yield* setupAlmaLinuxArgs( 510 509 isoPath || options.image, 511 - options.seed, 510 + options.seed 512 511 ); 513 512 let rockylinuxArgs: string[] = yield* setupRockyLinuxArgs( 514 513 isoPath || options.image, 515 - options.seed, 514 + options.seed 516 515 ); 517 516 518 517 if (coreosArgs.length > 0 && !isoPath) { ··· 585 584 options.image && [ 586 585 "-drive", 587 586 `file=${options.image},format=${options.diskFormat},if=virtio`, 588 - ], 587 + ] 589 588 ), 590 589 ]; 591 590 ··· 600 599 const logPath = `${LOGS_DIR}/${name}.log`; 601 600 602 601 const fullCommand = options.bridge 603 - ? `sudo ${qemu} ${ 604 - qemuArgs 602 + ? `sudo ${qemu} ${qemuArgs 605 603 .slice(1) 606 - .join(" ") 607 - } >> "${logPath}" 2>&1 & echo $!` 604 + .join(" ")} >> "${logPath}" 2>&1 & echo $!` 608 605 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 609 606 610 607 const { stdout } = yield* Effect.tryPromise({ ··· 630 627 cpus: options.cpus, 631 628 cpu: options.cpu, 632 629 diskSize: options.size || "20G", 633 - diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 630 + diskFormat: 631 + (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 634 632 options.diskFormat || 635 633 "raw", 636 634 portForward: options.portForward, ··· 643 641 : isoPath?.endsWith("qcow2") 644 642 ? Deno.realPathSync(isoPath) 645 643 : undefined, 646 - version: DEFAULT_VERSION, 647 644 status: "RUNNING", 648 645 pid: qemuPid, 649 646 seed: options.seed, 650 647 }); 651 648 652 649 console.log( 653 - `Virtual machine ${name} started in background (PID: ${qemuPid})`, 650 + `Virtual machine ${name} started in background (PID: ${qemuPid})` 654 651 ); 655 652 console.log(`Logs will be written to: ${logPath}`); 656 653 ··· 673 670 cpus: options.cpus, 674 671 cpu: options.cpu, 675 672 diskSize: options.size || "20G", 676 - diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 673 + diskFormat: 674 + (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 677 675 options.diskFormat || 678 676 "raw", 679 677 portForward: options.portForward, ··· 686 684 : isoPath?.endsWith("qcow2") 687 685 ? Deno.realPathSync(isoPath) 688 686 : undefined, 689 - version: DEFAULT_VERSION, 690 687 status: "RUNNING", 691 688 pid: cmd.pid, 692 689 seed: options.seed ? Deno.realPathSync(options.seed) : undefined, ··· 782 779 if (pathExists) { 783 780 console.log( 784 781 chalk.yellowBright( 785 - `Drive image ${path} already exists, skipping creation.`, 786 - ), 782 + `Drive image ${path} already exists, skipping creation.` 783 + ) 787 784 ); 788 785 return; 789 786 } ··· 810 807 }); 811 808 812 809 export const fileExists = ( 813 - path: string, 810 + path: string 814 811 ): Effect.Effect<void, NoSuchFileError, never> => 815 812 Effect.try({ 816 813 try: () => Deno.statSync(path), ··· 818 815 }); 819 816 820 817 export const constructCoreOSImageURL = ( 821 - image: string, 818 + image: string 822 819 ): Effect.Effect<string, InvalidImageNameError, never> => { 823 820 // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version> 824 821 const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/; ··· 826 823 if (match) { 827 824 const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION; 828 825 return Effect.succeed( 829 - FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version), 826 + FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version) 830 827 ); 831 828 } 832 829 ··· 834 831 new InvalidImageNameError({ 835 832 image, 836 833 cause: "Image name does not match CoreOS naming conventions.", 837 - }), 834 + }) 838 835 ); 839 836 }; 840 837 ··· 863 860 }); 864 861 865 862 export const constructNixOSImageURL = ( 866 - image: string, 863 + image: string 867 864 ): Effect.Effect<string, InvalidImageNameError, never> => { 868 865 // detect with regex if image matches NixOS pattern: nixos or nixos-<version> 869 866 const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/; ··· 871 868 if (match) { 872 869 const version = match[3] || NIXOS_DEFAULT_VERSION; 873 870 return Effect.succeed( 874 - NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version), 871 + NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version) 875 872 ); 876 873 } 877 874 ··· 879 876 new InvalidImageNameError({ 880 877 image, 881 878 cause: "Image name does not match NixOS naming conventions.", 882 - }), 879 + }) 883 880 ); 884 881 }; 885 882 886 883 export const constructFedoraImageURL = ( 887 884 image: string, 888 - cloud: boolean = false, 885 + cloud: boolean = false 889 886 ): Effect.Effect<string, InvalidImageNameError, never> => { 890 887 // detect with regex if image matches Fedora pattern: fedora 891 888 const fedoraRegex = /^(fedora)$/; ··· 898 895 new InvalidImageNameError({ 899 896 image, 900 897 cause: "Image name does not match Fedora naming conventions.", 901 - }), 898 + }) 902 899 ); 903 900 }; 904 901 905 902 export const constructGentooImageURL = ( 906 - image: string, 903 + image: string 907 904 ): Effect.Effect<string, InvalidImageNameError, never> => { 908 905 // detect with regex if image matches genroo pattern: gentoo-20251116T161545Z or gentoo 909 906 const gentooRegex = /^(gentoo)(-(\d{8}T\d{6}Z))?$/; ··· 912 909 return Effect.succeed( 913 910 GENTOO_IMG_URL.replaceAll("20251116T161545Z", match[3]).replaceAll( 914 911 "20251116T233105Z", 915 - match[3], 916 - ), 912 + match[3] 913 + ) 917 914 ); 918 915 } 919 916 ··· 925 922 new InvalidImageNameError({ 926 923 image, 927 924 cause: "Image name does not match Gentoo naming conventions.", 928 - }), 925 + }) 929 926 ); 930 927 }; 931 928 932 929 export const constructDebianImageURL = ( 933 930 image: string, 934 - cloud: boolean = false, 931 + cloud: boolean = false 935 932 ): Effect.Effect<string, InvalidImageNameError, never> => { 936 933 if (cloud && image === "debian") { 937 934 return Effect.succeed(DEBIAN_CLOUD_IMG_URL); ··· 942 939 const match = image.match(debianRegex); 943 940 if (match?.[3]) { 944 941 return Effect.succeed( 945 - DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]), 942 + DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]) 946 943 ); 947 944 } 948 945 ··· 954 951 new InvalidImageNameError({ 955 952 image, 956 953 cause: "Image name does not match Debian naming conventions.", 957 - }), 954 + }) 958 955 ); 959 956 }; 960 957 961 958 export const constructAlpineImageURL = ( 962 - image: string, 959 + image: string 963 960 ): Effect.Effect<string, InvalidImageNameError, never> => { 964 961 // detect with regex if image matches alpine pattern: alpine-<version> or alpine 965 962 const alpineRegex = /^(alpine)(-(\d+\.\d+(\.\d+)?))?$/; 966 963 const match = image.match(alpineRegex); 967 964 if (match?.[3]) { 968 965 return Effect.succeed( 969 - ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3]), 966 + ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3]) 970 967 ); 971 968 } 972 969 ··· 978 975 new InvalidImageNameError({ 979 976 image, 980 977 cause: "Image name does not match Alpine naming conventions.", 981 - }), 978 + }) 982 979 ); 983 980 }; 984 981 985 982 export const constructUbuntuImageURL = ( 986 983 image: string, 987 - cloud: boolean = false, 984 + cloud: boolean = false 988 985 ): Effect.Effect<string, InvalidImageNameError, never> => { 989 986 // detect with regex if image matches ubuntu pattern: ubuntu 990 987 const ubuntuRegex = /^(ubuntu)$/; ··· 1000 997 new InvalidImageNameError({ 1001 998 image, 1002 999 cause: "Image name does not match Ubuntu naming conventions.", 1003 - }), 1000 + }) 1004 1001 ); 1005 1002 }; 1006 1003 1007 1004 export const constructAlmaLinuxImageURL = ( 1008 1005 image: string, 1009 - cloud: boolean = false, 1006 + cloud: boolean = false 1010 1007 ): Effect.Effect<string, InvalidImageNameError, never> => { 1011 1008 // detect with regex if image matches almalinux pattern: almalinux, almalinux 1012 1009 const almaLinuxRegex = /^(almalinux|alma)$/; ··· 1022 1019 new InvalidImageNameError({ 1023 1020 image, 1024 1021 cause: "Image name does not match AlmaLinux naming conventions.", 1025 - }), 1022 + }) 1026 1023 ); 1027 1024 }; 1028 1025 1029 1026 export const constructRockyLinuxImageURL = ( 1030 1027 image: string, 1031 - cloud: boolean = false, 1028 + cloud: boolean = false 1032 1029 ): Effect.Effect<string, InvalidImageNameError, never> => { 1033 1030 // detect with regex if image matches rockylinux pattern: rocky. rockylinux 1034 1031 const rockyLinuxRegex = /^(rockylinux|rocky)$/; ··· 1044 1041 new InvalidImageNameError({ 1045 1042 image, 1046 1043 cause: "Image name does not match RockyLinux naming conventions.", 1047 - }), 1044 + }) 1048 1045 ); 1049 1046 };