+68
-9
README.md
+68
-9
README.md
···
16
16
- ๐ **SSH Ready** - Pre-configured port forwarding (host:2222 โ guest:22)
17
17
- ๐๏ธ **Customizable** - Configure CPU, memory, cores, and disk options
18
18
- ๐ฅ **Smart Caching** - Skips re-downloading existing ISO files
19
+
- ๐ฎ **VM Management** - Start, stop, list, and inspect virtual machines
20
+
- ๐ **State Persistence** - SQLite database tracks VM state and configuration
21
+
- ๐ท๏ธ **Auto-naming** - Generates unique names for VMs automatically
22
+
- ๐ **Bridge Networking** - Support for bridge networking with custom network
23
+
interfaces
24
+
- ๐ **MAC Address Management** - Automatic MAC address generation for network
25
+
devices
19
26
20
27
## ๐ Requirements
21
28
···
55
62
56
63
### Options
57
64
58
-
| Option | Description | Default |
59
-
| ------------------------ | ------------------------------ | ------------ |
60
-
| `-o, --output <path>` | Output path for downloaded ISO | ISO filename |
61
-
| `-c, --cpu <type>` | CPU type to emulate | `host` |
62
-
| `-C, --cpus <number>` | Number of CPU cores | `2` |
63
-
| `-m, --memory <size>` | RAM allocation | `2G` |
64
-
| `-d, --drive <path>` | Path to virtual disk image | None |
65
-
| `--disk-format <format>` | Disk format (qcow2, raw, etc.) | `raw` |
65
+
| Option | Description | Default |
66
+
| ------------------------ | ------------------------------------------------------------ | ------------ |
67
+
| `-o, --output <path>` | Output path for downloaded ISO | ISO filename |
68
+
| `-c, --cpu <type>` | CPU type to emulate | `host` |
69
+
| `-C, --cpus <number>` | Number of CPU cores | `2` |
70
+
| `-m, --memory <size>` | RAM allocation | `2G` |
71
+
| `-i, --image <path>` | Path to virtual disk image | None |
72
+
| `--disk-format <format>` | Disk format (qcow2, raw, etc.) | `raw` |
73
+
| `--size <size>` | Size of the VM disk image to create if it does not exist | `20G` |
74
+
| `-b, --bridge <name>` | Name of the network bridge to use for networking (e.g., br0) | None |
75
+
76
+
### VM Management Commands
77
+
78
+
| Command | Description |
79
+
| ---------------------------------- | --------------------------------------------- |
80
+
| `openindiana-up ps` | List all running virtual machines |
81
+
| `openindiana-up ps --all` | List all virtual machines (including stopped) |
82
+
| `openindiana-up start <vm-name>` | Start a stopped virtual machine |
83
+
| `openindiana-up stop <vm-name>` | Stop a running virtual machine |
84
+
| `openindiana-up inspect <vm-name>` | Inspect virtual machine configuration |
66
85
67
86
## ๐ก Examples
68
87
···
94
113
openindiana-up -o ~/isos/openindiana.iso
95
114
```
96
115
116
+
### VM Management Examples
117
+
118
+
```bash
119
+
# List all running VMs
120
+
openindiana-up ps
121
+
122
+
# List all VMs (including stopped ones)
123
+
openindiana-up ps --all
124
+
125
+
# Start a specific VM
126
+
openindiana-up start my-vm-name
127
+
128
+
# Stop a running VM
129
+
openindiana-up stop my-vm-name
130
+
131
+
# Inspect VM configuration
132
+
openindiana-up inspect my-vm-name
133
+
```
134
+
135
+
### Bridge Networking
136
+
137
+
```bash
138
+
# Use bridge networking (requires bridge setup)
139
+
openindiana-up --bridge br0
140
+
```
141
+
142
+
### Automatic Disk Creation
143
+
144
+
```bash
145
+
# Automatically create a 50GB disk if it doesn't exist
146
+
openindiana-up --image my-disk.qcow2 --disk-format qcow2 --size 50G
147
+
```
148
+
97
149
## ๐ฅ๏ธ Console Setup
98
150
99
151
When OpenIndiana boots, you'll see the boot menu. For the best experience with
···
118
170
- **Memory**: 2GB RAM (configurable with `--memory`)
119
171
- **Cores**: 2 virtual CPUs (configurable with `--cpus`)
120
172
- **Storage**: ISO-only by default; optional persistent disk (configurable with
121
-
`--drive`)
173
+
`--image`)
122
174
- **Network**: User mode networking with SSH forwarding
123
175
- **Console**: Enhanced serial console via stdio with proper signal handling
124
176
- **Default Version**: OpenIndiana 20251026 (when no arguments provided)
···
157
209
- Downloaded ISOs are cached and won't be re-downloaded if they exist
158
210
- KVM acceleration requires `/dev/kvm` access on your host system
159
211
- Serial console is connected to stdio for direct interaction
212
+
- VM state is automatically persisted in a SQLite database at
213
+
`~/.openindiana-up/state.sqlite`
214
+
- Each VM gets a unique randomly generated name using the Moniker library
215
+
- MAC addresses are automatically generated for network devices
216
+
- Bridge networking requires proper bridge configuration and may need sudo
217
+
privileges
218
+
- VMs can be managed independently with start/stop/inspect commands
160
219
161
220
## ๐ License
162
221
+16
-2
deno.json
+16
-2
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",
12
+
"@cliffy/table": "jsr:@cliffy/table@^1.0.0-rc.8",
13
+
"@db/sqlite": "jsr:@db/sqlite@^0.12.0",
14
+
"@es-toolkit/es-toolkit": "jsr:@es-toolkit/es-toolkit@^1.41.0",
15
+
"@paralleldrive/cuid2": "npm:@paralleldrive/cuid2@^3.0.4",
16
+
"@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.2.0",
7
17
"@std/assert": "jsr:@std/assert@1",
8
-
"chalk": "npm:chalk@^5.6.2"
18
+
"chalk": "npm:chalk@^5.6.2",
19
+
"dayjs": "npm:dayjs@^1.11.19",
20
+
"effect": "npm:effect@^3.19.2",
21
+
"kysely": "npm:kysely@0.27.6",
22
+
"moniker": "npm:moniker@^0.1.2"
9
23
}
10
-
}
24
+
}
+134
-7
deno.lock
+134
-7
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",
9
+
"jsr:@cliffy/table@^1.0.0-rc.8": "1.0.0-rc.8",
10
+
"jsr:@db/sqlite@0.12": "0.12.0",
11
+
"jsr:@denosaurs/plug@1": "1.1.0",
12
+
"jsr:@es-toolkit/es-toolkit@^1.41.0": "1.41.0",
13
+
"jsr:@soapbox/kysely-deno-sqlite@^2.2.0": "2.2.0",
14
+
"jsr:@std/assert@0.217": "0.217.0",
8
15
"jsr:@std/assert@1": "1.0.15",
16
+
"jsr:@std/encoding@1": "1.0.10",
17
+
"jsr:@std/fmt@1": "1.0.8",
9
18
"jsr:@std/fmt@~1.0.2": "1.0.8",
19
+
"jsr:@std/fs@1": "1.0.19",
20
+
"jsr:@std/internal@^1.0.10": "1.0.12",
10
21
"jsr:@std/internal@^1.0.12": "1.0.12",
22
+
"jsr:@std/internal@^1.0.9": "1.0.12",
23
+
"jsr:@std/path@0.217": "0.217.0",
24
+
"jsr:@std/path@1": "1.1.2",
25
+
"jsr:@std/path@^1.1.1": "1.1.2",
11
26
"jsr:@std/text@~1.0.7": "1.0.16",
12
-
"npm:chalk@^5.6.2": "5.6.2"
27
+
"npm:@paralleldrive/cuid2@^3.0.4": "3.0.4",
28
+
"npm:chalk@^5.6.2": "5.6.2",
29
+
"npm:dayjs@^1.11.19": "1.11.19",
30
+
"npm:effect@^3.19.2": "3.19.2",
31
+
"npm:kysely@0.27.6": "0.27.6",
32
+
"npm:kysely@~0.27.2": "0.27.6",
33
+
"npm:moniker@~0.1.2": "0.1.2"
13
34
},
14
35
"jsr": {
15
36
"@cliffy/command@1.0.0-rc.8": {
16
37
"integrity": "758147790797c74a707e5294cc7285df665422a13d2a483437092ffce40b5557",
17
38
"dependencies": [
18
-
"jsr:@cliffy/flags",
39
+
"jsr:@cliffy/flags@1.0.0-rc.8",
19
40
"jsr:@cliffy/internal",
20
-
"jsr:@cliffy/table",
21
-
"jsr:@std/fmt",
41
+
"jsr:@cliffy/table@1.0.0-rc.8",
42
+
"jsr:@std/fmt@~1.0.2",
22
43
"jsr:@std/text"
23
44
]
24
45
},
···
34
55
"@cliffy/table@1.0.0-rc.8": {
35
56
"integrity": "8bbcdc2ba5e0061b4b13810a24e6f5c6ab19c09f0cce9eb691ccd76c7c6c9db5",
36
57
"dependencies": [
37
-
"jsr:@std/fmt"
58
+
"jsr:@std/fmt@~1.0.2"
59
+
]
60
+
},
61
+
"@db/sqlite@0.12.0": {
62
+
"integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f",
63
+
"dependencies": [
64
+
"jsr:@denosaurs/plug",
65
+
"jsr:@std/path@0.217"
66
+
]
67
+
},
68
+
"@denosaurs/plug@1.1.0": {
69
+
"integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044",
70
+
"dependencies": [
71
+
"jsr:@std/encoding",
72
+
"jsr:@std/fmt@1",
73
+
"jsr:@std/fs",
74
+
"jsr:@std/path@1"
75
+
]
76
+
},
77
+
"@es-toolkit/es-toolkit@1.41.0": {
78
+
"integrity": "4df54a18e80b869880cee8a8a9ff7a5e1c424a9fd0916dccd38d34686f110071"
79
+
},
80
+
"@soapbox/kysely-deno-sqlite@2.2.0": {
81
+
"integrity": "668ec94600bc4b4d7bd618dd7ca65d4ef30ee61c46ffcb379b6f45203c08517a",
82
+
"dependencies": [
83
+
"npm:kysely@~0.27.2"
38
84
]
85
+
},
86
+
"@std/assert@0.217.0": {
87
+
"integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642"
39
88
},
40
89
"@std/assert@1.0.15": {
41
90
"integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b",
42
91
"dependencies": [
43
-
"jsr:@std/internal"
92
+
"jsr:@std/internal@^1.0.12"
44
93
]
45
94
},
95
+
"@std/encoding@1.0.10": {
96
+
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
97
+
},
46
98
"@std/fmt@1.0.8": {
47
99
"integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7"
100
+
},
101
+
"@std/fs@1.0.19": {
102
+
"integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06",
103
+
"dependencies": [
104
+
"jsr:@std/internal@^1.0.9",
105
+
"jsr:@std/path@^1.1.1"
106
+
]
48
107
},
49
108
"@std/internal@1.0.12": {
50
109
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
51
110
},
111
+
"@std/path@0.217.0": {
112
+
"integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11",
113
+
"dependencies": [
114
+
"jsr:@std/assert@0.217"
115
+
]
116
+
},
117
+
"@std/path@1.1.2": {
118
+
"integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038",
119
+
"dependencies": [
120
+
"jsr:@std/internal@^1.0.10"
121
+
]
122
+
},
52
123
"@std/text@1.0.16": {
53
124
"integrity": "ddb9853b75119a2473857d691cf1ec02ad90793a2e8b4a4ac49d7354281a0cf8"
54
125
}
55
126
},
56
127
"npm": {
128
+
"@noble/hashes@2.0.1": {
129
+
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="
130
+
},
131
+
"@paralleldrive/cuid2@3.0.4": {
132
+
"integrity": "sha512-sM6M2PWrByOEpN2QYAdulhEbSZmChwj0e52u4hpwB7u4PznFiNAavtE6m7O8tWUlzX+jT2eKKtc5/ZgX+IHrtg==",
133
+
"dependencies": [
134
+
"@noble/hashes",
135
+
"bignumber.js",
136
+
"error-causes"
137
+
],
138
+
"bin": true
139
+
},
140
+
"@standard-schema/spec@1.0.0": {
141
+
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="
142
+
},
143
+
"bignumber.js@9.3.1": {
144
+
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="
145
+
},
57
146
"chalk@5.6.2": {
58
147
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="
148
+
},
149
+
"dayjs@1.11.19": {
150
+
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="
151
+
},
152
+
"effect@3.19.2": {
153
+
"integrity": "sha512-AHkxfzl5RbWfHO9HOdLE4oZ0c3nxqkXKHc69t83GWYoAquZmSeoCjmLP5rPgbHvwv4DcfLr8WW8PWbtNIQI+vw==",
154
+
"dependencies": [
155
+
"@standard-schema/spec",
156
+
"fast-check"
157
+
]
158
+
},
159
+
"error-causes@3.0.2": {
160
+
"integrity": "sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw=="
161
+
},
162
+
"fast-check@3.23.2": {
163
+
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
164
+
"dependencies": [
165
+
"pure-rand"
166
+
]
167
+
},
168
+
"kysely@0.27.6": {
169
+
"integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ=="
170
+
},
171
+
"moniker@0.1.2": {
172
+
"integrity": "sha512-Uj9iV0QYr6281G+o0TvqhKwHHWB2Q/qUTT4LPQ3qDGc0r8cbMuqQjRXPZuVZ+gcL7APx+iQgE8lcfWPrj1LsLA=="
173
+
},
174
+
"pure-rand@6.1.0": {
175
+
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="
59
176
}
60
177
},
61
178
"workspace": {
62
179
"dependencies": [
63
180
"jsr:@cliffy/command@^1.0.0-rc.8",
181
+
"jsr:@cliffy/flags@^1.0.0-rc.8",
182
+
"jsr:@cliffy/table@^1.0.0-rc.8",
183
+
"jsr:@db/sqlite@0.12",
184
+
"jsr:@es-toolkit/es-toolkit@^1.41.0",
185
+
"jsr:@soapbox/kysely-deno-sqlite@^2.2.0",
64
186
"jsr:@std/assert@1",
65
-
"npm:chalk@^5.6.2"
187
+
"npm:@paralleldrive/cuid2@^3.0.4",
188
+
"npm:chalk@^5.6.2",
189
+
"npm:dayjs@^1.11.19",
190
+
"npm:effect@^3.19.2",
191
+
"npm:kysely@0.27.6",
192
+
"npm:moniker@~0.1.2"
66
193
]
67
194
}
68
195
}
+140
-157
main.ts
+140
-157
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 chalk from "chalk";
5
-
6
-
const DEFAULT_VERSION = "20251026";
7
-
8
-
interface Options {
9
-
output?: string;
10
-
cpu: string;
11
-
cpus: number;
12
-
memory: string;
13
-
drive?: string;
14
-
diskFormat: string;
15
-
size?: string;
16
-
}
17
-
18
-
async function downloadIso(url: string, outputPath?: string): Promise<string> {
19
-
const filename = url.split("/").pop()!;
20
-
outputPath = outputPath ?? filename;
21
-
22
-
if (await Deno.stat(outputPath).catch(() => false)) {
23
-
console.log(
24
-
chalk.yellowBright(
25
-
`File ${outputPath} already exists, skipping download.`,
26
-
),
27
-
);
28
-
return outputPath;
29
-
}
30
-
31
-
const cmd = new Deno.Command("curl", {
32
-
args: ["-L", "-o", outputPath, url],
33
-
stdin: "inherit",
34
-
stdout: "inherit",
35
-
stderr: "inherit",
36
-
});
37
-
38
-
const status = await cmd.spawn().status;
39
-
if (!status.success) {
40
-
console.error(chalk.redBright("Failed to download ISO image."));
41
-
Deno.exit(status.code);
42
-
}
43
-
44
-
console.log(chalk.greenBright(`Downloaded ISO to ${outputPath}`));
45
-
return outputPath;
46
-
}
47
-
48
-
function constructDownloadUrl(version: string): string {
49
-
return `https://dlc.openindiana.org/isos/hipster/${version}/OI-hipster-text-${version}.iso`;
50
-
}
51
-
52
-
async function runQemu(isoPath: string, options: Options): Promise<void> {
53
-
const cmd = new Deno.Command("qemu-system-x86_64", {
54
-
args: [
55
-
"-enable-kvm",
56
-
"-cpu",
57
-
options.cpu,
58
-
"-m",
59
-
options.memory,
60
-
"-smp",
61
-
options.cpus.toString(),
62
-
"-cdrom",
63
-
isoPath,
64
-
"-netdev",
65
-
"user,id=net0,hostfwd=tcp::2222-:22",
66
-
"-device",
67
-
"e1000,netdev=net0",
68
-
"-nographic",
69
-
"-monitor",
70
-
"none",
71
-
"-chardev",
72
-
"stdio,id=con0,signal=off",
73
-
"-serial",
74
-
"chardev:con0",
75
-
...(options.drive
76
-
? [
77
-
"-drive",
78
-
`file=${options.drive},format=${options.diskFormat},if=virtio`,
79
-
]
80
-
: []),
81
-
],
82
-
stdin: "inherit",
83
-
stdout: "inherit",
84
-
stderr: "inherit",
85
-
});
86
-
87
-
const status = await cmd.spawn().status;
88
-
89
-
if (!status.success) {
90
-
Deno.exit(status.code);
91
-
}
92
-
}
93
-
94
-
function handleInput(input?: string): string {
95
-
if (!input) {
96
-
console.log(
97
-
`No ISO path provided, defaulting to ${chalk.cyan("OpenIndiana")} ${
98
-
chalk.cyan(DEFAULT_VERSION)
99
-
}...`,
100
-
);
101
-
return constructDownloadUrl(DEFAULT_VERSION);
102
-
}
103
-
104
-
const versionRegex = /^\d{8}$/;
105
-
106
-
if (versionRegex.test(input)) {
107
-
console.log(
108
-
`Detected version ${chalk.cyan(input)}, constructing download URL...`,
109
-
);
110
-
return constructDownloadUrl(input);
111
-
}
112
-
113
-
return input;
114
-
}
115
-
116
-
async function createDriveImageIfNeeded(
117
-
{
118
-
drive: path,
119
-
diskFormat: format,
120
-
size,
121
-
}: Options,
122
-
): Promise<void> {
123
-
if (await Deno.stat(path!).catch(() => false)) {
124
-
console.log(
125
-
chalk.yellowBright(
126
-
`Drive image ${path} already exists, skipping creation.`,
127
-
),
128
-
);
129
-
return;
130
-
}
131
-
132
-
const cmd = new Deno.Command("qemu-img", {
133
-
args: ["create", "-f", format, path!, size!],
134
-
stdin: "inherit",
135
-
stdout: "inherit",
136
-
stderr: "inherit",
137
-
});
138
-
139
-
const status = await cmd.spawn().status;
140
-
if (!status.success) {
141
-
console.error(chalk.redBright("Failed to create drive image."));
142
-
Deno.exit(status.code);
143
-
}
144
-
145
-
console.log(chalk.greenBright(`Created drive image at ${path}`));
146
-
}
4
+
import pkg from "./deno.json" with { type: "json" };
5
+
import { createBridgeNetworkIfNeeded } from "./src/network.ts";
6
+
import inspect from "./src/subcommands/inspect.ts";
7
+
import logs from "./src/subcommands/logs.ts";
8
+
import ps from "./src/subcommands/ps.ts";
9
+
import restart from "./src/subcommands/restart.ts";
10
+
import rm from "./src/subcommands/rm.ts";
11
+
import start from "./src/subcommands/start.ts";
12
+
import stop from "./src/subcommands/stop.ts";
13
+
import {
14
+
createDriveImageIfNeeded,
15
+
downloadIso,
16
+
emptyDiskImage,
17
+
handleInput,
18
+
type Options,
19
+
runQemu,
20
+
} from "./src/utils.ts";
147
21
148
22
if (import.meta.main) {
149
23
await new Command()
150
24
.name("openindiana-up")
151
-
.version("0.1.0")
25
+
.version(pkg.version)
152
26
.description("Start a OpenIndiana virtual machine using QEMU")
153
27
.arguments(
154
28
"[path-or-url-to-iso-or-version:string]",
···
163
37
.option("-m, --memory <size:string>", "Amount of memory for the VM", {
164
38
default: "2G",
165
39
})
166
-
.option("-d, --drive <path:string>", "Path to VM disk image")
40
+
.option("-i, --image <path:string>", "Path to VM disk image")
167
41
.option(
168
42
"--disk-format <format:string>",
169
43
"Disk image format (e.g., qcow2, raw)",
···
173
47
)
174
48
.option(
175
49
"--size <size:string>",
176
-
"Size of the VM disk image (e.g., 20G)",
50
+
"Size of the VM disk image to create if it does not exist (e.g., 20G)",
177
51
{
178
52
default: "20G",
179
53
},
180
54
)
55
+
.option(
56
+
"-b, --bridge <name:string>",
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)",
66
+
)
181
67
.example(
182
68
"Default usage",
183
69
"openindiana-up",
···
194
80
"Download URL",
195
81
"openindiana-up https://dlc.openindiana.org/isos/hipster/20251026/OI-hipster-text-20251026.iso",
196
82
)
83
+
.example(
84
+
"List running VMs",
85
+
"openindiana-up ps",
86
+
)
87
+
.example(
88
+
"List all VMs",
89
+
"openindiana-up ps --all",
90
+
)
91
+
.example(
92
+
"Start a VM",
93
+
"openindiana-up start my-vm",
94
+
)
95
+
.example(
96
+
"Stop a VM",
97
+
"openindiana-up stop my-vm",
98
+
)
99
+
.example(
100
+
"Inspect a VM",
101
+
"openindiana-up inspect my-vm",
102
+
)
103
+
.example(
104
+
"Remove a VM",
105
+
"openindiana-up rm my-vm",
106
+
)
197
107
.action(async (options: Options, input?: string) => {
198
108
const resolvedInput = handleInput(input);
199
-
let isoPath = resolvedInput;
109
+
let isoPath: string | null = resolvedInput;
200
110
201
111
if (
202
112
resolvedInput.startsWith("https://") ||
203
113
resolvedInput.startsWith("http://")
204
114
) {
205
-
isoPath = await downloadIso(resolvedInput, options.output);
115
+
isoPath = await downloadIso(resolvedInput, options);
206
116
}
207
117
208
-
if (options.drive) {
118
+
if (options.image) {
209
119
await createDriveImageIfNeeded(options);
210
120
}
211
121
212
-
await runQemu(isoPath, {
213
-
cpu: options.cpu,
214
-
memory: options.memory,
215
-
cpus: options.cpus,
216
-
drive: options.drive,
217
-
diskFormat: options.diskFormat,
218
-
size: options.size,
219
-
});
122
+
if (!input && options.image && !await emptyDiskImage(options.image)) {
123
+
isoPath = null;
124
+
}
125
+
126
+
if (options.bridge) {
127
+
await createBridgeNetworkIfNeeded(options.bridge);
128
+
}
129
+
130
+
await runQemu(isoPath, options);
131
+
})
132
+
.command("ps", "List all virtual machines")
133
+
.option("--all, -a", "Show all virtual machines, including stopped ones")
134
+
.action(async (options: { all?: unknown }) => {
135
+
await ps(Boolean(options.all));
136
+
})
137
+
.command("start", "Start a virtual machine")
138
+
.arguments("<vm-name:string>")
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));
177
+
})
178
+
.command("stop", "Stop a virtual machine")
179
+
.arguments("<vm-name:string>")
180
+
.action(async (_options: unknown, vmName: string) => {
181
+
await stop(vmName);
182
+
})
183
+
.command("inspect", "Inspect a virtual machine")
184
+
.arguments("<vm-name:string>")
185
+
.action(async (_options: unknown, vmName: string) => {
186
+
await inspect(vmName);
187
+
})
188
+
.command("rm", "Remove a virtual machine")
189
+
.arguments("<vm-name:string>")
190
+
.action(async (_options: unknown, vmName: string) => {
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);
220
203
})
221
204
.parse(Deno.args);
222
205
}
+4
src/constants.ts
+4
src/constants.ts
+11
src/context.ts
+11
src/context.ts
+113
src/db.ts
+113
src/db.ts
···
1
+
import { Database as Sqlite } from "@db/sqlite";
2
+
import { DenoSqlite3Dialect } from "@soapbox/kysely-deno-sqlite";
3
+
import {
4
+
Kysely,
5
+
type Migration,
6
+
type MigrationProvider,
7
+
Migrator,
8
+
sql,
9
+
} from "kysely";
10
+
import { CONFIG_DIR } from "./constants.ts";
11
+
import type { STATUS } from "./types.ts";
12
+
13
+
export const createDb = (location: string): Database => {
14
+
Deno.mkdirSync(CONFIG_DIR, { recursive: true });
15
+
return new Kysely<DatabaseSchema>({
16
+
dialect: new DenoSqlite3Dialect({
17
+
database: new Sqlite(location),
18
+
}),
19
+
});
20
+
};
21
+
22
+
export type DatabaseSchema = {
23
+
virtual_machines: VirtualMachine;
24
+
};
25
+
26
+
export type VirtualMachine = {
27
+
id: string;
28
+
name: string;
29
+
bridge?: string;
30
+
macAddress: string;
31
+
memory: string;
32
+
cpus: number;
33
+
cpu: string;
34
+
diskSize: string;
35
+
drivePath?: string;
36
+
diskFormat: string;
37
+
isoPath?: string;
38
+
portForward?: string;
39
+
version: string;
40
+
status: STATUS;
41
+
pid: number;
42
+
createdAt?: string;
43
+
updatedAt?: string;
44
+
};
45
+
46
+
const migrations: Record<string, Migration> = {};
47
+
48
+
const migrationProvider: MigrationProvider = {
49
+
// deno-lint-ignore require-await
50
+
async getMigrations() {
51
+
return migrations;
52
+
},
53
+
};
54
+
55
+
migrations["001"] = {
56
+
async up(db: Kysely<unknown>): Promise<void> {
57
+
await db.schema
58
+
.createTable("virtual_machines")
59
+
.addColumn("id", "varchar", (col) => col.primaryKey())
60
+
.addColumn("name", "varchar", (col) => col.notNull().unique())
61
+
.addColumn("bridge", "varchar")
62
+
.addColumn("macAddress", "varchar", (col) => col.notNull().unique())
63
+
.addColumn("memory", "varchar", (col) => col.notNull())
64
+
.addColumn("cpus", "integer", (col) => col.notNull())
65
+
.addColumn("cpu", "varchar", (col) => col.notNull())
66
+
.addColumn("diskSize", "varchar", (col) => col.notNull())
67
+
.addColumn("drivePath", "varchar")
68
+
.addColumn("version", "varchar", (col) => col.notNull())
69
+
.addColumn("diskFormat", "varchar")
70
+
.addColumn("isoPath", "varchar")
71
+
.addColumn("status", "varchar", (col) => col.notNull())
72
+
.addColumn("pid", "integer")
73
+
.addColumn(
74
+
"createdAt",
75
+
"varchar",
76
+
(col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
77
+
)
78
+
.addColumn(
79
+
"updatedAt",
80
+
"varchar",
81
+
(col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
82
+
)
83
+
.execute();
84
+
},
85
+
86
+
async down(db: Kysely<unknown>): Promise<void> {
87
+
await db.schema.dropTable("virtual_machines").execute();
88
+
},
89
+
};
90
+
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> => {
108
+
const migrator = new Migrator({ db, provider: migrationProvider });
109
+
const { error } = await migrator.migrateToLatest();
110
+
if (error) throw error;
111
+
};
112
+
113
+
export type Database = Kysely<DatabaseSchema>;
+120
src/network.ts
+120
src/network.ts
···
1
+
import chalk from "chalk";
2
+
3
+
export async function setupQemuBridge(bridgeName: string): Promise<void> {
4
+
const bridgeConfPath = "/etc/qemu/bridge.conf";
5
+
const bridgeConfContent = await Deno.readTextFile(bridgeConfPath).catch(
6
+
() => "",
7
+
);
8
+
if (bridgeConfContent.includes(`allow ${bridgeName}`)) {
9
+
console.log(
10
+
chalk.greenBright(
11
+
`QEMU bridge configuration for ${bridgeName} already exists.`,
12
+
),
13
+
);
14
+
return;
15
+
}
16
+
17
+
console.log(
18
+
chalk.blueBright(
19
+
`Adding QEMU bridge configuration for ${bridgeName}...`,
20
+
),
21
+
);
22
+
23
+
const cmd = new Deno.Command("sudo", {
24
+
args: [
25
+
"sh",
26
+
"-c",
27
+
`mkdir -p /etc/qemu && echo "allow ${bridgeName}" >> ${bridgeConfPath}`,
28
+
],
29
+
stdin: "inherit",
30
+
stdout: "inherit",
31
+
stderr: "inherit",
32
+
});
33
+
const status = await cmd.spawn().status;
34
+
35
+
if (!status.success) {
36
+
console.error(
37
+
chalk.redBright(
38
+
`Failed to add QEMU bridge configuration for ${bridgeName}.`,
39
+
),
40
+
);
41
+
Deno.exit(status.code);
42
+
}
43
+
44
+
console.log(
45
+
chalk.greenBright(
46
+
`QEMU bridge configuration for ${bridgeName} added successfully.`,
47
+
),
48
+
);
49
+
}
50
+
51
+
export async function createBridgeNetworkIfNeeded(
52
+
bridgeName: string,
53
+
): Promise<void> {
54
+
const bridgeExistsCmd = new Deno.Command("ip", {
55
+
args: ["link", "show", bridgeName],
56
+
stdout: "null",
57
+
stderr: "null",
58
+
});
59
+
60
+
const bridgeExistsStatus = await bridgeExistsCmd.spawn().status;
61
+
if (bridgeExistsStatus.success) {
62
+
console.log(
63
+
chalk.greenBright(`Network bridge ${bridgeName} already exists.`),
64
+
);
65
+
await setupQemuBridge(bridgeName);
66
+
return;
67
+
}
68
+
69
+
console.log(chalk.blueBright(`Creating network bridge ${bridgeName}...`));
70
+
const createBridgeCmd = new Deno.Command("sudo", {
71
+
args: ["ip", "link", "add", bridgeName, "type", "bridge"],
72
+
stdin: "inherit",
73
+
stdout: "inherit",
74
+
stderr: "inherit",
75
+
});
76
+
77
+
let status = await createBridgeCmd.spawn().status;
78
+
if (!status.success) {
79
+
console.error(
80
+
chalk.redBright(`Failed to create network bridge ${bridgeName}.`),
81
+
);
82
+
Deno.exit(status.code);
83
+
}
84
+
85
+
const bringUpBridgeCmd = new Deno.Command("sudo", {
86
+
args: ["ip", "link", "set", "dev", bridgeName, "up"],
87
+
stdin: "inherit",
88
+
stdout: "inherit",
89
+
stderr: "inherit",
90
+
});
91
+
status = await bringUpBridgeCmd.spawn().status;
92
+
if (!status.success) {
93
+
console.error(
94
+
chalk.redBright(`Failed to bring up network bridge ${bridgeName}.`),
95
+
);
96
+
Deno.exit(status.code);
97
+
}
98
+
99
+
console.log(
100
+
chalk.greenBright(`Network bridge ${bridgeName} created and up.`),
101
+
);
102
+
103
+
await setupQemuBridge(bridgeName);
104
+
}
105
+
106
+
export function generateRandomMacAddress(): string {
107
+
const hexDigits = "0123456789ABCDEF";
108
+
let macAddress = "52:54:00";
109
+
110
+
for (let i = 0; i < 3; i++) {
111
+
macAddress += ":";
112
+
for (let j = 0; j < 2; j++) {
113
+
macAddress += hexDigits.charAt(
114
+
Math.floor(Math.random() * hexDigits.length),
115
+
);
116
+
}
117
+
}
118
+
119
+
return macAddress;
120
+
}
+52
src/state.ts
+52
src/state.ts
···
1
+
import { ctx } from "./context.ts";
2
+
import type { VirtualMachine } from "./db.ts";
3
+
import type { STATUS } from "./types.ts";
4
+
5
+
export async function saveInstanceState(vm: VirtualMachine) {
6
+
await ctx.db.insertInto("virtual_machines")
7
+
.values(vm)
8
+
.execute();
9
+
}
10
+
11
+
export async function updateInstanceState(
12
+
name: string,
13
+
status: STATUS,
14
+
pid?: number,
15
+
) {
16
+
await ctx.db.updateTable("virtual_machines")
17
+
.set({ status, pid })
18
+
.where((eb) =>
19
+
eb.or([
20
+
eb("name", "=", name),
21
+
eb("id", "=", name),
22
+
])
23
+
)
24
+
.execute();
25
+
}
26
+
27
+
export async function removeInstanceState(name: string) {
28
+
await ctx.db.deleteFrom("virtual_machines")
29
+
.where((eb) =>
30
+
eb.or([
31
+
eb("name", "=", name),
32
+
eb("id", "=", name),
33
+
])
34
+
)
35
+
.execute();
36
+
}
37
+
38
+
export async function getInstanceState(
39
+
name: string,
40
+
): Promise<VirtualMachine | undefined> {
41
+
const vm = await ctx.db.selectFrom("virtual_machines")
42
+
.selectAll()
43
+
.where((eb) =>
44
+
eb.or([
45
+
eb("name", "=", name),
46
+
eb("id", "=", name),
47
+
])
48
+
)
49
+
.executeTakeFirst();
50
+
51
+
return vm;
52
+
}
+13
src/subcommands/inspect.ts
+13
src/subcommands/inspect.ts
···
1
+
import { getInstanceState } from "../state.ts";
2
+
3
+
export default async function (name: string) {
4
+
const vm = await getInstanceState(name);
5
+
if (!vm) {
6
+
console.error(
7
+
`Virtual machine with name or ID ${name} not found.`,
8
+
);
9
+
Deno.exit(1);
10
+
}
11
+
12
+
console.log(vm);
13
+
}
+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
+
}
+39
src/subcommands/ps.ts
+39
src/subcommands/ps.ts
···
1
+
import { Table } from "@cliffy/table";
2
+
import dayjs from "dayjs";
3
+
import relativeTime from "dayjs/plugin/relativeTime.js";
4
+
import utc from "dayjs/plugin/utc.js";
5
+
import { ctx } from "../context.ts";
6
+
7
+
dayjs.extend(relativeTime);
8
+
dayjs.extend(utc);
9
+
10
+
export default async function (all: boolean) {
11
+
const results = await ctx.db.selectFrom("virtual_machines")
12
+
.selectAll()
13
+
.where((eb) => {
14
+
if (all) {
15
+
return eb("id", "!=", "");
16
+
}
17
+
return eb("status", "=", "RUNNING");
18
+
})
19
+
.execute();
20
+
21
+
const table: Table = new Table(
22
+
["NAME", "VCPU", "MEMORY", "STATUS", "PID", "BRIDGE", "MAC", "CREATED"],
23
+
);
24
+
25
+
for (const vm of results) {
26
+
table.push([
27
+
vm.name,
28
+
vm.cpus.toString(),
29
+
vm.memory,
30
+
vm.status,
31
+
vm.pid?.toString() ?? "-",
32
+
vm.bridge ?? "-",
33
+
vm.macAddress,
34
+
dayjs.utc(vm.createdAt).local().fromNow(),
35
+
]);
36
+
}
37
+
38
+
console.log(table.padding(2).toString());
39
+
}
+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
+
}
+14
src/subcommands/rm.ts
+14
src/subcommands/rm.ts
···
1
+
import { getInstanceState, removeInstanceState } from "../state.ts";
2
+
3
+
export default async function (name: string) {
4
+
const vm = await getInstanceState(name);
5
+
if (!vm) {
6
+
console.error(
7
+
`Virtual machine with name or ID ${name} not found.`,
8
+
);
9
+
Deno.exit(1);
10
+
}
11
+
12
+
console.log(`Removing virtual machine ${vm.name} (ID: ${vm.id})...`);
13
+
await removeInstanceState(name);
14
+
}
+119
src/subcommands/start.ts
+119
src/subcommands/start.ts
···
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";
5
+
import { getInstanceState, updateInstanceState } from "../state.ts";
6
+
7
+
export default async function (name: string, detach: boolean = false) {
8
+
let vm = await getInstanceState(name);
9
+
if (!vm) {
10
+
console.error(
11
+
`Virtual machine with name or ID ${name} not found.`,
12
+
);
13
+
Deno.exit(1);
14
+
}
15
+
16
+
console.log(`Starting virtual machine ${vm.name} (ID: ${vm.id})...`);
17
+
18
+
vm = mergeFlags(vm);
19
+
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
+
];
54
+
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
+
});
92
+
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);
99
+
100
+
if (!status.success) {
101
+
Deno.exit(status.code);
102
+
}
103
+
}
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
+
}
+43
src/subcommands/stop.ts
+43
src/subcommands/stop.ts
···
1
+
import _ from "@es-toolkit/es-toolkit/compat";
2
+
import chalk from "chalk";
3
+
import { getInstanceState, updateInstanceState } from "../state.ts";
4
+
5
+
export default async function (name: string) {
6
+
const vm = await getInstanceState(name);
7
+
if (!vm) {
8
+
console.error(
9
+
`Virtual machine with name or ID ${chalk.greenBright(name)} not found.`,
10
+
);
11
+
Deno.exit(1);
12
+
}
13
+
14
+
console.log(
15
+
`Stopping virtual machine ${chalk.greenBright(vm.name)} (ID: ${
16
+
chalk.greenBright(vm.id)
17
+
})...`,
18
+
);
19
+
20
+
const cmd = new Deno.Command(vm.bridge ? "sudo" : "kill", {
21
+
args: [
22
+
..._.compact([vm.bridge && "kill"]),
23
+
"-TERM",
24
+
vm.pid.toString(),
25
+
],
26
+
stdin: "inherit",
27
+
stdout: "inherit",
28
+
stderr: "inherit",
29
+
});
30
+
31
+
const status = await cmd.spawn().status;
32
+
33
+
if (!status.success) {
34
+
console.error(
35
+
`Failed to stop virtual machine ${chalk.greenBright(vm.name)}.`,
36
+
);
37
+
Deno.exit(status.code);
38
+
}
39
+
40
+
await updateInstanceState(vm.name, "STOPPED");
41
+
42
+
console.log(`Virtual machine ${chalk.greenBright(vm.name)} stopped.`);
43
+
}
+1
src/types.ts
+1
src/types.ts
···
1
+
export type STATUS = "RUNNING" | "STOPPED";
+343
src/utils.ts
+343
src/utils.ts
···
1
+
import _ from "@es-toolkit/es-toolkit/compat";
2
+
import { createId } from "@paralleldrive/cuid2";
3
+
import chalk from "chalk";
4
+
import Moniker from "moniker";
5
+
import { EMPTY_DISK_THRESHOLD_KB, LOGS_DIR } from "./constants.ts";
6
+
import { generateRandomMacAddress } from "./network.ts";
7
+
import { saveInstanceState, updateInstanceState } from "./state.ts";
8
+
9
+
const DEFAULT_VERSION = "20251026";
10
+
11
+
export interface Options {
12
+
output?: string;
13
+
cpu: string;
14
+
cpus: number;
15
+
memory: string;
16
+
image?: string;
17
+
diskFormat: string;
18
+
size: string;
19
+
bridge?: string;
20
+
portForward?: string;
21
+
detach?: boolean;
22
+
}
23
+
24
+
async function du(path: string): Promise<number> {
25
+
const cmd = new Deno.Command("du", {
26
+
args: [path],
27
+
stdout: "piped",
28
+
stderr: "inherit",
29
+
});
30
+
31
+
const { stdout } = await cmd.spawn().output();
32
+
const output = new TextDecoder().decode(stdout).trim();
33
+
const size = parseInt(output.split("\t")[0], 10);
34
+
return size;
35
+
}
36
+
37
+
export async function emptyDiskImage(path: string): Promise<boolean> {
38
+
if (!await Deno.stat(path).catch(() => false)) {
39
+
return true;
40
+
}
41
+
42
+
const size = await du(path);
43
+
return size < EMPTY_DISK_THRESHOLD_KB;
44
+
}
45
+
46
+
export async function downloadIso(
47
+
url: string,
48
+
options: Options,
49
+
): Promise<string | null> {
50
+
const filename = url.split("/").pop()!;
51
+
const outputPath = options.output ?? filename;
52
+
53
+
if (options.image && await Deno.stat(options.image).catch(() => false)) {
54
+
const driveSize = await du(options.image);
55
+
if (driveSize > EMPTY_DISK_THRESHOLD_KB) {
56
+
console.log(
57
+
chalk.yellowBright(
58
+
`Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`,
59
+
),
60
+
);
61
+
return null;
62
+
}
63
+
}
64
+
65
+
if (await Deno.stat(outputPath).catch(() => false)) {
66
+
console.log(
67
+
chalk.yellowBright(
68
+
`File ${outputPath} already exists, skipping download.`,
69
+
),
70
+
);
71
+
return outputPath;
72
+
}
73
+
74
+
const cmd = new Deno.Command("curl", {
75
+
args: ["-L", "-o", outputPath, url],
76
+
stdin: "inherit",
77
+
stdout: "inherit",
78
+
stderr: "inherit",
79
+
});
80
+
81
+
const status = await cmd.spawn().status;
82
+
if (!status.success) {
83
+
console.error(chalk.redBright("Failed to download ISO image."));
84
+
Deno.exit(status.code);
85
+
}
86
+
87
+
console.log(chalk.greenBright(`Downloaded ISO to ${outputPath}`));
88
+
return outputPath;
89
+
}
90
+
91
+
export function constructDownloadUrl(version: string): string {
92
+
return `https://dlc.openindiana.org/isos/hipster/${version}/OI-hipster-text-${version}.iso`;
93
+
}
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
+
117
+
export async function runQemu(
118
+
isoPath: string | null,
119
+
options: Options,
120
+
): Promise<void> {
121
+
const macAddress = generateRandomMacAddress();
122
+
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
+
];
157
+
158
+
const name = Moniker.choose();
159
+
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
+
}
243
+
}
244
+
}
245
+
246
+
export function handleInput(input?: string): string {
247
+
if (!input) {
248
+
console.log(
249
+
`No ISO path provided, defaulting to ${chalk.cyan("OpenIndiana")} ${
250
+
chalk.cyan(DEFAULT_VERSION)
251
+
}...`,
252
+
);
253
+
return constructDownloadUrl(DEFAULT_VERSION);
254
+
}
255
+
256
+
const versionRegex = /^\d{8}$/;
257
+
258
+
if (versionRegex.test(input)) {
259
+
console.log(
260
+
`Detected version ${chalk.cyan(input)}, constructing download URL...`,
261
+
);
262
+
return constructDownloadUrl(input);
263
+
}
264
+
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;
311
+
}
312
+
313
+
export async function createDriveImageIfNeeded(
314
+
{
315
+
image: path,
316
+
diskFormat: format,
317
+
size,
318
+
}: Options,
319
+
): Promise<void> {
320
+
if (await Deno.stat(path!).catch(() => false)) {
321
+
console.log(
322
+
chalk.yellowBright(
323
+
`Drive image ${path} already exists, skipping creation.`,
324
+
),
325
+
);
326
+
return;
327
+
}
328
+
329
+
const cmd = new Deno.Command("qemu-img", {
330
+
args: ["create", "-f", format, path!, size],
331
+
stdin: "inherit",
332
+
stdout: "inherit",
333
+
stderr: "inherit",
334
+
});
335
+
336
+
const status = await cmd.spawn().status;
337
+
if (!status.success) {
338
+
console.error(chalk.redBright("Failed to create drive image."));
339
+
Deno.exit(status.code);
340
+
}
341
+
342
+
console.log(chalk.greenBright(`Created drive image at ${path}`));
343
+
}