+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",
+27
deno.lock
+27
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",
···
24
25
"jsr:@std/path@1": "1.1.2",
25
26
"jsr:@std/path@^1.1.1": "1.1.2",
26
27
"jsr:@std/text@~1.0.7": "1.0.16",
28
+
"jsr:@std/toml@^1.0.11": "1.0.11",
29
+
"jsr:@zod/zod@^4.1.12": "4.1.12",
27
30
"npm:@paralleldrive/cuid2@^3.0.4": "3.0.4",
31
+
"npm:@types/node@*": "24.2.0",
28
32
"npm:chalk@^5.6.2": "5.6.2",
29
33
"npm:dayjs@^1.11.19": "1.11.19",
30
34
"npm:effect@^3.19.2": "3.19.2",
···
92
96
"jsr:@std/internal@^1.0.12"
93
97
]
94
98
},
99
+
"@std/collections@1.1.3": {
100
+
"integrity": "bf8b0818886df6a32b64c7d3b037a425111f28278d69fd0995aeb62777c986b0"
101
+
},
95
102
"@std/encoding@1.0.10": {
96
103
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
97
104
},
···
122
129
},
123
130
"@std/text@1.0.16": {
124
131
"integrity": "ddb9853b75119a2473857d691cf1ec02ad90793a2e8b4a4ac49d7354281a0cf8"
132
+
},
133
+
"@std/toml@1.0.11": {
134
+
"integrity": "e084988b872ca4bad6aedfb7350f6eeed0e8ba88e9ee5e1590621c5b5bb8f715",
135
+
"dependencies": [
136
+
"jsr:@std/collections"
137
+
]
138
+
},
139
+
"@zod/zod@4.1.12": {
140
+
"integrity": "5876ed4c6d44673faf5120f0a461a2ada2eb6c735329d3ebaf5ba1fc08387695"
125
141
}
126
142
},
127
143
"npm": {
···
140
156
"@standard-schema/spec@1.0.0": {
141
157
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="
142
158
},
159
+
"@types/node@24.2.0": {
160
+
"integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==",
161
+
"dependencies": [
162
+
"undici-types"
163
+
]
164
+
},
143
165
"bignumber.js@9.3.1": {
144
166
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="
145
167
},
···
173
195
},
174
196
"pure-rand@6.1.0": {
175
197
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="
198
+
},
199
+
"undici-types@7.10.0": {
200
+
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="
176
201
}
177
202
},
178
203
"workspace": {
···
184
209
"jsr:@es-toolkit/es-toolkit@^1.41.0",
185
210
"jsr:@soapbox/kysely-deno-sqlite@^2.2.0",
186
211
"jsr:@std/assert@1",
212
+
"jsr:@std/toml@^1.0.11",
213
+
"jsr:@zod/zod@^4.1.12",
187
214
"npm:@paralleldrive/cuid2@^3.0.4",
188
215
"npm:chalk@^5.6.2",
189
216
"npm:dayjs@^1.11.19",
+52
-12
main.ts
+52
-12
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";
···
32
36
)
33
37
.option("-o, --output <path:string>", "Output path for downloaded ISO")
34
38
.option("-c, --cpu <type:string>", "Type of CPU to emulate", {
35
-
default: "host",
39
+
default: Deno.build.os === "darwin" && Deno.build.arch === "aarch64"
40
+
? "max"
41
+
: "host",
36
42
})
37
43
.option("-C, --cpus <number:number>", "Number of CPU cores", {
38
44
default: 2,
···
66
72
.option(
67
73
"-p, --port-forward <mappings:string>",
68
74
"Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)",
75
+
)
76
+
.example(
77
+
"Create a default VM configuration file",
78
+
"dflybsd-up init",
69
79
)
70
80
.example(
71
81
"Default usage",
···
108
118
const resolvedInput = handleInput(input);
109
119
let isoPath: string | null = resolvedInput;
110
120
111
-
if (
112
-
resolvedInput.startsWith("https://") ||
113
-
resolvedInput.startsWith("http://")
114
-
) {
121
+
const config = yield* pipe(
122
+
parseVmFile(CONFIG_FILE_NAME),
123
+
Effect.tap(() => Effect.log("Parsed VM configuration file.")),
124
+
Effect.catchAll(() => Effect.succeed(null)),
125
+
);
126
+
127
+
if (!input && (isValidISOurl(config?.vm?.iso))) {
128
+
isoPath = yield* downloadIso(config!.vm!.iso!, options);
129
+
}
130
+
131
+
options = yield* mergeConfig(config, options);
132
+
133
+
if (input && isValidISOurl(resolvedInput)) {
115
134
isoPath = yield* downloadIso(resolvedInput, options);
116
135
}
117
136
···
119
138
yield* createDriveImageIfNeeded(options);
120
139
}
121
140
122
-
if (
123
-
!input && options.image &&
124
-
!(yield* emptyDiskImage(options.image))
125
-
) {
126
-
isoPath = null;
141
+
if (!input && options.image) {
142
+
const isEmpty = yield* emptyDiskImage(options.image);
143
+
if (!isEmpty) {
144
+
isoPath = null;
145
+
}
127
146
}
128
147
129
148
if (options.bridge) {
130
149
yield* createBridgeNetworkIfNeeded(options.bridge);
131
150
}
151
+
152
+
if (!input && !config?.vm?.iso && isValidISOurl(isoPath!)) {
153
+
isoPath = null;
154
+
}
155
+
132
156
yield* runQemu(isoPath, options);
133
157
});
134
158
···
142
166
.command("start", "Start a virtual machine")
143
167
.arguments("<vm-name:string>")
144
168
.option("-c, --cpu <type:string>", "Type of CPU to emulate", {
145
-
default: "host",
169
+
default: Deno.build.os === "darwin" && Deno.build.arch === "aarch64"
170
+
? "max"
171
+
: "host",
146
172
})
147
173
.option("-C, --cpus <number:number>", "Number of CPU cores", {
148
174
default: 2,
···
205
231
.arguments("<vm-name:string>")
206
232
.action(async (_options: unknown, vmName: string) => {
207
233
await restart(vmName);
234
+
})
235
+
.command("init", "Initialize a default VM configuration file")
236
+
.action(async () => {
237
+
await Effect.runPromise(initVmFile(CONFIG_FILE_NAME));
238
+
console.log(
239
+
`New VM configuration file created at ${
240
+
chalk.greenBright("./") +
241
+
chalk.greenBright(CONFIG_FILE_NAME)
242
+
}`,
243
+
);
244
+
console.log(
245
+
`You can edit this file to customize your VM settings and then start the VM with:`,
246
+
);
247
+
console.log(` ${chalk.greenBright(`dflybsd-up`)}`);
208
248
})
209
249
.parse(Deno.args);
210
250
}
+124
src/config.ts
+124
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: Deno.build.os === "darwin" && Deno.build.arch === "aarch64"
47
+
? "max"
48
+
: "host",
49
+
cpus: 2,
50
+
memory: "2G",
51
+
},
52
+
network: {
53
+
port_forward: "2222:22",
54
+
},
55
+
options: {
56
+
detach: false,
57
+
},
58
+
};
59
+
const tomlString = toml.stringify(defaultConfig);
60
+
await Deno.writeTextFile(path, tomlString);
61
+
},
62
+
catch: (error) => new VmConfigError({ cause: String(error) }),
63
+
});
64
+
65
+
export const parseVmFile = (
66
+
path: string,
67
+
): Effect.Effect<VmConfig, VmConfigError, never> =>
68
+
Effect.tryPromise({
69
+
try: async () => {
70
+
const fileContent = await Deno.readTextFile(path);
71
+
const parsedToml = toml.parse(fileContent);
72
+
return VmConfigSchema.parse(parsedToml);
73
+
},
74
+
catch: (error) => new VmConfigError({ cause: String(error) }),
75
+
});
76
+
77
+
export const mergeConfig = (
78
+
config: VmConfig | null,
79
+
options: Options,
80
+
): Effect.Effect<Options, never, never> => {
81
+
const { flags } = parseFlags(Deno.args);
82
+
const defaultConfig: VmConfig = {
83
+
vm: {
84
+
iso: _.get(config, "vm.iso"),
85
+
cpu: _.get(
86
+
config,
87
+
"vm.cpu",
88
+
Deno.build.os === "darwin" && Deno.build.arch === "aarch64"
89
+
? "max"
90
+
: "host",
91
+
),
92
+
cpus: _.get(config, "vm.cpus", 2),
93
+
memory: _.get(config, "vm.memory", "2G"),
94
+
image: _.get(config, "vm.image", options.image),
95
+
disk_format: _.get(config, "vm.disk_format", "raw"),
96
+
size: _.get(config, "vm.size", "20G"),
97
+
},
98
+
network: {
99
+
bridge: _.get(config, "network.bridge"),
100
+
port_forward: _.get(config, "network.port_forward", "2222:22"),
101
+
},
102
+
options: {
103
+
detach: _.get(config, "options.detach", false),
104
+
},
105
+
};
106
+
return Effect.succeed({
107
+
memory: _.get(flags, "memory", defaultConfig.vm.memory!) as string,
108
+
cpus: _.get(flags, "cpus", defaultConfig.vm.cpus!) as number,
109
+
cpu: _.get(flags, "cpu", defaultConfig.vm.cpu!) as string,
110
+
diskFormat: _.get(
111
+
flags,
112
+
"diskFormat",
113
+
defaultConfig.vm.disk_format!,
114
+
) as string,
115
+
portForward: _.get(
116
+
flags,
117
+
"portForward",
118
+
defaultConfig.network.port_forward!,
119
+
) as string,
120
+
image: _.get(flags, "image", defaultConfig.vm.image!) as string,
121
+
bridge: _.get(flags, "bridge", defaultConfig.network.bridge!) as string,
122
+
size: _.get(flags, "size", defaultConfig.vm.size!) as string,
123
+
});
124
+
};
+1
src/constants.ts
+1
src/constants.ts
+1
src/mod.ts
+1
src/mod.ts
+5
-1
src/subcommands/inspect.ts
+5
-1
src/subcommands/inspect.ts
+9
-2
src/utils.ts
+9
-2
src/utils.ts
···
25
25
pid: number;
26
26
}> {}
27
27
28
-
const DEFAULT_VERSION = "6.4.2";
28
+
export const DEFAULT_VERSION = "6.4.2";
29
29
30
30
export interface Options {
31
31
output?: string;
···
39
39
portForward?: string;
40
40
detach?: boolean;
41
41
}
42
+
43
+
export const isValidISOurl = (url?: string): boolean => {
44
+
return Boolean(
45
+
(url?.startsWith("http://") || url?.startsWith("https://")) &&
46
+
url?.endsWith(".iso"),
47
+
);
48
+
};
42
49
43
50
const du = (path: string) =>
44
51
Effect.tryPromise({
···
273
280
catch: (cause) =>
274
281
new CommandExecutionError({
275
282
cause,
276
-
message: "Failed to start detached QEMU process",
283
+
message: `Failed to start detached QEMU process: ${cause}`,
277
284
}),
278
285
}),
279
286
Effect.flatMap((qemuPid) =>