A simple command-line tool to start NetBSD virtual machines using QEMU with sensible defaults.

Refactor subcommands to use Effect for error handling and asynchronous operations

- Updated `restart.ts` to utilize Effect for managing virtual machine state and process control, improving error handling with tagged errors.
- Refactored `rm.ts` to implement Effect for finding and removing virtual machines, enhancing error management.
- Modified `start.ts` to leverage Effect for starting virtual machines, including support for detached and interactive modes, with improved error handling.
- Enhanced `stop.ts` to use Effect for stopping virtual machines, incorporating detailed error handling for various failure scenarios.
- Refactored utility functions in `utils.ts` to utilize Effect for asynchronous operations and error handling, including disk image management and command execution.

+1152 -600
-5
deno.lock
··· 30 30 "npm:effect@^3.19.2": "3.19.2", 31 31 "npm:kysely@0.27.6": "0.27.6", 32 32 "npm:kysely@~0.27.2": "0.27.6", 33 - "npm:lodash@^4.17.21": "4.17.21", 34 33 "npm:moniker@~0.1.2": "0.1.2" 35 34 }, 36 35 "jsr": { ··· 169 168 "kysely@0.27.6": { 170 169 "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==" 171 170 }, 172 - "lodash@4.17.21": { 173 - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" 174 - }, 175 171 "moniker@0.1.2": { 176 172 "integrity": "sha512-Uj9iV0QYr6281G+o0TvqhKwHHWB2Q/qUTT4LPQ3qDGc0r8cbMuqQjRXPZuVZ+gcL7APx+iQgE8lcfWPrj1LsLA==" 177 173 }, ··· 193 189 "npm:dayjs@^1.11.19", 194 190 "npm:effect@^3.19.2", 195 191 "npm:kysely@0.27.6", 196 - "npm:lodash@^4.17.21", 197 192 "npm:moniker@~0.1.2" 198 193 ] 199 194 }
+26 -18
main.ts
··· 1 1 #!/usr/bin/env -S deno run --allow-run --allow-read --allow-env 2 2 3 3 import { Command } from "@cliffy/command"; 4 + import { Effect } from "effect"; 4 5 import pkg from "./deno.json" with { type: "json" }; 5 6 import { createBridgeNetworkIfNeeded } from "./src/network.ts"; 6 7 import inspect from "./src/subcommands/inspect.ts"; ··· 107 108 "netbsd-up rm my-vm", 108 109 ) 109 110 .action(async (options: Options, input?: string) => { 110 - const resolvedInput = handleInput(input); 111 - let isoPath: string | null = resolvedInput; 111 + const program = Effect.gen(function* () { 112 + const resolvedInput = handleInput(input); 113 + let isoPath: string | null = resolvedInput; 112 114 113 - if ( 114 - resolvedInput.startsWith("https://") || 115 - resolvedInput.startsWith("http://") 116 - ) { 117 - isoPath = await downloadIso(resolvedInput, options); 118 - } 115 + if ( 116 + resolvedInput.startsWith("https://") || 117 + resolvedInput.startsWith("http://") 118 + ) { 119 + isoPath = yield* downloadIso(resolvedInput, options); 120 + } 119 121 120 - if (options.image) { 121 - await createDriveImageIfNeeded(options); 122 - } 122 + if (options.image) { 123 + yield* createDriveImageIfNeeded(options); 124 + } 125 + 126 + if (!input && options.image) { 127 + const isEmpty = yield* emptyDiskImage(options.image); 128 + if (!isEmpty) { 129 + isoPath = null; 130 + } 131 + } 123 132 124 - if (!input && options.image && !await emptyDiskImage(options.image)) { 125 - isoPath = null; 126 - } 133 + if (options.bridge) { 134 + yield* createBridgeNetworkIfNeeded(options.bridge); 135 + } 127 136 128 - if (options.bridge) { 129 - await createBridgeNetworkIfNeeded(options.bridge); 130 - } 137 + yield* runQemu(isoPath, options); 138 + }); 131 139 132 - await runQemu(isoPath, options); 140 + await Effect.runPromise(program); 133 141 }) 134 142 .command("ps", "List all virtual machines") 135 143 .option("--all, -a", "Show all virtual machines, including stopped ones")
+120 -102
src/network.ts
··· 1 1 import chalk from "chalk"; 2 + import { Data, Effect } from "effect"; 2 3 3 - export async function setupQemuBridge(bridgeName: string): Promise<void> { 4 - const bridgeConfPath = "/etc/qemu/bridge.conf"; 5 - const bridgeConfContent = await Deno.readTextFile(bridgeConfPath).catch( 6 - () => "", 7 - ); 8 - if (bridgeConfContent.includes(`allow ${bridgeName}`)) { 9 - console.log( 10 - chalk.greenBright( 11 - `QEMU bridge configuration for ${bridgeName} already exists.`, 12 - ), 13 - ); 14 - return; 15 - } 4 + export class NetworkError extends Data.TaggedError("NetworkError")<{ 5 + cause?: unknown; 6 + }> {} 16 7 17 - console.log( 18 - chalk.blueBright( 19 - `Adding QEMU bridge configuration for ${bridgeName}...`, 20 - ), 21 - ); 8 + export class BridgeSetupError extends Data.TaggedError("BridgeSetupError")<{ 9 + cause?: unknown; 10 + }> {} 22 11 23 - const cmd = new Deno.Command("sudo", { 24 - args: [ 25 - "sh", 26 - "-c", 27 - `mkdir -p /etc/qemu && echo "allow ${bridgeName}" >> ${bridgeConfPath}`, 28 - ], 29 - stdin: "inherit", 30 - stdout: "inherit", 31 - stderr: "inherit", 32 - }); 33 - const status = await cmd.spawn().status; 12 + export const setupQemuBridge = (bridgeName: string) => 13 + Effect.tryPromise({ 14 + try: async () => { 15 + const bridgeConfPath = "/etc/qemu/bridge.conf"; 16 + const bridgeConfContent = await Deno.readTextFile(bridgeConfPath).catch( 17 + () => "", 18 + ); 19 + if (bridgeConfContent.includes(`allow ${bridgeName}`)) { 20 + console.log( 21 + chalk.greenBright( 22 + `QEMU bridge configuration for ${bridgeName} already exists.`, 23 + ), 24 + ); 25 + return; 26 + } 34 27 35 - if (!status.success) { 36 - console.error( 37 - chalk.redBright( 38 - `Failed to add QEMU bridge configuration for ${bridgeName}.`, 39 - ), 40 - ); 41 - Deno.exit(status.code); 42 - } 28 + console.log( 29 + chalk.blueBright( 30 + `Adding QEMU bridge configuration for ${bridgeName}...`, 31 + ), 32 + ); 43 33 44 - console.log( 45 - chalk.greenBright( 46 - `QEMU bridge configuration for ${bridgeName} added successfully.`, 47 - ), 48 - ); 49 - } 34 + const cmd = new Deno.Command("sudo", { 35 + args: [ 36 + "sh", 37 + "-c", 38 + `mkdir -p /etc/qemu && echo "allow ${bridgeName}" >> ${bridgeConfPath}`, 39 + ], 40 + stdin: "inherit", 41 + stdout: "inherit", 42 + stderr: "inherit", 43 + }); 44 + const status = await cmd.spawn().status; 50 45 51 - export async function createBridgeNetworkIfNeeded( 52 - bridgeName: string, 53 - ): Promise<void> { 54 - const bridgeExistsCmd = new Deno.Command("ip", { 55 - args: ["link", "show", bridgeName], 56 - stdout: "null", 57 - stderr: "null", 58 - }); 46 + if (!status.success) { 47 + console.error( 48 + chalk.redBright( 49 + `Failed to add QEMU bridge configuration for ${bridgeName}.`, 50 + ), 51 + ); 52 + Deno.exit(status.code); 53 + } 59 54 60 - const bridgeExistsStatus = await bridgeExistsCmd.spawn().status; 61 - if (bridgeExistsStatus.success) { 62 - console.log( 63 - chalk.greenBright(`Network bridge ${bridgeName} already exists.`), 64 - ); 65 - await setupQemuBridge(bridgeName); 66 - return; 67 - } 68 - 69 - console.log(chalk.blueBright(`Creating network bridge ${bridgeName}...`)); 70 - const createBridgeCmd = new Deno.Command("sudo", { 71 - args: ["ip", "link", "add", bridgeName, "type", "bridge"], 72 - stdin: "inherit", 73 - stdout: "inherit", 74 - stderr: "inherit", 55 + console.log( 56 + chalk.greenBright( 57 + `QEMU bridge configuration for ${bridgeName} added successfully.`, 58 + ), 59 + ); 60 + }, 61 + catch: (error) => new BridgeSetupError({ cause: error }), 75 62 }); 76 63 77 - let status = await createBridgeCmd.spawn().status; 78 - if (!status.success) { 79 - console.error( 80 - chalk.redBright(`Failed to create network bridge ${bridgeName}.`), 81 - ); 82 - Deno.exit(status.code); 83 - } 64 + export const createBridgeNetworkIfNeeded = ( 65 + bridgeName: string, 66 + ) => 67 + Effect.tryPromise({ 68 + try: async () => { 69 + const bridgeExistsCmd = new Deno.Command("ip", { 70 + args: ["link", "show", bridgeName], 71 + stdout: "null", 72 + stderr: "null", 73 + }); 84 74 85 - const bringUpBridgeCmd = new Deno.Command("sudo", { 86 - args: ["ip", "link", "set", "dev", bridgeName, "up"], 87 - stdin: "inherit", 88 - stdout: "inherit", 89 - stderr: "inherit", 90 - }); 91 - status = await bringUpBridgeCmd.spawn().status; 92 - if (!status.success) { 93 - console.error( 94 - chalk.redBright(`Failed to bring up network bridge ${bridgeName}.`), 95 - ); 96 - Deno.exit(status.code); 97 - } 75 + const bridgeExistsStatus = await bridgeExistsCmd.spawn().status; 76 + if (bridgeExistsStatus.success) { 77 + console.log( 78 + chalk.greenBright(`Network bridge ${bridgeName} already exists.`), 79 + ); 80 + await setupQemuBridge(bridgeName); 81 + return; 82 + } 98 83 99 - console.log( 100 - chalk.greenBright(`Network bridge ${bridgeName} created and up.`), 101 - ); 84 + console.log(chalk.blueBright(`Creating network bridge ${bridgeName}...`)); 85 + const createBridgeCmd = new Deno.Command("sudo", { 86 + args: ["ip", "link", "add", bridgeName, "type", "bridge"], 87 + stdin: "inherit", 88 + stdout: "inherit", 89 + stderr: "inherit", 90 + }); 102 91 103 - await setupQemuBridge(bridgeName); 104 - } 92 + let status = await createBridgeCmd.spawn().status; 93 + if (!status.success) { 94 + console.error( 95 + chalk.redBright(`Failed to create network bridge ${bridgeName}.`), 96 + ); 97 + Deno.exit(status.code); 98 + } 105 99 106 - export function generateRandomMacAddress(): string { 107 - const hexDigits = "0123456789ABCDEF"; 108 - let macAddress = "52:54:00"; 100 + const bringUpBridgeCmd = new Deno.Command("sudo", { 101 + args: ["ip", "link", "set", "dev", bridgeName, "up"], 102 + stdin: "inherit", 103 + stdout: "inherit", 104 + stderr: "inherit", 105 + }); 106 + status = await bringUpBridgeCmd.spawn().status; 107 + if (!status.success) { 108 + console.error( 109 + chalk.redBright(`Failed to bring up network bridge ${bridgeName}.`), 110 + ); 111 + Deno.exit(status.code); 112 + } 109 113 110 - for (let i = 0; i < 3; i++) { 111 - macAddress += ":"; 112 - for (let j = 0; j < 2; j++) { 113 - macAddress += hexDigits.charAt( 114 - Math.floor(Math.random() * hexDigits.length), 114 + console.log( 115 + chalk.greenBright(`Network bridge ${bridgeName} created and up.`), 115 116 ); 117 + 118 + await setupQemuBridge(bridgeName); 119 + }, 120 + catch: (error) => new NetworkError({ cause: error }), 121 + }); 122 + 123 + export const generateRandomMacAddress = () => 124 + Effect.sync(() => { 125 + const hexDigits = "0123456789ABCDEF"; 126 + let macAddress = "52:54:00"; 127 + 128 + for (let i = 0; i < 3; i++) { 129 + macAddress += ":"; 130 + for (let j = 0; j < 2; j++) { 131 + macAddress += hexDigits.charAt( 132 + Math.floor(Math.random() * hexDigits.length), 133 + ); 134 + } 116 135 } 117 - } 118 136 119 - return macAddress; 120 - } 137 + return macAddress; 138 + });
+64 -45
src/state.ts
··· 1 + import { Data, Effect } from "effect"; 1 2 import { ctx } from "./context.ts"; 2 3 import type { VirtualMachine } from "./db.ts"; 3 4 import type { STATUS } from "./types.ts"; 4 5 5 - export async function saveInstanceState(vm: VirtualMachine) { 6 - await ctx.db.insertInto("virtual_machines") 7 - .values(vm) 8 - .execute(); 9 - } 6 + export class DbError extends Data.TaggedError("DatabaseError")<{ 7 + cause?: unknown; 8 + }> {} 10 9 11 - export async function updateInstanceState( 10 + export const saveInstanceState = ( 11 + vm: VirtualMachine, 12 + ) => 13 + Effect.tryPromise({ 14 + try: () => 15 + ctx.db.insertInto("virtual_machines") 16 + .values(vm) 17 + .execute(), 18 + catch: (error) => new DbError({ cause: error }), 19 + }); 20 + 21 + export const updateInstanceState = ( 12 22 name: string, 13 23 status: STATUS, 14 24 pid?: number, 15 - ) { 16 - await ctx.db.updateTable("virtual_machines") 17 - .set({ 18 - status, 19 - pid, 20 - updatedAt: new Date().toISOString(), 21 - }) 22 - .where((eb) => 23 - eb.or([ 24 - eb("name", "=", name), 25 - eb("id", "=", name), 26 - ]) 27 - ) 28 - .execute(); 29 - } 25 + ) => 26 + Effect.tryPromise({ 27 + try: () => 28 + ctx.db.updateTable("virtual_machines") 29 + .set({ 30 + status, 31 + pid, 32 + updatedAt: new Date().toISOString(), 33 + }) 34 + .where((eb) => 35 + eb.or([ 36 + eb("name", "=", name), 37 + eb("id", "=", name), 38 + ]) 39 + ) 40 + .execute(), 41 + catch: (error) => new DbError({ cause: error }), 42 + }); 30 43 31 - export async function removeInstanceState(name: string) { 32 - await ctx.db.deleteFrom("virtual_machines") 33 - .where((eb) => 34 - eb.or([ 35 - eb("name", "=", name), 36 - eb("id", "=", name), 37 - ]) 38 - ) 39 - .execute(); 40 - } 41 - 42 - export async function getInstanceState( 44 + export const removeInstanceState = ( 43 45 name: string, 44 - ): Promise<VirtualMachine | undefined> { 45 - const vm = await ctx.db.selectFrom("virtual_machines") 46 - .selectAll() 47 - .where((eb) => 48 - eb.or([ 49 - eb("name", "=", name), 50 - eb("id", "=", name), 51 - ]) 52 - ) 53 - .executeTakeFirst(); 46 + ) => 47 + Effect.tryPromise({ 48 + try: () => 49 + ctx.db.deleteFrom("virtual_machines") 50 + .where((eb) => 51 + eb.or([ 52 + eb("name", "=", name), 53 + eb("id", "=", name), 54 + ]) 55 + ) 56 + .execute(), 57 + catch: (error) => new DbError({ cause: error }), 58 + }); 54 59 55 - return vm; 56 - } 60 + export const getInstanceState = ( 61 + name: string, 62 + ): Effect.Effect<VirtualMachine | undefined, DbError, never> => 63 + Effect.tryPromise({ 64 + try: () => 65 + ctx.db.selectFrom("virtual_machines") 66 + .selectAll() 67 + .where((eb) => 68 + eb.or([ 69 + eb("name", "=", name), 70 + eb("id", "=", name), 71 + ]) 72 + ) 73 + .executeTakeFirst(), 74 + catch: (error) => new DbError({ cause: error }), 75 + });
+38 -8
src/subcommands/inspect.ts
··· 1 + import { Data, Effect, pipe } from "effect"; 2 + import type { VirtualMachine } from "../db.ts"; 1 3 import { getInstanceState } from "../state.ts"; 2 4 3 - export default async function (name: string) { 4 - const vm = await getInstanceState(name); 5 - if (!vm) { 6 - console.error( 7 - `Virtual machine with name or ID ${name} not found.`, 8 - ); 5 + class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 6 + name: string; 7 + }> {} 8 + 9 + const findVm = (name: string) => 10 + pipe( 11 + getInstanceState(name), 12 + Effect.flatMap((vm) => 13 + vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 14 + ), 15 + ); 16 + 17 + const displayVm = (vm: VirtualMachine) => 18 + Effect.sync(() => { 19 + console.log(vm); 20 + }); 21 + 22 + const handleError = (error: VmNotFoundError | Error) => 23 + Effect.sync(() => { 24 + if (error instanceof VmNotFoundError) { 25 + console.error( 26 + `Virtual machine with name or ID ${error.name} not found.`, 27 + ); 28 + } else { 29 + console.error(`An error occurred: ${error}`); 30 + } 9 31 Deno.exit(1); 10 - } 32 + }); 33 + 34 + const inspectEffect = (name: string) => 35 + pipe( 36 + findVm(name), 37 + Effect.flatMap(displayVm), 38 + Effect.catchAll(handleError), 39 + ); 11 40 12 - console.log(vm); 41 + export default async function (name: string) { 42 + await Effect.runPromise(inspectEffect(name)); 13 43 }
+64 -16
src/subcommands/logs.ts
··· 1 + import { Data, Effect, pipe } from "effect"; 1 2 import { LOGS_DIR } from "../constants.ts"; 2 3 3 - export default async function (name: string, follow: boolean) { 4 - await Deno.mkdir(LOGS_DIR, { recursive: true }); 5 - const logPath = `${LOGS_DIR}/${name}.log`; 4 + class LogCommandError extends Data.TaggedError("LogCommandError")<{ 5 + vmName: string; 6 + exitCode: number; 7 + }> {} 6 8 7 - const cmd = new Deno.Command(follow ? "tail" : "cat", { 8 - args: [ 9 - ...(follow ? ["-n", "100", "-f"] : []), 10 - logPath, 11 - ], 12 - stdin: "inherit", 13 - stdout: "inherit", 14 - stderr: "inherit", 9 + class CommandError extends Data.TaggedError("CommandError")<{ 10 + cause?: unknown; 11 + }> {} 12 + 13 + const createLogsDir = () => 14 + Effect.tryPromise({ 15 + try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), 16 + catch: (error) => new CommandError({ cause: error }), 15 17 }); 16 18 17 - const status = await cmd.spawn().status; 19 + const buildLogPath = (name: string) => 20 + Effect.succeed(`${LOGS_DIR}/${name}.log`); 18 21 19 - if (!status.success) { 20 - console.error(`Failed to view logs for virtual machine ${name}.`); 21 - Deno.exit(status.code); 22 - } 22 + const viewLogs = (name: string, follow: boolean, logPath: string) => 23 + Effect.tryPromise({ 24 + try: async () => { 25 + const cmd = new Deno.Command(follow ? "tail" : "cat", { 26 + args: [ 27 + ...(follow ? ["-n", "100", "-f"] : []), 28 + logPath, 29 + ], 30 + stdin: "inherit", 31 + stdout: "inherit", 32 + stderr: "inherit", 33 + }); 34 + 35 + const status = await cmd.spawn().status; 36 + return { name, status }; 37 + }, 38 + catch: (error) => new CommandError({ cause: error }), 39 + }).pipe( 40 + Effect.flatMap(({ name, status }) => 41 + status.success ? Effect.succeed(undefined) : Effect.fail( 42 + new LogCommandError({ 43 + vmName: name, 44 + exitCode: status.code || 1, 45 + }), 46 + ) 47 + ), 48 + ); 49 + 50 + const handleError = (error: LogCommandError | CommandError | Error) => 51 + Effect.sync(() => { 52 + if (error instanceof LogCommandError) { 53 + console.error(`Failed to view logs for virtual machine ${error.vmName}.`); 54 + Deno.exit(error.exitCode); 55 + } else { 56 + console.error(`An error occurred: ${error}`); 57 + Deno.exit(1); 58 + } 59 + }); 60 + 61 + const logsEffect = (name: string, follow: boolean) => 62 + pipe( 63 + createLogsDir(), 64 + Effect.flatMap(() => buildLogPath(name)), 65 + Effect.flatMap((logPath) => viewLogs(name, follow, logPath)), 66 + Effect.catchAll(handleError), 67 + ); 68 + 69 + export default async function (name: string, follow: boolean) { 70 + await Effect.runPromise(logsEffect(name, follow)); 23 71 }
+61 -25
src/subcommands/ps.ts
··· 2 2 import dayjs from "dayjs"; 3 3 import relativeTime from "dayjs/plugin/relativeTime.js"; 4 4 import utc from "dayjs/plugin/utc.js"; 5 + import { Data, Effect, pipe } from "effect"; 5 6 import { ctx } from "../context.ts"; 6 7 import type { VirtualMachine } from "../db.ts"; 7 8 8 9 dayjs.extend(relativeTime); 9 10 dayjs.extend(utc); 10 11 11 - export default async function (all: boolean) { 12 - const results = await ctx.db.selectFrom("virtual_machines") 13 - .selectAll() 14 - .where((eb) => { 15 - if (all) { 16 - return eb("id", "!=", ""); 17 - } 18 - return eb("status", "=", "RUNNING"); 19 - }) 20 - .execute(); 12 + class DbQueryError extends Data.TaggedError("DbQueryError")<{ 13 + cause?: unknown; 14 + }> {} 15 + 16 + const fetchVMs = (all: boolean) => 17 + Effect.tryPromise({ 18 + try: () => 19 + ctx.db.selectFrom("virtual_machines") 20 + .selectAll() 21 + .where((eb) => { 22 + if (all) { 23 + return eb("id", "!=", ""); 24 + } 25 + return eb("status", "=", "RUNNING"); 26 + }) 27 + .execute(), 28 + catch: (error) => new DbQueryError({ cause: error }), 29 + }); 21 30 22 - const table: Table = new Table( 23 - ["NAME", "VCPU", "MEMORY", "STATUS", "PID", "BRIDGE", "PORTS", "CREATED"], 31 + const createTable = () => 32 + Effect.succeed( 33 + new Table( 34 + ["NAME", "VCPU", "MEMORY", "STATUS", "PID", "BRIDGE", "PORTS", "CREATED"], 35 + ), 24 36 ); 25 37 26 - for (const vm of results) { 27 - table.push([ 28 - vm.name, 29 - vm.cpus.toString(), 30 - vm.memory, 31 - formatStatus(vm), 32 - vm.pid?.toString() ?? "-", 33 - vm.bridge ?? "-", 34 - formatPorts(vm.portForward), 35 - dayjs.utc(vm.createdAt).local().fromNow(), 36 - ]); 37 - } 38 + const populateTable = (table: Table, vms: VirtualMachine[]) => 39 + Effect.sync(() => { 40 + for (const vm of vms) { 41 + table.push([ 42 + vm.name, 43 + vm.cpus.toString(), 44 + vm.memory, 45 + formatStatus(vm), 46 + vm.pid?.toString() ?? "-", 47 + vm.bridge ?? "-", 48 + formatPorts(vm.portForward), 49 + dayjs.utc(vm.createdAt).local().fromNow(), 50 + ]); 51 + } 52 + return table; 53 + }); 54 + 55 + const displayTable = (table: Table) => 56 + Effect.sync(() => { 57 + console.log(table.padding(2).toString()); 58 + }); 59 + 60 + const handleError = (error: DbQueryError | Error) => 61 + Effect.sync(() => { 62 + console.error(`Failed to fetch virtual machines: ${error}`); 63 + Deno.exit(1); 64 + }); 65 + 66 + const psEffect = (all: boolean) => 67 + pipe( 68 + Effect.all([fetchVMs(all), createTable()]), 69 + Effect.flatMap(([vms, table]) => populateTable(table, vms)), 70 + Effect.flatMap(displayTable), 71 + Effect.catchAll(handleError), 72 + ); 38 73 39 - console.log(table.padding(2).toString()); 74 + export default async function (all: boolean) { 75 + await Effect.runPromise(psEffect(all)); 40 76 } 41 77 42 78 function formatStatus(vm: VirtualMachine) {
+122 -40
src/subcommands/restart.ts
··· 1 1 import _ from "@es-toolkit/es-toolkit/compat"; 2 2 import chalk from "chalk"; 3 + import { Data, Effect, pipe } from "effect"; 3 4 import { LOGS_DIR } from "../constants.ts"; 5 + import type { VirtualMachine } from "../db.ts"; 4 6 import { getInstanceState, updateInstanceState } from "../state.ts"; 5 7 import { 6 8 safeKillQemu, ··· 8 10 setupNATNetworkArgs, 9 11 } from "../utils.ts"; 10 12 11 - export default async function (name: string) { 12 - const vm = await getInstanceState(name); 13 - if (!vm) { 14 - console.error( 15 - `Virtual machine with name or ID ${chalk.greenBright(name)} not found.`, 16 - ); 17 - Deno.exit(1); 18 - } 13 + class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 14 + name: string; 15 + }> {} 16 + 17 + class KillQemuError extends Data.TaggedError("KillQemuError")<{ 18 + vmName: string; 19 + }> {} 20 + 21 + class CommandError extends Data.TaggedError("CommandError")<{ 22 + cause?: unknown; 23 + }> {} 24 + 25 + const findVm = (name: string) => 26 + pipe( 27 + getInstanceState(name), 28 + Effect.flatMap((vm) => 29 + vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 30 + ), 31 + ); 19 32 20 - const success = await safeKillQemu(vm.pid, Boolean(vm.bridge)); 33 + const killQemu = (vm: VirtualMachine) => 34 + safeKillQemu(vm.pid, Boolean(vm.bridge)).pipe( 35 + Effect.flatMap((success) => 36 + success 37 + ? Effect.succeed(vm) 38 + : Effect.fail(new KillQemuError({ vmName: vm.name })) 39 + ), 40 + ); 21 41 22 - if (!success) { 23 - console.error( 24 - `Failed to stop virtual machine ${chalk.greenBright(vm.name)}.`, 25 - ); 26 - Deno.exit(1); 27 - } 28 - await updateInstanceState(vm.id, "STOPPED"); 42 + const sleep = (ms: number) => 43 + Effect.tryPromise({ 44 + try: () => new Promise((resolve) => setTimeout(resolve, ms)), 45 + catch: (error) => new CommandError({ cause: error }), 46 + }); 29 47 30 - await new Promise((resolve) => setTimeout(resolve, 2000)); 48 + const createLogsDir = () => 49 + Effect.tryPromise({ 50 + try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), 51 + catch: (error) => new CommandError({ cause: error }), 52 + }); 31 53 32 - await Deno.mkdir(LOGS_DIR, { recursive: true }); 33 - const logPath = `${LOGS_DIR}/${vm.name}.log`; 54 + const setupFirmware = () => setupFirmwareFilesIfNeeded(); 34 55 56 + const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 35 57 const qemu = Deno.build.arch === "aarch64" 36 58 ? "qemu-system-aarch64" 37 59 : "qemu-system-x86_64"; 38 60 39 - const qemuArgs = [ 61 + return Effect.succeed([ 40 62 ..._.compact([vm.bridge && qemu]), 41 63 ...Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"], 42 64 ...Deno.build.arch === "aarch64" ? ["-machine", "virt,highmem=on"] : [], ··· 60 82 "stdio,id=con0,signal=off", 61 83 "-serial", 62 84 "chardev:con0", 63 - ...await setupFirmwareFilesIfNeeded(), 85 + ...firmwareArgs, 64 86 ..._.compact( 65 87 vm.drivePath && [ 66 88 "-drive", ··· 71 93 "rng-random,filename=/dev/urandom,id=rng0", 72 94 "-device", 73 95 "virtio-rng-pci,rng=rng0", 74 - ]; 96 + ]); 97 + }; 98 + 99 + const startQemu = (vm: VirtualMachine, qemuArgs: string[]) => { 100 + const qemu = Deno.build.arch === "aarch64" 101 + ? "qemu-system-aarch64" 102 + : "qemu-system-x86_64"; 103 + 104 + const logPath = `${LOGS_DIR}/${vm.name}.log`; 75 105 76 106 const fullCommand = vm.bridge 77 107 ? `sudo ${qemu} ${ ··· 79 109 } >> "${logPath}" 2>&1 & echo $!` 80 110 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 81 111 82 - const cmd = new Deno.Command("sh", { 83 - args: ["-c", fullCommand], 84 - stdin: "null", 85 - stdout: "piped", 86 - }); 112 + return Effect.tryPromise({ 113 + try: async () => { 114 + const cmd = new Deno.Command("sh", { 115 + args: ["-c", fullCommand], 116 + stdin: "null", 117 + stdout: "piped", 118 + }); 87 119 88 - const { stdout } = await cmd.spawn().output(); 89 - const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 120 + const { stdout } = await cmd.spawn().output(); 121 + const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 122 + return { qemuPid, logPath }; 123 + }, 124 + catch: (error) => new CommandError({ cause: error }), 125 + }); 126 + }; 90 127 91 - await new Promise((resolve) => setTimeout(resolve, 2000)); 128 + const logSuccess = (vm: VirtualMachine, qemuPid: number, logPath: string) => 129 + Effect.sync(() => { 130 + console.log( 131 + `${chalk.greenBright(vm.name)} restarted with PID ${ 132 + chalk.greenBright(qemuPid) 133 + }.`, 134 + ); 135 + console.log( 136 + `Logs are being written to ${chalk.blueBright(logPath)}`, 137 + ); 138 + }); 92 139 93 - await updateInstanceState(vm.id, "RUNNING", qemuPid); 140 + const handleError = ( 141 + error: VmNotFoundError | KillQemuError | CommandError | Error, 142 + ) => 143 + Effect.sync(() => { 144 + if (error instanceof VmNotFoundError) { 145 + console.error( 146 + `Virtual machine with name or ID ${ 147 + chalk.greenBright(error.name) 148 + } not found.`, 149 + ); 150 + } else if (error instanceof KillQemuError) { 151 + console.error( 152 + `Failed to stop virtual machine ${chalk.greenBright(error.vmName)}.`, 153 + ); 154 + } else { 155 + console.error(`An error occurred: ${error}`); 156 + } 157 + Deno.exit(1); 158 + }); 94 159 95 - console.log( 96 - `${chalk.greenBright(vm.name)} restarted with PID ${ 97 - chalk.greenBright(qemuPid) 98 - }.`, 99 - ); 100 - console.log( 101 - `Logs are being written to ${chalk.blueBright(logPath)}`, 160 + const restartEffect = (name: string) => 161 + pipe( 162 + findVm(name), 163 + Effect.tap((vm) => Effect.log(`Found VM: ${vm.name}`)), 164 + Effect.flatMap(killQemu), 165 + Effect.tap((vm) => updateInstanceState(vm.id, "STOPPED")), 166 + Effect.flatMap((vm) => 167 + pipe( 168 + sleep(2000), 169 + Effect.flatMap(() => createLogsDir()), 170 + Effect.flatMap(() => setupFirmware()), 171 + Effect.flatMap((firmwareArgs) => buildQemuArgs(vm, firmwareArgs)), 172 + Effect.flatMap((qemuArgs) => startQemu(vm, qemuArgs)), 173 + Effect.tap(() => sleep(2000)), 174 + Effect.flatMap(({ qemuPid, logPath }) => 175 + pipe( 176 + updateInstanceState(vm.id, "RUNNING", qemuPid), 177 + Effect.flatMap(() => logSuccess(vm, qemuPid, logPath)), 178 + Effect.flatMap(() => sleep(2000)), 179 + ) 180 + ), 181 + ) 182 + ), 183 + Effect.catchAll(handleError), 102 184 ); 103 185 104 - await new Promise((resolve) => setTimeout(resolve, 2000)); 105 - 186 + export default async function (name: string) { 187 + await Effect.runPromise(restartEffect(name)); 106 188 Deno.exit(0); 107 189 }
+45 -9
src/subcommands/rm.ts
··· 1 + import { Data, Effect, pipe } from "effect"; 2 + import type { VirtualMachine } from "../db.ts"; 1 3 import { getInstanceState, removeInstanceState } from "../state.ts"; 2 4 3 - export default async function (name: string) { 4 - const vm = await getInstanceState(name); 5 - if (!vm) { 6 - console.error( 7 - `Virtual machine with name or ID ${name} not found.`, 8 - ); 5 + class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 6 + name: string; 7 + }> {} 8 + 9 + const findVm = (name: string) => 10 + pipe( 11 + getInstanceState(name), 12 + Effect.flatMap((vm) => 13 + vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 14 + ), 15 + ); 16 + 17 + const logRemoving = (vm: VirtualMachine) => 18 + Effect.sync(() => { 19 + console.log(`Removing virtual machine ${vm.name} (ID: ${vm.id})...`); 20 + }); 21 + 22 + const removeVm = (name: string, vm: VirtualMachine) => 23 + pipe( 24 + removeInstanceState(name), 25 + Effect.map(() => vm), 26 + ); 27 + 28 + const handleError = (error: VmNotFoundError | Error) => 29 + Effect.sync(() => { 30 + if (error instanceof VmNotFoundError) { 31 + console.error( 32 + `Virtual machine with name or ID ${error.name} not found.`, 33 + ); 34 + } else { 35 + console.error(`An error occurred: ${error}`); 36 + } 9 37 Deno.exit(1); 10 - } 38 + }); 39 + 40 + const removeEffect = (name: string) => 41 + pipe( 42 + findVm(name), 43 + Effect.tap(logRemoving), 44 + Effect.flatMap((vm) => removeVm(name, vm)), 45 + Effect.catchAll(handleError), 46 + ); 11 47 12 - console.log(`Removing virtual machine ${vm.name} (ID: ${vm.id})...`); 13 - await removeInstanceState(name); 48 + export default async function (name: string) { 49 + await Effect.runPromise(removeEffect(name)); 14 50 }
+164 -44
src/subcommands/start.ts
··· 1 1 import { parseFlags } from "@cliffy/flags"; 2 2 import _ from "@es-toolkit/es-toolkit/compat"; 3 + import { Data, Effect, pipe } from "effect"; 3 4 import { LOGS_DIR } from "../constants.ts"; 4 5 import type { VirtualMachine } from "../db.ts"; 5 6 import { getInstanceState, updateInstanceState } from "../state.ts"; 6 7 import { setupFirmwareFilesIfNeeded, setupNATNetworkArgs } from "../utils.ts"; 7 8 8 - export default async function (name: string, detach: boolean = false) { 9 - let vm = await getInstanceState(name); 10 - if (!vm) { 11 - console.error( 12 - `Virtual machine with name or ID ${name} not found.`, 13 - ); 14 - Deno.exit(1); 15 - } 9 + class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 10 + name: string; 11 + }> {} 16 12 17 - console.log(`Starting virtual machine ${vm.name} (ID: ${vm.id})...`); 13 + class CommandError extends Data.TaggedError("CommandError")<{ 14 + cause?: unknown; 15 + }> {} 18 16 19 - vm = mergeFlags(vm); 17 + const findVm = (name: string) => 18 + pipe( 19 + getInstanceState(name), 20 + Effect.flatMap((vm) => 21 + vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 22 + ), 23 + ); 20 24 25 + const logStarting = (vm: VirtualMachine) => 26 + Effect.sync(() => { 27 + console.log(`Starting virtual machine ${vm.name} (ID: ${vm.id})...`); 28 + }); 29 + 30 + const applyFlags = (vm: VirtualMachine) => Effect.succeed(mergeFlags(vm)); 31 + 32 + const setupFirmware = () => setupFirmwareFilesIfNeeded(); 33 + 34 + const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 21 35 const qemu = Deno.build.arch === "aarch64" 22 36 ? "qemu-system-aarch64" 23 37 : "qemu-system-x86_64"; 24 38 25 - const qemuArgs = [ 39 + return Effect.succeed([ 26 40 ..._.compact([vm.bridge && qemu]), 27 41 ...Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"], 28 42 ...Deno.build.arch === "aarch64" ? ["-machine", "virt,highmem=on"] : [], ··· 46 60 "stdio,id=con0,signal=off", 47 61 "-serial", 48 62 "chardev:con0", 49 - ...await setupFirmwareFilesIfNeeded(), 63 + ...firmwareArgs, 50 64 ..._.compact( 51 65 vm.drivePath && [ 52 66 "-drive", ··· 57 71 "rng-random,filename=/dev/urandom,id=rng0", 58 72 "-device", 59 73 "virtio-rng-pci,rng=rng0", 60 - ]; 74 + ]); 75 + }; 61 76 62 - if (detach) { 63 - await Deno.mkdir(LOGS_DIR, { recursive: true }); 64 - const logPath = `${LOGS_DIR}/${vm.name}.log`; 77 + const createLogsDir = () => 78 + Effect.tryPromise({ 79 + try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), 80 + catch: (error) => new CommandError({ cause: error }), 81 + }); 65 82 66 - const fullCommand = vm.bridge 67 - ? `sudo ${qemu} ${ 68 - qemuArgs.slice(1).join(" ") 69 - } >> "${logPath}" 2>&1 & echo $!` 70 - : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 83 + const startDetachedQemu = ( 84 + name: string, 85 + vm: VirtualMachine, 86 + qemuArgs: string[], 87 + ) => { 88 + const qemu = Deno.build.arch === "aarch64" 89 + ? "qemu-system-aarch64" 90 + : "qemu-system-x86_64"; 71 91 72 - const cmd = new Deno.Command("sh", { 73 - args: ["-c", fullCommand], 74 - stdin: "null", 75 - stdout: "piped", 76 - }); 92 + const logPath = `${LOGS_DIR}/${vm.name}.log`; 77 93 78 - const { stdout } = await cmd.spawn().output(); 79 - const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 94 + const fullCommand = vm.bridge 95 + ? `sudo ${qemu} ${ 96 + qemuArgs.slice(1).join(" ") 97 + } >> "${logPath}" 2>&1 & echo $!` 98 + : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 80 99 81 - await updateInstanceState(name, "RUNNING", qemuPid); 100 + return Effect.tryPromise({ 101 + try: async () => { 102 + const cmd = new Deno.Command("sh", { 103 + args: ["-c", fullCommand], 104 + stdin: "null", 105 + stdout: "piped", 106 + }); 82 107 108 + const { stdout } = await cmd.spawn().output(); 109 + const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 110 + return { qemuPid, logPath }; 111 + }, 112 + catch: (error) => new CommandError({ cause: error }), 113 + }).pipe( 114 + Effect.flatMap(({ qemuPid, logPath }) => 115 + pipe( 116 + updateInstanceState(name, "RUNNING", qemuPid), 117 + Effect.map(() => ({ vm, qemuPid, logPath })), 118 + ) 119 + ), 120 + ); 121 + }; 122 + 123 + const logDetachedSuccess = ( 124 + { vm, qemuPid, logPath }: { 125 + vm: VirtualMachine; 126 + qemuPid: number; 127 + logPath: string; 128 + }, 129 + ) => 130 + Effect.sync(() => { 83 131 console.log( 84 132 `Virtual machine ${vm.name} started in background (PID: ${qemuPid})`, 85 133 ); 86 134 console.log(`Logs will be written to: ${logPath}`); 135 + }); 87 136 88 - // Exit successfully while keeping VM running in background 89 - Deno.exit(0); 90 - } else { 91 - const cmd = new Deno.Command(vm.bridge ? "sudo" : qemu, { 92 - args: qemuArgs, 93 - stdin: "inherit", 94 - stdout: "inherit", 95 - stderr: "inherit", 96 - }); 137 + const startInteractiveQemu = ( 138 + name: string, 139 + vm: VirtualMachine, 140 + qemuArgs: string[], 141 + ) => { 142 + const qemu = Deno.build.arch === "aarch64" 143 + ? "qemu-system-aarch64" 144 + : "qemu-system-x86_64"; 97 145 98 - const child = cmd.spawn(); 99 - await updateInstanceState(name, "RUNNING", child.pid); 146 + return Effect.tryPromise({ 147 + try: async () => { 148 + const cmd = new Deno.Command(vm.bridge ? "sudo" : qemu, { 149 + args: qemuArgs, 150 + stdin: "inherit", 151 + stdout: "inherit", 152 + stderr: "inherit", 153 + }); 100 154 101 - const status = await child.status; 155 + const child = cmd.spawn(); 156 + 157 + await Effect.runPromise(updateInstanceState(name, "RUNNING", child.pid)); 102 158 103 - await updateInstanceState(name, "STOPPED", child.pid); 159 + const status = await child.status; 104 160 105 - if (!status.success) { 106 - Deno.exit(status.code); 161 + await Effect.runPromise(updateInstanceState(name, "STOPPED", child.pid)); 162 + 163 + return status; 164 + }, 165 + catch: (error) => new CommandError({ cause: error }), 166 + }); 167 + }; 168 + 169 + const handleError = (error: VmNotFoundError | CommandError | Error) => 170 + Effect.sync(() => { 171 + if (error instanceof VmNotFoundError) { 172 + console.error( 173 + `Virtual machine with name or ID ${error.name} not found.`, 174 + ); 175 + } else { 176 + console.error(`An error occurred: ${error}`); 107 177 } 178 + Deno.exit(1); 179 + }); 180 + 181 + const startDetachedEffect = (name: string) => 182 + pipe( 183 + findVm(name), 184 + Effect.tap(logStarting), 185 + Effect.flatMap(applyFlags), 186 + Effect.flatMap((vm) => 187 + pipe( 188 + setupFirmware(), 189 + Effect.flatMap((firmwareArgs) => buildQemuArgs(vm, firmwareArgs)), 190 + Effect.flatMap((qemuArgs) => 191 + pipe( 192 + createLogsDir(), 193 + Effect.flatMap(() => startDetachedQemu(name, vm, qemuArgs)), 194 + Effect.tap(logDetachedSuccess), 195 + Effect.map(() => 0), // Exit code 0 196 + ) 197 + ), 198 + ) 199 + ), 200 + Effect.catchAll(handleError), 201 + ); 202 + 203 + const startInteractiveEffect = (name: string) => 204 + pipe( 205 + findVm(name), 206 + Effect.tap(logStarting), 207 + Effect.flatMap(applyFlags), 208 + Effect.flatMap((vm) => 209 + pipe( 210 + setupFirmware(), 211 + Effect.flatMap((firmwareArgs) => buildQemuArgs(vm, firmwareArgs)), 212 + Effect.flatMap((qemuArgs) => startInteractiveQemu(name, vm, qemuArgs)), 213 + Effect.map((status) => status.success ? 0 : (status.code || 1)), 214 + ) 215 + ), 216 + Effect.catchAll(handleError), 217 + ); 218 + 219 + export default async function (name: string, detach: boolean = false) { 220 + const exitCode = await Effect.runPromise( 221 + detach ? startDetachedEffect(name) : startInteractiveEffect(name), 222 + ); 223 + 224 + if (detach) { 225 + Deno.exit(exitCode); 226 + } else if (exitCode !== 0) { 227 + Deno.exit(exitCode); 108 228 } 109 229 } 110 230
+98 -30
src/subcommands/stop.ts
··· 1 1 import _ from "@es-toolkit/es-toolkit/compat"; 2 2 import chalk from "chalk"; 3 + import { Data, Effect, pipe } from "effect"; 4 + import type { VirtualMachine } from "../db.ts"; 3 5 import { getInstanceState, updateInstanceState } from "../state.ts"; 4 6 5 - export default async function (name: string) { 6 - const vm = await getInstanceState(name); 7 - if (!vm) { 8 - console.error( 9 - `Virtual machine with name or ID ${chalk.greenBright(name)} not found.`, 10 - ); 11 - Deno.exit(1); 12 - } 7 + class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 8 + name: string; 9 + }> {} 13 10 14 - console.log( 15 - `Stopping virtual machine ${chalk.greenBright(vm.name)} (ID: ${ 16 - chalk.greenBright(vm.id) 17 - })...`, 11 + class StopCommandError extends Data.TaggedError("StopCommandError")<{ 12 + vmName: string; 13 + exitCode: number; 14 + }> {} 15 + 16 + class CommandError extends Data.TaggedError("CommandError")<{ 17 + cause?: unknown; 18 + }> {} 19 + 20 + const findVm = (name: string) => 21 + pipe( 22 + getInstanceState(name), 23 + Effect.flatMap((vm) => 24 + vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 25 + ), 18 26 ); 19 27 20 - const cmd = new Deno.Command(vm.bridge ? "sudo" : "kill", { 21 - args: [ 22 - ..._.compact([vm.bridge && "kill"]), 23 - "-TERM", 24 - vm.pid.toString(), 25 - ], 26 - stdin: "inherit", 27 - stdout: "inherit", 28 - stderr: "inherit", 28 + const logStopping = (vm: VirtualMachine) => 29 + Effect.sync(() => { 30 + console.log( 31 + `Stopping virtual machine ${chalk.greenBright(vm.name)} (ID: ${ 32 + chalk.greenBright(vm.id) 33 + })...`, 34 + ); 29 35 }); 30 36 31 - const status = await cmd.spawn().status; 37 + const killProcess = (vm: VirtualMachine) => 38 + Effect.tryPromise({ 39 + try: async () => { 40 + const cmd = new Deno.Command(vm.bridge ? "sudo" : "kill", { 41 + args: [ 42 + ..._.compact([vm.bridge && "kill"]), 43 + "-TERM", 44 + vm.pid.toString(), 45 + ], 46 + stdin: "inherit", 47 + stdout: "inherit", 48 + stderr: "inherit", 49 + }); 32 50 33 - if (!status.success) { 34 - console.error( 35 - `Failed to stop virtual machine ${chalk.greenBright(vm.name)}.`, 36 - ); 37 - Deno.exit(status.code); 38 - } 51 + const status = await cmd.spawn().status; 52 + return { vm, status }; 53 + }, 54 + catch: (error) => new CommandError({ cause: error }), 55 + }).pipe( 56 + Effect.flatMap(({ vm, status }) => 57 + status.success ? Effect.succeed(vm) : Effect.fail( 58 + new StopCommandError({ 59 + vmName: vm.name, 60 + exitCode: status.code || 1, 61 + }), 62 + ) 63 + ), 64 + ); 39 65 40 - await updateInstanceState(vm.name, "STOPPED"); 66 + const updateToStopped = (vm: VirtualMachine) => 67 + pipe( 68 + updateInstanceState(vm.name, "STOPPED"), 69 + Effect.map(() => vm), 70 + ); 41 71 42 - console.log(`Virtual machine ${chalk.greenBright(vm.name)} stopped.`); 72 + const logSuccess = (vm: VirtualMachine) => 73 + Effect.sync(() => { 74 + console.log(`Virtual machine ${chalk.greenBright(vm.name)} stopped.`); 75 + }); 76 + 77 + const handleError = ( 78 + error: VmNotFoundError | StopCommandError | CommandError | Error, 79 + ) => 80 + Effect.sync(() => { 81 + if (error instanceof VmNotFoundError) { 82 + console.error( 83 + `Virtual machine with name or ID ${ 84 + chalk.greenBright(error.name) 85 + } not found.`, 86 + ); 87 + Deno.exit(1); 88 + } else if (error instanceof StopCommandError) { 89 + console.error( 90 + `Failed to stop virtual machine ${chalk.greenBright(error.vmName)}.`, 91 + ); 92 + Deno.exit(error.exitCode); 93 + } else { 94 + console.error(`An error occurred: ${error}`); 95 + Deno.exit(1); 96 + } 97 + }); 98 + 99 + const stopEffect = (name: string) => 100 + pipe( 101 + findVm(name), 102 + Effect.tap(logStopping), 103 + Effect.flatMap(killProcess), 104 + Effect.flatMap(updateToStopped), 105 + Effect.tap(logSuccess), 106 + Effect.catchAll(handleError), 107 + ); 108 + 109 + export default async function (name: string) { 110 + await Effect.runPromise(stopEffect(name)); 43 111 }
+350 -258
src/utils.ts
··· 1 1 import _ from "@es-toolkit/es-toolkit/compat"; 2 2 import { createId } from "@paralleldrive/cuid2"; 3 3 import chalk from "chalk"; 4 + import { Data, Effect } from "effect"; 4 5 import Moniker from "moniker"; 5 6 import { EMPTY_DISK_THRESHOLD_KB, LOGS_DIR } from "./constants.ts"; 6 7 import { generateRandomMacAddress } from "./network.ts"; ··· 21 22 detach?: boolean; 22 23 } 23 24 24 - async function du(path: string): Promise<number> { 25 - const cmd = new Deno.Command("du", { 26 - args: [path], 27 - stdout: "piped", 28 - stderr: "inherit", 29 - }); 25 + class LogCommandError extends Data.TaggedError("LogCommandError")<{ 26 + cause?: unknown; 27 + }> {} 30 28 31 - const { stdout } = await cmd.spawn().output(); 32 - const output = new TextDecoder().decode(stdout).trim(); 33 - const size = parseInt(output.split("\t")[0], 10); 34 - return size; 35 - } 29 + const du = (path: string) => 30 + Effect.tryPromise({ 31 + try: async () => { 32 + const cmd = new Deno.Command("du", { 33 + args: [path], 34 + stdout: "piped", 35 + stderr: "inherit", 36 + }); 36 37 37 - export async function emptyDiskImage(path: string): Promise<boolean> { 38 - if (!await Deno.stat(path).catch(() => false)) { 39 - return true; 40 - } 38 + const { stdout } = await cmd.spawn().output(); 39 + const output = new TextDecoder().decode(stdout).trim(); 40 + const size = parseInt(output.split("\t")[0], 10); 41 + return size; 42 + }, 43 + catch: (error) => new LogCommandError({ cause: error }), 44 + }); 41 45 42 - const size = await du(path); 43 - return size < EMPTY_DISK_THRESHOLD_KB; 44 - } 46 + export const emptyDiskImage = (path: string) => 47 + Effect.tryPromise({ 48 + try: async () => { 49 + if (!await Deno.stat(path).catch(() => false)) { 50 + return true; 51 + } 52 + return false; 53 + }, 54 + catch: (error) => new LogCommandError({ cause: error }), 55 + }).pipe( 56 + Effect.flatMap((exists) => 57 + exists ? Effect.succeed(true) : du(path).pipe( 58 + Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB), 59 + ) 60 + ), 61 + ); 45 62 46 - export async function downloadIso( 63 + export const downloadIso = ( 47 64 url: string, 48 65 options: Options, 49 - ): Promise<string | null> { 50 - const filename = url.split("/").pop()!; 51 - const outputPath = options.output ?? filename; 66 + ) => 67 + Effect.gen(function* () { 68 + const filename = url.split("/").pop()!; 69 + const outputPath = options.output ?? filename; 70 + 71 + if (options.image) { 72 + const imageExists = yield* Effect.tryPromise({ 73 + try: () => 74 + Deno.stat(options.image!).then(() => true).catch(() => false), 75 + catch: (error) => new LogCommandError({ cause: error }), 76 + }); 52 77 53 - if (options.image && await Deno.stat(options.image).catch(() => false)) { 54 - const driveSize = await du(options.image); 55 - if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 78 + if (imageExists) { 79 + const driveSize = yield* du(options.image); 80 + if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 81 + console.log( 82 + chalk.yellowBright( 83 + `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, 84 + ), 85 + ); 86 + return null; 87 + } 88 + } 89 + } 90 + 91 + const outputExists = yield* Effect.tryPromise({ 92 + try: () => Deno.stat(outputPath).then(() => true).catch(() => false), 93 + catch: (error) => new LogCommandError({ cause: error }), 94 + }); 95 + 96 + if (outputExists) { 56 97 console.log( 57 98 chalk.yellowBright( 58 - `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, 99 + `File ${outputPath} already exists, skipping download.`, 59 100 ), 60 101 ); 61 - return null; 102 + return outputPath; 62 103 } 63 - } 64 104 65 - if (await Deno.stat(outputPath).catch(() => false)) { 66 - console.log( 67 - chalk.yellowBright( 68 - `File ${outputPath} already exists, skipping download.`, 69 - ), 70 - ); 71 - return outputPath; 72 - } 73 - 74 - const cmd = new Deno.Command("curl", { 75 - args: ["-L", "-o", outputPath, url], 76 - stdin: "inherit", 77 - stdout: "inherit", 78 - stderr: "inherit", 79 - }); 105 + yield* Effect.tryPromise({ 106 + try: async () => { 107 + const cmd = new Deno.Command("curl", { 108 + args: ["-L", "-o", outputPath, url], 109 + stdin: "inherit", 110 + stdout: "inherit", 111 + stderr: "inherit", 112 + }); 80 113 81 - const status = await cmd.spawn().status; 82 - if (!status.success) { 83 - console.error(chalk.redBright("Failed to download ISO image.")); 84 - Deno.exit(status.code); 85 - } 114 + const status = await cmd.spawn().status; 115 + if (!status.success) { 116 + console.error(chalk.redBright("Failed to download ISO image.")); 117 + Deno.exit(status.code); 118 + } 119 + }, 120 + catch: (error) => new LogCommandError({ cause: error }), 121 + }); 86 122 87 - console.log(chalk.greenBright(`Downloaded ISO to ${outputPath}`)); 88 - return outputPath; 89 - } 123 + console.log(chalk.greenBright(`Downloaded ISO to ${outputPath}`)); 124 + return outputPath; 125 + }); 90 126 91 127 export function constructDownloadUrl(version: string): string { 92 128 let arch = "amd64"; ··· 98 134 return `https://cdn.netbsd.org/pub/NetBSD/images/${version}/NetBSD-${version}-${arch}.iso`; 99 135 } 100 136 101 - export async function setupFirmwareFilesIfNeeded(): Promise<string[]> { 102 - if (Deno.build.arch !== "aarch64") { 103 - return []; 104 - } 137 + export const setupFirmwareFilesIfNeeded = () => 138 + Effect.gen(function* () { 139 + if (Deno.build.arch !== "aarch64") { 140 + return []; 141 + } 105 142 106 - const brewCmd = new Deno.Command("brew", { 107 - args: ["--prefix", "qemu"], 108 - stdout: "piped", 109 - stderr: "inherit", 110 - }); 111 - const { stdout, success } = await brewCmd.spawn().output(); 143 + const { stdout, success } = yield* Effect.tryPromise({ 144 + try: async () => { 145 + const brewCmd = new Deno.Command("brew", { 146 + args: ["--prefix", "qemu"], 147 + stdout: "piped", 148 + stderr: "inherit", 149 + }); 150 + return await brewCmd.spawn().output(); 151 + }, 152 + catch: (error) => new LogCommandError({ cause: error }), 153 + }); 112 154 113 - if (!success) { 114 - console.error( 115 - chalk.redBright( 116 - "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", 117 - ), 118 - ); 119 - Deno.exit(1); 120 - } 155 + if (!success) { 156 + console.error( 157 + chalk.redBright( 158 + "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", 159 + ), 160 + ); 161 + Deno.exit(1); 162 + } 121 163 122 - const brewPrefix = new TextDecoder().decode(stdout).trim(); 123 - const edk2Aarch64 = `${brewPrefix}/share/qemu/edk2-aarch64-code.fd`; 124 - const edk2VarsAarch64 = "./edk2-arm-vars.fd"; 164 + const brewPrefix = new TextDecoder().decode(stdout).trim(); 165 + const edk2Aarch64 = `${brewPrefix}/share/qemu/edk2-aarch64-code.fd`; 166 + const edk2VarsAarch64 = "./edk2-arm-vars.fd"; 125 167 126 - await Deno.copyFile( 127 - `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 128 - edk2VarsAarch64, 129 - ); 168 + yield* Effect.tryPromise({ 169 + try: () => 170 + Deno.copyFile( 171 + `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 172 + edk2VarsAarch64, 173 + ), 174 + catch: (error) => new LogCommandError({ cause: error }), 175 + }); 130 176 131 - return [ 132 - "-drive", 133 - `if=pflash,format=raw,file=${edk2Aarch64},readonly=on`, 134 - "-drive", 135 - `if=pflash,format=raw,file=${edk2VarsAarch64}`, 136 - ]; 137 - } 177 + return [ 178 + "-drive", 179 + `if=pflash,format=raw,file=${edk2Aarch64},readonly=on`, 180 + "-drive", 181 + `if=pflash,format=raw,file=${edk2VarsAarch64}`, 182 + ]; 183 + }); 138 184 139 185 export function setupPortForwardingArgs(portForward?: string): string { 140 186 if (!portForward) { ··· 158 204 return `user,id=net0,${portForwarding}`; 159 205 } 160 206 161 - export async function runQemu( 207 + export const runQemu = ( 162 208 isoPath: string | null, 163 209 options: Options, 164 - ): Promise<void> { 165 - const macAddress = generateRandomMacAddress(); 210 + ) => 211 + Effect.gen(function* () { 212 + const macAddress = yield* generateRandomMacAddress(); 166 213 167 - const qemu = Deno.build.arch === "aarch64" 168 - ? "qemu-system-aarch64" 169 - : "qemu-system-x86_64"; 214 + const qemu = Deno.build.arch === "aarch64" 215 + ? "qemu-system-aarch64" 216 + : "qemu-system-x86_64"; 170 217 171 - const qemuArgs = [ 172 - ..._.compact([options.bridge && qemu]), 173 - ...Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"], 174 - ..._.compact( 175 - Deno.build.arch === "aarch64" ? ["-machine", "virt,highmem=on"] : [], 176 - ), 177 - "-cpu", 178 - options.cpu, 179 - "-m", 180 - options.memory, 181 - "-smp", 182 - options.cpus.toString(), 183 - ..._.compact([isoPath && "-cdrom", isoPath]), 184 - "-netdev", 185 - options.bridge 186 - ? `bridge,id=net0,br=${options.bridge}` 187 - : setupNATNetworkArgs(options.portForward), 188 - "-device", 189 - `e1000,netdev=net0,mac=${macAddress}`, 190 - "-nographic", 191 - "-monitor", 192 - "none", 193 - "-chardev", 194 - "stdio,id=con0,signal=off", 195 - "-serial", 196 - "chardev:con0", 197 - ...await setupFirmwareFilesIfNeeded(), 198 - ..._.compact( 199 - options.image && [ 200 - "-drive", 201 - `file=${options.image},format=${options.diskFormat},if=virtio`, 202 - ], 203 - ), 204 - "-object", 205 - "rng-random,filename=/dev/urandom,id=rng0", 206 - "-device", 207 - "virtio-rng-pci,rng=rng0", 208 - ]; 218 + const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 209 219 210 - const name = Moniker.choose(); 220 + const qemuArgs = [ 221 + ..._.compact([options.bridge && qemu]), 222 + ...Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"], 223 + ...Deno.build.arch === "aarch64" ? ["-machine", "virt,highmem=on"] : [], 224 + "-cpu", 225 + options.cpu, 226 + "-m", 227 + options.memory, 228 + "-smp", 229 + options.cpus.toString(), 230 + ..._.compact([isoPath && "-cdrom", isoPath]), 231 + "-netdev", 232 + options.bridge 233 + ? `bridge,id=net0,br=${options.bridge}` 234 + : setupNATNetworkArgs(options.portForward), 235 + "-device", 236 + `e1000,netdev=net0,mac=${macAddress}`, 237 + "-nographic", 238 + "-monitor", 239 + "none", 240 + "-chardev", 241 + "stdio,id=con0,signal=off", 242 + "-serial", 243 + "chardev:con0", 244 + ...firmwareFiles, 245 + ..._.compact( 246 + options.image && [ 247 + "-drive", 248 + `file=${options.image},format=${options.diskFormat},if=virtio`, 249 + ], 250 + ), 251 + "-object", 252 + "rng-random,filename=/dev/urandom,id=rng0", 253 + "-device", 254 + "virtio-rng-pci,rng=rng0", 255 + ]; 211 256 212 - if (options.detach) { 213 - await Deno.mkdir(LOGS_DIR, { recursive: true }); 214 - const logPath = `${LOGS_DIR}/${name}.log`; 257 + const name = Moniker.choose(); 215 258 216 - const fullCommand = options.bridge 217 - ? `sudo ${qemu} ${ 218 - qemuArgs.slice(1).join(" ") 219 - } >> "${logPath}" 2>&1 & echo $!` 220 - : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 259 + if (options.detach) { 260 + yield* Effect.tryPromise({ 261 + try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), 262 + catch: (error) => new LogCommandError({ cause: error }), 263 + }); 221 264 222 - const cmd = new Deno.Command("sh", { 223 - args: ["-c", fullCommand], 224 - stdin: "null", 225 - stdout: "piped", 226 - }); 265 + const logPath = `${LOGS_DIR}/${name}.log`; 227 266 228 - const { stdout } = await cmd.spawn().output(); 229 - const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 267 + const fullCommand = options.bridge 268 + ? `sudo ${qemu} ${ 269 + qemuArgs.slice(1).join(" ") 270 + } >> "${logPath}" 2>&1 & echo $!` 271 + : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 230 272 231 - await saveInstanceState({ 232 - id: createId(), 233 - name, 234 - bridge: options.bridge, 235 - macAddress, 236 - memory: options.memory, 237 - cpus: options.cpus, 238 - cpu: options.cpu, 239 - diskSize: options.size, 240 - diskFormat: options.diskFormat, 241 - portForward: options.portForward, 242 - isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 243 - drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 244 - version: DEFAULT_VERSION, 245 - status: "RUNNING", 246 - pid: qemuPid, 247 - }); 273 + const { stdout } = yield* Effect.tryPromise({ 274 + try: async () => { 275 + const cmd = new Deno.Command("sh", { 276 + args: ["-c", fullCommand], 277 + stdin: "null", 278 + stdout: "piped", 279 + }); 280 + return await cmd.spawn().output(); 281 + }, 282 + catch: (error) => new LogCommandError({ cause: error }), 283 + }); 248 284 249 - console.log( 250 - `Virtual machine ${name} started in background (PID: ${qemuPid})`, 251 - ); 252 - console.log(`Logs will be written to: ${logPath}`); 285 + const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 253 286 254 - // Exit successfully while keeping VM running in background 255 - Deno.exit(0); 256 - } else { 257 - const cmd = new Deno.Command(options.bridge ? "sudo" : qemu, { 258 - args: qemuArgs, 259 - stdin: "inherit", 260 - stdout: "inherit", 261 - stderr: "inherit", 262 - }) 263 - .spawn(); 287 + yield* saveInstanceState({ 288 + id: createId(), 289 + name, 290 + bridge: options.bridge, 291 + macAddress, 292 + memory: options.memory, 293 + cpus: options.cpus, 294 + cpu: options.cpu, 295 + diskSize: options.size, 296 + diskFormat: options.diskFormat, 297 + portForward: options.portForward, 298 + isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 299 + drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 300 + version: DEFAULT_VERSION, 301 + status: "RUNNING", 302 + pid: qemuPid, 303 + }); 264 304 265 - await saveInstanceState({ 266 - id: createId(), 267 - name, 268 - bridge: options.bridge, 269 - macAddress, 270 - memory: options.memory, 271 - cpus: options.cpus, 272 - cpu: options.cpu, 273 - diskSize: options.size, 274 - diskFormat: options.diskFormat, 275 - portForward: options.portForward, 276 - isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 277 - drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 278 - version: DEFAULT_VERSION, 279 - status: "RUNNING", 280 - pid: cmd.pid, 281 - }); 305 + console.log( 306 + `Virtual machine ${name} started in background (PID: ${qemuPid})`, 307 + ); 308 + console.log(`Logs will be written to: ${logPath}`); 282 309 283 - const status = await cmd.status; 310 + // Exit successfully while keeping VM running in background 311 + Deno.exit(0); 312 + } else { 313 + const cmd = new Deno.Command(options.bridge ? "sudo" : qemu, { 314 + args: qemuArgs, 315 + stdin: "inherit", 316 + stdout: "inherit", 317 + stderr: "inherit", 318 + }) 319 + .spawn(); 284 320 285 - await updateInstanceState(name, "STOPPED"); 321 + yield* saveInstanceState({ 322 + id: createId(), 323 + name, 324 + bridge: options.bridge, 325 + macAddress, 326 + memory: options.memory, 327 + cpus: options.cpus, 328 + cpu: options.cpu, 329 + diskSize: options.size, 330 + diskFormat: options.diskFormat, 331 + portForward: options.portForward, 332 + isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 333 + drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 334 + version: DEFAULT_VERSION, 335 + status: "RUNNING", 336 + pid: cmd.pid, 337 + }); 286 338 287 - if (!status.success) { 288 - Deno.exit(status.code); 339 + const status = yield* Effect.tryPromise({ 340 + try: () => cmd.status, 341 + catch: (error) => new LogCommandError({ cause: error }), 342 + }); 343 + 344 + yield* updateInstanceState(name, "STOPPED"); 345 + 346 + if (!status.success) { 347 + Deno.exit(status.code); 348 + } 289 349 } 290 - } 291 - } 350 + }); 292 351 293 352 export function handleInput(input?: string): string { 294 353 if (!input) { 295 354 console.log( 296 - `No ISO path provided, defaulting to ${chalk.cyan("NetBSD")} ${ 297 - chalk.cyan(DEFAULT_VERSION) 298 - }...`, 355 + chalk.blueBright( 356 + `No ISO path provided, defaulting to ${chalk.cyan("FreeBSD")} ${ 357 + chalk.cyan(DEFAULT_VERSION) 358 + }...`, 359 + ), 299 360 ); 300 361 return constructDownloadUrl(DEFAULT_VERSION); 301 362 } ··· 304 365 305 366 if (versionRegex.test(input)) { 306 367 console.log( 307 - `Detected version ${chalk.cyan(input)}, constructing download URL...`, 368 + chalk.blueBright( 369 + `Detected version ${chalk.cyan(input)}, constructing download URL...`, 370 + ), 308 371 ); 309 372 return constructDownloadUrl(input); 310 373 } ··· 312 375 return input; 313 376 } 314 377 315 - export async function safeKillQemu( 378 + export const safeKillQemu = ( 316 379 pid: number, 317 380 useSudo: boolean = false, 318 - ): Promise<boolean> { 319 - const killArgs = useSudo 320 - ? ["sudo", "kill", "-TERM", pid.toString()] 321 - : ["kill", "-TERM", pid.toString()]; 322 - 323 - const termCmd = new Deno.Command(killArgs[0], { 324 - args: killArgs.slice(1), 325 - stdout: "null", 326 - stderr: "null", 327 - }); 381 + ) => 382 + Effect.gen(function* () { 383 + const killArgs = useSudo 384 + ? ["sudo", "kill", "-TERM", pid.toString()] 385 + : ["kill", "-TERM", pid.toString()]; 328 386 329 - const termStatus = await termCmd.spawn().status; 387 + const termStatus = yield* Effect.tryPromise({ 388 + try: async () => { 389 + const termCmd = new Deno.Command(killArgs[0], { 390 + args: killArgs.slice(1), 391 + stdout: "null", 392 + stderr: "null", 393 + }); 394 + return await termCmd.spawn().status; 395 + }, 396 + catch: (error) => new LogCommandError({ cause: error }), 397 + }); 330 398 331 - if (termStatus.success) { 332 - await new Promise((resolve) => setTimeout(resolve, 3000)); 399 + if (termStatus.success) { 400 + yield* Effect.tryPromise({ 401 + try: () => new Promise((resolve) => setTimeout(resolve, 3000)), 402 + catch: (error) => new LogCommandError({ cause: error }), 403 + }); 333 404 334 - const checkCmd = new Deno.Command("kill", { 335 - args: ["-0", pid.toString()], 336 - stdout: "null", 337 - stderr: "null", 338 - }); 405 + const checkStatus = yield* Effect.tryPromise({ 406 + try: async () => { 407 + const checkCmd = new Deno.Command("kill", { 408 + args: ["-0", pid.toString()], 409 + stdout: "null", 410 + stderr: "null", 411 + }); 412 + return await checkCmd.spawn().status; 413 + }, 414 + catch: (error) => new LogCommandError({ cause: error }), 415 + }); 339 416 340 - const checkStatus = await checkCmd.spawn().status; 341 - if (!checkStatus.success) { 342 - return true; 417 + if (!checkStatus.success) { 418 + return true; 419 + } 343 420 } 344 - } 345 421 346 - const killKillArgs = useSudo 347 - ? ["sudo", "kill", "-KILL", pid.toString()] 348 - : ["kill", "-KILL", pid.toString()]; 422 + const killKillArgs = useSudo 423 + ? ["sudo", "kill", "-KILL", pid.toString()] 424 + : ["kill", "-KILL", pid.toString()]; 349 425 350 - const killCmd = new Deno.Command(killKillArgs[0], { 351 - args: killKillArgs.slice(1), 352 - stdout: "null", 353 - stderr: "null", 426 + const killStatus = yield* Effect.tryPromise({ 427 + try: async () => { 428 + const killCmd = new Deno.Command(killKillArgs[0], { 429 + args: killKillArgs.slice(1), 430 + stdout: "null", 431 + stderr: "null", 432 + }); 433 + return await killCmd.spawn().status; 434 + }, 435 + catch: (error) => new LogCommandError({ cause: error }), 436 + }); 437 + 438 + return killStatus.success; 354 439 }); 355 440 356 - const killStatus = await killCmd.spawn().status; 357 - return killStatus.success; 358 - } 359 - 360 - export async function createDriveImageIfNeeded( 441 + export const createDriveImageIfNeeded = ( 361 442 { 362 443 image: path, 363 444 diskFormat: format, 364 445 size, 365 446 }: Options, 366 - ): Promise<void> { 367 - if (await Deno.stat(path!).catch(() => false)) { 368 - console.log( 369 - chalk.yellowBright( 370 - `Drive image ${path} already exists, skipping creation.`, 371 - ), 372 - ); 373 - return; 374 - } 447 + ) => 448 + Effect.gen(function* () { 449 + const pathExists = yield* Effect.tryPromise({ 450 + try: () => Deno.stat(path!).then(() => true).catch(() => false), 451 + catch: (error) => new LogCommandError({ cause: error }), 452 + }); 375 453 376 - const cmd = new Deno.Command("qemu-img", { 377 - args: ["create", "-f", format, path!, size!], 378 - stdin: "inherit", 379 - stdout: "inherit", 380 - stderr: "inherit", 381 - }); 454 + if (pathExists) { 455 + console.log( 456 + chalk.yellowBright( 457 + `Drive image ${path} already exists, skipping creation.`, 458 + ), 459 + ); 460 + return; 461 + } 382 462 383 - const status = await cmd.spawn().status; 384 - if (!status.success) { 385 - console.error(chalk.redBright("Failed to create drive image.")); 386 - Deno.exit(status.code); 387 - } 463 + const status = yield* Effect.tryPromise({ 464 + try: async () => { 465 + const cmd = new Deno.Command("qemu-img", { 466 + args: ["create", "-f", format, path!, size!], 467 + stdin: "inherit", 468 + stdout: "inherit", 469 + stderr: "inherit", 470 + }); 471 + return await cmd.spawn().status; 472 + }, 473 + catch: (error) => new LogCommandError({ cause: error }), 474 + }); 388 475 389 - console.log(chalk.greenBright(`Created drive image at ${path}`)); 390 - } 476 + if (!status.success) { 477 + console.error(chalk.redBright("Failed to create drive image.")); 478 + Deno.exit(status.code); 479 + } 480 + 481 + console.log(chalk.greenBright(`Created drive image at ${path}`)); 482 + });