A simple, zero-configuration script to quickly boot FreeBSD ISO images using QEMU
1#!/usr/bin/env -S deno run --allow-run --allow-read --allow-env
2
3import { Command } from "@cliffy/command";
4import { Secret } from "@cliffy/prompt";
5import { readAll } from "@std/io";
6import chalk from "chalk";
7import { Effect, pipe } from "effect";
8import pkg from "./deno.json" with { type: "json" };
9import { initVmFile, mergeConfig, parseVmFile } from "./src/config.ts";
10import { CONFIG_FILE_NAME } from "./src/constants.ts";
11import { getImage } from "./src/images.ts";
12import { createBridgeNetworkIfNeeded } from "./src/network.ts";
13import { getImageArchivePath } from "./src/oras.ts";
14import images from "./src/subcommands/images.ts";
15import inspect from "./src/subcommands/inspect.ts";
16import login from "./src/subcommands/login.ts";
17import logout from "./src/subcommands/logout.ts";
18import logs from "./src/subcommands/logs.ts";
19import ps from "./src/subcommands/ps.ts";
20import pull from "./src/subcommands/pull.ts";
21import push from "./src/subcommands/push.ts";
22import restart from "./src/subcommands/restart.ts";
23import rm from "./src/subcommands/rm.ts";
24import rmi from "./src/subcommands/rmi.ts";
25import run from "./src/subcommands/run.ts";
26import start from "./src/subcommands/start.ts";
27import stop from "./src/subcommands/stop.ts";
28import tag from "./src/subcommands/tag.ts";
29import * as volumes from "./src/subcommands/volume.ts";
30import {
31 createDriveImageIfNeeded,
32 downloadIso,
33 emptyDiskImage,
34 handleInput,
35 isValidISOurl,
36 type Options,
37 runQemu,
38} from "./src/utils.ts";
39import serve from "./src/subcommands/serve.ts";
40
41export * from "./src/mod.ts";
42
43if (import.meta.main) {
44 await new Command()
45 .name("freebsd-up")
46 .version(pkg.version)
47 .description("Start a FreeBSD virtual machine using QEMU")
48 .arguments(
49 "[path-or-url-to-iso-or-version:string]",
50 )
51 .option("-o, --output <path:string>", "Output path for downloaded ISO")
52 .option("-c, --cpu <type:string>", "Type of CPU to emulate", {
53 default: "host",
54 })
55 .option("-C, --cpus <number:number>", "Number of CPU cores", {
56 default: 2,
57 })
58 .option("-m, --memory <size:string>", "Amount of memory for the VM", {
59 default: "2G",
60 })
61 .option("-i, --image <path:string>", "Path to VM disk image")
62 .option(
63 "--disk-format <format:string>",
64 "Disk image format (e.g., qcow2, raw)",
65 {
66 default: "raw",
67 },
68 )
69 .option(
70 "-s, --size <size:string>",
71 "Size of the disk image to create if it doesn't exist (e.g., 20G)",
72 {
73 default: "20G",
74 },
75 )
76 .option(
77 "-b, --bridge <name:string>",
78 "Name of the network bridge to use for networking (e.g., br0)",
79 )
80 .option(
81 "-d, --detach",
82 "Run VM in the background and print VM name",
83 )
84 .option(
85 "-p, --port-forward <mappings:string>",
86 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)",
87 )
88 .option(
89 "--install",
90 "Persist changes to the VM disk image",
91 )
92 .example(
93 "Create a default VM configuration file",
94 "freebsd-up init",
95 )
96 .example(
97 "Default usage",
98 "freebsd-up",
99 )
100 .example(
101 "Specific version",
102 "freebsd-up 14.3-RELEASE",
103 )
104 .example(
105 "Local ISO file",
106 "freebsd-up /path/to/freebsd.iso",
107 )
108 .example(
109 "Download URL",
110 "freebsd-up https://download.freebsd.org/ftp/releases/ISO-IMAGES/14.3/FreeBSD-14.3-RELEASE-amd64-disc1.iso",
111 )
112 .example(
113 "From OCI Registry",
114 "freebsd-up ghcr.io/tsirysndr/freebsd:15.0-BETA4",
115 )
116 .example(
117 "List running VMs",
118 "freebsd-up ps",
119 )
120 .example(
121 "List all VMs",
122 "freebsd-up ps --all",
123 )
124 .example(
125 "Start a VM",
126 "freebsd-up start my-vm",
127 )
128 .example(
129 "Stop a VM",
130 "freebsd-up stop my-vm",
131 )
132 .example(
133 "Inspect a VM",
134 "freebsd-up inspect my-vm",
135 )
136 .action(async (options: Options, input?: string) => {
137 const program = Effect.gen(function* () {
138 if (input) {
139 const [image, archivePath] = yield* Effect.all([
140 getImage(input),
141 pipe(
142 getImageArchivePath(input),
143 Effect.catchAll(() => Effect.succeed(null)),
144 ),
145 ]);
146
147 if (image || archivePath) {
148 yield* Effect.tryPromise({
149 try: () => run(input),
150 catch: () => {},
151 });
152 return;
153 }
154 }
155
156 const resolvedInput = handleInput(input);
157 let isoPath: string | null = resolvedInput;
158
159 const config = yield* pipe(
160 parseVmFile(CONFIG_FILE_NAME),
161 Effect.tap(() => Effect.log("Parsed VM configuration file.")),
162 Effect.catchAll(() => Effect.succeed(null)),
163 );
164
165 if (!input && (isValidISOurl(config?.vm?.iso))) {
166 isoPath = yield* downloadIso(config!.vm!.iso!, options);
167 }
168
169 options = yield* mergeConfig(config, options);
170
171 if (input && isValidISOurl(resolvedInput)) {
172 isoPath = yield* downloadIso(resolvedInput, options);
173 }
174
175 if (options.image) {
176 yield* createDriveImageIfNeeded(options);
177 }
178
179 if (!input && options.image) {
180 const isEmpty = yield* emptyDiskImage(options.image);
181 if (!isEmpty) {
182 isoPath = null;
183 }
184 }
185
186 if (options.bridge) {
187 yield* createBridgeNetworkIfNeeded(options.bridge);
188 }
189
190 if (!input && !config?.vm?.iso && isValidISOurl(isoPath!)) {
191 isoPath = null;
192 }
193
194 yield* runQemu(isoPath, options);
195 });
196
197 await Effect.runPromise(program);
198 })
199 .command("ps", "List all virtual machines")
200 .option("--all, -a", "Show all virtual machines, including stopped ones")
201 .action(async (options: { all?: unknown }) => {
202 await ps(Boolean(options.all));
203 })
204 .command("start", "Start a virtual machine")
205 .arguments("<vm-name:string>")
206 .option("-c, --cpu <type:string>", "Type of CPU to emulate", {
207 default: "host",
208 })
209 .option("-C, --cpus <number:number>", "Number of CPU cores", {
210 default: 2,
211 })
212 .option("-m, --memory <size:string>", "Amount of memory for the VM", {
213 default: "2G",
214 })
215 .option("-i, --image <path:string>", "Path to VM disk image")
216 .option(
217 "--disk-format <format:string>",
218 "Disk image format (e.g., qcow2, raw)",
219 {
220 default: "raw",
221 },
222 )
223 .option(
224 "--size <size:string>",
225 "Size of the VM disk image to create if it doesn't exist (e.g., 20G)",
226 {
227 default: "20G",
228 },
229 )
230 .option(
231 "-b, --bridge <name:string>",
232 "Name of the network bridge to use for networking (e.g., br0)",
233 )
234 .option(
235 "-d, --detach",
236 "Run VM in the background and print VM name",
237 )
238 .option(
239 "-p, --port-forward <mappings:string>",
240 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)",
241 )
242 .option(
243 "-v, --volume <name:string>",
244 "Name of the volume to attach to the VM, will be created if it doesn't exist",
245 )
246 .action(async (options: unknown, vmName: string) => {
247 await start(vmName, Boolean((options as { detach: boolean }).detach));
248 })
249 .command("stop", "Stop a virtual machine")
250 .arguments("<vm-name:string>")
251 .action(async (_options: unknown, vmName: string) => {
252 await stop(vmName);
253 })
254 .command("inspect", "Inspect a virtual machine")
255 .arguments("<vm-name:string>")
256 .action(async (_options: unknown, vmName: string) => {
257 await inspect(vmName);
258 })
259 .command("rm", "Remove a virtual machine")
260 .arguments("<vm-name:string>")
261 .action(async (_options: unknown, vmName: string) => {
262 await rm(vmName);
263 })
264 .command("logs", "View logs of a virtual machine")
265 .option("--follow, -f", "Follow log output")
266 .arguments("<vm-name:string>")
267 .action(async (options: unknown, vmName: string) => {
268 await logs(vmName, Boolean((options as { follow: boolean }).follow));
269 })
270 .command("restart", "Restart a virtual machine")
271 .arguments("<vm-name:string>")
272 .action(async (_options: unknown, vmName: string) => {
273 await restart(vmName);
274 })
275 .command("init", "Initialize a default VM configuration file")
276 .action(async () => {
277 await Effect.runPromise(initVmFile(CONFIG_FILE_NAME));
278 console.log(
279 `New VM configuration file created at ${
280 chalk.greenBright("./") +
281 chalk.greenBright(CONFIG_FILE_NAME)
282 }`,
283 );
284 console.log(
285 `You can edit this file to customize your VM settings and then start the VM with:`,
286 );
287 console.log(` ${chalk.greenBright(`freebsd-up`)}`);
288 })
289 .command(
290 "pull",
291 "Pull VM image from an OCI-compliant registry, e.g., ghcr.io, docker hub",
292 )
293 .arguments("<image:string>")
294 .action(async (_options: unknown, image: string) => {
295 await pull(image);
296 })
297 .command(
298 "push",
299 "Push VM image to an OCI-compliant registry, e.g., ghcr.io, docker hub",
300 )
301 .arguments("<image:string>")
302 .action(async (_options: unknown, image: string) => {
303 await push(image);
304 })
305 .command(
306 "tag",
307 "Create a tag 'image' that refers to the VM image of 'vm-name'",
308 )
309 .arguments("<vm-name:string> <image:string>")
310 .action(async (_options: unknown, vmName: string, image: string) => {
311 await tag(vmName, image);
312 })
313 .command(
314 "login",
315 "Authenticate to an OCI-compliant registry, e.g., ghcr.io, docker.io (docker hub), etc.",
316 )
317 .option("-u, --username <username:string>", "Registry username")
318 .arguments("<registry:string>")
319 .action(async (options: unknown, registry: string) => {
320 const username = (options as { username: string }).username;
321
322 let password: string | undefined;
323 const stdinIsTTY = Deno.stdin.isTerminal();
324
325 if (!stdinIsTTY) {
326 const buffer = await readAll(Deno.stdin);
327 password = new TextDecoder().decode(buffer).trim();
328 } else {
329 password = await Secret.prompt("Registry Password: ");
330 }
331
332 console.log(
333 `Authenticating to registry ${chalk.greenBright(registry)} as ${
334 chalk.greenBright(username)
335 }...`,
336 );
337 await login(username, password, registry);
338 })
339 .command("logout", "Logout from an OCI-compliant registry")
340 .arguments("<registry:string>")
341 .action(async (_options: unknown, registry: string) => {
342 await logout(registry);
343 })
344 .command("images", "List all local VM images")
345 .action(async () => {
346 await images();
347 })
348 .command("rmi", "Remove a local VM image")
349 .arguments("<image:string>")
350 .action(async (_options: unknown, image: string) => {
351 await rmi(image);
352 })
353 .command("run", "Create and run a VM from an image")
354 .arguments("<image:string>")
355 .option("-c, --cpu <type:string>", "Type of CPU to emulate", {
356 default: "host",
357 })
358 .option("-C, --cpus <number:number>", "Number of CPU cores", {
359 default: 2,
360 })
361 .option("-m, --memory <size:string>", "Amount of memory for the VM", {
362 default: "2G",
363 })
364 .option(
365 "-b, --bridge <name:string>",
366 "Name of the network bridge to use for networking (e.g., br0)",
367 )
368 .option(
369 "-d, --detach",
370 "Run VM in the background and print VM name",
371 )
372 .option(
373 "-p, --port-forward <mappings:string>",
374 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)",
375 )
376 .option(
377 "-v, --volume <name:string>",
378 "Name of the volume to attach to the VM, will be created if it doesn't exist",
379 )
380 .action(async (_options: unknown, image: string) => {
381 await run(image);
382 })
383 .command("volumes", "List all volumes")
384 .action(async () => {
385 await volumes.list();
386 })
387 .command(
388 "volume",
389 new Command()
390 .command("rm", "Remove a volume")
391 .arguments("<volume-name:string>")
392 .action(async (_options: unknown, volumeName: string) => {
393 await volumes.remove(volumeName);
394 })
395 .command("inspect", "Inspect a volume")
396 .arguments("<volume-name:string>")
397 .action(async (_options: unknown, volumeName: string) => {
398 await volumes.inspect(volumeName);
399 }),
400 )
401 .description("Manage volumes")
402 .command("serve", "Start the FreeBSD-Up HTTP API server")
403 .option("-p, --port <port:number>", "Port to listen on", { default: 8890 })
404 .action(() => {
405 serve();
406 })
407 .parse(Deno.args);
408}