A simple, zero-configuration script to quickly boot FreeBSD ISO images using QEMU

Refactor subcommands to use Effect for error handling and asynchronous operations

- Updated `restart.ts` to utilize Effect for managing VM state and process control, including error handling with tagged errors.
- Refactored `rm.ts` to implement Effect for finding and removing VMs, enhancing error management.
- Modified `start.ts` to support both detached and interactive modes using Effect, improving command execution and error handling.
- Enhanced `stop.ts` to utilize Effect for stopping VMs and managing errors effectively.
- Refactored utility functions in `utils.ts` to use Effect for asynchronous operations and error handling, including disk image management and QEMU command execution.

-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"; ··· 103 104 "freebsd-up inspect my-vm", 104 105 ) 105 106 .action(async (options: Options, input?: string) => { 106 - const resolvedInput = handleInput(input); 107 - let isoPath: string | null = resolvedInput; 107 + const program = Effect.gen(function* () { 108 + const resolvedInput = handleInput(input); 109 + let isoPath: string | null = resolvedInput; 108 110 109 - if ( 110 - resolvedInput.startsWith("https://") || 111 - resolvedInput.startsWith("http://") 112 - ) { 113 - isoPath = await downloadIso(resolvedInput, options); 114 - } 111 + if ( 112 + resolvedInput.startsWith("https://") || 113 + resolvedInput.startsWith("http://") 114 + ) { 115 + isoPath = yield* downloadIso(resolvedInput, options); 116 + } 115 117 116 - if (options.image) { 117 - await createDriveImageIfNeeded(options); 118 - } 118 + if (options.image) { 119 + yield* createDriveImageIfNeeded(options); 120 + } 121 + 122 + if (!input && options.image) { 123 + const isEmpty = yield* emptyDiskImage(options.image); 124 + if (!isEmpty) { 125 + isoPath = null; 126 + } 127 + } 119 128 120 - if (!input && options.image && !await emptyDiskImage(options.image)) { 121 - isoPath = null; 122 - } 129 + if (options.bridge) { 130 + yield* createBridgeNetworkIfNeeded(options.bridge); 131 + } 123 132 124 - if (options.bridge) { 125 - await createBridgeNetworkIfNeeded(options.bridge); 126 - } 133 + yield* runQemu(isoPath, options); 134 + }); 127 135 128 - await runQemu(isoPath, options); 136 + await Effect.runPromise(program); 129 137 }) 130 138 .command("ps", "List all virtual machines") 131 139 .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", 67 89 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 68 90 ], 69 91 ), 70 - ]; 92 + ]); 93 + }; 94 + 95 + const startQemu = (vm: VirtualMachine, qemuArgs: string[]) => { 96 + const qemu = Deno.build.arch === "aarch64" 97 + ? "qemu-system-aarch64" 98 + : "qemu-system-x86_64"; 99 + 100 + const logPath = `${LOGS_DIR}/${vm.name}.log`; 71 101 72 102 const fullCommand = vm.bridge 73 103 ? `sudo ${qemu} ${ ··· 75 105 } >> "${logPath}" 2>&1 & echo $!` 76 106 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 77 107 78 - const cmd = new Deno.Command("sh", { 79 - args: ["-c", fullCommand], 80 - stdin: "null", 81 - stdout: "piped", 82 - }); 108 + return Effect.tryPromise({ 109 + try: async () => { 110 + const cmd = new Deno.Command("sh", { 111 + args: ["-c", fullCommand], 112 + stdin: "null", 113 + stdout: "piped", 114 + }); 83 115 84 - const { stdout } = await cmd.spawn().output(); 85 - const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 116 + const { stdout } = await cmd.spawn().output(); 117 + const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 118 + return { qemuPid, logPath }; 119 + }, 120 + catch: (error) => new CommandError({ cause: error }), 121 + }); 122 + }; 86 123 87 - await new Promise((resolve) => setTimeout(resolve, 2000)); 124 + const logSuccess = (vm: VirtualMachine, qemuPid: number, logPath: string) => 125 + Effect.sync(() => { 126 + console.log( 127 + `${chalk.greenBright(vm.name)} restarted with PID ${ 128 + chalk.greenBright(qemuPid) 129 + }.`, 130 + ); 131 + console.log( 132 + `Logs are being written to ${chalk.blueBright(logPath)}`, 133 + ); 134 + }); 88 135 89 - await updateInstanceState(vm.id, "RUNNING", qemuPid); 136 + const handleError = ( 137 + error: VmNotFoundError | KillQemuError | CommandError | Error, 138 + ) => 139 + Effect.sync(() => { 140 + if (error instanceof VmNotFoundError) { 141 + console.error( 142 + `Virtual machine with name or ID ${ 143 + chalk.greenBright(error.name) 144 + } not found.`, 145 + ); 146 + } else if (error instanceof KillQemuError) { 147 + console.error( 148 + `Failed to stop virtual machine ${chalk.greenBright(error.vmName)}.`, 149 + ); 150 + } else { 151 + console.error(`An error occurred: ${error}`); 152 + } 153 + Deno.exit(1); 154 + }); 90 155 91 - console.log( 92 - `${chalk.greenBright(vm.name)} restarted with PID ${ 93 - chalk.greenBright(qemuPid) 94 - }.`, 95 - ); 96 - console.log( 97 - `Logs are being written to ${chalk.blueBright(logPath)}`, 156 + const restartEffect = (name: string) => 157 + pipe( 158 + findVm(name), 159 + Effect.tap((vm) => Effect.log(`Found VM: ${vm.name}`)), 160 + Effect.flatMap(killQemu), 161 + Effect.tap((vm) => updateInstanceState(vm.id, "STOPPED")), 162 + Effect.flatMap((vm) => 163 + pipe( 164 + sleep(2000), 165 + Effect.flatMap(() => createLogsDir()), 166 + Effect.flatMap(() => setupFirmware()), 167 + Effect.flatMap((firmwareArgs) => buildQemuArgs(vm, firmwareArgs)), 168 + Effect.flatMap((qemuArgs) => startQemu(vm, qemuArgs)), 169 + Effect.tap(() => sleep(2000)), 170 + Effect.flatMap(({ qemuPid, logPath }) => 171 + pipe( 172 + updateInstanceState(vm.id, "RUNNING", qemuPid), 173 + Effect.flatMap(() => logSuccess(vm, qemuPid, logPath)), 174 + Effect.flatMap(() => sleep(2000)), 175 + ) 176 + ), 177 + ) 178 + ), 179 + Effect.catchAll(handleError), 98 180 ); 99 181 100 - await new Promise((resolve) => setTimeout(resolve, 2000)); 101 - 182 + export default async function (name: string) { 183 + await Effect.runPromise(restartEffect(name)); 102 184 Deno.exit(0); 103 185 }
+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", 53 67 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 54 68 ], 55 69 ), 56 - ]; 70 + ]); 71 + }; 57 72 58 - if (detach) { 59 - await Deno.mkdir(LOGS_DIR, { recursive: true }); 60 - const logPath = `${LOGS_DIR}/${vm.name}.log`; 73 + const createLogsDir = () => 74 + Effect.tryPromise({ 75 + try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), 76 + catch: (error) => new CommandError({ cause: error }), 77 + }); 61 78 62 - const fullCommand = vm.bridge 63 - ? `sudo ${qemu} ${ 64 - qemuArgs.slice(1).join(" ") 65 - } >> "${logPath}" 2>&1 & echo $!` 66 - : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 79 + const startDetachedQemu = ( 80 + name: string, 81 + vm: VirtualMachine, 82 + qemuArgs: string[], 83 + ) => { 84 + const qemu = Deno.build.arch === "aarch64" 85 + ? "qemu-system-aarch64" 86 + : "qemu-system-x86_64"; 67 87 68 - const cmd = new Deno.Command("sh", { 69 - args: ["-c", fullCommand], 70 - stdin: "null", 71 - stdout: "piped", 72 - }); 88 + const logPath = `${LOGS_DIR}/${vm.name}.log`; 73 89 74 - const { stdout } = await cmd.spawn().output(); 75 - const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 90 + const fullCommand = vm.bridge 91 + ? `sudo ${qemu} ${ 92 + qemuArgs.slice(1).join(" ") 93 + } >> "${logPath}" 2>&1 & echo $!` 94 + : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 76 95 77 - await updateInstanceState(name, "RUNNING", qemuPid); 96 + return Effect.tryPromise({ 97 + try: async () => { 98 + const cmd = new Deno.Command("sh", { 99 + args: ["-c", fullCommand], 100 + stdin: "null", 101 + stdout: "piped", 102 + }); 78 103 104 + const { stdout } = await cmd.spawn().output(); 105 + const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 106 + return { qemuPid, logPath }; 107 + }, 108 + catch: (error) => new CommandError({ cause: error }), 109 + }).pipe( 110 + Effect.flatMap(({ qemuPid, logPath }) => 111 + pipe( 112 + updateInstanceState(name, "RUNNING", qemuPid), 113 + Effect.map(() => ({ vm, qemuPid, logPath })), 114 + ) 115 + ), 116 + ); 117 + }; 118 + 119 + const logDetachedSuccess = ( 120 + { vm, qemuPid, logPath }: { 121 + vm: VirtualMachine; 122 + qemuPid: number; 123 + logPath: string; 124 + }, 125 + ) => 126 + Effect.sync(() => { 79 127 console.log( 80 128 `Virtual machine ${vm.name} started in background (PID: ${qemuPid})`, 81 129 ); 82 130 console.log(`Logs will be written to: ${logPath}`); 131 + }); 83 132 84 - // Exit successfully while keeping VM running in background 85 - Deno.exit(0); 86 - } else { 87 - const cmd = new Deno.Command(vm.bridge ? "sudo" : qemu, { 88 - args: qemuArgs, 89 - stdin: "inherit", 90 - stdout: "inherit", 91 - stderr: "inherit", 92 - }); 133 + const startInteractiveQemu = ( 134 + name: string, 135 + vm: VirtualMachine, 136 + qemuArgs: string[], 137 + ) => { 138 + const qemu = Deno.build.arch === "aarch64" 139 + ? "qemu-system-aarch64" 140 + : "qemu-system-x86_64"; 93 141 94 - const child = cmd.spawn(); 95 - await updateInstanceState(name, "RUNNING", child.pid); 142 + return Effect.tryPromise({ 143 + try: async () => { 144 + const cmd = new Deno.Command(vm.bridge ? "sudo" : qemu, { 145 + args: qemuArgs, 146 + stdin: "inherit", 147 + stdout: "inherit", 148 + stderr: "inherit", 149 + }); 96 150 97 - const status = await child.status; 151 + const child = cmd.spawn(); 152 + 153 + await Effect.runPromise(updateInstanceState(name, "RUNNING", child.pid)); 98 154 99 - await updateInstanceState(name, "STOPPED", child.pid); 155 + const status = await child.status; 100 156 101 - if (!status.success) { 102 - Deno.exit(status.code); 157 + await Effect.runPromise(updateInstanceState(name, "STOPPED", child.pid)); 158 + 159 + return status; 160 + }, 161 + catch: (error) => new CommandError({ cause: error }), 162 + }); 163 + }; 164 + 165 + const handleError = (error: VmNotFoundError | CommandError | Error) => 166 + Effect.sync(() => { 167 + if (error instanceof VmNotFoundError) { 168 + console.error( 169 + `Virtual machine with name or ID ${error.name} not found.`, 170 + ); 171 + } else { 172 + console.error(`An error occurred: ${error}`); 103 173 } 174 + Deno.exit(1); 175 + }); 176 + 177 + const startDetachedEffect = (name: string) => 178 + pipe( 179 + findVm(name), 180 + Effect.tap(logStarting), 181 + Effect.flatMap(applyFlags), 182 + Effect.flatMap((vm) => 183 + pipe( 184 + setupFirmware(), 185 + Effect.flatMap((firmwareArgs) => buildQemuArgs(vm, firmwareArgs)), 186 + Effect.flatMap((qemuArgs) => 187 + pipe( 188 + createLogsDir(), 189 + Effect.flatMap(() => startDetachedQemu(name, vm, qemuArgs)), 190 + Effect.tap(logDetachedSuccess), 191 + Effect.map(() => 0), // Exit code 0 192 + ) 193 + ), 194 + ) 195 + ), 196 + Effect.catchAll(handleError), 197 + ); 198 + 199 + const startInteractiveEffect = (name: string) => 200 + pipe( 201 + findVm(name), 202 + Effect.tap(logStarting), 203 + Effect.flatMap(applyFlags), 204 + Effect.flatMap((vm) => 205 + pipe( 206 + setupFirmware(), 207 + Effect.flatMap((firmwareArgs) => buildQemuArgs(vm, firmwareArgs)), 208 + Effect.flatMap((qemuArgs) => startInteractiveQemu(name, vm, qemuArgs)), 209 + Effect.map((status) => status.success ? 0 : (status.code || 1)), 210 + ) 211 + ), 212 + Effect.catchAll(handleError), 213 + ); 214 + 215 + export default async function (name: string, detach: boolean = false) { 216 + const exitCode = await Effect.runPromise( 217 + detach ? startDetachedEffect(name) : startInteractiveEffect(name), 218 + ); 219 + 220 + if (detach) { 221 + Deno.exit(exitCode); 222 + } else if (exitCode !== 0) { 223 + Deno.exit(exitCode); 104 224 } 105 225 } 106 226
+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 }
+338 -248
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 + }); 77 + 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 + }); 52 95 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) { 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 - } 104 + 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 + }); 113 + 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 + }); 64 122 65 - if (await Deno.stat(outputPath).catch(() => false)) { 66 - console.log( 67 - chalk.yellowBright( 68 - `File ${outputPath} already exists, skipping download.`, 69 - ), 70 - ); 123 + console.log(chalk.greenBright(`Downloaded ISO to ${outputPath}`)); 71 124 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 125 }); 80 126 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 - } 86 - 87 - console.log(chalk.greenBright(`Downloaded ISO to ${outputPath}`)); 88 - return outputPath; 89 - } 90 - 91 127 export function constructDownloadUrl(version: string): string { 92 128 let arch = "amd64"; 93 129 ··· 100 136 }/FreeBSD-${version}-${arch}-disc1.iso`; 101 137 } 102 138 103 - export async function setupFirmwareFilesIfNeeded(): Promise<string[]> { 104 - if (Deno.build.arch !== "aarch64") { 105 - return []; 106 - } 139 + export const setupFirmwareFilesIfNeeded = () => 140 + Effect.gen(function* () { 141 + if (Deno.build.arch !== "aarch64") { 142 + return []; 143 + } 107 144 108 - const brewCmd = new Deno.Command("brew", { 109 - args: ["--prefix", "qemu"], 110 - stdout: "piped", 111 - stderr: "inherit", 112 - }); 113 - const { stdout, success } = await brewCmd.spawn().output(); 145 + const { stdout, success } = yield* Effect.tryPromise({ 146 + try: async () => { 147 + const brewCmd = new Deno.Command("brew", { 148 + args: ["--prefix", "qemu"], 149 + stdout: "piped", 150 + stderr: "inherit", 151 + }); 152 + return await brewCmd.spawn().output(); 153 + }, 154 + catch: (error) => new LogCommandError({ cause: error }), 155 + }); 114 156 115 - if (!success) { 116 - console.error( 117 - chalk.redBright( 118 - "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", 119 - ), 120 - ); 121 - Deno.exit(1); 122 - } 157 + if (!success) { 158 + console.error( 159 + chalk.redBright( 160 + "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", 161 + ), 162 + ); 163 + Deno.exit(1); 164 + } 123 165 124 - const brewPrefix = new TextDecoder().decode(stdout).trim(); 125 - const edk2Aarch64 = `${brewPrefix}/share/qemu/edk2-aarch64-code.fd`; 126 - const edk2VarsAarch64 = "./edk2-arm-vars.fd"; 166 + const brewPrefix = new TextDecoder().decode(stdout).trim(); 167 + const edk2Aarch64 = `${brewPrefix}/share/qemu/edk2-aarch64-code.fd`; 168 + const edk2VarsAarch64 = "./edk2-arm-vars.fd"; 127 169 128 - await Deno.copyFile( 129 - `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 130 - edk2VarsAarch64, 131 - ); 170 + yield* Effect.tryPromise({ 171 + try: () => 172 + Deno.copyFile( 173 + `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 174 + edk2VarsAarch64, 175 + ), 176 + catch: (error) => new LogCommandError({ cause: error }), 177 + }); 132 178 133 - return [ 134 - "-drive", 135 - `if=pflash,format=raw,file=${edk2Aarch64},readonly=on`, 136 - "-drive", 137 - `if=pflash,format=raw,file=${edk2VarsAarch64}`, 138 - ]; 139 - } 179 + return [ 180 + "-drive", 181 + `if=pflash,format=raw,file=${edk2Aarch64},readonly=on`, 182 + "-drive", 183 + `if=pflash,format=raw,file=${edk2VarsAarch64}`, 184 + ]; 185 + }); 140 186 141 187 export function setupPortForwardingArgs(portForward?: string): string { 142 188 if (!portForward) { ··· 160 206 return `user,id=net0,${portForwarding}`; 161 207 } 162 208 163 - export async function runQemu( 209 + export const runQemu = ( 164 210 isoPath: string | null, 165 211 options: Options, 166 - ): Promise<void> { 167 - const macAddress = generateRandomMacAddress(); 212 + ) => 213 + Effect.gen(function* () { 214 + const macAddress = yield* generateRandomMacAddress(); 168 215 169 - const qemu = Deno.build.arch === "aarch64" 170 - ? "qemu-system-aarch64" 171 - : "qemu-system-x86_64"; 216 + const qemu = Deno.build.arch === "aarch64" 217 + ? "qemu-system-aarch64" 218 + : "qemu-system-x86_64"; 172 219 173 - const qemuArgs = [ 174 - ..._.compact([options.bridge && qemu]), 175 - ...Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"], 176 - ...Deno.build.arch === "aarch64" ? ["-machine", "virt,highmem=on"] : [], 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 - ]; 220 + const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 205 221 206 - const name = Moniker.choose(); 222 + const qemuArgs = [ 223 + ..._.compact([options.bridge && qemu]), 224 + ...Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"], 225 + ...Deno.build.arch === "aarch64" ? ["-machine", "virt,highmem=on"] : [], 226 + "-cpu", 227 + options.cpu, 228 + "-m", 229 + options.memory, 230 + "-smp", 231 + options.cpus.toString(), 232 + ..._.compact([isoPath && "-cdrom", isoPath]), 233 + "-netdev", 234 + options.bridge 235 + ? `bridge,id=net0,br=${options.bridge}` 236 + : setupNATNetworkArgs(options.portForward), 237 + "-device", 238 + `e1000,netdev=net0,mac=${macAddress}`, 239 + "-nographic", 240 + "-monitor", 241 + "none", 242 + "-chardev", 243 + "stdio,id=con0,signal=off", 244 + "-serial", 245 + "chardev:con0", 246 + ...firmwareFiles, 247 + ..._.compact( 248 + options.image && [ 249 + "-drive", 250 + `file=${options.image},format=${options.diskFormat},if=virtio`, 251 + ], 252 + ), 253 + ]; 207 254 208 - if (options.detach) { 209 - await Deno.mkdir(LOGS_DIR, { recursive: true }); 210 - const logPath = `${LOGS_DIR}/${name}.log`; 255 + const name = Moniker.choose(); 211 256 212 - const fullCommand = options.bridge 213 - ? `sudo ${qemu} ${ 214 - qemuArgs.slice(1).join(" ") 215 - } >> "${logPath}" 2>&1 & echo $!` 216 - : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 257 + if (options.detach) { 258 + yield* Effect.tryPromise({ 259 + try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), 260 + catch: (error) => new LogCommandError({ cause: error }), 261 + }); 217 262 218 - const cmd = new Deno.Command("sh", { 219 - args: ["-c", fullCommand], 220 - stdin: "null", 221 - stdout: "piped", 222 - }); 263 + const logPath = `${LOGS_DIR}/${name}.log`; 223 264 224 - const { stdout } = await cmd.spawn().output(); 225 - const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 265 + const fullCommand = options.bridge 266 + ? `sudo ${qemu} ${ 267 + qemuArgs.slice(1).join(" ") 268 + } >> "${logPath}" 2>&1 & echo $!` 269 + : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 226 270 227 - await saveInstanceState({ 228 - id: createId(), 229 - name, 230 - bridge: options.bridge, 231 - macAddress, 232 - memory: options.memory, 233 - cpus: options.cpus, 234 - cpu: options.cpu, 235 - diskSize: options.size, 236 - diskFormat: options.diskFormat, 237 - portForward: options.portForward, 238 - isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 239 - drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 240 - version: DEFAULT_VERSION, 241 - status: "RUNNING", 242 - pid: qemuPid, 243 - }); 271 + const { stdout } = yield* Effect.tryPromise({ 272 + try: async () => { 273 + const cmd = new Deno.Command("sh", { 274 + args: ["-c", fullCommand], 275 + stdin: "null", 276 + stdout: "piped", 277 + }); 278 + return await cmd.spawn().output(); 279 + }, 280 + catch: (error) => new LogCommandError({ cause: error }), 281 + }); 282 + 283 + const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 284 + 285 + yield* saveInstanceState({ 286 + id: createId(), 287 + name, 288 + bridge: options.bridge, 289 + macAddress, 290 + memory: options.memory, 291 + cpus: options.cpus, 292 + cpu: options.cpu, 293 + diskSize: options.size, 294 + diskFormat: options.diskFormat, 295 + portForward: options.portForward, 296 + isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 297 + drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 298 + version: DEFAULT_VERSION, 299 + status: "RUNNING", 300 + pid: qemuPid, 301 + }); 244 302 245 - console.log( 246 - `Virtual machine ${name} started in background (PID: ${qemuPid})`, 247 - ); 248 - console.log(`Logs will be written to: ${logPath}`); 303 + console.log( 304 + `Virtual machine ${name} started in background (PID: ${qemuPid})`, 305 + ); 306 + console.log(`Logs will be written to: ${logPath}`); 249 307 250 - // Exit successfully while keeping VM running in background 251 - Deno.exit(0); 252 - } else { 253 - const cmd = new Deno.Command(options.bridge ? "sudo" : qemu, { 254 - args: qemuArgs, 255 - stdin: "inherit", 256 - stdout: "inherit", 257 - stderr: "inherit", 258 - }) 259 - .spawn(); 308 + // Exit successfully while keeping VM running in background 309 + Deno.exit(0); 310 + } else { 311 + const cmd = new Deno.Command(options.bridge ? "sudo" : qemu, { 312 + args: qemuArgs, 313 + stdin: "inherit", 314 + stdout: "inherit", 315 + stderr: "inherit", 316 + }) 317 + .spawn(); 260 318 261 - await saveInstanceState({ 262 - id: createId(), 263 - name, 264 - bridge: options.bridge, 265 - macAddress, 266 - memory: options.memory, 267 - cpus: options.cpus, 268 - cpu: options.cpu, 269 - diskSize: options.size, 270 - diskFormat: options.diskFormat, 271 - portForward: options.portForward, 272 - isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 273 - drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 274 - version: DEFAULT_VERSION, 275 - status: "RUNNING", 276 - pid: cmd.pid, 277 - }); 319 + yield* saveInstanceState({ 320 + id: createId(), 321 + name, 322 + bridge: options.bridge, 323 + macAddress, 324 + memory: options.memory, 325 + cpus: options.cpus, 326 + cpu: options.cpu, 327 + diskSize: options.size, 328 + diskFormat: options.diskFormat, 329 + portForward: options.portForward, 330 + isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 331 + drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 332 + version: DEFAULT_VERSION, 333 + status: "RUNNING", 334 + pid: cmd.pid, 335 + }); 278 336 279 - const status = await cmd.status; 337 + const status = yield* Effect.tryPromise({ 338 + try: () => cmd.status, 339 + catch: (error) => new LogCommandError({ cause: error }), 340 + }); 280 341 281 - await updateInstanceState(name, "STOPPED"); 342 + yield* updateInstanceState(name, "STOPPED"); 282 343 283 - if (!status.success) { 284 - Deno.exit(status.code); 344 + if (!status.success) { 345 + Deno.exit(status.code); 346 + } 285 347 } 286 - } 287 - } 348 + }); 288 349 289 350 export function handleInput(input?: string): string { 290 351 if (!input) { ··· 312 373 return input; 313 374 } 314 375 315 - export async function safeKillQemu( 376 + export const safeKillQemu = ( 316 377 pid: number, 317 378 useSudo: boolean = false, 318 - ): Promise<boolean> { 319 - const killArgs = useSudo 320 - ? ["sudo", "kill", "-TERM", pid.toString()] 321 - : ["kill", "-TERM", pid.toString()]; 379 + ) => 380 + Effect.gen(function* () { 381 + const killArgs = useSudo 382 + ? ["sudo", "kill", "-TERM", pid.toString()] 383 + : ["kill", "-TERM", pid.toString()]; 322 384 323 - const termCmd = new Deno.Command(killArgs[0], { 324 - args: killArgs.slice(1), 325 - stdout: "null", 326 - stderr: "null", 327 - }); 385 + const termStatus = yield* Effect.tryPromise({ 386 + try: async () => { 387 + const termCmd = new Deno.Command(killArgs[0], { 388 + args: killArgs.slice(1), 389 + stdout: "null", 390 + stderr: "null", 391 + }); 392 + return await termCmd.spawn().status; 393 + }, 394 + catch: (error) => new LogCommandError({ cause: error }), 395 + }); 328 396 329 - const termStatus = await termCmd.spawn().status; 397 + if (termStatus.success) { 398 + yield* Effect.tryPromise({ 399 + try: () => new Promise((resolve) => setTimeout(resolve, 3000)), 400 + catch: (error) => new LogCommandError({ cause: error }), 401 + }); 330 402 331 - if (termStatus.success) { 332 - await new Promise((resolve) => setTimeout(resolve, 3000)); 333 - 334 - const checkCmd = new Deno.Command("kill", { 335 - args: ["-0", pid.toString()], 336 - stdout: "null", 337 - stderr: "null", 338 - }); 403 + const checkStatus = yield* Effect.tryPromise({ 404 + try: async () => { 405 + const checkCmd = new Deno.Command("kill", { 406 + args: ["-0", pid.toString()], 407 + stdout: "null", 408 + stderr: "null", 409 + }); 410 + return await checkCmd.spawn().status; 411 + }, 412 + catch: (error) => new LogCommandError({ cause: error }), 413 + }); 339 414 340 - const checkStatus = await checkCmd.spawn().status; 341 - if (!checkStatus.success) { 342 - return true; 415 + if (!checkStatus.success) { 416 + return true; 417 + } 343 418 } 344 - } 345 419 346 - const killKillArgs = useSudo 347 - ? ["sudo", "kill", "-KILL", pid.toString()] 348 - : ["kill", "-KILL", pid.toString()]; 420 + const killKillArgs = useSudo 421 + ? ["sudo", "kill", "-KILL", pid.toString()] 422 + : ["kill", "-KILL", pid.toString()]; 349 423 350 - const killCmd = new Deno.Command(killKillArgs[0], { 351 - args: killKillArgs.slice(1), 352 - stdout: "null", 353 - stderr: "null", 354 - }); 424 + const killStatus = yield* Effect.tryPromise({ 425 + try: async () => { 426 + const killCmd = new Deno.Command(killKillArgs[0], { 427 + args: killKillArgs.slice(1), 428 + stdout: "null", 429 + stderr: "null", 430 + }); 431 + return await killCmd.spawn().status; 432 + }, 433 + catch: (error) => new LogCommandError({ cause: error }), 434 + }); 355 435 356 - const killStatus = await killCmd.spawn().status; 357 - return killStatus.success; 358 - } 436 + return killStatus.success; 437 + }); 359 438 360 - export async function createDriveImageIfNeeded( 439 + export const createDriveImageIfNeeded = ( 361 440 { 362 441 image: path, 363 442 diskFormat: format, 364 443 size, 365 444 }: 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 - } 445 + ) => 446 + Effect.gen(function* () { 447 + const pathExists = yield* Effect.tryPromise({ 448 + try: () => Deno.stat(path!).then(() => true).catch(() => false), 449 + catch: (error) => new LogCommandError({ cause: error }), 450 + }); 451 + 452 + if (pathExists) { 453 + console.log( 454 + chalk.yellowBright( 455 + `Drive image ${path} already exists, skipping creation.`, 456 + ), 457 + ); 458 + return; 459 + } 375 460 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 - }); 461 + const status = yield* Effect.tryPromise({ 462 + try: async () => { 463 + const cmd = new Deno.Command("qemu-img", { 464 + args: ["create", "-f", format, path!, size!], 465 + stdin: "inherit", 466 + stdout: "inherit", 467 + stderr: "inherit", 468 + }); 469 + return await cmd.spawn().status; 470 + }, 471 + catch: (error) => new LogCommandError({ cause: error }), 472 + }); 382 473 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 - } 474 + if (!status.success) { 475 + console.error(chalk.redBright("Failed to create drive image.")); 476 + Deno.exit(status.code); 477 + } 388 478 389 - console.log(chalk.greenBright(`Created drive image at ${path}`)); 390 - } 479 + console.log(chalk.greenBright(`Created drive image at ${path}`)); 480 + });