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