+2
deno.json
+2
deno.json
···
15
15
"@paralleldrive/cuid2": "npm:@paralleldrive/cuid2@^3.0.4",
16
16
"@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.2.0",
17
17
"@std/assert": "jsr:@std/assert@1",
18
+
"@std/toml": "jsr:@std/toml@^1.0.11",
19
+
"@zod/zod": "jsr:@zod/zod@^4.1.12",
18
20
"chalk": "npm:chalk@^5.6.2",
19
21
"dayjs": "npm:dayjs@^1.11.19",
20
22
"effect": "npm:effect@^3.19.2",
+23
-1
deno.lock
+23
-1
deno.lock
···
13
13
"jsr:@soapbox/kysely-deno-sqlite@^2.2.0": "2.2.0",
14
14
"jsr:@std/assert@0.217": "0.217.0",
15
15
"jsr:@std/assert@1": "1.0.15",
16
+
"jsr:@std/collections@^1.1.3": "1.1.3",
16
17
"jsr:@std/encoding@1": "1.0.10",
17
18
"jsr:@std/fmt@1": "1.0.8",
18
19
"jsr:@std/fmt@~1.0.2": "1.0.8",
···
23
24
"jsr:@std/path@0.217": "0.217.0",
24
25
"jsr:@std/path@1": "1.1.2",
25
26
"jsr:@std/path@^1.1.1": "1.1.2",
26
-
"jsr:@std/text@~1.0.7": "1.0.15",
27
+
"jsr:@std/text@~1.0.7": "1.0.16",
28
+
"jsr:@std/toml@*": "1.0.11",
29
+
"jsr:@std/toml@^1.0.11": "1.0.11",
30
+
"jsr:@zod/zod@*": "4.1.12",
31
+
"jsr:@zod/zod@^4.1.12": "4.1.12",
27
32
"npm:@paralleldrive/cuid2@^3.0.4": "3.0.4",
28
33
"npm:chalk@^5.6.2": "5.6.2",
29
34
"npm:dayjs@^1.11.19": "1.11.19",
···
92
97
"jsr:@std/internal@^1.0.12"
93
98
]
94
99
},
100
+
"@std/collections@1.1.3": {
101
+
"integrity": "bf8b0818886df6a32b64c7d3b037a425111f28278d69fd0995aeb62777c986b0"
102
+
},
95
103
"@std/encoding@1.0.10": {
96
104
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
97
105
},
···
122
130
},
123
131
"@std/text@1.0.15": {
124
132
"integrity": "91f5cc1e12779a3d95f1be34e763f9c28a75a078b7360e6fcaef0d8d9b1e3e7f"
133
+
},
134
+
"@std/text@1.0.16": {
135
+
"integrity": "ddb9853b75119a2473857d691cf1ec02ad90793a2e8b4a4ac49d7354281a0cf8"
136
+
},
137
+
"@std/toml@1.0.11": {
138
+
"integrity": "e084988b872ca4bad6aedfb7350f6eeed0e8ba88e9ee5e1590621c5b5bb8f715",
139
+
"dependencies": [
140
+
"jsr:@std/collections"
141
+
]
142
+
},
143
+
"@zod/zod@4.1.12": {
144
+
"integrity": "5876ed4c6d44673faf5120f0a461a2ada2eb6c735329d3ebaf5ba1fc08387695"
125
145
}
126
146
},
127
147
"npm": {
···
184
204
"jsr:@es-toolkit/es-toolkit@^1.41.0",
185
205
"jsr:@soapbox/kysely-deno-sqlite@^2.2.0",
186
206
"jsr:@std/assert@1",
207
+
"jsr:@std/toml@^1.0.11",
208
+
"jsr:@zod/zod@^4.1.12",
187
209
"npm:@paralleldrive/cuid2@^3.0.4",
188
210
"npm:chalk@^5.6.2",
189
211
"npm:dayjs@^1.11.19",
+40
-5
main.ts
+40
-5
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
+
import chalk from "chalk";
5
+
import { Effect, pipe } from "effect";
5
6
import pkg from "./deno.json" with { type: "json" };
7
+
import { initVmFile, mergeConfig, parseVmFile } from "./src/config.ts";
8
+
import { CONFIG_FILE_NAME } from "./src/constants.ts";
6
9
import { createBridgeNetworkIfNeeded } from "./src/network.ts";
7
10
import inspect from "./src/subcommands/inspect.ts";
8
11
import logs from "./src/subcommands/logs.ts";
···
16
19
downloadIso,
17
20
emptyDiskImage,
18
21
handleInput,
22
+
isValidISOurl,
19
23
type Options,
20
24
runQemu,
21
25
} from "./src/utils.ts";
···
68
72
"Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)",
69
73
)
70
74
.example(
75
+
"Create a default VM configuration file",
76
+
"freebsd-up init",
77
+
)
78
+
.example(
71
79
"Default usage",
72
80
"freebsd-up",
73
81
)
···
108
116
const resolvedInput = handleInput(input);
109
117
let isoPath: string | null = resolvedInput;
110
118
111
-
if (
112
-
resolvedInput.startsWith("https://") ||
113
-
resolvedInput.startsWith("http://")
114
-
) {
119
+
const config = yield* pipe(
120
+
parseVmFile(CONFIG_FILE_NAME),
121
+
Effect.tap(() => Effect.log("Parsed VM configuration file.")),
122
+
Effect.catchAll(() => Effect.succeed(null)),
123
+
);
124
+
125
+
if (!input && (isValidISOurl(config?.vm?.iso))) {
126
+
isoPath = yield* downloadIso(config!.vm!.iso!, options);
127
+
}
128
+
129
+
options = yield* mergeConfig(config, options);
130
+
131
+
if (input && isValidISOurl(resolvedInput)) {
115
132
isoPath = yield* downloadIso(resolvedInput, options);
116
133
}
117
134
···
128
145
129
146
if (options.bridge) {
130
147
yield* createBridgeNetworkIfNeeded(options.bridge);
148
+
}
149
+
150
+
if (!input && !config?.vm?.iso && isValidISOurl(isoPath!)) {
151
+
isoPath = null;
131
152
}
132
153
133
154
yield* runQemu(isoPath, options);
···
206
227
.arguments("<vm-name:string>")
207
228
.action(async (_options: unknown, vmName: string) => {
208
229
await restart(vmName);
230
+
})
231
+
.command("init", "Initialize a default VM configuration file")
232
+
.action(async () => {
233
+
await Effect.runPromise(initVmFile(CONFIG_FILE_NAME));
234
+
console.log(
235
+
`New VM configuration file created at ${
236
+
chalk.greenBright("./") +
237
+
chalk.greenBright(CONFIG_FILE_NAME)
238
+
}`,
239
+
);
240
+
console.log(
241
+
`You can edit this file to customize your VM settings and then start the VM with:`,
242
+
);
243
+
console.log(` ${chalk.greenBright(`freebsd-up`)}`);
209
244
})
210
245
.parse(Deno.args);
211
246
}
+115
src/config.ts
+115
src/config.ts
···
1
+
import { parseFlags } from "@cliffy/flags";
2
+
import _ from "@es-toolkit/es-toolkit/compat";
3
+
import * as toml from "@std/toml";
4
+
import z from "@zod/zod";
5
+
import { Data, Effect } from "effect";
6
+
import {
7
+
constructDownloadUrl,
8
+
DEFAULT_VERSION,
9
+
type Options,
10
+
} from "./utils.ts";
11
+
12
+
export const VmConfigSchema = z.object({
13
+
vm: z.object({
14
+
iso: z.string(),
15
+
output: z.string(),
16
+
cpu: z.string(),
17
+
cpus: z.number(),
18
+
memory: z.string(),
19
+
image: z.string(),
20
+
disk_format: z.enum(["qcow2", "raw"]),
21
+
size: z.string(),
22
+
}).partial(),
23
+
network: z.object({
24
+
bridge: z.string(),
25
+
port_forward: z.string(),
26
+
}).partial(),
27
+
options: z.object({
28
+
detach: z.boolean(),
29
+
}).partial(),
30
+
});
31
+
32
+
export type VmConfig = z.infer<typeof VmConfigSchema>;
33
+
34
+
class VmConfigError extends Data.TaggedError("VmConfigError")<{
35
+
cause?: string;
36
+
}> {}
37
+
38
+
export const initVmFile = (
39
+
path: string,
40
+
): Effect.Effect<void, VmConfigError, never> =>
41
+
Effect.tryPromise({
42
+
try: async () => {
43
+
const defaultConfig: VmConfig = {
44
+
vm: {
45
+
iso: constructDownloadUrl(DEFAULT_VERSION),
46
+
cpu: "host",
47
+
cpus: 2,
48
+
memory: "2G",
49
+
},
50
+
network: {
51
+
port_forward: "2222:22",
52
+
},
53
+
options: {
54
+
detach: false,
55
+
},
56
+
};
57
+
const tomlString = toml.stringify(defaultConfig);
58
+
await Deno.writeTextFile(path, tomlString);
59
+
},
60
+
catch: (error) => new VmConfigError({ cause: String(error) }),
61
+
});
62
+
63
+
export const parseVmFile = (
64
+
path: string,
65
+
): Effect.Effect<VmConfig, VmConfigError, never> =>
66
+
Effect.tryPromise({
67
+
try: async () => {
68
+
const fileContent = await Deno.readTextFile(path);
69
+
const parsedToml = toml.parse(fileContent);
70
+
return VmConfigSchema.parse(parsedToml);
71
+
},
72
+
catch: (error) => new VmConfigError({ cause: String(error) }),
73
+
});
74
+
75
+
export const mergeConfig = (
76
+
config: VmConfig | null,
77
+
options: Options,
78
+
): Effect.Effect<Options, never, never> => {
79
+
const { flags } = parseFlags(Deno.args);
80
+
const defaultConfig: VmConfig = {
81
+
vm: {
82
+
iso: _.get(config, "vm.iso"),
83
+
cpu: _.get(config, "vm.cpu", "host"),
84
+
cpus: _.get(config, "vm.cpus", 2),
85
+
memory: _.get(config, "vm.memory", "2G"),
86
+
image: _.get(config, "vm.image", options.image),
87
+
disk_format: _.get(config, "vm.disk_format", "raw"),
88
+
size: _.get(config, "vm.size", "20G"),
89
+
},
90
+
network: {
91
+
port_forward: _.get(config, "network.port_forward", "2222:22"),
92
+
},
93
+
options: {
94
+
detach: _.get(config, "options.detach", false),
95
+
},
96
+
};
97
+
return Effect.succeed({
98
+
memory: _.get(flags, "memory", defaultConfig.vm.memory!) as string,
99
+
cpus: _.get(flags, "cpus", defaultConfig.vm.cpus!) as number,
100
+
cpu: _.get(flags, "cpu", defaultConfig.vm.cpu!) as string,
101
+
diskFormat: _.get(
102
+
flags,
103
+
"diskFormat",
104
+
defaultConfig.vm.disk_format!,
105
+
) as string,
106
+
portForward: _.get(
107
+
flags,
108
+
"portForward",
109
+
defaultConfig.network.port_forward!,
110
+
) as string,
111
+
image: _.get(flags, "image", defaultConfig.vm.image!) as string,
112
+
bridge: _.get(flags, "bridge", defaultConfig.network.bridge!) as string,
113
+
size: _.get(flags, "size", defaultConfig.vm.size!) as string,
114
+
});
115
+
};
+1
src/constants.ts
+1
src/constants.ts
+9
-1
src/utils.ts
+9
-1
src/utils.ts
···
7
7
import { generateRandomMacAddress } from "./network.ts";
8
8
import { saveInstanceState, updateInstanceState } from "./state.ts";
9
9
10
-
const DEFAULT_VERSION = "14.3-RELEASE";
10
+
export const DEFAULT_VERSION = "14.3-RELEASE";
11
11
12
12
export interface Options {
13
13
output?: string;
···
25
25
class LogCommandError extends Data.TaggedError("LogCommandError")<{
26
26
cause?: unknown;
27
27
}> {}
28
+
29
+
export const isValidISOurl = (url?: string): boolean => {
30
+
return Boolean(
31
+
(url?.startsWith("http://") || url?.startsWith("https://")) &&
32
+
url?.endsWith(".iso"),
33
+
);
34
+
};
28
35
29
36
const du = (path: string) =>
30
37
Effect.tryPromise({
···
104
111
105
112
yield* Effect.tryPromise({
106
113
try: async () => {
114
+
console.log(chalk.blueBright(`Downloading ISO from ${url}...`));
107
115
const cmd = new Deno.Command("curl", {
108
116
args: ["-L", "-o", outputPath, url],
109
117
stdin: "inherit",