A convenient CLI tool to quickly spin up DragonflyBSD virtual machines using QEMU with sensible defaults.

Merge pull request #3 from tsirysndr/rewrite-in-effects

Refactor virtual machine commands to use Effect for better error handling and control flow

authored by tsiry-sandratraina.com and committed by

GitHub 3f244ff9 322675a7

+1144 -519
-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 }
+8 -4
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"; ··· 110 111 resolvedInput.startsWith("https://") || 111 112 resolvedInput.startsWith("http://") 112 113 ) { 113 - isoPath = await downloadIso(resolvedInput, options); 114 + isoPath = await Effect.runPromise(downloadIso(resolvedInput, options)); 114 115 } 115 116 116 117 if (options.image) { 117 - await createDriveImageIfNeeded(options); 118 + await Effect.runPromise(createDriveImageIfNeeded(options)); 118 119 } 119 120 120 - if (!input && options.image && !await emptyDiskImage(options.image)) { 121 + if ( 122 + !input && options.image && 123 + !await Effect.runPromise(emptyDiskImage(options.image)) 124 + ) { 121 125 isoPath = null; 122 126 } 123 127 ··· 125 129 await createBridgeNetworkIfNeeded(options.bridge); 126 130 } 127 131 128 - await runQemu(isoPath, options); 132 + await Effect.runPromise(runQemu(isoPath, options)); 129 133 }) 130 134 .command("ps", "List all virtual machines") 131 135 .option("--all, -a", "Show all virtual machines, including stopped ones")
+107 -41
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 DatabaseInsertError 7 + extends Data.TaggedError("DatabaseInsertError")<{ 8 + cause: unknown; 9 + message: string; 10 + }> {} 11 + 12 + export class DatabaseUpdateError 13 + extends Data.TaggedError("DatabaseUpdateError")<{ 14 + cause: unknown; 15 + message: string; 16 + }> {} 17 + 18 + export class DatabaseDeleteError 19 + extends Data.TaggedError("DatabaseDeleteError")<{ 20 + cause: unknown; 21 + message: string; 22 + }> {} 23 + 24 + export class DatabaseQueryError extends Data.TaggedError("DatabaseQueryError")<{ 25 + cause: unknown; 26 + message: string; 27 + }> {} 10 28 11 - export async function updateInstanceState( 29 + export class InstanceNotFoundError 30 + extends Data.TaggedError("InstanceNotFoundError")<{ 31 + name: string; 32 + message: string; 33 + }> {} 34 + 35 + export const saveInstanceState = (vm: VirtualMachine) => 36 + Effect.tryPromise({ 37 + try: () => 38 + ctx.db.insertInto("virtual_machines") 39 + .values(vm) 40 + .execute(), 41 + catch: (cause) => 42 + new DatabaseInsertError({ 43 + cause, 44 + message: `Failed to save instance state for VM: ${vm.name}`, 45 + }), 46 + }); 47 + 48 + export const updateInstanceState = ( 12 49 name: string, 13 50 status: STATUS, 14 51 pid?: number, 15 - ) { 16 - await ctx.db.updateTable("virtual_machines") 17 - .set({ status, pid }) 18 - .where((eb) => 19 - eb.or([ 20 - eb("name", "=", name), 21 - eb("id", "=", name), 22 - ]) 23 - ) 24 - .execute(); 25 - } 52 + ) => 53 + Effect.tryPromise({ 54 + try: () => 55 + ctx.db.updateTable("virtual_machines") 56 + .set({ status, pid }) 57 + .where((eb) => 58 + eb.or([ 59 + eb("name", "=", name), 60 + eb("id", "=", name), 61 + ]) 62 + ) 63 + .execute(), 64 + catch: (cause) => 65 + new DatabaseUpdateError({ 66 + cause, 67 + message: `Failed to update instance state for: ${name}`, 68 + }), 69 + }); 26 70 27 - export async function removeInstanceState(name: string) { 28 - await ctx.db.deleteFrom("virtual_machines") 29 - .where((eb) => 30 - eb.or([ 31 - eb("name", "=", name), 32 - eb("id", "=", name), 33 - ]) 34 - ) 35 - .execute(); 36 - } 71 + export const removeInstanceState = (name: string) => 72 + Effect.tryPromise({ 73 + try: () => 74 + ctx.db.deleteFrom("virtual_machines") 75 + .where((eb) => 76 + eb.or([ 77 + eb("name", "=", name), 78 + eb("id", "=", name), 79 + ]) 80 + ) 81 + .execute(), 82 + catch: (cause) => 83 + new DatabaseDeleteError({ 84 + cause, 85 + message: `Failed to remove instance state for: ${name}`, 86 + }), 87 + }); 37 88 38 - export async function getInstanceState( 39 - name: string, 40 - ): Promise<VirtualMachine | undefined> { 41 - const vm = await ctx.db.selectFrom("virtual_machines") 42 - .selectAll() 43 - .where((eb) => 44 - eb.or([ 45 - eb("name", "=", name), 46 - eb("id", "=", name), 47 - ]) 48 - ) 49 - .executeTakeFirst(); 89 + export const getInstanceState = (name: string) => 90 + Effect.tryPromise({ 91 + try: () => 92 + ctx.db.selectFrom("virtual_machines") 93 + .selectAll() 94 + .where((eb) => 95 + eb.or([ 96 + eb("name", "=", name), 97 + eb("id", "=", name), 98 + ]) 99 + ) 100 + .executeTakeFirst(), 101 + catch: (cause) => 102 + new DatabaseQueryError({ 103 + cause, 104 + message: `Failed to query instance state for: ${name}`, 105 + }), 106 + }); 50 107 51 - return vm; 52 - } 108 + export const getInstanceStateOrFail = (name: string) => 109 + Effect.flatMap( 110 + getInstanceState(name), 111 + (vm) => 112 + vm ? Effect.succeed(vm) : Effect.fail( 113 + new InstanceNotFoundError({ 114 + name, 115 + message: `Instance not found: ${name}`, 116 + }), 117 + ), 118 + );
+30 -9
src/subcommands/inspect.ts
··· 1 - import { getInstanceState } from "../state.ts"; 1 + import { Effect, pipe } from "effect"; 2 + import { getInstanceStateOrFail } from "../state.ts"; 3 + 4 + const inspectVirtualMachine = (name: string) => 5 + pipe( 6 + getInstanceStateOrFail(name), 7 + Effect.flatMap(Effect.log), 8 + ); 2 9 3 10 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 - ); 9 - Deno.exit(1); 10 - } 11 + const program = pipe( 12 + inspectVirtualMachine(name), 13 + Effect.catchTags({ 14 + InstanceNotFoundError: (_error) => 15 + Effect.sync(() => { 16 + console.error(`Virtual machine with name or ID ${name} not found.`); 17 + Deno.exit(1); 18 + }), 19 + DatabaseQueryError: (error) => 20 + Effect.sync(() => { 21 + console.error(`Database error: ${error.message}`); 22 + Deno.exit(1); 23 + }), 24 + }), 25 + Effect.catchAll((error) => 26 + Effect.sync(() => { 27 + console.error(`Error: ${String(error)}`); 28 + Deno.exit(1); 29 + }) 30 + ), 31 + ); 11 32 12 - console.log(vm); 33 + await Effect.runPromise(program); 13 34 }
+58 -12
src/subcommands/logs.ts
··· 1 + import { 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 + const ensureLogsDirectory = () => 5 + Effect.tryPromise({ 6 + try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), 7 + catch: (cause) => new Error(`Failed to create logs directory: ${cause}`), 8 + }); 6 9 7 - const cmd = new Deno.Command(follow ? "tail" : "cat", { 10 + const buildLogPath = (name: string) => 11 + Effect.sync(() => `${LOGS_DIR}/${name}.log`); 12 + 13 + const buildLogCommand = (follow: boolean, logPath: string) => 14 + Effect.sync(() => ({ 15 + command: follow ? "tail" : "cat", 8 16 args: [ 9 17 ...(follow ? ["-n", "100", "-f"] : []), 10 18 logPath, 11 19 ], 12 - stdin: "inherit", 13 - stdout: "inherit", 14 - stderr: "inherit", 20 + })); 21 + 22 + const executeLogCommand = (command: string, args: string[]) => 23 + Effect.tryPromise({ 24 + try: async () => { 25 + const cmd = new Deno.Command(command, { 26 + args, 27 + stdin: "inherit", 28 + stdout: "inherit", 29 + stderr: "inherit", 30 + }); 31 + 32 + return await cmd.spawn().status; 33 + }, 34 + catch: (cause) => new Error(`Failed to execute log command: ${cause}`), 15 35 }); 16 36 17 - const status = await cmd.spawn().status; 37 + const validateLogCommandResult = (name: string, status: Deno.CommandStatus) => 38 + Effect.sync(() => { 39 + if (!status.success) { 40 + throw new Error(`Failed to view logs for virtual machine ${name}`); 41 + } 42 + }); 18 43 19 - if (!status.success) { 20 - console.error(`Failed to view logs for virtual machine ${name}.`); 21 - Deno.exit(status.code); 22 - } 44 + const viewVirtualMachineLogs = (name: string, follow: boolean) => 45 + pipe( 46 + ensureLogsDirectory(), 47 + Effect.flatMap(() => buildLogPath(name)), 48 + Effect.flatMap((logPath) => 49 + pipe( 50 + buildLogCommand(follow, logPath), 51 + Effect.flatMap(({ command, args }) => executeLogCommand(command, args)), 52 + Effect.flatMap((status) => validateLogCommandResult(name, status)), 53 + ) 54 + ), 55 + ); 56 + 57 + export default async function (name: string, follow: boolean) { 58 + const program = pipe( 59 + viewVirtualMachineLogs(name, follow), 60 + Effect.catchAll((error) => 61 + Effect.sync(() => { 62 + console.error(`Error: ${String(error)}`); 63 + Deno.exit(1); 64 + }) 65 + ), 66 + ); 67 + 68 + await Effect.runPromise(program); 23 69 }
+70 -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 { 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 + const queryVirtualMachines = (all: boolean) => 13 + Effect.tryPromise({ 14 + try: () => 15 + ctx.db.selectFrom("virtual_machines") 16 + .selectAll() 17 + .where((eb) => { 18 + if (all) { 19 + return eb("id", "!=", ""); 20 + } 21 + return eb("status", "=", "RUNNING"); 22 + }) 23 + .execute(), 24 + catch: (cause) => new Error(`Failed to query virtual machines: ${cause}`), 25 + }); 26 + 27 + const createTableHeaders = () => 28 + new Table([ 29 + "NAME", 30 + "VCPU", 31 + "MEMORY", 32 + "STATUS", 33 + "PID", 34 + "BRIDGE", 35 + "PORTS", 36 + "CREATED", 37 + ]); 38 + 39 + const formatVmTableRow = (vm: VirtualMachine): string[] => [ 40 + vm.name, 41 + vm.cpus.toString(), 42 + vm.memory, 43 + formatStatus(vm), 44 + formatPid(vm), 45 + vm.bridge ?? "-", 46 + formatPorts(vm.portForward), 47 + dayjs.utc(vm.createdAt).local().fromNow(), 48 + ]; 49 + 50 + const populateTable = (vms: VirtualMachine[]) => 51 + Effect.sync(() => { 52 + const table = createTableHeaders(); 53 + 54 + for (const vm of vms) { 55 + table.push(formatVmTableRow(vm)); 56 + } 57 + 58 + return table; 59 + }); 21 60 22 - const table: Table = new Table( 23 - ["NAME", "VCPU", "MEMORY", "STATUS", "PID", "BRIDGE", "PORTS", "CREATED"], 61 + const displayTable = (table: Table) => 62 + Effect.sync(() => { 63 + console.log(table.padding(2).toString()); 64 + }); 65 + 66 + const listVirtualMachines = (all: boolean) => 67 + pipe( 68 + queryVirtualMachines(all), 69 + Effect.flatMap(populateTable), 70 + Effect.flatMap(displayTable), 24 71 ); 25 72 26 - for (const vm of results) { 27 - table.push([ 28 - vm.name, 29 - vm.cpus.toString(), 30 - vm.memory, 31 - formatStatus(vm), 32 - formatPid(vm), 33 - vm.bridge ?? "-", 34 - formatPorts(vm.portForward), 35 - dayjs.utc(vm.createdAt).local().fromNow(), 36 - ]); 37 - } 73 + export default async function (all: boolean) { 74 + const program = pipe( 75 + listVirtualMachines(all), 76 + Effect.catchAll((error) => 77 + Effect.sync(() => { 78 + console.error(`Error: ${String(error)}`); 79 + Deno.exit(1); 80 + }) 81 + ), 82 + ); 38 83 39 - console.log(table.padding(2).toString()); 84 + await Effect.runPromise(program); 40 85 } 41 86 42 87 function formatPid(vm: VirtualMachine) {
+154 -70
src/subcommands/restart.ts
··· 1 1 import _ from "@es-toolkit/es-toolkit/compat"; 2 2 import chalk from "chalk"; 3 + import { Effect, pipe } from "effect"; 3 4 import { LOGS_DIR } from "../constants.ts"; 4 - import { getInstanceState, updateInstanceState } from "../state.ts"; 5 + import type { VirtualMachine } from "../db.ts"; 6 + import { getInstanceStateOrFail, updateInstanceState } from "../state.ts"; 5 7 import { safeKillQemu, setupNATNetworkArgs } from "../utils.ts"; 6 8 7 - export default async function (name: string) { 8 - const vm = await getInstanceState(name); 9 - if (!vm) { 10 - console.error( 11 - `Virtual machine with name or ID ${chalk.greenBright(name)} not found.`, 12 - ); 13 - Deno.exit(1); 14 - } 15 - 16 - const success = await safeKillQemu(vm.pid, Boolean(vm.bridge)); 9 + const killQemuProcess = (vm: VirtualMachine) => 10 + pipe( 11 + safeKillQemu(vm.pid, Boolean(vm.bridge)), 12 + Effect.flatMap((success) => { 13 + if (!success) { 14 + return Effect.fail( 15 + new Error(`Failed to stop virtual machine ${vm.name}`), 16 + ); 17 + } 18 + return Effect.succeed(vm); 19 + }), 20 + ); 17 21 18 - if (!success) { 19 - console.error( 20 - `Failed to stop virtual machine ${chalk.greenBright(vm.name)}.`, 21 - ); 22 - Deno.exit(1); 23 - } 24 - await updateInstanceState(vm.id, "STOPPED"); 22 + const markInstanceAsStopped = (vm: VirtualMachine) => 23 + updateInstanceState(vm.id, "STOPPED"); 25 24 26 - await new Promise((resolve) => setTimeout(resolve, 2000)); 25 + const waitForDelay = (ms: number) => 26 + Effect.tryPromise({ 27 + try: () => new Promise((resolve) => setTimeout(resolve, ms)), 28 + catch: () => new Error("Sleep failed"), 29 + }); 27 30 28 - await Deno.mkdir(LOGS_DIR, { recursive: true }); 29 - const logPath = `${LOGS_DIR}/${vm.name}.log`; 31 + const createLogsDirectory = () => 32 + Effect.tryPromise({ 33 + try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), 34 + catch: (cause) => new Error(`Failed to create logs directory: ${cause}`), 35 + }); 30 36 31 - const qemuArgs = [ 32 - ..._.compact([vm.bridge && "qemu-system-x86_64"]), 33 - ...Deno.build.os === "linux" ? ["-enable-kvm"] : [], 34 - "-cpu", 35 - vm.cpu, 36 - "-m", 37 - vm.memory, 38 - "-smp", 39 - vm.cpus.toString(), 40 - ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]), 41 - "-netdev", 42 - vm.bridge 43 - ? `bridge,id=net0,br=${vm.bridge}` 44 - : setupNATNetworkArgs(vm.portForward), 45 - "-device", 46 - `e1000,netdev=net0,mac=${vm.macAddress}`, 47 - "-display", 48 - "none", 49 - "-vga", 50 - "none", 51 - "-monitor", 52 - "none", 53 - "-chardev", 54 - "stdio,id=con0,signal=off", 55 - "-serial", 56 - "chardev:con0", 57 - ..._.compact( 58 - vm.drivePath && [ 59 - "-drive", 60 - `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 61 - ], 62 - ), 63 - ]; 37 + const buildQemuArgs = (vm: VirtualMachine) => [ 38 + ..._.compact([vm.bridge && "qemu-system-x86_64"]), 39 + ...Deno.build.os === "linux" ? ["-enable-kvm"] : [], 40 + "-cpu", 41 + vm.cpu, 42 + "-m", 43 + vm.memory, 44 + "-smp", 45 + vm.cpus.toString(), 46 + ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]), 47 + "-netdev", 48 + vm.bridge 49 + ? `bridge,id=net0,br=${vm.bridge}` 50 + : setupNATNetworkArgs(vm.portForward), 51 + "-device", 52 + `e1000,netdev=net0,mac=${vm.macAddress}`, 53 + "-display", 54 + "none", 55 + "-vga", 56 + "none", 57 + "-monitor", 58 + "none", 59 + "-chardev", 60 + "stdio,id=con0,signal=off", 61 + "-serial", 62 + "chardev:con0", 63 + ..._.compact( 64 + vm.drivePath && [ 65 + "-drive", 66 + `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 67 + ], 68 + ), 69 + ]; 64 70 65 - const fullCommand = vm.bridge 71 + const buildQemuCommand = (vm: VirtualMachine, logPath: string) => { 72 + const qemuArgs = buildQemuArgs(vm); 73 + return vm.bridge 66 74 ? `sudo qemu-system-x86_64 ${ 67 75 qemuArgs.slice(1).join(" ") 68 76 } >> "${logPath}" 2>&1 & echo $!` 69 77 : `qemu-system-x86_64 ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 78 + }; 70 79 71 - const cmd = new Deno.Command("sh", { 72 - args: ["-c", fullCommand], 73 - stdin: "null", 74 - stdout: "piped", 80 + const startQemuProcess = (fullCommand: string) => 81 + Effect.tryPromise({ 82 + try: async () => { 83 + const cmd = new Deno.Command("sh", { 84 + args: ["-c", fullCommand], 85 + stdin: "null", 86 + stdout: "piped", 87 + }); 88 + 89 + const { stdout } = await cmd.spawn().output(); 90 + return parseInt(new TextDecoder().decode(stdout).trim(), 10); 91 + }, 92 + catch: (cause) => new Error(`Failed to start QEMU: ${cause}`), 75 93 }); 76 94 77 - const { stdout } = await cmd.spawn().output(); 78 - const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 95 + const markInstanceAsRunning = (vm: VirtualMachine, qemuPid: number) => 96 + updateInstanceState(vm.id, "RUNNING", qemuPid); 79 97 80 - await new Promise((resolve) => setTimeout(resolve, 2000)); 98 + const logRestartSuccess = ( 99 + vm: VirtualMachine, 100 + qemuPid: number, 101 + logPath: string, 102 + ) => 103 + Effect.sync(() => { 104 + console.log( 105 + `${chalk.greenBright(vm.name)} restarted with PID ${ 106 + chalk.greenBright(qemuPid) 107 + }.`, 108 + ); 109 + console.log( 110 + `Logs are being written to ${chalk.blueBright(logPath)}`, 111 + ); 112 + }); 81 113 82 - await updateInstanceState(vm.id, "RUNNING", qemuPid); 114 + const startVirtualMachineProcess = (vm: VirtualMachine) => { 115 + const logPath = `${LOGS_DIR}/${vm.name}.log`; 116 + const fullCommand = buildQemuCommand(vm, logPath); 83 117 84 - console.log( 85 - `${chalk.greenBright(vm.name)} restarted with PID ${ 86 - chalk.greenBright(qemuPid) 87 - }.`, 118 + return pipe( 119 + startQemuProcess(fullCommand), 120 + Effect.flatMap((qemuPid) => 121 + pipe( 122 + waitForDelay(2000), 123 + Effect.flatMap(() => markInstanceAsRunning(vm, qemuPid)), 124 + Effect.flatMap(() => logRestartSuccess(vm, qemuPid, logPath)), 125 + ) 126 + ), 88 127 ); 89 - console.log( 90 - `Logs are being written to ${chalk.blueBright(logPath)}`, 128 + }; 129 + 130 + const restartVirtualMachine = (name: string) => 131 + pipe( 132 + getInstanceStateOrFail(name), 133 + Effect.flatMap((vm) => 134 + pipe( 135 + killQemuProcess(vm), 136 + Effect.flatMap(() => markInstanceAsStopped(vm)), 137 + Effect.flatMap(() => waitForDelay(2000)), 138 + Effect.flatMap(() => createLogsDirectory()), 139 + Effect.flatMap(() => startVirtualMachineProcess(vm)), 140 + ) 141 + ), 91 142 ); 92 143 93 - await new Promise((resolve) => setTimeout(resolve, 2000)); 144 + export default async function (name: string) { 145 + const program = pipe( 146 + restartVirtualMachine(name), 147 + Effect.catchTags({ 148 + InstanceNotFoundError: (_error) => 149 + Effect.sync(() => { 150 + console.error( 151 + `Virtual machine with name or ID ${ 152 + chalk.greenBright(name) 153 + } not found.`, 154 + ); 155 + Deno.exit(1); 156 + }), 157 + DatabaseQueryError: (error) => 158 + Effect.sync(() => { 159 + console.error(`Database error: ${error.message}`); 160 + Deno.exit(1); 161 + }), 162 + DatabaseUpdateError: (error) => 163 + Effect.sync(() => { 164 + console.error(`Failed to update database: ${error.message}`); 165 + Deno.exit(1); 166 + }), 167 + }), 168 + Effect.catchAll((error) => 169 + Effect.sync(() => { 170 + console.error(`Error: ${error.message}`); 171 + Deno.exit(1); 172 + }) 173 + ), 174 + ); 94 175 176 + await Effect.runPromise(program); 177 + 178 + await new Promise((resolve) => setTimeout(resolve, 2000)); 95 179 Deno.exit(0); 96 180 }
+38 -10
src/subcommands/rm.ts
··· 1 - import { getInstanceState, removeInstanceState } from "../state.ts"; 1 + import { Effect, pipe } from "effect"; 2 + import { getInstanceStateOrFail, removeInstanceState } from "../state.ts"; 3 + 4 + const removeVirtualMachine = (name: string) => 5 + pipe( 6 + getInstanceStateOrFail(name), 7 + Effect.flatMap((vm) => { 8 + console.log(`Removing virtual machine ${vm.name} (ID: ${vm.id})...`); 9 + return removeInstanceState(name); 10 + }), 11 + ); 2 12 3 13 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 - ); 9 - Deno.exit(1); 10 - } 14 + const program = pipe( 15 + removeVirtualMachine(name), 16 + Effect.catchTags({ 17 + InstanceNotFoundError: (_error) => 18 + Effect.sync(() => { 19 + console.error(`Virtual machine with name or ID ${name} not found.`); 20 + Deno.exit(1); 21 + }), 22 + DatabaseQueryError: (error) => 23 + Effect.sync(() => { 24 + console.error(`Database error: ${error.message}`); 25 + Deno.exit(1); 26 + }), 27 + DatabaseDeleteError: (error) => 28 + Effect.sync(() => { 29 + console.error(`Failed to delete from database: ${error.message}`); 30 + Deno.exit(1); 31 + }), 32 + }), 33 + Effect.catchAll((error) => 34 + Effect.sync(() => { 35 + console.error(`Error: ${String(error)}`); 36 + Deno.exit(1); 37 + }) 38 + ), 39 + ); 11 40 12 - console.log(`Removing virtual machine ${vm.name} (ID: ${vm.id})...`); 13 - await removeInstanceState(name); 41 + await Effect.runPromise(program); 14 42 }
+175 -80
src/subcommands/start.ts
··· 1 1 import { parseFlags } from "@cliffy/flags"; 2 2 import _ from "@es-toolkit/es-toolkit/compat"; 3 + import { Effect, pipe } from "effect"; 3 4 import { LOGS_DIR } from "../constants.ts"; 4 5 import type { VirtualMachine } from "../db.ts"; 5 - import { getInstanceState, updateInstanceState } from "../state.ts"; 6 + import { getInstanceStateOrFail, updateInstanceState } from "../state.ts"; 6 7 import { 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 + const logStartingMessage = (vm: VirtualMachine) => 10 + Effect.sync(() => { 11 + console.log(`Starting virtual machine ${vm.name} (ID: ${vm.id})...`); 12 + }); 16 13 17 - console.log(`Starting virtual machine ${vm.name} (ID: ${vm.id})...`); 14 + const buildQemuArgs = (vm: VirtualMachine) => [ 15 + ..._.compact([vm.bridge && "qemu-system-x86_64"]), 16 + ...Deno.build.os === "linux" ? ["-enable-kvm"] : [], 17 + "-cpu", 18 + vm.cpu, 19 + "-m", 20 + vm.memory, 21 + "-smp", 22 + vm.cpus.toString(), 23 + ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]), 24 + "-netdev", 25 + vm.bridge 26 + ? `bridge,id=net0,br=${vm.bridge}` 27 + : setupNATNetworkArgs(vm.portForward), 28 + "-device", 29 + `e1000,netdev=net0,mac=${vm.macAddress}`, 30 + "-display", 31 + "none", 32 + "-vga", 33 + "none", 34 + "-monitor", 35 + "none", 36 + "-chardev", 37 + "stdio,id=con0,signal=off", 38 + "-serial", 39 + "chardev:con0", 40 + ..._.compact( 41 + vm.drivePath && [ 42 + "-drive", 43 + `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 44 + ], 45 + ), 46 + ]; 18 47 19 - vm = mergeFlags(vm); 48 + const createLogsDirectory = () => 49 + Effect.tryPromise({ 50 + try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), 51 + catch: (cause) => new Error(`Failed to create logs directory: ${cause}`), 52 + }); 20 53 21 - const qemuArgs = [ 22 - ..._.compact([vm.bridge && "qemu-system-x86_64"]), 23 - ...Deno.build.os === "linux" ? ["-enable-kvm"] : [], 24 - "-cpu", 25 - vm.cpu, 26 - "-m", 27 - vm.memory, 28 - "-smp", 29 - vm.cpus.toString(), 30 - ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]), 31 - "-netdev", 32 - vm.bridge 33 - ? `bridge,id=net0,br=${vm.bridge}` 34 - : setupNATNetworkArgs(vm.portForward), 35 - "-device", 36 - `e1000,netdev=net0,mac=${vm.macAddress}`, 37 - "-display", 38 - "none", 39 - "-vga", 40 - "none", 41 - "-monitor", 42 - "none", 43 - "-chardev", 44 - "stdio,id=con0,signal=off", 45 - "-serial", 46 - "chardev:con0", 47 - ..._.compact( 48 - vm.drivePath && [ 49 - "-drive", 50 - `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 51 - ], 52 - ), 53 - ]; 54 + const buildDetachedCommand = ( 55 + vm: VirtualMachine, 56 + qemuArgs: string[], 57 + logPath: string, 58 + ) => 59 + vm.bridge 60 + ? `sudo qemu-system-x86_64 ${ 61 + qemuArgs.slice(1).join(" ") 62 + } >> "${logPath}" 2>&1 & echo $!` 63 + : `qemu-system-x86_64 ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 54 64 55 - if (detach) { 56 - await Deno.mkdir(LOGS_DIR, { recursive: true }); 57 - const logPath = `${LOGS_DIR}/${vm.name}.log`; 65 + const startDetachedQemu = (fullCommand: string) => 66 + Effect.tryPromise({ 67 + try: async () => { 68 + const cmd = new Deno.Command("sh", { 69 + args: ["-c", fullCommand], 70 + stdin: "null", 71 + stdout: "piped", 72 + }); 58 73 59 - const fullCommand = vm.bridge 60 - ? `sudo qemu-system-x86_64 ${ 61 - qemuArgs.slice(1).join(" ") 62 - } >> "${logPath}" 2>&1 & echo $!` 63 - : `qemu-system-x86_64 ${ 64 - qemuArgs.join(" ") 65 - } >> "${logPath}" 2>&1 & echo $!`; 74 + const { stdout } = await cmd.spawn().output(); 75 + return parseInt(new TextDecoder().decode(stdout).trim(), 10); 76 + }, 77 + catch: (cause) => new Error(`Failed to start QEMU: ${cause}`), 78 + }); 66 79 67 - const cmd = new Deno.Command("sh", { 68 - args: ["-c", fullCommand], 69 - stdin: "null", 70 - stdout: "piped", 71 - }); 72 - 73 - const { stdout } = await cmd.spawn().output(); 74 - const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 75 - 76 - await updateInstanceState(name, "RUNNING", qemuPid); 77 - 80 + const logDetachedSuccess = ( 81 + vm: VirtualMachine, 82 + qemuPid: number, 83 + logPath: string, 84 + ) => 85 + Effect.sync(() => { 78 86 console.log( 79 87 `Virtual machine ${vm.name} started in background (PID: ${qemuPid})`, 80 88 ); 81 89 console.log(`Logs will be written to: ${logPath}`); 82 - 83 - // Exit successfully while keeping VM running in background 84 90 Deno.exit(0); 85 - } else { 86 - const cmd = new Deno.Command(vm.bridge ? "sudo" : "qemu-system-x86_64", { 87 - args: qemuArgs, 88 - stdin: "inherit", 89 - stdout: "inherit", 90 - stderr: "inherit", 91 - }); 91 + }); 92 92 93 - const child = cmd.spawn(); 94 - await updateInstanceState(name, "RUNNING", child.pid); 93 + const startVirtualMachineDetached = (name: string, vm: VirtualMachine) => { 94 + const qemuArgs = buildQemuArgs(vm); 95 + const logPath = `${LOGS_DIR}/${vm.name}.log`; 96 + const fullCommand = buildDetachedCommand(vm, qemuArgs, logPath); 95 97 96 - const status = await child.status; 98 + return pipe( 99 + createLogsDirectory(), 100 + Effect.flatMap(() => startDetachedQemu(fullCommand)), 101 + Effect.flatMap((qemuPid) => 102 + pipe( 103 + updateInstanceState(name, "RUNNING", qemuPid), 104 + Effect.flatMap(() => logDetachedSuccess(vm, qemuPid, logPath)), 105 + ) 106 + ), 107 + ); 108 + }; 109 + 110 + const startAttachedQemu = ( 111 + name: string, 112 + vm: VirtualMachine, 113 + qemuArgs: string[], 114 + ) => 115 + Effect.tryPromise({ 116 + try: async () => { 117 + const cmd = new Deno.Command( 118 + vm.bridge ? "sudo" : "qemu-system-x86_64", 119 + { 120 + args: qemuArgs, 121 + stdin: "inherit", 122 + stdout: "inherit", 123 + stderr: "inherit", 124 + }, 125 + ); 126 + 127 + const child = cmd.spawn(); 128 + await Effect.runPromise( 129 + updateInstanceState(name, "RUNNING", child.pid), 130 + ); 131 + 132 + const status = await child.status; 133 + await Effect.runPromise( 134 + updateInstanceState(name, "STOPPED", child.pid), 135 + ); 97 136 98 - await updateInstanceState(name, "STOPPED", child.pid); 137 + return status; 138 + }, 139 + catch: (cause) => new Error(`Failed to run QEMU: ${cause}`), 140 + }); 99 141 142 + const validateQemuExit = (status: Deno.CommandStatus) => 143 + Effect.sync(() => { 100 144 if (!status.success) { 101 - Deno.exit(status.code); 145 + throw new Error(`QEMU exited with code ${status.code}`); 102 146 } 103 - } 147 + }); 148 + 149 + const startVirtualMachineAttached = (name: string, vm: VirtualMachine) => { 150 + const qemuArgs = buildQemuArgs(vm); 151 + 152 + return pipe( 153 + startAttachedQemu(name, vm, qemuArgs), 154 + Effect.flatMap(validateQemuExit), 155 + ); 156 + }; 157 + 158 + const startVirtualMachine = (name: string, detach: boolean = false) => 159 + pipe( 160 + getInstanceStateOrFail(name), 161 + Effect.flatMap((vm) => { 162 + const mergedVm = mergeFlags(vm); 163 + 164 + return pipe( 165 + logStartingMessage(mergedVm), 166 + Effect.flatMap(() => 167 + detach 168 + ? startVirtualMachineDetached(name, mergedVm) 169 + : startVirtualMachineAttached(name, mergedVm) 170 + ), 171 + ); 172 + }), 173 + ); 174 + 175 + export default async function (name: string, detach: boolean = false) { 176 + const program = pipe( 177 + startVirtualMachine(name, detach), 178 + Effect.catchTags({ 179 + InstanceNotFoundError: (_error) => 180 + Effect.sync(() => { 181 + console.error(`Virtual machine with name or ID ${name} not found.`); 182 + Deno.exit(1); 183 + }), 184 + DatabaseQueryError: (error) => 185 + Effect.sync(() => { 186 + console.error(`Database error: ${error.message}`); 187 + Deno.exit(1); 188 + }), 189 + }), 190 + Effect.catchAll((error) => 191 + Effect.sync(() => { 192 + console.error(`Error: ${String(error)}`); 193 + Deno.exit(1); 194 + }) 195 + ), 196 + ); 197 + 198 + await Effect.runPromise(program); 104 199 } 105 200 106 201 function mergeFlags(vm: VirtualMachine): VirtualMachine {
+99 -31
src/subcommands/stop.ts
··· 1 1 import _ from "@es-toolkit/es-toolkit/compat"; 2 2 import chalk from "chalk"; 3 - import { getInstanceState, updateInstanceState } from "../state.ts"; 3 + import { Effect, pipe } from "effect"; 4 + import type { VirtualMachine } from "../db.ts"; 5 + import { getInstanceStateOrFail, 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.`, 7 + const logStoppingMessage = (vm: VirtualMachine) => 8 + Effect.sync(() => { 9 + console.log( 10 + `Stopping virtual machine ${chalk.greenBright(vm.name)} (ID: ${ 11 + chalk.greenBright(vm.id) 12 + })...`, 10 13 ); 11 - Deno.exit(1); 12 - } 14 + }); 15 + 16 + const buildKillCommand = (vm: VirtualMachine) => ({ 17 + command: vm.bridge ? "sudo" : "kill", 18 + args: [ 19 + ..._.compact([vm.bridge && "kill"]), 20 + "-TERM", 21 + vm.pid.toString(), 22 + ], 23 + }); 13 24 14 - console.log( 15 - `Stopping virtual machine ${chalk.greenBright(vm.name)} (ID: ${ 16 - chalk.greenBright(vm.id) 17 - })...`, 18 - ); 25 + const executeKillCommand = (vm: VirtualMachine) => { 26 + const { command, args } = buildKillCommand(vm); 27 + 28 + return Effect.tryPromise({ 29 + try: async () => { 30 + const cmd = new Deno.Command(command, { 31 + args, 32 + stdin: "inherit", 33 + stdout: "inherit", 34 + stderr: "inherit", 35 + }); 19 36 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", 37 + return await cmd.spawn().status; 38 + }, 39 + catch: (cause) => new Error(`Failed to execute kill command: ${cause}`), 29 40 }); 41 + }; 30 42 31 - const status = await cmd.spawn().status; 43 + const validateKillResult = (vm: VirtualMachine, status: Deno.CommandStatus) => 44 + Effect.sync(() => { 45 + if (!status.success) { 46 + throw new Error(`Failed to stop virtual machine ${vm.name}`); 47 + } 48 + return vm; 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 markInstanceAsStopped = (vm: VirtualMachine) => 52 + updateInstanceState(vm.name, "STOPPED"); 53 + 54 + const logStopSuccess = (vm: VirtualMachine) => 55 + Effect.sync(() => { 56 + console.log(`Virtual machine ${chalk.greenBright(vm.name)} stopped.`); 57 + }); 58 + 59 + const stopVirtualMachineProcess = (vm: VirtualMachine) => 60 + pipe( 61 + executeKillCommand(vm), 62 + Effect.flatMap((status) => validateKillResult(vm, status)), 63 + Effect.flatMap(() => markInstanceAsStopped(vm)), 64 + Effect.flatMap(() => logStopSuccess(vm)), 65 + ); 66 + 67 + const stopVirtualMachine = (name: string) => 68 + pipe( 69 + getInstanceStateOrFail(name), 70 + Effect.flatMap((vm) => 71 + pipe( 72 + logStoppingMessage(vm), 73 + Effect.flatMap(() => stopVirtualMachineProcess(vm)), 74 + ) 75 + ), 76 + ); 39 77 40 - await updateInstanceState(vm.name, "STOPPED"); 78 + export default async function (name: string) { 79 + const program = pipe( 80 + stopVirtualMachine(name), 81 + Effect.catchTags({ 82 + InstanceNotFoundError: (_error) => 83 + Effect.sync(() => { 84 + console.error( 85 + `Virtual machine with name or ID ${ 86 + chalk.greenBright(name) 87 + } not found.`, 88 + ); 89 + Deno.exit(1); 90 + }), 91 + DatabaseQueryError: (error) => 92 + Effect.sync(() => { 93 + console.error(`Database error: ${error.message}`); 94 + Deno.exit(1); 95 + }), 96 + DatabaseUpdateError: (error) => 97 + Effect.sync(() => { 98 + console.error(`Failed to update database: ${error.message}`); 99 + Deno.exit(1); 100 + }), 101 + }), 102 + Effect.catchAll((error) => 103 + Effect.sync(() => { 104 + console.error(`Error: ${error.message}`); 105 + Deno.exit(1); 106 + }) 107 + ), 108 + ); 41 109 42 - console.log(`Virtual machine ${chalk.greenBright(vm.name)} stopped.`); 110 + await Effect.runPromise(program); 43 111 }
+405 -232
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, pipe } 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"; 7 8 import { saveInstanceState, updateInstanceState } from "./state.ts"; 8 9 10 + export class FileSystemError extends Data.TaggedError("FileSystemError")<{ 11 + cause: unknown; 12 + message: string; 13 + }> {} 14 + 15 + export class CommandExecutionError 16 + extends Data.TaggedError("CommandExecutionError")<{ 17 + cause: unknown; 18 + message: string; 19 + exitCode?: number; 20 + }> {} 21 + 22 + export class ProcessKillError extends Data.TaggedError("ProcessKillError")<{ 23 + cause: unknown; 24 + message: string; 25 + pid: number; 26 + }> {} 27 + 9 28 const DEFAULT_VERSION = "6.4.2"; 10 29 11 30 export interface Options { ··· 21 40 detach?: boolean; 22 41 } 23 42 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 - }); 30 - 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 - } 43 + const du = (path: string) => 44 + Effect.tryPromise({ 45 + try: async () => { 46 + const cmd = new Deno.Command("du", { 47 + args: [path], 48 + stdout: "piped", 49 + stderr: "inherit", 50 + }); 36 51 37 - export async function emptyDiskImage(path: string): Promise<boolean> { 38 - if (!await Deno.stat(path).catch(() => false)) { 39 - return true; 40 - } 52 + const { stdout } = await cmd.spawn().output(); 53 + const output = new TextDecoder().decode(stdout).trim(); 54 + const size = parseInt(output.split("\t")[0], 10); 55 + return size; 56 + }, 57 + catch: (cause) => 58 + new CommandExecutionError({ 59 + cause, 60 + message: `Failed to get disk usage for path: ${path}`, 61 + }), 62 + }); 41 63 42 - const size = await du(path); 43 - return size < EMPTY_DISK_THRESHOLD_KB; 44 - } 64 + export const emptyDiskImage = (path: string) => 65 + pipe( 66 + Effect.tryPromise({ 67 + try: () => Deno.stat(path), 68 + catch: () => 69 + new FileSystemError({ 70 + cause: undefined, 71 + message: `File does not exist: ${path}`, 72 + }), 73 + }), 74 + Effect.catchAll(() => Effect.succeed(true)), // File doesn't exist, consider it empty 75 + Effect.flatMap((exists) => { 76 + if (exists === true) { 77 + return Effect.succeed(true); 78 + } 79 + return pipe( 80 + du(path), 81 + Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB), 82 + ); 83 + }), 84 + ); 45 85 46 - export async function downloadIso( 47 - url: string, 48 - options: Options, 49 - ): Promise<string | null> { 86 + export const downloadIso = (url: string, options: Options) => { 50 87 const filename = url.split("/").pop()!; 51 88 const outputPath = options.output ?? filename; 52 89 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) { 56 - console.log( 57 - chalk.yellowBright( 58 - `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, 59 - ), 60 - ); 61 - return null; 62 - } 63 - } 90 + return Effect.tryPromise({ 91 + try: async () => { 92 + // Check if image exists and is not empty 93 + if (options.image) { 94 + try { 95 + await Deno.stat(options.image); 96 + const driveSize = await Effect.runPromise(du(options.image)); 97 + if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 98 + console.log( 99 + chalk.yellowBright( 100 + `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, 101 + ), 102 + ); 103 + return null; 104 + } 105 + } catch { 106 + // Image doesn't exist, continue 107 + } 108 + } 64 109 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 - } 110 + // Check if output file already exists 111 + try { 112 + await Deno.stat(outputPath); 113 + console.log( 114 + chalk.yellowBright( 115 + `File ${outputPath} already exists, skipping download.`, 116 + ), 117 + ); 118 + return outputPath; 119 + } catch { 120 + // File doesn't exist, proceed with download 121 + } 73 122 74 - const cmd = new Deno.Command("curl", { 75 - args: ["-L", "-o", outputPath, url], 76 - stdin: "inherit", 77 - stdout: "inherit", 78 - stderr: "inherit", 79 - }); 123 + // Download the file 124 + const cmd = new Deno.Command("curl", { 125 + args: ["-L", "-o", outputPath, url], 126 + stdin: "inherit", 127 + stdout: "inherit", 128 + stderr: "inherit", 129 + }); 80 130 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 - } 131 + const status = await cmd.spawn().status; 132 + if (!status.success) { 133 + throw new Error(`Download failed with exit code ${status.code}`); 134 + } 86 135 87 - console.log(chalk.greenBright(`Downloaded ISO to ${outputPath}`)); 88 - return outputPath; 89 - } 136 + console.log(chalk.greenBright(`Downloaded ISO to ${outputPath}`)); 137 + return outputPath; 138 + }, 139 + catch: (cause) => 140 + new CommandExecutionError({ 141 + cause, 142 + message: `Failed to download ISO from ${url}`, 143 + }), 144 + }); 145 + }; 90 146 91 147 export function constructDownloadUrl(version: string): string { 92 148 return `https://mirror-master.dragonflybsd.org/iso-images/dfly-x86_64-${version}_REL.iso`; ··· 114 170 return `user,id=net0,${portForwarding}`; 115 171 } 116 172 117 - export async function runQemu( 173 + const buildQemuArgs = ( 118 174 isoPath: string | null, 119 175 options: Options, 120 - ): Promise<void> { 121 - const macAddress = generateRandomMacAddress(); 176 + macAddress: string, 177 + ) => [ 178 + ..._.compact([options.bridge && "qemu-system-x86_64"]), 179 + ...Deno.build.os === "linux" ? ["-enable-kvm"] : [], 180 + "-cpu", 181 + options.cpu, 182 + "-m", 183 + options.memory, 184 + "-smp", 185 + options.cpus.toString(), 186 + ..._.compact([isoPath && "-cdrom", isoPath]), 187 + "-netdev", 188 + options.bridge 189 + ? `bridge,id=net0,br=${options.bridge}` 190 + : setupNATNetworkArgs(options.portForward), 191 + "-device", 192 + `e1000,netdev=net0,mac=${macAddress}`, 193 + "-display", 194 + "none", 195 + "-vga", 196 + "none", 197 + "-monitor", 198 + "none", 199 + "-chardev", 200 + "stdio,id=con0,signal=off", 201 + "-serial", 202 + "chardev:con0", 203 + ..._.compact( 204 + options.image && [ 205 + "-drive", 206 + `file=${options.image},format=${options.diskFormat},if=virtio`, 207 + ], 208 + ), 209 + ]; 122 210 123 - const qemuArgs = [ 124 - ..._.compact([options.bridge && "qemu-system-x86_64"]), 125 - ...Deno.build.os === "linux" ? ["-enable-kvm"] : [], 126 - "-cpu", 127 - options.cpu, 128 - "-m", 129 - options.memory, 130 - "-smp", 131 - options.cpus.toString(), 132 - ..._.compact([isoPath && "-cdrom", isoPath]), 133 - "-netdev", 134 - options.bridge 135 - ? `bridge,id=net0,br=${options.bridge}` 136 - : setupNATNetworkArgs(options.portForward), 137 - "-device", 138 - `e1000,netdev=net0,mac=${macAddress}`, 139 - "-display", 140 - "none", 141 - "-vga", 142 - "none", 143 - "-monitor", 144 - "none", 145 - "-chardev", 146 - "stdio,id=con0,signal=off", 147 - "-serial", 148 - "chardev:con0", 149 - ..._.compact( 150 - options.image && [ 151 - "-drive", 152 - `file=${options.image},format=${options.diskFormat},if=virtio`, 153 - ], 154 - ), 155 - ]; 211 + const createVMInstance = ( 212 + name: string, 213 + isoPath: string | null, 214 + options: Options, 215 + macAddress: string, 216 + pid: number, 217 + ) => ({ 218 + id: createId(), 219 + name, 220 + bridge: options.bridge, 221 + macAddress, 222 + memory: options.memory, 223 + cpus: options.cpus, 224 + cpu: options.cpu, 225 + diskSize: options.size, 226 + diskFormat: options.diskFormat, 227 + portForward: options.portForward, 228 + isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 229 + drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 230 + version: DEFAULT_VERSION, 231 + status: "RUNNING" as const, 232 + pid, 233 + }); 156 234 157 - const name = Moniker.choose(); 235 + const runDetachedQemu = ( 236 + name: string, 237 + isoPath: string | null, 238 + options: Options, 239 + macAddress: string, 240 + qemuArgs: string[], 241 + ) => 242 + pipe( 243 + Effect.tryPromise({ 244 + try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), 245 + catch: (cause) => 246 + new FileSystemError({ 247 + cause, 248 + message: "Failed to create logs directory", 249 + }), 250 + }), 251 + Effect.flatMap(() => { 252 + const logPath = `${LOGS_DIR}/${name}.log`; 253 + const fullCommand = options.bridge 254 + ? `sudo qemu-system-x86_64 ${ 255 + qemuArgs.slice(1).join(" ") 256 + } >> "${logPath}" 2>&1 & echo $!` 257 + : `qemu-system-x86_64 ${ 258 + qemuArgs.join(" ") 259 + } >> "${logPath}" 2>&1 & echo $!`; 158 260 159 - if (options.detach) { 160 - await Deno.mkdir(LOGS_DIR, { recursive: true }); 161 - const logPath = `${LOGS_DIR}/${name}.log`; 261 + return pipe( 262 + Effect.tryPromise({ 263 + try: async () => { 264 + const cmd = new Deno.Command("sh", { 265 + args: ["-c", fullCommand], 266 + stdin: "null", 267 + stdout: "piped", 268 + }); 162 269 163 - const fullCommand = options.bridge 164 - ? `sudo qemu-system-x86_64 ${ 165 - qemuArgs.slice(1).join(" ") 166 - } >> "${logPath}" 2>&1 & echo $!` 167 - : `qemu-system-x86_64 ${ 168 - qemuArgs.join(" ") 169 - } >> "${logPath}" 2>&1 & echo $!`; 270 + const { stdout } = await cmd.spawn().output(); 271 + return parseInt(new TextDecoder().decode(stdout).trim(), 10); 272 + }, 273 + catch: (cause) => 274 + new CommandExecutionError({ 275 + cause, 276 + message: "Failed to start detached QEMU process", 277 + }), 278 + }), 279 + Effect.flatMap((qemuPid) => 280 + pipe( 281 + saveInstanceState( 282 + createVMInstance(name, isoPath, options, macAddress, qemuPid), 283 + ), 284 + Effect.flatMap(() => 285 + Effect.sync(() => { 286 + console.log( 287 + `Virtual machine ${name} started in background (PID: ${qemuPid})`, 288 + ); 289 + console.log(`Logs will be written to: ${logPath}`); 290 + Deno.exit(0); 291 + }) 292 + ), 293 + ) 294 + ), 295 + ); 296 + }), 297 + ); 170 298 171 - const cmd = new Deno.Command("sh", { 172 - args: ["-c", fullCommand], 173 - stdin: "null", 174 - stdout: "piped", 175 - }); 299 + const runAttachedQemu = ( 300 + name: string, 301 + isoPath: string | null, 302 + options: Options, 303 + macAddress: string, 304 + qemuArgs: string[], 305 + ) => 306 + Effect.tryPromise({ 307 + try: async () => { 308 + const cmd = new Deno.Command( 309 + options.bridge ? "sudo" : "qemu-system-x86_64", 310 + { 311 + args: qemuArgs, 312 + stdin: "inherit", 313 + stdout: "inherit", 314 + stderr: "inherit", 315 + }, 316 + ).spawn(); 176 317 177 - const { stdout } = await cmd.spawn().output(); 178 - const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 318 + await Effect.runPromise( 319 + saveInstanceState( 320 + createVMInstance(name, isoPath, options, macAddress, cmd.pid), 321 + ), 322 + ); 179 323 180 - await saveInstanceState({ 181 - id: createId(), 182 - name, 183 - bridge: options.bridge, 184 - macAddress, 185 - memory: options.memory, 186 - cpus: options.cpus, 187 - cpu: options.cpu, 188 - diskSize: options.size, 189 - diskFormat: options.diskFormat, 190 - portForward: options.portForward, 191 - isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 192 - drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 193 - version: DEFAULT_VERSION, 194 - status: "RUNNING", 195 - pid: qemuPid, 196 - }); 324 + const status = await cmd.status; 325 + await Effect.runPromise(updateInstanceState(name, "STOPPED")); 197 326 198 - console.log( 199 - `Virtual machine ${name} started in background (PID: ${qemuPid})`, 200 - ); 201 - console.log(`Logs will be written to: ${logPath}`); 327 + if (!status.success) { 328 + throw new Error(`QEMU exited with code ${status.code}`); 329 + } 330 + }, 331 + catch: (cause) => 332 + new CommandExecutionError({ 333 + cause, 334 + message: "Failed to run attached QEMU process", 335 + }), 336 + }); 202 337 203 - // Exit successfully while keeping VM running in background 204 - Deno.exit(0); 205 - } else { 206 - const cmd = new Deno.Command( 207 - options.bridge ? "sudo" : "qemu-system-x86_64", 208 - { 209 - args: qemuArgs, 210 - stdin: "inherit", 211 - stdout: "inherit", 212 - stderr: "inherit", 213 - }, 214 - ) 215 - .spawn(); 216 - 217 - await saveInstanceState({ 218 - id: createId(), 219 - name, 220 - bridge: options.bridge, 221 - macAddress, 222 - memory: options.memory, 223 - cpus: options.cpus, 224 - cpu: options.cpu, 225 - diskSize: options.size, 226 - diskFormat: options.diskFormat, 227 - portForward: options.portForward, 228 - isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 229 - drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 230 - version: DEFAULT_VERSION, 231 - status: "RUNNING", 232 - pid: cmd.pid, 233 - }); 234 - 235 - const status = await cmd.status; 338 + export const runQemu = (isoPath: string | null, options: Options) => { 339 + const macAddress = generateRandomMacAddress(); 340 + const name = Moniker.choose(); 341 + const qemuArgs = buildQemuArgs(isoPath, options, macAddress); 236 342 237 - await updateInstanceState(name, "STOPPED"); 238 - 239 - if (!status.success) { 240 - Deno.exit(status.code); 241 - } 242 - } 243 - } 343 + return options.detach 344 + ? runDetachedQemu(name, isoPath, options, macAddress, qemuArgs) 345 + : runAttachedQemu(name, isoPath, options, macAddress, qemuArgs); 346 + }; 244 347 245 348 export function handleInput(input?: string): string { 246 349 if (!input) { ··· 266 369 return input; 267 370 } 268 371 269 - export async function safeKillQemu( 270 - pid: number, 271 - useSudo: boolean = false, 272 - ): Promise<boolean> { 273 - const killArgs = useSudo 274 - ? ["sudo", "kill", "-TERM", pid.toString()] 275 - : ["kill", "-TERM", pid.toString()]; 276 - 277 - const termCmd = new Deno.Command(killArgs[0], { 278 - args: killArgs.slice(1), 279 - stdout: "null", 280 - stderr: "null", 372 + const executeKillCommand = (args: string[]) => 373 + Effect.tryPromise({ 374 + try: async () => { 375 + const cmd = new Deno.Command(args[0], { 376 + args: args.slice(1), 377 + stdout: "null", 378 + stderr: "null", 379 + }); 380 + return await cmd.spawn().status; 381 + }, 382 + catch: (cause) => 383 + new CommandExecutionError({ 384 + cause, 385 + message: `Failed to execute kill command: ${args.join(" ")}`, 386 + }), 281 387 }); 282 388 283 - const termStatus = await termCmd.spawn().status; 284 - 285 - if (termStatus.success) { 286 - await new Promise((resolve) => setTimeout(resolve, 3000)); 389 + const waitForDelay = (ms: number) => 390 + Effect.tryPromise({ 391 + try: () => new Promise((resolve) => setTimeout(resolve, ms)), 392 + catch: () => new Error("Wait delay failed"), 393 + }); 287 394 288 - const checkCmd = new Deno.Command("kill", { 289 - args: ["-0", pid.toString()], 290 - stdout: "null", 291 - stderr: "null", 292 - }); 395 + const checkProcessAlive = (pid: number) => 396 + Effect.tryPromise({ 397 + try: async () => { 398 + const checkCmd = new Deno.Command("kill", { 399 + args: ["-0", pid.toString()], 400 + stdout: "null", 401 + stderr: "null", 402 + }); 403 + const status = await checkCmd.spawn().status; 404 + return status.success; // true if process exists, false if not 405 + }, 406 + catch: (cause) => 407 + new ProcessKillError({ 408 + cause, 409 + message: `Failed to check if process ${pid} is alive`, 410 + pid, 411 + }), 412 + }); 293 413 294 - const checkStatus = await checkCmd.spawn().status; 295 - if (!checkStatus.success) { 296 - return true; 297 - } 298 - } 414 + export const safeKillQemu = (pid: number, useSudo: boolean = false) => { 415 + const termArgs = useSudo 416 + ? ["sudo", "kill", "-TERM", pid.toString()] 417 + : ["kill", "-TERM", pid.toString()]; 299 418 300 - const killKillArgs = useSudo 419 + const killArgs = useSudo 301 420 ? ["sudo", "kill", "-KILL", pid.toString()] 302 421 : ["kill", "-KILL", pid.toString()]; 303 422 304 - const killCmd = new Deno.Command(killKillArgs[0], { 305 - args: killKillArgs.slice(1), 306 - stdout: "null", 307 - stderr: "null", 423 + return pipe( 424 + executeKillCommand(termArgs), 425 + Effect.flatMap((termStatus) => { 426 + if (termStatus.success) { 427 + return pipe( 428 + waitForDelay(3000), 429 + Effect.flatMap(() => checkProcessAlive(pid)), 430 + Effect.flatMap((isAlive) => { 431 + if (!isAlive) { 432 + return Effect.succeed(true); 433 + } 434 + // Process still alive, use KILL signal 435 + return pipe( 436 + executeKillCommand(killArgs), 437 + Effect.map((killStatus) => killStatus.success), 438 + ); 439 + }), 440 + ); 441 + } 442 + // TERM failed, try KILL directly 443 + return pipe( 444 + executeKillCommand(killArgs), 445 + Effect.map((killStatus) => killStatus.success), 446 + ); 447 + }), 448 + ); 449 + }; 450 + 451 + const checkDriveImageExists = (path: string) => 452 + Effect.tryPromise({ 453 + try: () => Deno.stat(path), 454 + catch: () => 455 + new FileSystemError({ 456 + cause: undefined, 457 + message: `Drive image does not exist: ${path}`, 458 + }), 308 459 }); 309 460 310 - const killStatus = await killCmd.spawn().status; 311 - return killStatus.success; 312 - } 461 + const createDriveImageFile = (path: string, format: string, size: string) => 462 + Effect.tryPromise({ 463 + try: async () => { 464 + const cmd = new Deno.Command("qemu-img", { 465 + args: ["create", "-f", format, path, size], 466 + stdin: "inherit", 467 + stdout: "inherit", 468 + stderr: "inherit", 469 + }); 313 470 314 - export async function createDriveImageIfNeeded( 315 - { 316 - image: path, 317 - diskFormat: format, 318 - size, 319 - }: Options, 320 - ): Promise<void> { 321 - if (await Deno.stat(path!).catch(() => false)) { 322 - console.log( 323 - chalk.yellowBright( 324 - `Drive image ${path} already exists, skipping creation.`, 325 - ), 326 - ); 327 - return; 328 - } 471 + const status = await cmd.spawn().status; 472 + if (!status.success) { 473 + throw new Error(`qemu-img create failed with exit code ${status.code}`); 474 + } 475 + return path; 476 + }, 477 + catch: (cause) => 478 + new CommandExecutionError({ 479 + cause, 480 + message: `Failed to create drive image at ${path}`, 481 + }), 482 + }); 329 483 330 - const cmd = new Deno.Command("qemu-img", { 331 - args: ["create", "-f", format, path!, size!], 332 - stdin: "inherit", 333 - stdout: "inherit", 334 - stderr: "inherit", 335 - }); 484 + export const createDriveImageIfNeeded = ( 485 + options: Pick<Options, "image" | "diskFormat" | "size">, 486 + ) => { 487 + const { image: path, diskFormat: format, size } = options; 336 488 337 - const status = await cmd.spawn().status; 338 - if (!status.success) { 339 - console.error(chalk.redBright("Failed to create drive image.")); 340 - Deno.exit(status.code); 489 + if (!path || !format || !size) { 490 + return Effect.fail( 491 + new Error("Missing required parameters: image, diskFormat, or size"), 492 + ); 341 493 } 342 494 343 - console.log(chalk.greenBright(`Created drive image at ${path}`)); 344 - } 495 + return pipe( 496 + checkDriveImageExists(path), 497 + Effect.flatMap(() => { 498 + console.log( 499 + chalk.yellowBright( 500 + `Drive image ${path} already exists, skipping creation.`, 501 + ), 502 + ); 503 + return Effect.succeed(undefined); 504 + }), 505 + Effect.catchAll(() => 506 + pipe( 507 + createDriveImageFile(path, format, size), 508 + Effect.flatMap((createdPath) => { 509 + console.log( 510 + chalk.greenBright(`Created drive image at ${createdPath}`), 511 + ); 512 + return Effect.succeed(undefined); 513 + }), 514 + ) 515 + ), 516 + ); 517 + };