+7
-1
deno.json
+7
-1
deno.json
···
1
1
{
2
+
"name": "@tsiry/openindiana-up",
3
+
"version": "0.1.0",
4
+
"exports": "./main.ts",
5
+
"license": "MPL-2.0",
2
6
"tasks": {
3
7
"dev": "deno run --watch main.ts"
4
8
},
5
9
"imports": {
6
10
"@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.8",
11
+
"@cliffy/flags": "jsr:@cliffy/flags@^1.0.0-rc.8",
7
12
"@cliffy/table": "jsr:@cliffy/table@^1.0.0-rc.8",
8
13
"@db/sqlite": "jsr:@db/sqlite@^0.12.0",
14
+
"@es-toolkit/es-toolkit": "jsr:@es-toolkit/es-toolkit@^1.41.0",
9
15
"@paralleldrive/cuid2": "npm:@paralleldrive/cuid2@^3.0.4",
10
16
"@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.2.0",
11
17
"@std/assert": "jsr:@std/assert@1",
12
18
"chalk": "npm:chalk@^5.6.2",
13
19
"dayjs": "npm:dayjs@^1.11.19",
20
+
"effect": "npm:effect@^3.19.2",
14
21
"kysely": "npm:kysely@0.27.6",
15
-
"lodash": "npm:lodash@^4.17.21",
16
22
"moniker": "npm:moniker@^0.1.2"
17
23
}
18
24
}
+29
-6
deno.lock
+29
-6
deno.lock
···
3
3
"specifiers": {
4
4
"jsr:@cliffy/command@^1.0.0-rc.8": "1.0.0-rc.8",
5
5
"jsr:@cliffy/flags@1.0.0-rc.8": "1.0.0-rc.8",
6
+
"jsr:@cliffy/flags@^1.0.0-rc.8": "1.0.0-rc.8",
6
7
"jsr:@cliffy/internal@1.0.0-rc.8": "1.0.0-rc.8",
7
8
"jsr:@cliffy/table@1.0.0-rc.8": "1.0.0-rc.8",
8
9
"jsr:@cliffy/table@^1.0.0-rc.8": "1.0.0-rc.8",
9
10
"jsr:@db/sqlite@0.12": "0.12.0",
10
11
"jsr:@denosaurs/plug@1": "1.1.0",
12
+
"jsr:@es-toolkit/es-toolkit@^1.41.0": "1.41.0",
11
13
"jsr:@soapbox/kysely-deno-sqlite@^2.2.0": "2.2.0",
12
14
"jsr:@std/assert@0.217": "0.217.0",
13
15
"jsr:@std/assert@1": "1.0.15",
···
25
27
"npm:@paralleldrive/cuid2@^3.0.4": "3.0.4",
26
28
"npm:chalk@^5.6.2": "5.6.2",
27
29
"npm:dayjs@^1.11.19": "1.11.19",
30
+
"npm:effect@^3.19.2": "3.19.2",
28
31
"npm:kysely@0.27.6": "0.27.6",
29
32
"npm:kysely@~0.27.2": "0.27.6",
30
-
"npm:lodash@^4.17.21": "4.17.21",
31
33
"npm:moniker@~0.1.2": "0.1.2"
32
34
},
33
35
"jsr": {
34
36
"@cliffy/command@1.0.0-rc.8": {
35
37
"integrity": "758147790797c74a707e5294cc7285df665422a13d2a483437092ffce40b5557",
36
38
"dependencies": [
37
-
"jsr:@cliffy/flags",
39
+
"jsr:@cliffy/flags@1.0.0-rc.8",
38
40
"jsr:@cliffy/internal",
39
41
"jsr:@cliffy/table@1.0.0-rc.8",
40
42
"jsr:@std/fmt@~1.0.2",
···
71
73
"jsr:@std/fs",
72
74
"jsr:@std/path@1"
73
75
]
76
+
},
77
+
"@es-toolkit/es-toolkit@1.41.0": {
78
+
"integrity": "4df54a18e80b869880cee8a8a9ff7a5e1c424a9fd0916dccd38d34686f110071"
74
79
},
75
80
"@soapbox/kysely-deno-sqlite@2.2.0": {
76
81
"integrity": "668ec94600bc4b4d7bd618dd7ca65d4ef30ee61c46ffcb379b6f45203c08517a",
···
132
137
],
133
138
"bin": true
134
139
},
140
+
"@standard-schema/spec@1.0.0": {
141
+
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="
142
+
},
135
143
"bignumber.js@9.3.1": {
136
144
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="
137
145
},
···
141
149
"dayjs@1.11.19": {
142
150
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="
143
151
},
152
+
"effect@3.19.2": {
153
+
"integrity": "sha512-AHkxfzl5RbWfHO9HOdLE4oZ0c3nxqkXKHc69t83GWYoAquZmSeoCjmLP5rPgbHvwv4DcfLr8WW8PWbtNIQI+vw==",
154
+
"dependencies": [
155
+
"@standard-schema/spec",
156
+
"fast-check"
157
+
]
158
+
},
144
159
"error-causes@3.0.2": {
145
160
"integrity": "sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw=="
146
161
},
162
+
"fast-check@3.23.2": {
163
+
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
164
+
"dependencies": [
165
+
"pure-rand"
166
+
]
167
+
},
147
168
"kysely@0.27.6": {
148
169
"integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ=="
149
170
},
150
-
"lodash@4.17.21": {
151
-
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
152
-
},
153
171
"moniker@0.1.2": {
154
172
"integrity": "sha512-Uj9iV0QYr6281G+o0TvqhKwHHWB2Q/qUTT4LPQ3qDGc0r8cbMuqQjRXPZuVZ+gcL7APx+iQgE8lcfWPrj1LsLA=="
173
+
},
174
+
"pure-rand@6.1.0": {
175
+
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="
155
176
}
156
177
},
157
178
"workspace": {
158
179
"dependencies": [
159
180
"jsr:@cliffy/command@^1.0.0-rc.8",
181
+
"jsr:@cliffy/flags@^1.0.0-rc.8",
160
182
"jsr:@cliffy/table@^1.0.0-rc.8",
161
183
"jsr:@db/sqlite@0.12",
184
+
"jsr:@es-toolkit/es-toolkit@^1.41.0",
162
185
"jsr:@soapbox/kysely-deno-sqlite@^2.2.0",
163
186
"jsr:@std/assert@1",
164
187
"npm:@paralleldrive/cuid2@^3.0.4",
165
188
"npm:chalk@^5.6.2",
166
189
"npm:dayjs@^1.11.19",
190
+
"npm:effect@^3.19.2",
167
191
"npm:kysely@0.27.6",
168
-
"npm:lodash@^4.17.21",
169
192
"npm:moniker@~0.1.2"
170
193
]
171
194
}
+61
-3
main.ts
+61
-3
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 pkg from "./deno.json" with { type: "json" };
4
5
import { createBridgeNetworkIfNeeded } from "./src/network.ts";
5
6
import inspect from "./src/subcommands/inspect.ts";
7
+
import logs from "./src/subcommands/logs.ts";
6
8
import ps from "./src/subcommands/ps.ts";
9
+
import restart from "./src/subcommands/restart.ts";
7
10
import rm from "./src/subcommands/rm.ts";
8
11
import start from "./src/subcommands/start.ts";
9
12
import stop from "./src/subcommands/stop.ts";
···
19
22
if (import.meta.main) {
20
23
await new Command()
21
24
.name("openindiana-up")
22
-
.version("0.1.0")
25
+
.version(pkg.version)
23
26
.description("Start a OpenIndiana virtual machine using QEMU")
24
27
.arguments(
25
28
"[path-or-url-to-iso-or-version:string]",
···
52
55
.option(
53
56
"-b, --bridge <name:string>",
54
57
"Name of the network bridge to use for networking (e.g., br0)",
58
+
)
59
+
.option(
60
+
"-d, --detach",
61
+
"Run VM in the background and print VM name",
62
+
)
63
+
.option(
64
+
"-p, --port-forward <mappings:string>",
65
+
"Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)",
55
66
)
56
67
.example(
57
68
"Default usage",
···
125
136
})
126
137
.command("start", "Start a virtual machine")
127
138
.arguments("<vm-name:string>")
128
-
.action(async (_options: unknown, vmName: string) => {
129
-
await start(vmName);
139
+
.option("-c, --cpu <type:string>", "Type of CPU to emulate", {
140
+
default: "host",
141
+
})
142
+
.option("-C, --cpus <number:number>", "Number of CPU cores", {
143
+
default: 2,
144
+
})
145
+
.option("-m, --memory <size:string>", "Amount of memory for the VM", {
146
+
default: "2G",
147
+
})
148
+
.option("-i, --image <path:string>", "Path to VM disk image")
149
+
.option(
150
+
"--disk-format <format:string>",
151
+
"Disk image format (e.g., qcow2, raw)",
152
+
{
153
+
default: "raw",
154
+
},
155
+
)
156
+
.option(
157
+
"--size <size:string>",
158
+
"Size of the VM disk image to create if it doesn't exist (e.g., 20G)",
159
+
{
160
+
default: "20G",
161
+
},
162
+
)
163
+
.option(
164
+
"-b, --bridge <name:string>",
165
+
"Name of the network bridge to use for networking (e.g., br0)",
166
+
)
167
+
.option(
168
+
"-d, --detach",
169
+
"Run VM in the background and print VM name",
170
+
)
171
+
.option(
172
+
"-p, --port-forward <mappings:string>",
173
+
"Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)",
174
+
)
175
+
.action(async (options: unknown, vmName: string) => {
176
+
await start(vmName, Boolean((options as { detach: boolean }).detach));
130
177
})
131
178
.command("stop", "Stop a virtual machine")
132
179
.arguments("<vm-name:string>")
···
142
189
.arguments("<vm-name:string>")
143
190
.action(async (_options: unknown, vmName: string) => {
144
191
await rm(vmName);
192
+
})
193
+
.command("logs", "View logs of a virtual machine")
194
+
.option("--follow, -f", "Follow log output")
195
+
.arguments("<vm-name:string>")
196
+
.action(async (options: unknown, vmName: string) => {
197
+
await logs(vmName, Boolean((options as { follow: boolean }).follow));
198
+
})
199
+
.command("restart", "Restart a virtual machine")
200
+
.arguments("<vm-name:string>")
201
+
.action(async (_options: unknown, vmName: string) => {
202
+
await restart(vmName);
145
203
})
146
204
.parse(Deno.args);
147
205
}
+2
src/constants.ts
+2
src/constants.ts
+19
-2
src/db.ts
+19
-2
src/db.ts
···
35
35
drivePath?: string;
36
36
diskFormat: string;
37
37
isoPath?: string;
38
+
portForward?: string;
38
39
version: string;
39
40
status: STATUS;
40
41
pid: number;
···
68
69
.addColumn("diskFormat", "varchar")
69
70
.addColumn("isoPath", "varchar")
70
71
.addColumn("status", "varchar", (col) => col.notNull())
71
-
.addColumn("pid", "integer", (col) => col.notNull().unique())
72
+
.addColumn("pid", "integer")
72
73
.addColumn(
73
74
"createdAt",
74
75
"varchar",
···
87
88
},
88
89
};
89
90
90
-
export const migrateToLatest = async (db: Database) => {
91
+
migrations["002"] = {
92
+
async up(db: Kysely<unknown>): Promise<void> {
93
+
await db.schema
94
+
.alterTable("virtual_machines")
95
+
.addColumn("portForward", "varchar")
96
+
.execute();
97
+
},
98
+
99
+
async down(db: Kysely<unknown>): Promise<void> {
100
+
await db.schema
101
+
.alterTable("virtual_machines")
102
+
.dropColumn("portForward")
103
+
.execute();
104
+
},
105
+
};
106
+
107
+
export const migrateToLatest = async (db: Database): Promise<void> => {
91
108
const migrator = new Migrator({ db, provider: migrationProvider });
92
109
const { error } = await migrator.migrateToLatest();
93
110
if (error) throw error;
+23
src/subcommands/logs.ts
+23
src/subcommands/logs.ts
···
1
+
import { LOGS_DIR } from "../constants.ts";
2
+
3
+
export default async function (name: string, follow: boolean) {
4
+
await Deno.mkdir(LOGS_DIR, { recursive: true });
5
+
const logPath = `${LOGS_DIR}/${name}.log`;
6
+
7
+
const cmd = new Deno.Command(follow ? "tail" : "cat", {
8
+
args: [
9
+
...(follow ? ["-n", "100", "-f"] : []),
10
+
logPath,
11
+
],
12
+
stdin: "inherit",
13
+
stdout: "inherit",
14
+
stderr: "inherit",
15
+
});
16
+
17
+
const status = await cmd.spawn().status;
18
+
19
+
if (!status.success) {
20
+
console.error(`Failed to view logs for virtual machine ${name}.`);
21
+
Deno.exit(status.code);
22
+
}
23
+
}
+97
src/subcommands/restart.ts
+97
src/subcommands/restart.ts
···
1
+
import _ from "@es-toolkit/es-toolkit/compat";
2
+
import chalk from "chalk";
3
+
import { LOGS_DIR } from "../constants.ts";
4
+
import { getInstanceState, updateInstanceState } from "../state.ts";
5
+
import { safeKillQemu } from "../utils.ts";
6
+
7
+
export default async function (name: string) {
8
+
const vm = await getInstanceState(name);
9
+
if (!vm) {
10
+
console.error(
11
+
`Virtual machine with name or ID ${chalk.greenBright(name)} not found.`,
12
+
);
13
+
Deno.exit(1);
14
+
}
15
+
16
+
const success = await safeKillQemu(vm.pid, Boolean(vm.bridge));
17
+
18
+
if (!success) {
19
+
console.error(
20
+
`Failed to stop virtual machine ${chalk.greenBright(vm.name)}.`,
21
+
);
22
+
Deno.exit(1);
23
+
}
24
+
await updateInstanceState(vm.id, "STOPPED");
25
+
26
+
await new Promise((resolve) => setTimeout(resolve, 2000));
27
+
28
+
await Deno.mkdir(LOGS_DIR, { recursive: true });
29
+
const logPath = `${LOGS_DIR}/${vm.name}.log`;
30
+
31
+
const qemuArgs = [
32
+
..._.compact([vm.bridge && "qemu-system-x86_64"]),
33
+
...Deno.build.os === "linux" ? ["-enable-kvm"] : [],
34
+
"-cpu",
35
+
vm.cpu,
36
+
"-m",
37
+
vm.memory,
38
+
"-smp",
39
+
vm.cpus.toString(),
40
+
..._.compact([vm.isoPath && "-cdrom", vm.isoPath]),
41
+
"-netdev",
42
+
vm.bridge
43
+
? `bridge,id=net0,br=${vm.bridge}`
44
+
: "user,id=net0,hostfwd=tcp::2222-:22",
45
+
"-device",
46
+
`e1000,netdev=net0,mac=${vm.macAddress}`,
47
+
"-device",
48
+
"ahci,id=ahci0",
49
+
"-nographic",
50
+
"-monitor",
51
+
"none",
52
+
"-chardev",
53
+
"stdio,id=con0,signal=off",
54
+
"-serial",
55
+
"chardev:con0",
56
+
..._.compact(
57
+
vm.drivePath && [
58
+
"-drive",
59
+
`file=${vm.drivePath},format=${vm.diskFormat},if=none,id=disk0`,
60
+
"-device",
61
+
"ide-hd,drive=disk0,bus=ahci0.0",
62
+
],
63
+
),
64
+
];
65
+
66
+
const fullCommand = vm.bridge
67
+
? `sudo qemu-system-x86_64 ${
68
+
qemuArgs.slice(1).join(" ")
69
+
} >> "${logPath}" 2>&1 & echo $!`
70
+
: `qemu-system-x86_64 ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`;
71
+
72
+
const cmd = new Deno.Command("sh", {
73
+
args: ["-c", fullCommand],
74
+
stdin: "null",
75
+
stdout: "piped",
76
+
});
77
+
78
+
const { stdout } = await cmd.spawn().output();
79
+
const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10);
80
+
81
+
await new Promise((resolve) => setTimeout(resolve, 2000));
82
+
83
+
await updateInstanceState(vm.id, "RUNNING", qemuPid);
84
+
85
+
console.log(
86
+
`${chalk.greenBright(vm.name)} restarted with PID ${
87
+
chalk.greenBright(qemuPid)
88
+
}.`,
89
+
);
90
+
console.log(
91
+
`Logs are being written to ${chalk.blueBright(logPath)}`,
92
+
);
93
+
94
+
await new Promise((resolve) => setTimeout(resolve, 2000));
95
+
96
+
Deno.exit(0);
97
+
}
+102
-44
src/subcommands/start.ts
+102
-44
src/subcommands/start.ts
···
1
-
import _ from "lodash";
1
+
import { parseFlags } from "@cliffy/flags";
2
+
import _ from "@es-toolkit/es-toolkit/compat";
3
+
import { LOGS_DIR } from "../constants.ts";
4
+
import type { VirtualMachine } from "../db.ts";
2
5
import { getInstanceState, updateInstanceState } from "../state.ts";
3
6
4
-
export default async function (name: string) {
5
-
const vm = await getInstanceState(name);
7
+
export default async function (name: string, detach: boolean = false) {
8
+
let vm = await getInstanceState(name);
6
9
if (!vm) {
7
10
console.error(
8
11
`Virtual machine with name or ID ${name} not found.`,
···
12
15
13
16
console.log(`Starting virtual machine ${vm.name} (ID: ${vm.id})...`);
14
17
15
-
const cmd = new Deno.Command(vm.bridge ? "sudo" : "qemu-system-x86_64", {
16
-
args: [
17
-
..._.compact([vm.bridge && "qemu-system-x86_64"]),
18
-
"-enable-kvm",
19
-
"-cpu",
20
-
vm.cpu,
21
-
"-m",
22
-
vm.memory,
23
-
"-smp",
24
-
vm.cpus.toString(),
25
-
..._.compact([vm.isoPath && "-cdrom", vm.isoPath]),
26
-
"-netdev",
27
-
vm.bridge
28
-
? `bridge,id=net0,br=${vm.bridge}`
29
-
: "user,id=net0,hostfwd=tcp::2222-:22",
30
-
"-device",
31
-
`e1000,netdev=net0,mac=${vm.macAddress}`,
32
-
"-nographic",
33
-
"-monitor",
34
-
"none",
35
-
"-chardev",
36
-
"stdio,id=con0,signal=off",
37
-
"-serial",
38
-
"chardev:con0",
39
-
..._.compact(
40
-
vm.drivePath && [
41
-
"-drive",
42
-
`file=${vm.drivePath},format=${vm.diskFormat},if=virtio`,
43
-
],
44
-
),
45
-
],
46
-
stdin: "inherit",
47
-
stdout: "inherit",
48
-
stderr: "inherit",
49
-
})
50
-
.spawn();
18
+
vm = mergeFlags(vm);
51
19
52
-
await updateInstanceState(name, "RUNNING", cmd.pid);
20
+
const qemuArgs = [
21
+
..._.compact([vm.bridge && "qemu-system-x86_64"]),
22
+
...Deno.build.os === "linux" ? ["-enable-kvm"] : [],
23
+
"-cpu",
24
+
vm.cpu,
25
+
"-m",
26
+
vm.memory,
27
+
"-smp",
28
+
vm.cpus.toString(),
29
+
..._.compact([vm.isoPath && "-cdrom", vm.isoPath]),
30
+
"-netdev",
31
+
vm.bridge
32
+
? `bridge,id=net0,br=${vm.bridge}`
33
+
: "user,id=net0,hostfwd=tcp::2222-:22",
34
+
"-device",
35
+
`e1000,netdev=net0,mac=${vm.macAddress}`,
36
+
"-device",
37
+
"ahci,id=ahci0",
38
+
"-nographic",
39
+
"-monitor",
40
+
"none",
41
+
"-chardev",
42
+
"stdio,id=con0,signal=off",
43
+
"-serial",
44
+
"chardev:con0",
45
+
..._.compact(
46
+
vm.drivePath && [
47
+
"-drive",
48
+
`file=${vm.drivePath},format=${vm.diskFormat},if=none,id=disk0`,
49
+
"-device",
50
+
"ide-hd,drive=disk0,bus=ahci0.0",
51
+
],
52
+
),
53
+
];
53
54
54
-
const status = await cmd.status;
55
+
if (detach) {
56
+
await Deno.mkdir(LOGS_DIR, { recursive: true });
57
+
const logPath = `${LOGS_DIR}/${vm.name}.log`;
58
+
59
+
const fullCommand = vm.bridge
60
+
? `sudo qemu-system-x86_64 ${
61
+
qemuArgs.slice(1).join(" ")
62
+
} >> "${logPath}" 2>&1 & echo $!`
63
+
: `qemu-system-x86_64 ${
64
+
qemuArgs.join(" ")
65
+
} >> "${logPath}" 2>&1 & echo $!`;
66
+
67
+
const cmd = new Deno.Command("sh", {
68
+
args: ["-c", fullCommand],
69
+
stdin: "null",
70
+
stdout: "piped",
71
+
});
72
+
73
+
const { stdout } = await cmd.spawn().output();
74
+
const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10);
75
+
76
+
await updateInstanceState(name, "RUNNING", qemuPid);
77
+
78
+
console.log(
79
+
`Virtual machine ${vm.name} started in background (PID: ${qemuPid})`,
80
+
);
81
+
console.log(`Logs will be written to: ${logPath}`);
82
+
83
+
// Exit successfully while keeping VM running in background
84
+
Deno.exit(0);
85
+
} else {
86
+
const cmd = new Deno.Command(vm.bridge ? "sudo" : "qemu-system-x86_64", {
87
+
args: qemuArgs,
88
+
stdin: "inherit",
89
+
stdout: "inherit",
90
+
stderr: "inherit",
91
+
});
55
92
56
-
await updateInstanceState(name, "STOPPED", cmd.pid);
93
+
const child = cmd.spawn();
94
+
await updateInstanceState(name, "RUNNING", child.pid);
95
+
96
+
const status = await child.status;
97
+
98
+
await updateInstanceState(name, "STOPPED", child.pid);
57
99
58
-
if (!status.success) {
59
-
Deno.exit(status.code);
100
+
if (!status.success) {
101
+
Deno.exit(status.code);
102
+
}
60
103
}
61
104
}
105
+
106
+
function mergeFlags(vm: VirtualMachine): VirtualMachine {
107
+
const { flags } = parseFlags(Deno.args);
108
+
return {
109
+
...vm,
110
+
memory: flags.memory ? String(flags.memory) : vm.memory,
111
+
cpus: flags.cpus ? Number(flags.cpus) : vm.cpus,
112
+
cpu: flags.cpu ? String(flags.cpu) : vm.cpu,
113
+
diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat,
114
+
portForward: flags.portForward ? String(flags.portForward) : vm.portForward,
115
+
drivePath: flags.image ? String(flags.image) : vm.drivePath,
116
+
bridge: flags.bridge ? String(flags.bridge) : vm.bridge,
117
+
diskSize: flags.size ? String(flags.size) : vm.diskSize,
118
+
};
119
+
}
+1
-1
src/subcommands/stop.ts
+1
-1
src/subcommands/stop.ts
+192
-58
src/utils.ts
+192
-58
src/utils.ts
···
1
+
import _ from "@es-toolkit/es-toolkit/compat";
1
2
import { createId } from "@paralleldrive/cuid2";
2
3
import chalk from "chalk";
3
-
import _ from "lodash";
4
4
import Moniker from "moniker";
5
+
import { EMPTY_DISK_THRESHOLD_KB, LOGS_DIR } from "./constants.ts";
5
6
import { generateRandomMacAddress } from "./network.ts";
6
-
import { saveInstanceState } from "./state.ts";
7
+
import { saveInstanceState, updateInstanceState } from "./state.ts";
7
8
8
9
const DEFAULT_VERSION = "20251026";
9
10
···
16
17
diskFormat: string;
17
18
size: string;
18
19
bridge?: string;
20
+
portForward?: string;
21
+
detach?: boolean;
19
22
}
20
23
21
24
async function du(path: string): Promise<number> {
···
37
40
}
38
41
39
42
const size = await du(path);
40
-
return size < 10;
43
+
return size < EMPTY_DISK_THRESHOLD_KB;
41
44
}
42
45
43
46
export async function downloadIso(
···
49
52
50
53
if (options.image && await Deno.stat(options.image).catch(() => false)) {
51
54
const driveSize = await du(options.image);
52
-
if (driveSize > 10) {
55
+
if (driveSize > EMPTY_DISK_THRESHOLD_KB) {
53
56
console.log(
54
57
chalk.yellowBright(
55
58
`Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`,
···
89
92
return `https://dlc.openindiana.org/isos/hipster/${version}/OI-hipster-text-${version}.iso`;
90
93
}
91
94
95
+
export function setupPortForwardingArgs(portForward?: string): string {
96
+
if (!portForward) {
97
+
return "";
98
+
}
99
+
100
+
const forwards = portForward.split(",").map((pair) => {
101
+
const [hostPort, guestPort] = pair.split(":");
102
+
return `hostfwd=tcp::${hostPort}-:${guestPort}`;
103
+
});
104
+
105
+
return forwards.join(",");
106
+
}
107
+
108
+
export function setupNATNetworkArgs(portForward?: string): string {
109
+
if (!portForward) {
110
+
return "user,id=net0";
111
+
}
112
+
113
+
const portForwarding = setupPortForwardingArgs(portForward);
114
+
return `user,id=net0,${portForwarding}`;
115
+
}
116
+
92
117
export async function runQemu(
93
118
isoPath: string | null,
94
119
options: Options,
95
120
): Promise<void> {
96
121
const macAddress = generateRandomMacAddress();
97
-
const cmd = new Deno.Command(options.bridge ? "sudo" : "qemu-system-x86_64", {
98
-
args: [
99
-
..._.compact([options.bridge && "qemu-system-x86_64"]),
100
-
"-enable-kvm",
101
-
"-cpu",
102
-
options.cpu,
103
-
"-m",
104
-
options.memory,
105
-
"-smp",
106
-
options.cpus.toString(),
107
-
..._.compact([isoPath && "-cdrom", isoPath]),
108
-
"-netdev",
109
-
options.bridge
110
-
? `bridge,id=net0,br=${options.bridge}`
111
-
: "user,id=net0,hostfwd=tcp::2222-:22",
112
-
"-device",
113
-
`e1000,netdev=net0,mac=${macAddress}`,
114
-
"-nographic",
115
-
"-monitor",
116
-
"none",
117
-
"-chardev",
118
-
"stdio,id=con0,signal=off",
119
-
"-serial",
120
-
"chardev:con0",
121
-
..._.compact(
122
-
options.image && [
123
-
"-drive",
124
-
`file=${options.image},format=${options.diskFormat},if=virtio`,
125
-
],
126
-
),
127
-
],
128
-
stdin: "inherit",
129
-
stdout: "inherit",
130
-
stderr: "inherit",
131
-
}).spawn();
132
122
133
-
await saveInstanceState({
134
-
id: createId(),
135
-
name: Moniker.choose(),
136
-
bridge: options.bridge,
137
-
macAddress,
138
-
memory: options.memory,
139
-
cpus: options.cpus,
140
-
cpu: options.cpu,
141
-
diskSize: options.size,
142
-
diskFormat: options.diskFormat,
143
-
isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined,
144
-
drivePath: options.image ? Deno.realPathSync(options.image) : undefined,
145
-
version: DEFAULT_VERSION,
146
-
status: "RUNNING",
147
-
pid: cmd.pid,
148
-
});
123
+
const qemuArgs = [
124
+
..._.compact([options.bridge && "qemu-system-x86_64"]),
125
+
...Deno.build.os === "linux" ? ["-enable-kvm"] : [],
126
+
"-cpu",
127
+
options.cpu,
128
+
"-m",
129
+
options.memory,
130
+
"-smp",
131
+
options.cpus.toString(),
132
+
..._.compact([isoPath && "-cdrom", isoPath]),
133
+
"-netdev",
134
+
options.bridge
135
+
? `bridge,id=net0,br=${options.bridge}`
136
+
: "user,id=net0,hostfwd=tcp::2222-:22",
137
+
"-device",
138
+
`e1000,netdev=net0,mac=${macAddress}`,
139
+
"-device",
140
+
"ahci,id=ahci0",
141
+
"-nographic",
142
+
"-monitor",
143
+
"none",
144
+
"-chardev",
145
+
"stdio,id=con0,signal=off",
146
+
"-serial",
147
+
"chardev:con0",
148
+
..._.compact(
149
+
options.image && [
150
+
"-drive",
151
+
`file=${options.image},format=${options.diskFormat},if=none,id=disk0`,
152
+
"-device",
153
+
"ide-hd,drive=disk0,bus=ahci0.0",
154
+
],
155
+
),
156
+
];
149
157
150
-
const status = await cmd.status;
158
+
const name = Moniker.choose();
151
159
152
-
if (!status.success) {
153
-
Deno.exit(status.code);
160
+
if (options.detach) {
161
+
await Deno.mkdir(LOGS_DIR, { recursive: true });
162
+
const logPath = `${LOGS_DIR}/${name}.log`;
163
+
164
+
const fullCommand = options.bridge
165
+
? `sudo qemu-system-x86_64 ${
166
+
qemuArgs.slice(1).join(" ")
167
+
} >> "${logPath}" 2>&1 & echo $!`
168
+
: `qemu-system-x86_64 ${
169
+
qemuArgs.join(" ")
170
+
} >> "${logPath}" 2>&1 & echo $!`;
171
+
172
+
const cmd = new Deno.Command("sh", {
173
+
args: ["-c", fullCommand],
174
+
stdin: "null",
175
+
stdout: "piped",
176
+
});
177
+
178
+
const { stdout } = await cmd.spawn().output();
179
+
const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10);
180
+
181
+
await saveInstanceState({
182
+
id: createId(),
183
+
name,
184
+
bridge: options.bridge,
185
+
macAddress,
186
+
memory: options.memory,
187
+
cpus: options.cpus,
188
+
cpu: options.cpu,
189
+
diskSize: options.size,
190
+
diskFormat: options.diskFormat,
191
+
portForward: options.portForward,
192
+
isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined,
193
+
drivePath: options.image ? Deno.realPathSync(options.image) : undefined,
194
+
version: DEFAULT_VERSION,
195
+
status: "RUNNING",
196
+
pid: qemuPid,
197
+
});
198
+
199
+
console.log(
200
+
`Virtual machine ${name} started in background (PID: ${qemuPid})`,
201
+
);
202
+
console.log(`Logs will be written to: ${logPath}`);
203
+
204
+
// Exit successfully while keeping VM running in background
205
+
Deno.exit(0);
206
+
} else {
207
+
const cmd = new Deno.Command(
208
+
options.bridge ? "sudo" : "qemu-system-x86_64",
209
+
{
210
+
args: qemuArgs,
211
+
stdin: "inherit",
212
+
stdout: "inherit",
213
+
stderr: "inherit",
214
+
},
215
+
)
216
+
.spawn();
217
+
218
+
await saveInstanceState({
219
+
id: createId(),
220
+
name,
221
+
bridge: options.bridge,
222
+
macAddress,
223
+
memory: options.memory,
224
+
cpus: options.cpus,
225
+
cpu: options.cpu,
226
+
diskSize: options.size,
227
+
diskFormat: options.diskFormat,
228
+
portForward: options.portForward,
229
+
isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined,
230
+
drivePath: options.image ? Deno.realPathSync(options.image) : undefined,
231
+
version: DEFAULT_VERSION,
232
+
status: "RUNNING",
233
+
pid: cmd.pid,
234
+
});
235
+
236
+
const status = await cmd.status;
237
+
238
+
await updateInstanceState(name, "STOPPED");
239
+
240
+
if (!status.success) {
241
+
Deno.exit(status.code);
242
+
}
154
243
}
155
244
}
156
245
···
174
263
}
175
264
176
265
return input;
266
+
}
267
+
268
+
export async function safeKillQemu(
269
+
pid: number,
270
+
useSudo: boolean = false,
271
+
): Promise<boolean> {
272
+
const killArgs = useSudo
273
+
? ["sudo", "kill", "-TERM", pid.toString()]
274
+
: ["kill", "-TERM", pid.toString()];
275
+
276
+
const termCmd = new Deno.Command(killArgs[0], {
277
+
args: killArgs.slice(1),
278
+
stdout: "null",
279
+
stderr: "null",
280
+
});
281
+
282
+
const termStatus = await termCmd.spawn().status;
283
+
284
+
if (termStatus.success) {
285
+
await new Promise((resolve) => setTimeout(resolve, 3000));
286
+
287
+
const checkCmd = new Deno.Command("kill", {
288
+
args: ["-0", pid.toString()],
289
+
stdout: "null",
290
+
stderr: "null",
291
+
});
292
+
293
+
const checkStatus = await checkCmd.spawn().status;
294
+
if (!checkStatus.success) {
295
+
return true;
296
+
}
297
+
}
298
+
299
+
const killKillArgs = useSudo
300
+
? ["sudo", "kill", "-KILL", pid.toString()]
301
+
: ["kill", "-KILL", pid.toString()];
302
+
303
+
const killCmd = new Deno.Command(killKillArgs[0], {
304
+
args: killKillArgs.slice(1),
305
+
stdout: "null",
306
+
stderr: "null",
307
+
});
308
+
309
+
const killStatus = await killCmd.spawn().status;
310
+
return killStatus.success;
177
311
}
178
312
179
313
export async function createDriveImageIfNeeded(