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

feat: refactor error handling and improve image management functionality

+338 -268
+1 -1
main.ts
··· 9 9 import pkg from "./deno.json" with { type: "json" }; 10 10 import { initVmFile, mergeConfig, parseVmFile } from "./src/config.ts"; 11 11 import { CONFIG_FILE_NAME } from "./src/constants.ts"; 12 + import { NoSuchFileError } from "./src/errors.ts"; 12 13 import { getImage } from "./src/images.ts"; 13 14 import { constructCoreOSImageURL } from "./src/mod.ts"; 14 15 import { createBridgeNetworkIfNeeded } from "./src/network.ts"; ··· 46 47 extractXz, 47 48 fileExists, 48 49 isValidISOurl, 49 - NoSuchFileError, 50 50 type Options, 51 51 runQemu, 52 52 } from "./src/utils.ts";
+65 -10
src/api/images.ts
··· 1 + import { createId } from "@paralleldrive/cuid2"; 2 + import { Effect, pipe } from "effect"; 1 3 import { Hono } from "hono"; 2 - import { Effect, pipe } from "effect"; 3 - import { parseParams, presentation } from "./utils.ts"; 4 - import { getImage, listImages } from "../images.ts"; 4 + import type { VirtualMachine } from "../db.ts"; 5 + import { ImageNotFoundError, VmNotFoundError } from "../errors.ts"; 6 + import { deleteImage, getImage, listImages, saveImage } from "../images.ts"; 7 + import { getInstanceState } from "../state.ts"; 8 + import { du, extractTag } from "../utils.ts"; 9 + import { 10 + handleError, 11 + parseCreateImageRequest, 12 + parseParams, 13 + presentation, 14 + } from "./utils.ts"; 5 15 6 16 const app = new Hono(); 7 17 18 + const failIfNoVM = ([vm, tag]: [VirtualMachine | undefined, string]) => 19 + Effect.gen(function* () { 20 + if (!vm) { 21 + return yield* Effect.fail(new VmNotFoundError({ name: "unknown" })); 22 + } 23 + if (!vm.drivePath) { 24 + return yield* Effect.fail(new ImageNotFoundError({ id: "unknown" })); 25 + } 26 + 27 + const size = yield* du(vm.drivePath); 28 + 29 + return [vm, tag, size] as [VirtualMachine, string, number]; 30 + }); 31 + 8 32 app.get("/", (c) => 9 33 Effect.runPromise( 10 34 pipe( 11 35 listImages(), 12 36 presentation(c), 37 + Effect.catchAll((error) => handleError(error, c)), 13 38 ), 14 39 )); 15 40 ··· 19 44 parseParams(c), 20 45 Effect.flatMap(({ id }) => getImage(id)), 21 46 presentation(c), 47 + Effect.catchAll((error) => handleError(error, c)), 22 48 ), 23 49 )); 24 50 25 - app.post("/", (c) => { 26 - return c.json({ message: "New image created" }); 27 - }); 51 + app.post("/", (c) => 52 + Effect.runPromise( 53 + pipe( 54 + parseCreateImageRequest(c), 55 + Effect.flatMap(({ from, image }) => 56 + Effect.gen(function* () { 57 + return yield* pipe( 58 + Effect.all([getInstanceState(from), extractTag(image)]), 59 + Effect.flatMap(failIfNoVM), 60 + Effect.flatMap(([vm, tag, size]) => 61 + saveImage({ 62 + id: createId(), 63 + repository: image.split(":")[0], 64 + tag, 65 + size, 66 + path: vm.drivePath!, 67 + format: vm.diskFormat, 68 + }) 69 + ), 70 + Effect.flatMap(() => getImage(image)), 71 + ); 72 + }) 73 + ), 74 + presentation(c), 75 + Effect.catchAll((error) => handleError(error, c)), 76 + ), 77 + )); 28 78 29 - app.delete("/:id", (c) => { 30 - const { id } = c.req.param(); 31 - return c.json({ message: `Image with ID ${id} deleted` }); 32 - }); 79 + app.delete("/:id", (c) => 80 + Effect.runPromise( 81 + pipe( 82 + parseParams(c), 83 + Effect.flatMap(({ id }) => deleteImage(id)), 84 + presentation(c), 85 + Effect.catchAll((error) => handleError(error, c)), 86 + ), 87 + )); 33 88 34 89 export default app;
+2 -11
src/api/machines.ts
··· 1 1 import _ from "@es-toolkit/es-toolkit/compat"; 2 2 import { createId } from "@paralleldrive/cuid2"; 3 - import { Data, Effect, pipe } from "effect"; 3 + import { Effect, pipe } from "effect"; 4 4 import { Hono } from "hono"; 5 5 import Moniker from "moniker"; 6 6 import { SEED_DIR } from "../constants.ts"; 7 + import { ImageNotFoundError, RemoveRunningVmError } from "../errors.ts"; 7 8 import { getImage } from "../images.ts"; 8 9 import { DEFAULT_VERSION, getInstanceState } from "../mod.ts"; 9 10 import { generateRandomMacAddress } from "../network.ts"; ··· 32 33 parseStartRequest, 33 34 presentation, 34 35 } from "./utils.ts"; 35 - 36 - export class ImageNotFoundError extends Data.TaggedError("ImageNotFoundError")<{ 37 - id: string; 38 - }> {} 39 - 40 - export class RemoveRunningVmError extends Data.TaggedError( 41 - "RemoveRunningVmError", 42 - )<{ 43 - id: string; 44 - }> {} 45 36 46 37 const app = new Hono(); 47 38
+40 -11
src/api/utils.ts
··· 1 - import { Data, Effect } from "effect"; 1 + import { Effect } from "effect"; 2 2 import type { Context } from "hono"; 3 3 import type { Image, Volume } from "../db.ts"; 4 - import { VmAlreadyRunningError } from "../subcommands/start.ts"; 5 4 import { 6 5 type CommandError, 6 + ImageNotFoundError, 7 + ParseRequestError, 8 + RemoveRunningVmError, 7 9 StopCommandError, 10 + VmAlreadyRunningError, 8 11 VmNotFoundError, 9 - } from "../subcommands/stop.ts"; 12 + } from "../errors.ts"; 10 13 import { 11 14 MachineParamsSchema, 15 + NewImageSchema, 12 16 NewMachineSchema, 13 17 NewVolumeSchema, 14 18 } from "../types.ts"; 15 19 import { createVolume, getVolume } from "../volumes.ts"; 16 - import { FileSystemError, XorrisoError } from "../xorriso.ts"; 17 - import { ImageNotFoundError, RemoveRunningVmError } from "./machines.ts"; 20 + import type { FileSystemError, XorrisoError } from "../xorriso.ts"; 18 21 19 22 export const parseQueryParams = (c: Context) => Effect.succeed(c.req.query()); 20 23 21 24 export const parseParams = (c: Context) => Effect.succeed(c.req.param()); 22 25 23 - export const presentation = (c: Context) => 24 - Effect.flatMap((data) => Effect.succeed(c.json(data))); 26 + const convertBigIntToNumber = (obj: unknown): unknown => { 27 + if (typeof obj === "bigint") { 28 + return Number(obj); 29 + } 30 + if (Array.isArray(obj)) { 31 + return obj.map(convertBigIntToNumber); 32 + } 33 + if (obj !== null && typeof obj === "object") { 34 + return Object.fromEntries( 35 + Object.entries(obj).map(([key, value]) => [ 36 + key, 37 + convertBigIntToNumber(value), 38 + ]), 39 + ); 40 + } 41 + return obj; 42 + }; 25 43 26 - export class ParseRequestError extends Data.TaggedError("ParseRequestError")<{ 27 - cause?: unknown; 28 - message: string; 29 - }> {} 44 + export const presentation = (c: Context) => 45 + Effect.flatMap((data) => Effect.succeed(c.json(convertBigIntToNumber(data)))); 30 46 31 47 export const handleError = ( 32 48 error: ··· 121 137 try: async () => { 122 138 const body = await c.req.json(); 123 139 return NewMachineSchema.parse(body); 140 + }, 141 + catch: (error) => 142 + new ParseRequestError({ 143 + cause: error, 144 + message: error instanceof Error ? error.message : String(error), 145 + }), 146 + }); 147 + 148 + export const parseCreateImageRequest = (c: Context) => 149 + Effect.tryPromise({ 150 + try: async () => { 151 + const body = await c.req.json(); 152 + return NewImageSchema.parse(body); 124 153 }, 125 154 catch: (error) => 126 155 new ParseRequestError({
+7 -13
src/api/volumes.ts
··· 1 + import { Effect, pipe } from "effect"; 1 2 import { Hono } from "hono"; 2 - import { Effect, pipe } from "effect"; 3 + import { ImageNotFoundError } from "../errors.ts"; 4 + import { getImage } from "../images.ts"; 5 + import { listVolumes } from "../mod.ts"; 6 + import type { NewVolume } from "../types.ts"; 7 + import { deleteVolume, getVolume } from "../volumes.ts"; 3 8 import { 4 9 createVolumeIfNeeded, 5 10 handleError, ··· 7 12 parseParams, 8 13 presentation, 9 14 } from "./utils.ts"; 10 - import { listVolumes } from "../mod.ts"; 11 - import { deleteVolume, getVolume } from "../volumes.ts"; 12 - import type { NewVolume } from "../types.ts"; 13 - import { getImage } from "../images.ts"; 14 - import { ImageNotFoundError } from "./machines.ts"; 15 15 16 16 const app = new Hono(); 17 17 18 - app.get("/", (c) => 19 - Effect.runPromise( 20 - pipe( 21 - listVolumes(), 22 - presentation(c), 23 - ), 24 - )); 18 + app.get("/", (c) => Effect.runPromise(pipe(listVolumes(), presentation(c)))); 25 19 26 20 app.get("/:id", (c) => 27 21 Effect.runPromise(
+2 -5
src/config.ts
··· 2 2 import _ from "@es-toolkit/es-toolkit/compat"; 3 3 import * as toml from "@std/toml"; 4 4 import z from "@zod/zod"; 5 - import { Data, Effect } from "effect"; 5 + import { Effect } from "effect"; 6 6 import { UBUNTU_ISO_URL } from "./constants.ts"; 7 + import { VmConfigError } from "./errors.ts"; 7 8 import type { Options } from "./utils.ts"; 8 9 9 10 export const VmConfigSchema = z.object({ ··· 33 34 }); 34 35 35 36 export type VmConfig = z.infer<typeof VmConfigSchema>; 36 - 37 - class VmConfigError extends Data.TaggedError("VmConfigError")<{ 38 - cause?: string; 39 - }> {} 40 37 41 38 export const initVmFile = ( 42 39 path: string,
+115
src/errors.ts
··· 1 + import { Data } from "effect"; 2 + 3 + // API Errors 4 + export class ImageNotFoundError extends Data.TaggedError("ImageNotFoundError")<{ 5 + id: string; 6 + }> {} 7 + 8 + export class RemoveRunningVmError extends Data.TaggedError( 9 + "RemoveRunningVmError", 10 + )<{ 11 + id: string; 12 + }> {} 13 + 14 + export class ParseRequestError extends Data.TaggedError("ParseRequestError")<{ 15 + cause?: unknown; 16 + message: string; 17 + }> {} 18 + 19 + // Config Errors 20 + export class VmConfigError extends Data.TaggedError("VmConfigError")<{ 21 + cause?: string; 22 + }> {} 23 + 24 + // Volume Errors 25 + export class VolumeError extends Data.TaggedError("VolumeError")<{ 26 + message?: unknown; 27 + }> {} 28 + 29 + // ORAS/Image Registry Errors 30 + export class PushImageError extends Data.TaggedError("PushImageError")<{ 31 + cause?: unknown; 32 + }> {} 33 + 34 + export class PullImageError extends Data.TaggedError("PullImageError")<{ 35 + cause?: unknown; 36 + }> {} 37 + 38 + export class CreateDirectoryError extends Data.TaggedError( 39 + "CreateDirectoryError", 40 + )<{ 41 + cause?: unknown; 42 + }> {} 43 + 44 + export class ImageAlreadyPulledError extends Data.TaggedError( 45 + "ImageAlreadyPulledError", 46 + )<{ 47 + name: string; 48 + }> {} 49 + 50 + // Database Errors 51 + export class DbError extends Data.TaggedError("DatabaseError")<{ 52 + message?: string; 53 + cause?: unknown; 54 + }> {} 55 + 56 + export class DbQueryError extends Data.TaggedError("DbQueryError")<{ 57 + cause?: unknown; 58 + }> {} 59 + 60 + // Network Errors 61 + export class NetworkError extends Data.TaggedError("NetworkError")<{ 62 + cause?: unknown; 63 + }> {} 64 + 65 + export class BridgeSetupError extends Data.TaggedError("BridgeSetupError")<{ 66 + cause?: unknown; 67 + }> {} 68 + 69 + // VM Operation Errors 70 + export class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 71 + name: string; 72 + }> {} 73 + 74 + export class VmAlreadyRunningError extends Data.TaggedError( 75 + "VmAlreadyRunningError", 76 + )<{ 77 + name: string; 78 + }> {} 79 + 80 + export class StopCommandError extends Data.TaggedError("StopCommandError")<{ 81 + vmName: string; 82 + exitCode: number; 83 + message?: string; 84 + }> {} 85 + 86 + export class KillQemuError extends Data.TaggedError("KillQemuError")<{ 87 + vmName: string; 88 + }> {} 89 + 90 + export class CommandError extends Data.TaggedError("CommandError")<{ 91 + cause?: unknown; 92 + }> {} 93 + 94 + // Log Errors 95 + export class LogCommandError extends Data.TaggedError("LogCommandError")<{ 96 + vmName?: string; 97 + exitCode?: number; 98 + cause?: unknown; 99 + }> {} 100 + 101 + // Image/File Errors 102 + export class InvalidImageNameError extends Data.TaggedError( 103 + "InvalidImageNameError", 104 + )<{ 105 + image: string; 106 + cause?: unknown; 107 + }> {} 108 + 109 + export class NoSuchImageError extends Data.TaggedError("NoSuchImageError")<{ 110 + cause: string; 111 + }> {} 112 + 113 + export class NoSuchFileError extends Data.TaggedError("NoSuchFileError")<{ 114 + cause: string; 115 + }> {}
+22 -24
src/images.ts
··· 1 - import { Data, Effect } from "effect"; 1 + import { Effect } from "effect"; 2 2 import type { DeleteResult, InsertResult } from "kysely"; 3 3 import { ctx } from "./context.ts"; 4 4 import type { Image } from "./db.ts"; 5 - 6 - export class DbError extends Data.TaggedError("DatabaseError")<{ 7 - message?: string; 8 - }> {} 5 + import { DbError } from "./errors.ts"; 9 6 10 7 export const listImages = (): Effect.Effect<Image[], DbError, never> => 11 8 Effect.tryPromise({ ··· 46 43 ): Effect.Effect<InsertResult[], DbError, never> => 47 44 Effect.tryPromise({ 48 45 try: () => 49 - ctx.db.insertInto("images") 46 + ctx.db 47 + .insertInto("images") 50 48 .values(image) 51 49 .onConflict((oc) => 52 - oc 53 - .column("repository") 54 - .column("tag") 55 - .doUpdateSet({ 56 - size: image.size, 57 - path: image.path, 58 - format: image.format, 59 - digest: image.digest, 60 - }) 50 + oc.column("repository").column("tag").doUpdateSet({ 51 + size: image.size, 52 + path: image.path, 53 + format: image.format, 54 + digest: image.digest, 55 + }) 61 56 ) 62 57 .execute(), 63 58 catch: (error) => ··· 71 66 ): Effect.Effect<DeleteResult[], DbError, never> => 72 67 Effect.tryPromise({ 73 68 try: () => 74 - ctx.db.deleteFrom("images").where((eb) => 75 - eb.or([ 76 - eb.and([ 77 - eb("repository", "=", id.split(":")[0]), 78 - eb("tag", "=", id.split(":")[1] || "latest"), 79 - ]), 80 - eb("id", "=", id), 81 - ]) 82 - ).execute(), 69 + ctx.db 70 + .deleteFrom("images") 71 + .where((eb) => 72 + eb.or([ 73 + eb.and([ 74 + eb("repository", "=", id.split(":")[0]), 75 + eb("tag", "=", id.split(":")[1] || "latest"), 76 + ]), 77 + eb("id", "=", id), 78 + ]) 79 + ) 80 + .execute(), 83 81 catch: (error) => 84 82 new DbError({ 85 83 message: error instanceof Error ? error.message : String(error),
+3 -12
src/network.ts
··· 1 1 import chalk from "chalk"; 2 - import { Data, Effect } from "effect"; 3 - 4 - export class NetworkError extends Data.TaggedError("NetworkError")<{ 5 - cause?: unknown; 6 - }> {} 7 - 8 - export class BridgeSetupError extends Data.TaggedError("BridgeSetupError")<{ 9 - cause?: unknown; 10 - }> {} 2 + import { Effect } from "effect"; 3 + import { BridgeSetupError, NetworkError } from "./errors.ts"; 11 4 12 5 export const setupQemuBridge = (bridgeName: string) => 13 6 Effect.tryPromise({ ··· 61 54 catch: (error) => new BridgeSetupError({ cause: error }), 62 55 }); 63 56 64 - export const createBridgeNetworkIfNeeded = ( 65 - bridgeName: string, 66 - ) => 57 + export const createBridgeNetworkIfNeeded = (bridgeName: string) => 67 58 Effect.tryPromise({ 68 59 try: async () => { 69 60 const bridgeExistsCmd = new Deno.Command("ip", {
+6 -21
src/oras.ts
··· 1 1 import { createId } from "@paralleldrive/cuid2"; 2 2 import { basename, dirname } from "@std/path"; 3 3 import chalk from "chalk"; 4 - import { Data, Effect, pipe } from "effect"; 4 + import { Effect, pipe } from "effect"; 5 5 import { IMAGE_DIR } from "./constants.ts"; 6 + import { 7 + ImageAlreadyPulledError, 8 + PullImageError, 9 + PushImageError, 10 + } from "./errors.ts"; 6 11 import { getImage, saveImage } from "./images.ts"; 7 12 import { CONFIG_DIR, failOnMissingImage } from "./mod.ts"; 8 13 import { du, getCurrentArch } from "./utils.ts"; 9 14 10 15 const DEFAULT_ORAS_VERSION = "1.3.0"; 11 - 12 - export class PushImageError extends Data.TaggedError("PushImageError")<{ 13 - cause?: unknown; 14 - }> {} 15 - 16 - export class PullImageError extends Data.TaggedError("PullImageError")<{ 17 - cause?: unknown; 18 - }> {} 19 - 20 - export class CreateDirectoryError extends Data.TaggedError( 21 - "CreateDirectoryError", 22 - )<{ 23 - cause?: unknown; 24 - }> {} 25 - 26 - export class ImageAlreadyPulledError extends Data.TaggedError( 27 - "ImageAlreadyPulledError", 28 - )<{ 29 - name: string; 30 - }> {} 31 16 32 17 export async function setupOrasBinary(): Promise<void> { 33 18 Deno.env.set("PATH", `${CONFIG_DIR}/bin:${Deno.env.get("PATH")}`);
+16 -37
src/state.ts
··· 1 - import { Data, Effect } from "effect"; 1 + import { Effect } from "effect"; 2 2 import { ctx } from "./context.ts"; 3 3 import type { VirtualMachine } from "./db.ts"; 4 + import { DbError } from "./errors.ts"; 4 5 import type { STATUS } from "./types.ts"; 5 6 6 - export class DbError extends Data.TaggedError("DatabaseError")<{ 7 - cause?: unknown; 8 - }> {} 9 - 10 - export const saveInstanceState = ( 11 - vm: VirtualMachine, 12 - ) => 7 + export const saveInstanceState = (vm: VirtualMachine) => 13 8 Effect.tryPromise({ 14 - try: () => 15 - ctx.db.insertInto("virtual_machines") 16 - .values(vm) 17 - .execute(), 9 + try: () => ctx.db.insertInto("virtual_machines").values(vm).execute(), 18 10 catch: (error) => new DbError({ cause: error }), 19 11 }); 20 12 ··· 25 17 ) => 26 18 Effect.tryPromise({ 27 19 try: () => 28 - ctx.db.updateTable("virtual_machines") 20 + ctx.db 21 + .updateTable("virtual_machines") 29 22 .set({ 30 23 status, 31 24 pid, 32 25 updatedAt: new Date().toISOString(), 33 26 }) 34 - .where((eb) => 35 - eb.or([ 36 - eb("name", "=", name), 37 - eb("id", "=", name), 38 - ]) 39 - ) 27 + .where((eb) => eb.or([eb("name", "=", name), eb("id", "=", name)])) 40 28 .execute(), 41 29 catch: (error) => new DbError({ cause: error }), 42 30 }); 43 31 44 - export const removeInstanceState = ( 45 - name: string, 46 - ) => 32 + export const removeInstanceState = (name: string) => 47 33 Effect.tryPromise({ 48 34 try: () => 49 - ctx.db.deleteFrom("virtual_machines") 50 - .where((eb) => 51 - eb.or([ 52 - eb("name", "=", name), 53 - eb("id", "=", name), 54 - ]) 55 - ) 35 + ctx.db 36 + .deleteFrom("virtual_machines") 37 + .where((eb) => eb.or([eb("name", "=", name), eb("id", "=", name)])) 56 38 .execute(), 57 39 catch: (error) => new DbError({ cause: error }), 58 40 }); ··· 62 44 ): Effect.Effect<VirtualMachine | undefined, DbError, never> => 63 45 Effect.tryPromise({ 64 46 try: () => 65 - ctx.db.selectFrom("virtual_machines") 47 + ctx.db 48 + .selectFrom("virtual_machines") 66 49 .selectAll() 67 - .where((eb) => 68 - eb.or([ 69 - eb("name", "=", name), 70 - eb("id", "=", name), 71 - ]) 72 - ) 50 + .where((eb) => eb.or([eb("name", "=", name), eb("id", "=", name)])) 73 51 .executeTakeFirst(), 74 52 catch: (error) => new DbError({ cause: error }), 75 53 }); ··· 79 57 ): Effect.Effect<VirtualMachine[], DbError, never> => 80 58 Effect.tryPromise({ 81 59 try: () => 82 - ctx.db.selectFrom("virtual_machines") 60 + ctx.db 61 + .selectFrom("virtual_machines") 83 62 .selectAll() 84 63 .where((eb) => { 85 64 if (all) {
+4 -13
src/subcommands/inspect.ts
··· 1 - import { Data, Effect, pipe } from "effect"; 1 + import { Effect, pipe } from "effect"; 2 2 import type { VirtualMachine } from "../db.ts"; 3 + import { VmNotFoundError } from "../errors.ts"; 3 4 import { getInstanceState } from "../state.ts"; 4 - 5 - class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 6 - name: string; 7 - }> {} 8 5 9 6 const findVm = (name: string) => 10 7 pipe( ··· 22 19 const handleError = (error: VmNotFoundError | Error) => 23 20 Effect.sync(() => { 24 21 if (error instanceof VmNotFoundError) { 25 - console.error( 26 - `Virtual machine with name or ID ${error.name} not found.`, 27 - ); 22 + console.error(`Virtual machine with name or ID ${error.name} not found.`); 28 23 } else { 29 24 console.error(`An error occurred: ${error}`); 30 25 } ··· 32 27 }); 33 28 34 29 const inspectEffect = (name: string) => 35 - pipe( 36 - findVm(name), 37 - Effect.flatMap(displayVm), 38 - Effect.catchAll(handleError), 39 - ); 30 + pipe(findVm(name), Effect.flatMap(displayVm), Effect.catchAll(handleError)); 40 31 41 32 export default async function (name: string) { 42 33 await Effect.runPromise(inspectEffect(name));
+3 -14
src/subcommands/logs.ts
··· 1 - import { Data, Effect, pipe } from "effect"; 1 + import { Effect, pipe } from "effect"; 2 2 import { LOGS_DIR } from "../constants.ts"; 3 - 4 - class LogCommandError extends Data.TaggedError("LogCommandError")<{ 5 - vmName: string; 6 - exitCode: number; 7 - }> {} 8 - 9 - class CommandError extends Data.TaggedError("CommandError")<{ 10 - cause?: unknown; 11 - }> {} 3 + import { CommandError, LogCommandError } from "../errors.ts"; 12 4 13 5 const createLogsDir = () => 14 6 Effect.tryPromise({ ··· 23 15 Effect.tryPromise({ 24 16 try: async () => { 25 17 const cmd = new Deno.Command(follow ? "tail" : "cat", { 26 - args: [ 27 - ...(follow ? ["-n", "100", "-f"] : []), 28 - logPath, 29 - ], 18 + args: [...(follow ? ["-n", "100", "-f"] : []), logPath], 30 19 stdin: "inherit", 31 20 stdout: "inherit", 32 21 stderr: "inherit",
+2 -5
src/subcommands/ps.ts
··· 3 3 import dayjs from "dayjs"; 4 4 import relativeTime from "dayjs/plugin/relativeTime.js"; 5 5 import utc from "dayjs/plugin/utc.js"; 6 - import { Data, Effect, pipe } from "effect"; 6 + import { Effect, pipe } from "effect"; 7 7 import { ctx } from "../context.ts"; 8 8 import type { VirtualMachine } from "../db.ts"; 9 + import { DbQueryError } from "../errors.ts"; 9 10 10 11 dayjs.extend(relativeTime); 11 12 dayjs.extend(utc); 12 - 13 - class DbQueryError extends Data.TaggedError("DbQueryError")<{ 14 - cause?: unknown; 15 - }> {} 16 13 17 14 const fetchVMs = (all: boolean) => 18 15 Effect.tryPromise({
+2 -13
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 + import { Effect, pipe } from "effect"; 4 4 import { LOGS_DIR } from "../constants.ts"; 5 5 import type { VirtualMachine } from "../db.ts"; 6 + import { CommandError, KillQemuError, VmNotFoundError } from "../errors.ts"; 6 7 import { getInstanceState, updateInstanceState } from "../state.ts"; 7 8 import { 8 9 safeKillQemu, ··· 17 18 setupRockyLinuxArgs, 18 19 setupUbuntuArgs, 19 20 } from "../utils.ts"; 20 - 21 - class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 22 - name: string; 23 - }> {} 24 - 25 - class KillQemuError extends Data.TaggedError("KillQemuError")<{ 26 - vmName: string; 27 - }> {} 28 - 29 - class CommandError extends Data.TaggedError("CommandError")<{ 30 - cause?: unknown; 31 - }> {} 32 21 33 22 const findVm = (name: string) => 34 23 pipe(
+3 -8
src/subcommands/rm.ts
··· 1 - import { Data, Effect, pipe } from "effect"; 1 + import { Effect, pipe } from "effect"; 2 2 import type { VirtualMachine } from "../db.ts"; 3 + import { VmNotFoundError } from "../errors.ts"; 3 4 import { getInstanceState, removeInstanceState } from "../state.ts"; 4 - 5 - class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 6 - name: string; 7 - }> {} 8 5 9 6 const findVm = (name: string) => 10 7 pipe( ··· 28 25 const handleError = (error: VmNotFoundError | Error) => 29 26 Effect.sync(() => { 30 27 if (error instanceof VmNotFoundError) { 31 - console.error( 32 - `Virtual machine with name or ID ${error.name} not found.`, 33 - ); 28 + console.error(`Virtual machine with name or ID ${error.name} not found.`); 34 29 } else { 35 30 console.error(`An error occurred: ${error}`); 36 31 }
+2 -1
src/subcommands/run.ts
··· 1 1 import { parseFlags } from "@cliffy/flags"; 2 2 import { Effect, pipe } from "effect"; 3 3 import type { Image, Volume } from "../db.ts"; 4 + import { PullImageError } from "../errors.ts"; 4 5 import { getImage } from "../images.ts"; 5 6 import { createBridgeNetworkIfNeeded } from "../network.ts"; 6 - import { pullImage, PullImageError, setupOrasBinary } from "../oras.ts"; 7 + import { pullImage, setupOrasBinary } from "../oras.ts"; 7 8 import { type Options, runQemu, validateImage } from "../utils.ts"; 8 9 import { createVolume, getVolume } from "../volumes.ts"; 9 10
+6 -15
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 + import { Effect, pipe } from "effect"; 4 4 import { LOGS_DIR } from "../constants.ts"; 5 5 import type { VirtualMachine, Volume } from "../db.ts"; 6 + import { 7 + CommandError, 8 + VmAlreadyRunningError, 9 + VmNotFoundError, 10 + } from "../errors.ts"; 6 11 import { getImage } from "../images.ts"; 7 12 import { getInstanceState, updateInstanceState } from "../state.ts"; 8 13 import { ··· 18 23 setupUbuntuArgs, 19 24 } from "../utils.ts"; 20 25 import { createVolume, getVolume } from "../volumes.ts"; 21 - 22 - export class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 23 - name: string; 24 - }> {} 25 - 26 - export class VmAlreadyRunningError extends Data.TaggedError( 27 - "VmAlreadyRunningError", 28 - )<{ 29 - name: string; 30 - }> {} 31 - 32 - export class CommandError extends Data.TaggedError("CommandError")<{ 33 - cause?: unknown; 34 - }> {} 35 26 36 27 const findVm = (name: string) => 37 28 pipe(
+11 -24
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"; 3 + import { Effect, pipe } from "effect"; 4 4 import type { VirtualMachine } from "../db.ts"; 5 + import { CommandError, StopCommandError, VmNotFoundError } from "../errors.ts"; 5 6 import { getInstanceState, updateInstanceState } from "../state.ts"; 6 7 7 - export class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 8 - name: string; 9 - }> {} 10 - 11 - export class StopCommandError extends Data.TaggedError("StopCommandError")<{ 12 - vmName: string; 13 - exitCode: number; 14 - message?: string; 15 - }> {} 16 - 17 - export class CommandError extends Data.TaggedError("CommandError")<{ 18 - cause?: unknown; 19 - }> {} 20 - 21 8 export const findVm = (name: string) => 22 9 pipe( 23 10 getInstanceState(name), ··· 29 16 export const logStopping = (vm: VirtualMachine) => 30 17 Effect.sync(() => { 31 18 console.log( 32 - `Stopping virtual machine ${chalk.greenBright(vm.name)} (ID: ${ 33 - chalk.greenBright(vm.id) 34 - })...`, 19 + `Stopping virtual machine ${ 20 + chalk.greenBright( 21 + vm.name, 22 + ) 23 + } (ID: ${chalk.greenBright(vm.id)})...`, 35 24 ); 36 25 }); 37 26 ··· 39 28 Effect.tryPromise({ 40 29 try: async () => { 41 30 const cmd = new Deno.Command(vm.bridge ? "sudo" : "kill", { 42 - args: [ 43 - ..._.compact([vm.bridge && "kill"]), 44 - "-TERM", 45 - vm.pid.toString(), 46 - ], 31 + args: [..._.compact([vm.bridge && "kill"]), "-TERM", vm.pid.toString()], 47 32 stdin: "inherit", 48 33 stdout: "inherit", 49 34 stderr: "inherit", ··· 84 69 if (error instanceof VmNotFoundError) { 85 70 console.error( 86 71 `Virtual machine with name or ID ${ 87 - chalk.greenBright(error.name) 72 + chalk.greenBright( 73 + error.name, 74 + ) 88 75 } not found.`, 89 76 ); 90 77 Deno.exit(1);
+2 -6
src/subcommands/volume.ts
··· 4 4 import utc from "dayjs/plugin/utc.js"; 5 5 import { Effect, pipe } from "effect"; 6 6 import type { Volume } from "../db.ts"; 7 - import type { DbError } from "../mod.ts"; 7 + import type { DbError } from "../errors.ts"; 8 8 import { deleteVolume, getVolume, listVolumes } from "../volumes.ts"; 9 9 10 10 dayjs.extend(relativeTime); 11 11 dayjs.extend(utc); 12 12 13 13 const createTable = () => 14 - Effect.succeed( 15 - new Table( 16 - ["NAME", "VOLUME ID", "CREATED"], 17 - ), 18 - ); 14 + Effect.succeed(new Table(["NAME", "VOLUME ID", "CREATED"])); 19 15 20 16 const populateTable = (table: Table, volumes: Volume[]) => 21 17 Effect.sync(() => {
+15 -1
src/types.ts
··· 81 81 export type NewMachine = z.infer<typeof NewMachineSchema>; 82 82 83 83 export const NewVolumeSchema = z.object({ 84 - name: z.string(), 84 + name: z.string().trim(), 85 85 baseImage: z 86 86 .string() 87 + .trim() 87 88 .regex( 88 89 /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/, 89 90 ), 90 91 size: z 91 92 .string() 93 + .trim() 92 94 .regex(/^\d+(M|G|T)$/) 93 95 .optional(), 94 96 }); 95 97 96 98 export type NewVolume = z.infer<typeof NewVolumeSchema>; 99 + 100 + export const NewImageSchema = z.object({ 101 + from: z.string().trim(), 102 + image: z 103 + .string() 104 + .trim() 105 + .regex( 106 + /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/, 107 + ), 108 + }); 109 + 110 + export type NewImage = z.infer<typeof NewImageSchema>;
+7 -18
src/utils.ts
··· 2 2 import { createId } from "@paralleldrive/cuid2"; 3 3 import { dirname } from "@std/path"; 4 4 import chalk from "chalk"; 5 - import { Data, Effect, pipe } from "effect"; 5 + import { Effect, pipe } from "effect"; 6 6 import Moniker from "moniker"; 7 7 import { 8 8 ALMA_LINUX_IMG_URL, ··· 25 25 UBUNTU_ISO_URL, 26 26 } from "./constants.ts"; 27 27 import type { Image } from "./db.ts"; 28 + import { 29 + InvalidImageNameError, 30 + LogCommandError, 31 + NoSuchFileError, 32 + NoSuchImageError, 33 + } from "./errors.ts"; 28 34 import { generateRandomMacAddress } from "./network.ts"; 29 35 import { saveInstanceState, updateInstanceState } from "./state.ts"; 30 36 ··· 46 52 cloud?: boolean; 47 53 seed?: string; 48 54 } 49 - 50 - class LogCommandError extends Data.TaggedError("LogCommandError")<{ 51 - cause?: unknown; 52 - }> {} 53 - 54 - class InvalidImageNameError extends Data.TaggedError("InvalidImageNameError")<{ 55 - image: string; 56 - cause?: unknown; 57 - }> {} 58 - 59 - class NoSuchImageError extends Data.TaggedError("NoSuchImageError")<{ 60 - cause: string; 61 - }> {} 62 - 63 - export class NoSuchFileError extends Data.TaggedError("NoSuchFileError")<{ 64 - cause: string; 65 - }> {} 66 55 67 56 export const getCurrentArch = (): string => { 68 57 switch (Deno.build.arch) {
+2 -5
src/volumes.ts
··· 1 1 import { createId } from "@paralleldrive/cuid2"; 2 - import { Data, Effect } from "effect"; 2 + import { Effect } from "effect"; 3 3 import type { DeleteResult, InsertResult } from "kysely"; 4 4 import { VOLUME_DIR } from "./constants.ts"; 5 5 import { ctx } from "./context.ts"; 6 6 import type { Image, Volume } from "./db.ts"; 7 - 8 - export class VolumeError extends Data.TaggedError("VolumeError")<{ 9 - message?: unknown; 10 - }> {} 7 + import { VolumeError } from "./errors.ts"; 11 8 12 9 export const listVolumes = () => 13 10 Effect.tryPromise({