+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
+15
-2
deno.json
+15
-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
18
"chalk": "npm:chalk@^5.6.2",
9
-
"lodash": "npm:lodash@^4.17.21"
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"
10
23
}
11
-
}
24
+
}
+131
-9
deno.lock
+131
-9
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",
27
+
"npm:@paralleldrive/cuid2@^3.0.4": "3.0.4",
12
28
"npm:chalk@^5.6.2": "5.6.2",
13
-
"npm:lodash@^4.17.21": "4.17.21"
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"
14
34
},
15
35
"jsr": {
16
36
"@cliffy/command@1.0.0-rc.8": {
17
37
"integrity": "758147790797c74a707e5294cc7285df665422a13d2a483437092ffce40b5557",
18
38
"dependencies": [
19
-
"jsr:@cliffy/flags",
39
+
"jsr:@cliffy/flags@1.0.0-rc.8",
20
40
"jsr:@cliffy/internal",
21
-
"jsr:@cliffy/table",
22
-
"jsr:@std/fmt",
41
+
"jsr:@cliffy/table@1.0.0-rc.8",
42
+
"jsr:@std/fmt@~1.0.2",
23
43
"jsr:@std/text"
24
44
]
25
45
},
···
35
55
"@cliffy/table@1.0.0-rc.8": {
36
56
"integrity": "8bbcdc2ba5e0061b4b13810a24e6f5c6ab19c09f0cce9eb691ccd76c7c6c9db5",
37
57
"dependencies": [
38
-
"jsr:@std/fmt"
58
+
"jsr:@std/fmt@~1.0.2"
39
59
]
40
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"
84
+
]
85
+
},
86
+
"@std/assert@0.217.0": {
87
+
"integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642"
88
+
},
41
89
"@std/assert@1.0.15": {
42
90
"integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b",
43
91
"dependencies": [
44
-
"jsr:@std/internal"
92
+
"jsr:@std/internal@^1.0.12"
45
93
]
46
94
},
95
+
"@std/encoding@1.0.10": {
96
+
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
97
+
},
47
98
"@std/fmt@1.0.8": {
48
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
+
]
49
107
},
50
108
"@std/internal@1.0.12": {
51
109
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
52
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
+
},
53
123
"@std/text@1.0.16": {
54
124
"integrity": "ddb9853b75119a2473857d691cf1ec02ad90793a2e8b4a4ac49d7354281a0cf8"
55
125
}
56
126
},
57
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
+
},
58
146
"chalk@5.6.2": {
59
147
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="
60
148
},
61
-
"lodash@4.17.21": {
62
-
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
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=="
63
176
}
64
177
},
65
178
"workspace": {
66
179
"dependencies": [
67
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",
68
186
"jsr:@std/assert@1",
187
+
"npm:@paralleldrive/cuid2@^3.0.4",
69
188
"npm:chalk@^5.6.2",
70
-
"npm:lodash@^4.17.21"
189
+
"npm:dayjs@^1.11.19",
190
+
"npm:effect@^3.19.2",
191
+
"npm:kysely@0.27.6",
192
+
"npm:moniker@~0.1.2"
71
193
]
72
194
}
73
195
}
+127
-6
main.ts
+127
-6
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" };
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";
4
13
import {
5
14
createDriveImageIfNeeded,
6
15
downloadIso,
7
16
emptyDiskImage,
8
17
handleInput,
9
-
Options,
18
+
type Options,
10
19
runQemu,
11
-
} from "./utils.ts";
20
+
} from "./src/utils.ts";
12
21
13
22
if (import.meta.main) {
14
23
await new Command()
15
24
.name("openindiana-up")
16
-
.version("0.1.0")
25
+
.version(pkg.version)
17
26
.description("Start a OpenIndiana virtual machine using QEMU")
18
27
.arguments(
19
28
"[path-or-url-to-iso-or-version:string]",
···
28
37
.option("-m, --memory <size:string>", "Amount of memory for the VM", {
29
38
default: "2G",
30
39
})
31
-
.option("-d, --drive <path:string>", "Path to VM disk image")
40
+
.option("-i, --image <path:string>", "Path to VM disk image")
32
41
.option(
33
42
"--disk-format <format:string>",
34
43
"Disk image format (e.g., qcow2, raw)",
···
43
52
default: "20G",
44
53
},
45
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
+
)
46
67
.example(
47
68
"Default usage",
48
69
"openindiana-up",
···
59
80
"Download URL",
60
81
"openindiana-up https://dlc.openindiana.org/isos/hipster/20251026/OI-hipster-text-20251026.iso",
61
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
+
)
62
107
.action(async (options: Options, input?: string) => {
63
108
const resolvedInput = handleInput(input);
64
109
let isoPath: string | null = resolvedInput;
···
70
115
isoPath = await downloadIso(resolvedInput, options);
71
116
}
72
117
73
-
if (options.drive) {
118
+
if (options.image) {
74
119
await createDriveImageIfNeeded(options);
75
120
}
76
121
77
-
if (!input && options.drive && !await emptyDiskImage(options.drive)) {
122
+
if (!input && options.image && !await emptyDiskImage(options.image)) {
78
123
isoPath = null;
79
124
}
80
125
126
+
if (options.bridge) {
127
+
await createBridgeNetworkIfNeeded(options.bridge);
128
+
}
129
+
81
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);
82
203
})
83
204
.parse(Deno.args);
84
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
+
}
-183
utils.ts
-183
utils.ts
···
1
-
import chalk from "chalk";
2
-
import _ from "lodash";
3
-
4
-
const DEFAULT_VERSION = "20251026";
5
-
6
-
export interface Options {
7
-
output?: string;
8
-
cpu: string;
9
-
cpus: number;
10
-
memory: string;
11
-
drive?: string;
12
-
diskFormat: string;
13
-
size: string;
14
-
}
15
-
16
-
async function du(path: string): Promise<number> {
17
-
const cmd = new Deno.Command("du", {
18
-
args: [path],
19
-
stdout: "piped",
20
-
stderr: "inherit",
21
-
});
22
-
23
-
const { stdout } = await cmd.spawn().output();
24
-
const output = new TextDecoder().decode(stdout).trim();
25
-
const size = parseInt(output.split("\t")[0], 10);
26
-
return size;
27
-
}
28
-
29
-
export async function emptyDiskImage(path: string): Promise<boolean> {
30
-
if (!await Deno.stat(path).catch(() => false)) {
31
-
return true;
32
-
}
33
-
34
-
const size = await du(path);
35
-
return size < 10;
36
-
}
37
-
38
-
export async function downloadIso(
39
-
url: string,
40
-
options: Options,
41
-
): Promise<string | null> {
42
-
const filename = url.split("/").pop()!;
43
-
const outputPath = options.output ?? filename;
44
-
45
-
if (options.drive && await Deno.stat(options.drive).catch(() => false)) {
46
-
const driveSize = await du(options.drive);
47
-
if (driveSize > 10) {
48
-
console.log(
49
-
chalk.yellowBright(
50
-
`Drive image ${options.drive} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`,
51
-
),
52
-
);
53
-
return null;
54
-
}
55
-
}
56
-
57
-
if (await Deno.stat(outputPath).catch(() => false)) {
58
-
console.log(
59
-
chalk.yellowBright(
60
-
`File ${outputPath} already exists, skipping download.`,
61
-
),
62
-
);
63
-
return outputPath;
64
-
}
65
-
66
-
const cmd = new Deno.Command("curl", {
67
-
args: ["-L", "-o", outputPath, url],
68
-
stdin: "inherit",
69
-
stdout: "inherit",
70
-
stderr: "inherit",
71
-
});
72
-
73
-
const status = await cmd.spawn().status;
74
-
if (!status.success) {
75
-
console.error(chalk.redBright("Failed to download ISO image."));
76
-
Deno.exit(status.code);
77
-
}
78
-
79
-
console.log(chalk.greenBright(`Downloaded ISO to ${outputPath}`));
80
-
return outputPath;
81
-
}
82
-
83
-
export function constructDownloadUrl(version: string): string {
84
-
return `https://dlc.openindiana.org/isos/hipster/${version}/OI-hipster-text-${version}.iso`;
85
-
}
86
-
87
-
export async function runQemu(
88
-
isoPath: string | null,
89
-
options: Options,
90
-
): Promise<void> {
91
-
const cmd = new Deno.Command("qemu-system-x86_64", {
92
-
args: [
93
-
"-enable-kvm",
94
-
"-cpu",
95
-
options.cpu,
96
-
"-m",
97
-
options.memory,
98
-
"-smp",
99
-
options.cpus.toString(),
100
-
..._.compact([isoPath && "-cdrom", isoPath]),
101
-
"-netdev",
102
-
"user,id=net0,hostfwd=tcp::2222-:22",
103
-
"-device",
104
-
"e1000,netdev=net0",
105
-
"-nographic",
106
-
"-monitor",
107
-
"none",
108
-
"-chardev",
109
-
"stdio,id=con0,signal=off",
110
-
"-serial",
111
-
"chardev:con0",
112
-
..._.compact(
113
-
options.drive && [
114
-
"-drive",
115
-
`file=${options.drive},format=${options.diskFormat},if=virtio`,
116
-
],
117
-
),
118
-
],
119
-
stdin: "inherit",
120
-
stdout: "inherit",
121
-
stderr: "inherit",
122
-
});
123
-
124
-
const status = await cmd.spawn().status;
125
-
126
-
if (!status.success) {
127
-
Deno.exit(status.code);
128
-
}
129
-
}
130
-
131
-
export function handleInput(input?: string): string {
132
-
if (!input) {
133
-
console.log(
134
-
`No ISO path provided, defaulting to ${chalk.cyan("OpenIndiana")} ${
135
-
chalk.cyan(DEFAULT_VERSION)
136
-
}...`,
137
-
);
138
-
return constructDownloadUrl(DEFAULT_VERSION);
139
-
}
140
-
141
-
const versionRegex = /^\d{8}$/;
142
-
143
-
if (versionRegex.test(input)) {
144
-
console.log(
145
-
`Detected version ${chalk.cyan(input)}, constructing download URL...`,
146
-
);
147
-
return constructDownloadUrl(input);
148
-
}
149
-
150
-
return input;
151
-
}
152
-
153
-
export async function createDriveImageIfNeeded(
154
-
{
155
-
drive: path,
156
-
diskFormat: format,
157
-
size,
158
-
}: Options,
159
-
): Promise<void> {
160
-
if (await Deno.stat(path!).catch(() => false)) {
161
-
console.log(
162
-
chalk.yellowBright(
163
-
`Drive image ${path} already exists, skipping creation.`,
164
-
),
165
-
);
166
-
return;
167
-
}
168
-
169
-
const cmd = new Deno.Command("qemu-img", {
170
-
args: ["create", "-f", format, path!, size],
171
-
stdin: "inherit",
172
-
stdout: "inherit",
173
-
stderr: "inherit",
174
-
});
175
-
176
-
const status = await cmd.spawn().status;
177
-
if (!status.success) {
178
-
console.error(chalk.redBright("Failed to create drive image."));
179
-
Deno.exit(status.code);
180
-
}
181
-
182
-
console.log(chalk.greenBright(`Created drive image at ${path}`));
183
-
}