+3
-2
deno.json
+3
-2
deno.json
···
10
10
"@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.2.0",
11
11
"@std/assert": "jsr:@std/assert@1",
12
12
"chalk": "npm:chalk@^5.6.2",
13
-
"kysely": "npm:kysely@^0.28.8",
13
+
"dayjs": "npm:dayjs@^1.11.19",
14
+
"kysely": "npm:kysely@0.27.6",
14
15
"lodash": "npm:lodash@^4.17.21",
15
16
"moniker": "npm:moniker@^0.1.2"
16
17
}
17
-
}
18
+
}
+7
-5
deno.lock
+7
-5
deno.lock
···
24
24
"jsr:@std/text@~1.0.7": "1.0.16",
25
25
"npm:@paralleldrive/cuid2@^3.0.4": "3.0.4",
26
26
"npm:chalk@^5.6.2": "5.6.2",
27
+
"npm:dayjs@^1.11.19": "1.11.19",
28
+
"npm:kysely@0.27.6": "0.27.6",
27
29
"npm:kysely@~0.27.2": "0.27.6",
28
-
"npm:kysely@~0.28.8": "0.28.8",
29
30
"npm:lodash@^4.17.21": "4.17.21",
30
31
"npm:moniker@~0.1.2": "0.1.2"
31
32
},
···
137
138
"chalk@5.6.2": {
138
139
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="
139
140
},
141
+
"dayjs@1.11.19": {
142
+
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="
143
+
},
140
144
"error-causes@3.0.2": {
141
145
"integrity": "sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw=="
142
146
},
143
147
"kysely@0.27.6": {
144
148
"integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ=="
145
-
},
146
-
"kysely@0.28.8": {
147
-
"integrity": "sha512-QUOgl5ZrS9IRuhq5FvOKFSsD/3+IA6MLE81/bOOTRA/YQpKDza2sFdN5g6JCB9BOpqMJDGefLCQ9F12hRS13TA=="
148
149
},
149
150
"lodash@4.17.21": {
150
151
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
···
162
163
"jsr:@std/assert@1",
163
164
"npm:@paralleldrive/cuid2@^3.0.4",
164
165
"npm:chalk@^5.6.2",
165
-
"npm:kysely@~0.28.8",
166
+
"npm:dayjs@^1.11.19",
167
+
"npm:kysely@0.27.6",
166
168
"npm:lodash@^4.17.21",
167
169
"npm:moniker@~0.1.2"
168
170
]
+45
-1
main.ts
+45
-1
main.ts
···
2
2
3
3
import { Command } from "@cliffy/command";
4
4
import { createBridgeNetworkIfNeeded } from "./src/network.ts";
5
+
import inspect from "./src/subcommands/inspect.ts";
6
+
import ps from "./src/subcommands/ps.ts";
7
+
import start from "./src/subcommands/start.ts";
8
+
import stop from "./src/subcommands/stop.ts";
5
9
import {
6
10
createDriveImageIfNeeded,
7
11
downloadIso,
8
12
emptyDiskImage,
9
13
handleInput,
10
-
Options,
14
+
type Options,
11
15
runQemu,
12
16
} from "./src/utils.ts";
13
17
···
64
68
"Download URL",
65
69
"openindiana-up https://dlc.openindiana.org/isos/hipster/20251026/OI-hipster-text-20251026.iso",
66
70
)
71
+
.example(
72
+
"List running VMs",
73
+
"openindiana-up ps",
74
+
)
75
+
.example(
76
+
"List all VMs",
77
+
"openindiana-up ps --all",
78
+
)
79
+
.example(
80
+
"Start a VM",
81
+
"openindiana-up start my-vm",
82
+
)
83
+
.example(
84
+
"Stop a VM",
85
+
"openindiana-up stop my-vm",
86
+
)
87
+
.example(
88
+
"Inspect a VM",
89
+
"openindiana-up inspect my-vm",
90
+
)
67
91
.action(async (options: Options, input?: string) => {
68
92
const resolvedInput = handleInput(input);
69
93
let isoPath: string | null = resolvedInput;
···
88
112
}
89
113
90
114
await runQemu(isoPath, options);
115
+
})
116
+
.command("ps", "List all virtual machines")
117
+
.option("--all, -a", "Show all virtual machines, including stopped ones")
118
+
.action(async (options: { all: boolean }) => {
119
+
await ps(options.all);
120
+
})
121
+
.command("start", "Start a virtual machine")
122
+
.arguments("<vm-name:string>")
123
+
.action(async (_options: unknown, vmName: string) => {
124
+
await start(vmName);
125
+
})
126
+
.command("stop", "Stop a virtual machine")
127
+
.arguments("<vm-name:string>")
128
+
.action(async (_options: unknown, vmName: string) => {
129
+
await stop(vmName);
130
+
})
131
+
.command("inspect", "Inspect a virtual machine")
132
+
.arguments("<vm-name:string>")
133
+
.action(async (_options: unknown, vmName: string) => {
134
+
await inspect(vmName);
91
135
})
92
136
.parse(Deno.args);
93
137
}
+2
src/constants.ts
+2
src/constants.ts
+11
src/context.ts
+11
src/context.ts
+96
src/db.ts
+96
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
+
version: string;
39
+
status: STATUS;
40
+
pid: number;
41
+
createdAt?: string;
42
+
updatedAt?: string;
43
+
};
44
+
45
+
const migrations: Record<string, Migration> = {};
46
+
47
+
const migrationProvider: MigrationProvider = {
48
+
// deno-lint-ignore require-await
49
+
async getMigrations() {
50
+
return migrations;
51
+
},
52
+
};
53
+
54
+
migrations["001"] = {
55
+
async up(db: Kysely<unknown>): Promise<void> {
56
+
await db.schema
57
+
.createTable("virtual_machines")
58
+
.addColumn("id", "varchar", (col) => col.primaryKey())
59
+
.addColumn("name", "varchar", (col) => col.notNull().unique())
60
+
.addColumn("bridge", "varchar")
61
+
.addColumn("macAddress", "varchar", (col) => col.notNull().unique())
62
+
.addColumn("memory", "varchar", (col) => col.notNull())
63
+
.addColumn("cpus", "integer", (col) => col.notNull())
64
+
.addColumn("cpu", "varchar", (col) => col.notNull())
65
+
.addColumn("diskSize", "varchar", (col) => col.notNull())
66
+
.addColumn("drivePath", "varchar")
67
+
.addColumn("version", "varchar", (col) => col.notNull())
68
+
.addColumn("diskFormat", "varchar")
69
+
.addColumn("isoPath", "varchar")
70
+
.addColumn("status", "varchar", (col) => col.notNull())
71
+
.addColumn("pid", "integer", (col) => col.notNull().unique())
72
+
.addColumn(
73
+
"createdAt",
74
+
"varchar",
75
+
(col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
76
+
)
77
+
.addColumn(
78
+
"updatedAt",
79
+
"varchar",
80
+
(col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
81
+
)
82
+
.execute();
83
+
},
84
+
85
+
async down(db: Kysely<unknown>): Promise<void> {
86
+
await db.schema.dropTable("virtual_machines").execute();
87
+
},
88
+
};
89
+
90
+
export const migrateToLatest = async (db: Database) => {
91
+
const migrator = new Migrator({ db, provider: migrationProvider });
92
+
const { error } = await migrator.migrateToLatest();
93
+
if (error) throw error;
94
+
};
95
+
96
+
export type Database = Kysely<DatabaseSchema>;
+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
+
}
+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
+
}
+61
src/subcommands/start.ts
+61
src/subcommands/start.ts
···
1
+
import _ from "lodash";
2
+
import { getInstanceState, updateInstanceState } from "../state.ts";
3
+
4
+
export default async function (name: string) {
5
+
const vm = await getInstanceState(name);
6
+
if (!vm) {
7
+
console.error(
8
+
`Virtual machine with name or ID ${name} not found.`,
9
+
);
10
+
Deno.exit(1);
11
+
}
12
+
13
+
console.log(`Starting virtual machine ${vm.name} (ID: ${vm.id})...`);
14
+
15
+
const cmd = new Deno.Command(vm.bridge ? "sudo" : "qemu-system-x86_64", {
16
+
args: [
17
+
..._.compact([vm.bridge && "qemu-system-x86_64"]),
18
+
"-enable-kvm",
19
+
"-cpu",
20
+
vm.cpu,
21
+
"-m",
22
+
vm.memory,
23
+
"-smp",
24
+
vm.cpus.toString(),
25
+
..._.compact([vm.isoPath && "-cdrom", vm.isoPath]),
26
+
"-netdev",
27
+
vm.bridge
28
+
? `bridge,id=net0,br=${vm.bridge}`
29
+
: "user,id=net0,hostfwd=tcp::2222-:22",
30
+
"-device",
31
+
`e1000,netdev=net0,mac=${vm.macAddress}`,
32
+
"-nographic",
33
+
"-monitor",
34
+
"none",
35
+
"-chardev",
36
+
"stdio,id=con0,signal=off",
37
+
"-serial",
38
+
"chardev:con0",
39
+
..._.compact(
40
+
vm.drivePath && [
41
+
"-drive",
42
+
`file=${vm.drivePath},format=${vm.diskFormat},if=virtio`,
43
+
],
44
+
),
45
+
],
46
+
stdin: "inherit",
47
+
stdout: "inherit",
48
+
stderr: "inherit",
49
+
})
50
+
.spawn();
51
+
52
+
await updateInstanceState(name, "RUNNING", cmd.pid);
53
+
54
+
const status = await cmd.status;
55
+
56
+
await updateInstanceState(name, "STOPPED", cmd.pid);
57
+
58
+
if (!status.success) {
59
+
Deno.exit(status.code);
60
+
}
61
+
}
+43
src/subcommands/stop.ts
+43
src/subcommands/stop.ts
···
1
+
import chalk from "chalk";
2
+
import _ from "lodash";
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";
+23
-2
src/utils.ts
+23
-2
src/utils.ts
···
1
+
import { createId } from "@paralleldrive/cuid2";
1
2
import chalk from "chalk";
2
3
import _ from "lodash";
4
+
import Moniker from "moniker";
3
5
import { generateRandomMacAddress } from "./network.ts";
6
+
import { saveInstanceState } from "./state.ts";
4
7
5
8
const DEFAULT_VERSION = "20251026";
6
9
···
90
93
isoPath: string | null,
91
94
options: Options,
92
95
): Promise<void> {
96
+
const macAddress = generateRandomMacAddress();
93
97
const cmd = new Deno.Command(options.bridge ? "sudo" : "qemu-system-x86_64", {
94
98
args: [
95
99
..._.compact([options.bridge && "qemu-system-x86_64"]),
···
106
110
? `bridge,id=net0,br=${options.bridge}`
107
111
: "user,id=net0,hostfwd=tcp::2222-:22",
108
112
"-device",
109
-
`e1000,netdev=net0,mac=${generateRandomMacAddress()}`,
113
+
`e1000,netdev=net0,mac=${macAddress}`,
110
114
"-nographic",
111
115
"-monitor",
112
116
"none",
···
124
128
stdin: "inherit",
125
129
stdout: "inherit",
126
130
stderr: "inherit",
131
+
}).spawn();
132
+
133
+
await saveInstanceState({
134
+
id: createId(),
135
+
name: Moniker.choose(),
136
+
bridge: options.bridge,
137
+
macAddress,
138
+
memory: options.memory,
139
+
cpus: options.cpus,
140
+
cpu: options.cpu,
141
+
diskSize: options.size,
142
+
diskFormat: options.diskFormat,
143
+
isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined,
144
+
drivePath: options.drive ? Deno.realPathSync(options.drive) : undefined,
145
+
version: DEFAULT_VERSION,
146
+
status: "RUNNING",
147
+
pid: cmd.pid,
127
148
});
128
149
129
-
const status = await cmd.spawn().status;
150
+
const status = await cmd.status;
130
151
131
152
if (!status.success) {
132
153
Deno.exit(status.code);