+3
deno.json
+3
deno.json
···
9
9
"imports": {
10
10
"@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.8",
11
11
"@cliffy/flags": "jsr:@cliffy/flags@^1.0.0-rc.8",
12
+
"@cliffy/prompt": "jsr:@cliffy/prompt@^1.0.0-rc.8",
12
13
"@cliffy/table": "jsr:@cliffy/table@^1.0.0-rc.8",
13
14
"@db/sqlite": "jsr:@db/sqlite@^0.12.0",
14
15
"@es-toolkit/es-toolkit": "jsr:@es-toolkit/es-toolkit@^1.41.0",
15
16
"@paralleldrive/cuid2": "npm:@paralleldrive/cuid2@^3.0.4",
16
17
"@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.2.0",
17
18
"@std/assert": "jsr:@std/assert@1",
19
+
"@std/io": "jsr:@std/io@^0.225.2",
20
+
"@std/path": "jsr:@std/path@^1.1.2",
18
21
"@std/toml": "jsr:@std/toml@^1.0.11",
19
22
"@zod/zod": "jsr:@zod/zod@^4.1.12",
20
23
"chalk": "npm:chalk@^5.6.2",
+47
-1
deno.lock
+47
-1
deno.lock
···
1
1
{
2
2
"version": "5",
3
3
"specifiers": {
4
+
"jsr:@cliffy/ansi@1.0.0-rc.8": "1.0.0-rc.8",
4
5
"jsr:@cliffy/command@^1.0.0-rc.8": "1.0.0-rc.8",
5
6
"jsr:@cliffy/flags@1.0.0-rc.8": "1.0.0-rc.8",
6
7
"jsr:@cliffy/flags@^1.0.0-rc.8": "1.0.0-rc.8",
7
8
"jsr:@cliffy/internal@1.0.0-rc.8": "1.0.0-rc.8",
9
+
"jsr:@cliffy/keycode@1.0.0-rc.8": "1.0.0-rc.8",
10
+
"jsr:@cliffy/prompt@^1.0.0-rc.8": "1.0.0-rc.8",
8
11
"jsr:@cliffy/table@1.0.0-rc.8": "1.0.0-rc.8",
9
12
"jsr:@cliffy/table@^1.0.0-rc.8": "1.0.0-rc.8",
10
13
"jsr:@db/sqlite@0.12": "0.12.0",
···
13
16
"jsr:@soapbox/kysely-deno-sqlite@^2.2.0": "2.2.0",
14
17
"jsr:@std/assert@0.217": "0.217.0",
15
18
"jsr:@std/assert@1": "1.0.15",
19
+
"jsr:@std/assert@~1.0.6": "1.0.15",
20
+
"jsr:@std/bytes@^1.0.5": "1.0.6",
16
21
"jsr:@std/collections@^1.1.3": "1.1.3",
17
22
"jsr:@std/encoding@1": "1.0.10",
23
+
"jsr:@std/encoding@~1.0.5": "1.0.10",
18
24
"jsr:@std/fmt@1": "1.0.8",
19
25
"jsr:@std/fmt@~1.0.2": "1.0.8",
20
26
"jsr:@std/fs@1": "1.0.19",
21
27
"jsr:@std/internal@^1.0.10": "1.0.12",
22
28
"jsr:@std/internal@^1.0.12": "1.0.12",
23
29
"jsr:@std/internal@^1.0.9": "1.0.12",
30
+
"jsr:@std/io@~0.225.2": "0.225.2",
24
31
"jsr:@std/path@0.217": "0.217.0",
25
32
"jsr:@std/path@1": "1.1.2",
26
33
"jsr:@std/path@^1.1.1": "1.1.2",
34
+
"jsr:@std/path@^1.1.2": "1.1.2",
35
+
"jsr:@std/path@~1.0.6": "1.0.9",
27
36
"jsr:@std/text@~1.0.7": "1.0.16",
28
37
"jsr:@std/toml@*": "1.0.11",
29
38
"jsr:@std/toml@^1.0.11": "1.0.11",
···
39
48
"npm:moniker@~0.1.2": "0.1.2"
40
49
},
41
50
"jsr": {
51
+
"@cliffy/ansi@1.0.0-rc.8": {
52
+
"integrity": "ba37f10ce55bbfbdd8ddd987f91f029b17bce88385b98ba3058870f3b007b80c",
53
+
"dependencies": [
54
+
"jsr:@cliffy/internal",
55
+
"jsr:@std/encoding@~1.0.5"
56
+
]
57
+
},
42
58
"@cliffy/command@1.0.0-rc.8": {
43
59
"integrity": "758147790797c74a707e5294cc7285df665422a13d2a483437092ffce40b5557",
44
60
"dependencies": [
···
58
74
"@cliffy/internal@1.0.0-rc.8": {
59
75
"integrity": "34cdf2fad9b084b5aed493b138d573f52d4e988767215f7460daf0b918ff43d8"
60
76
},
77
+
"@cliffy/keycode@1.0.0-rc.8": {
78
+
"integrity": "76dbf85a67ec0aea2e29ca049b8507b6b3f62a2a971bd744d8d3fc447c177cd9"
79
+
},
80
+
"@cliffy/prompt@1.0.0-rc.8": {
81
+
"integrity": "eba403ea1d47b9971bf2210fa35f4dc7ebd2aba87beec9540ae47552806e2f25",
82
+
"dependencies": [
83
+
"jsr:@cliffy/ansi",
84
+
"jsr:@cliffy/internal",
85
+
"jsr:@cliffy/keycode",
86
+
"jsr:@std/assert@~1.0.6",
87
+
"jsr:@std/fmt@~1.0.2",
88
+
"jsr:@std/path@~1.0.6",
89
+
"jsr:@std/text"
90
+
]
91
+
},
61
92
"@cliffy/table@1.0.0-rc.8": {
62
93
"integrity": "8bbcdc2ba5e0061b4b13810a24e6f5c6ab19c09f0cce9eb691ccd76c7c6c9db5",
63
94
"dependencies": [
···
74
105
"@denosaurs/plug@1.1.0": {
75
106
"integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044",
76
107
"dependencies": [
77
-
"jsr:@std/encoding",
108
+
"jsr:@std/encoding@1",
78
109
"jsr:@std/fmt@1",
79
110
"jsr:@std/fs",
80
111
"jsr:@std/path@1"
···
97
128
"dependencies": [
98
129
"jsr:@std/internal@^1.0.12"
99
130
]
131
+
},
132
+
"@std/bytes@1.0.6": {
133
+
"integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a"
100
134
},
101
135
"@std/collections@1.1.3": {
102
136
"integrity": "bf8b0818886df6a32b64c7d3b037a425111f28278d69fd0995aeb62777c986b0"
···
117
151
"@std/internal@1.0.12": {
118
152
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
119
153
},
154
+
"@std/io@0.225.2": {
155
+
"integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7",
156
+
"dependencies": [
157
+
"jsr:@std/bytes"
158
+
]
159
+
},
120
160
"@std/path@0.217.0": {
121
161
"integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11",
122
162
"dependencies": [
123
163
"jsr:@std/assert@0.217"
124
164
]
165
+
},
166
+
"@std/path@1.0.9": {
167
+
"integrity": "260a49f11edd3db93dd38350bf9cd1b4d1366afa98e81b86167b4e3dd750129e"
125
168
},
126
169
"@std/path@1.1.2": {
127
170
"integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038",
···
209
252
"dependencies": [
210
253
"jsr:@cliffy/command@^1.0.0-rc.8",
211
254
"jsr:@cliffy/flags@^1.0.0-rc.8",
255
+
"jsr:@cliffy/prompt@^1.0.0-rc.8",
212
256
"jsr:@cliffy/table@^1.0.0-rc.8",
213
257
"jsr:@db/sqlite@0.12",
214
258
"jsr:@es-toolkit/es-toolkit@^1.41.0",
215
259
"jsr:@soapbox/kysely-deno-sqlite@^2.2.0",
216
260
"jsr:@std/assert@1",
261
+
"jsr:@std/io@~0.225.2",
262
+
"jsr:@std/path@^1.1.2",
217
263
"jsr:@std/toml@^1.0.11",
218
264
"jsr:@zod/zod@^4.1.12",
219
265
"npm:@paralleldrive/cuid2@^3.0.4",
+128
main.ts
+128
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 { Secret } from "@cliffy/prompt";
5
+
import { readAll } from "@std/io";
4
6
import chalk from "chalk";
5
7
import { Effect, pipe } from "effect";
6
8
import pkg from "./deno.json" with { type: "json" };
7
9
import { initVmFile, mergeConfig, parseVmFile } from "./src/config.ts";
8
10
import { CONFIG_FILE_NAME } from "./src/constants.ts";
11
+
import { getImage } from "./src/images.ts";
9
12
import { createBridgeNetworkIfNeeded } from "./src/network.ts";
13
+
import { getImageArchivePath } from "./src/oras.ts";
14
+
import images from "./src/subcommands/images.ts";
10
15
import inspect from "./src/subcommands/inspect.ts";
16
+
import login from "./src/subcommands/login.ts";
17
+
import logout from "./src/subcommands/logout.ts";
11
18
import logs from "./src/subcommands/logs.ts";
12
19
import ps from "./src/subcommands/ps.ts";
20
+
import pull from "./src/subcommands/pull.ts";
21
+
import push from "./src/subcommands/push.ts";
13
22
import restart from "./src/subcommands/restart.ts";
14
23
import rm from "./src/subcommands/rm.ts";
24
+
import rmi from "./src/subcommands/rmi.ts";
25
+
import run from "./src/subcommands/run.ts";
15
26
import start from "./src/subcommands/start.ts";
16
27
import stop from "./src/subcommands/stop.ts";
28
+
import tag from "./src/subcommands/tag.ts";
17
29
import {
18
30
createDriveImageIfNeeded,
19
31
downloadIso,
···
70
82
.option(
71
83
"-p, --port-forward <mappings:string>",
72
84
"Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)",
85
+
)
86
+
.option(
87
+
"--install",
88
+
"Persist changes to the VM disk image",
73
89
)
74
90
.example(
75
91
"Create a default VM configuration file",
···
92
108
"freebsd-up https://download.freebsd.org/ftp/releases/ISO-IMAGES/14.3/FreeBSD-14.3-RELEASE-amd64-disc1.iso",
93
109
)
94
110
.example(
111
+
"From OCI Registry",
112
+
"freebsd-up ghcr.io/tsirysndr/freebsd:15.0-BETA4",
113
+
)
114
+
.example(
95
115
"List running VMs",
96
116
"freebsd-up ps",
97
117
)
···
113
133
)
114
134
.action(async (options: Options, input?: string) => {
115
135
const program = Effect.gen(function* () {
136
+
if (input) {
137
+
const [image, archivePath] = yield* Effect.all([
138
+
getImage(input),
139
+
pipe(
140
+
getImageArchivePath(input),
141
+
Effect.catchAll(() => Effect.succeed(null)),
142
+
),
143
+
]);
144
+
145
+
if (image || archivePath) {
146
+
yield* Effect.tryPromise({
147
+
try: () => run(input),
148
+
catch: () => {},
149
+
});
150
+
return;
151
+
}
152
+
}
153
+
116
154
const resolvedInput = handleInput(input);
117
155
let isoPath: string | null = resolvedInput;
118
156
···
241
279
`You can edit this file to customize your VM settings and then start the VM with:`,
242
280
);
243
281
console.log(` ${chalk.greenBright(`freebsd-up`)}`);
282
+
})
283
+
.command(
284
+
"pull",
285
+
"Pull VM image from an OCI-compliant registry, e.g., ghcr.io, docker hub",
286
+
)
287
+
.arguments("<image:string>")
288
+
.action(async (_options: unknown, image: string) => {
289
+
await pull(image);
290
+
})
291
+
.command(
292
+
"push",
293
+
"Push VM image to an OCI-compliant registry, e.g., ghcr.io, docker hub",
294
+
)
295
+
.arguments("<image:string>")
296
+
.action(async (_options: unknown, image: string) => {
297
+
await push(image);
298
+
})
299
+
.command(
300
+
"tag",
301
+
"Create a tag 'image' that refers to the VM image of 'vm-name'",
302
+
)
303
+
.arguments("<vm-name:string> <image:string>")
304
+
.action(async (_options: unknown, vmName: string, image: string) => {
305
+
await tag(vmName, image);
306
+
})
307
+
.command(
308
+
"login",
309
+
"Authenticate to an OCI-compliant registry, e.g., ghcr.io, docker.io (docker hub), etc.",
310
+
)
311
+
.option("-u, --username <username:string>", "Registry username")
312
+
.arguments("<registry:string>")
313
+
.action(async (options: unknown, registry: string) => {
314
+
const username = (options as { username: string }).username;
315
+
316
+
let password: string | undefined;
317
+
const stdinIsTTY = Deno.stdin.isTerminal();
318
+
319
+
if (!stdinIsTTY) {
320
+
const buffer = await readAll(Deno.stdin);
321
+
password = new TextDecoder().decode(buffer).trim();
322
+
} else {
323
+
password = await Secret.prompt("Registry Password: ");
324
+
}
325
+
326
+
console.log(
327
+
`Authenticating to registry ${chalk.greenBright(registry)} as ${
328
+
chalk.greenBright(username)
329
+
}...`,
330
+
);
331
+
await login(username, password, registry);
332
+
})
333
+
.command("logout", "Logout from an OCI-compliant registry")
334
+
.arguments("<registry:string>")
335
+
.action(async (_options: unknown, registry: string) => {
336
+
await logout(registry);
337
+
})
338
+
.command("images", "List all local VM images")
339
+
.action(async () => {
340
+
await images();
341
+
})
342
+
.command("rmi", "Remove a local VM image")
343
+
.arguments("<image:string>")
344
+
.action(async (_options: unknown, image: string) => {
345
+
await rmi(image);
346
+
})
347
+
.command("run", "Create and run a VM from an image")
348
+
.arguments("<image:string>")
349
+
.option("-c, --cpu <type:string>", "Type of CPU to emulate", {
350
+
default: "host",
351
+
})
352
+
.option("-C, --cpus <number:number>", "Number of CPU cores", {
353
+
default: 2,
354
+
})
355
+
.option("-m, --memory <size:string>", "Amount of memory for the VM", {
356
+
default: "2G",
357
+
})
358
+
.option(
359
+
"-b, --bridge <name:string>",
360
+
"Name of the network bridge to use for networking (e.g., br0)",
361
+
)
362
+
.option(
363
+
"-d, --detach",
364
+
"Run VM in the background and print VM name",
365
+
)
366
+
.option(
367
+
"-p, --port-forward <mappings:string>",
368
+
"Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)",
369
+
)
370
+
.action(async (_options: unknown, image: string) => {
371
+
await run(image);
244
372
})
245
373
.parse(Deno.args);
246
374
}
+1
src/constants.ts
+1
src/constants.ts
+2
-1
src/context.ts
+2
-1
src/context.ts
···
1
1
import { DB_PATH } from "./constants.ts";
2
-
import { createDb, type Database, migrateToLatest } from "./db.ts";
2
+
import { createDb, type Database } from "./db.ts";
3
+
import { migrateToLatest } from "./migrations.ts";
3
4
4
5
export const db: Database = createDb(DB_PATH);
5
6
await migrateToLatest(db);
+11
-72
src/db.ts
+11
-72
src/db.ts
···
1
1
import { Database as Sqlite } from "@db/sqlite";
2
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";
3
+
import { Kysely } from "kysely";
10
4
import { CONFIG_DIR } from "./constants.ts";
11
5
import type { STATUS } from "./types.ts";
12
6
···
21
15
22
16
export type DatabaseSchema = {
23
17
virtual_machines: VirtualMachine;
18
+
images: Image;
24
19
};
25
20
26
21
export type VirtualMachine = {
···
43
38
updatedAt?: string;
44
39
};
45
40
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;
41
+
export type Image = {
42
+
id: string;
43
+
repository: string;
44
+
tag: string;
45
+
size: number;
46
+
path: string;
47
+
format: string;
48
+
digest?: string;
49
+
createdAt?: string;
111
50
};
112
51
113
52
export type Database = Kysely<DatabaseSchema>;
+87
src/images.ts
+87
src/images.ts
···
1
+
import { Data, Effect } from "effect";
2
+
import type { DeleteResult, InsertResult } from "kysely";
3
+
import { ctx } from "./context.ts";
4
+
import type { Image } from "./db.ts";
5
+
6
+
export class DbError extends Data.TaggedError("DatabaseError")<{
7
+
message?: string;
8
+
}> {}
9
+
10
+
export const listImages = (): Effect.Effect<Image[], DbError, never> =>
11
+
Effect.tryPromise({
12
+
try: () => ctx.db.selectFrom("images").selectAll().execute(),
13
+
catch: (error) =>
14
+
new DbError({
15
+
message: error instanceof Error ? error.message : String(error),
16
+
}),
17
+
});
18
+
19
+
export const getImage = (
20
+
id: string,
21
+
): Effect.Effect<Image | undefined, DbError, never> =>
22
+
Effect.tryPromise({
23
+
try: () =>
24
+
ctx.db
25
+
.selectFrom("images")
26
+
.selectAll()
27
+
.where((eb) =>
28
+
eb.or([
29
+
eb.and([
30
+
eb("repository", "=", id.split(":")[0]),
31
+
eb("tag", "=", id.split(":")[1] || "latest"),
32
+
]),
33
+
eb("id", "=", id),
34
+
eb("digest", "=", id),
35
+
])
36
+
)
37
+
.executeTakeFirst(),
38
+
catch: (error) =>
39
+
new DbError({
40
+
message: error instanceof Error ? error.message : String(error),
41
+
}),
42
+
});
43
+
44
+
export const saveImage = (
45
+
image: Image,
46
+
): Effect.Effect<InsertResult[], DbError, never> =>
47
+
Effect.tryPromise({
48
+
try: () =>
49
+
ctx.db.insertInto("images")
50
+
.values(image)
51
+
.onConflict((oc) =>
52
+
oc
53
+
.column("repository")
54
+
.column("tag")
55
+
.doUpdateSet({
56
+
size: image.size,
57
+
path: image.path,
58
+
format: image.format,
59
+
digest: image.digest,
60
+
})
61
+
)
62
+
.execute(),
63
+
catch: (error) =>
64
+
new DbError({
65
+
message: error instanceof Error ? error.message : String(error),
66
+
}),
67
+
});
68
+
69
+
export const deleteImage = (
70
+
id: string,
71
+
): Effect.Effect<DeleteResult[], DbError, never> =>
72
+
Effect.tryPromise({
73
+
try: () =>
74
+
ctx.db.deleteFrom("images").where((eb) =>
75
+
eb.or([
76
+
eb.and([
77
+
eb("repository", "=", id.split(":")[0]),
78
+
eb("tag", "=", id.split(":")[1] || "latest"),
79
+
]),
80
+
eb("id", "=", id),
81
+
])
82
+
).execute(),
83
+
catch: (error) =>
84
+
new DbError({
85
+
message: error instanceof Error ? error.message : String(error),
86
+
}),
87
+
});
+225
src/migrations.ts
+225
src/migrations.ts
···
1
+
import {
2
+
type Kysely,
3
+
type Migration,
4
+
type MigrationProvider,
5
+
Migrator,
6
+
sql,
7
+
} from "kysely";
8
+
import type { Database } from "./db.ts";
9
+
10
+
const migrations: Record<string, Migration> = {};
11
+
12
+
const migrationProvider: MigrationProvider = {
13
+
// deno-lint-ignore require-await
14
+
async getMigrations() {
15
+
return migrations;
16
+
},
17
+
};
18
+
19
+
migrations["001"] = {
20
+
async up(db: Kysely<unknown>): Promise<void> {
21
+
await db.schema
22
+
.createTable("virtual_machines")
23
+
.addColumn("id", "varchar", (col) => col.primaryKey())
24
+
.addColumn("name", "varchar", (col) => col.notNull().unique())
25
+
.addColumn("bridge", "varchar")
26
+
.addColumn("macAddress", "varchar", (col) => col.notNull().unique())
27
+
.addColumn("memory", "varchar", (col) => col.notNull())
28
+
.addColumn("cpus", "integer", (col) => col.notNull())
29
+
.addColumn("cpu", "varchar", (col) => col.notNull())
30
+
.addColumn("diskSize", "varchar", (col) => col.notNull())
31
+
.addColumn("drivePath", "varchar")
32
+
.addColumn("version", "varchar", (col) => col.notNull())
33
+
.addColumn("diskFormat", "varchar")
34
+
.addColumn("isoPath", "varchar")
35
+
.addColumn("status", "varchar", (col) => col.notNull())
36
+
.addColumn("pid", "integer")
37
+
.addColumn(
38
+
"createdAt",
39
+
"varchar",
40
+
(col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
41
+
)
42
+
.addColumn(
43
+
"updatedAt",
44
+
"varchar",
45
+
(col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
46
+
)
47
+
.execute();
48
+
},
49
+
50
+
async down(db: Kysely<unknown>): Promise<void> {
51
+
await db.schema.dropTable("virtual_machines").execute();
52
+
},
53
+
};
54
+
55
+
migrations["002"] = {
56
+
async up(db: Kysely<unknown>): Promise<void> {
57
+
await db.schema
58
+
.alterTable("virtual_machines")
59
+
.addColumn("portForward", "varchar")
60
+
.execute();
61
+
},
62
+
63
+
async down(db: Kysely<unknown>): Promise<void> {
64
+
await db.schema
65
+
.alterTable("virtual_machines")
66
+
.dropColumn("portForward")
67
+
.execute();
68
+
},
69
+
};
70
+
71
+
migrations["003"] = {
72
+
async up(db: Kysely<unknown>): Promise<void> {
73
+
await db.schema
74
+
.createTable("images")
75
+
.addColumn("id", "varchar", (col) => col.primaryKey())
76
+
.addColumn("repository", "varchar", (col) => col.notNull())
77
+
.addColumn("tag", "varchar", (col) => col.notNull())
78
+
.addColumn("size", "integer", (col) => col.notNull())
79
+
.addColumn("path", "varchar", (col) => col.notNull())
80
+
.addColumn("createdAt", "varchar", (col) => col.notNull())
81
+
.execute();
82
+
},
83
+
84
+
async down(db: Kysely<unknown>): Promise<void> {
85
+
await db.schema.dropTable("images").execute();
86
+
},
87
+
};
88
+
89
+
migrations["004"] = {
90
+
async up(db: Kysely<unknown>): Promise<void> {
91
+
await db.schema
92
+
.alterTable("images")
93
+
.addColumn("format", "varchar", (col) => col.notNull().defaultTo("qcow2"))
94
+
.execute();
95
+
},
96
+
97
+
async down(db: Kysely<unknown>): Promise<void> {
98
+
await db.schema
99
+
.alterTable("images")
100
+
.dropColumn("format")
101
+
.execute();
102
+
},
103
+
};
104
+
105
+
migrations["005"] = {
106
+
async up(db: Kysely<unknown>): Promise<void> {
107
+
await db.schema
108
+
.createTable("images_new")
109
+
.addColumn("id", "varchar", (col) => col.primaryKey())
110
+
.addColumn("repository", "varchar", (col) => col.notNull())
111
+
.addColumn("tag", "varchar", (col) => col.notNull())
112
+
.addColumn("size", "integer", (col) => col.notNull())
113
+
.addColumn("path", "varchar", (col) => col.notNull())
114
+
.addColumn("format", "varchar", (col) => col.notNull().defaultTo("qcow2"))
115
+
.addColumn("createdAt", "varchar", (col) => col.notNull())
116
+
.addUniqueConstraint("images_repository_tag_unique", [
117
+
"repository",
118
+
"tag",
119
+
])
120
+
.execute();
121
+
122
+
await sql`
123
+
INSERT INTO images_new (id, repository, tag, size, path, format, createdAt)
124
+
SELECT id, repository, tag, size, path, format, createdAt FROM images
125
+
`.execute(db);
126
+
127
+
await db.schema.dropTable("images").execute();
128
+
await sql`ALTER TABLE images_new RENAME TO images`.execute(db);
129
+
},
130
+
131
+
async down(db: Kysely<unknown>): Promise<void> {
132
+
await db.schema
133
+
.createTable("images_old")
134
+
.addColumn("id", "varchar", (col) => col.primaryKey())
135
+
.addColumn("repository", "varchar", (col) => col.notNull())
136
+
.addColumn("tag", "varchar", (col) => col.notNull())
137
+
.addColumn("size", "integer", (col) => col.notNull())
138
+
.addColumn("path", "varchar", (col) => col.notNull())
139
+
.addColumn("format", "varchar", (col) => col.notNull().defaultTo("qcow2"))
140
+
.addColumn("createdAt", "varchar", (col) => col.notNull())
141
+
.execute();
142
+
143
+
await sql`
144
+
INSERT INTO images_old (id, repository, tag, size, path, format, createdAt)
145
+
SELECT id, repository, tag, size, path, format, createdAt FROM images
146
+
`.execute(db);
147
+
148
+
await db.schema.dropTable("images").execute();
149
+
await sql`ALTER TABLE images_old RENAME TO images`.execute(db);
150
+
},
151
+
};
152
+
153
+
migrations["006"] = {
154
+
async up(db: Kysely<unknown>): Promise<void> {
155
+
await db.schema
156
+
.createTable("images_new")
157
+
.addColumn("id", "varchar", (col) => col.primaryKey())
158
+
.addColumn("repository", "varchar", (col) => col.notNull())
159
+
.addColumn("tag", "varchar", (col) => col.notNull())
160
+
.addColumn("size", "integer", (col) => col.notNull())
161
+
.addColumn("path", "varchar", (col) => col.notNull())
162
+
.addColumn("format", "varchar", (col) => col.notNull().defaultTo("qcow2"))
163
+
.addColumn(
164
+
"createdAt",
165
+
"varchar",
166
+
(col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
167
+
)
168
+
.addUniqueConstraint("images_repository_tag_unique", [
169
+
"repository",
170
+
"tag",
171
+
])
172
+
.execute();
173
+
174
+
await sql`
175
+
INSERT INTO images_new (id, repository, tag, size, path, format, createdAt)
176
+
SELECT id, repository, tag, size, path, format, createdAt FROM images
177
+
`.execute(db);
178
+
179
+
await db.schema.dropTable("images").execute();
180
+
await sql`ALTER TABLE images_new RENAME TO images`.execute(db);
181
+
},
182
+
183
+
async down(db: Kysely<unknown>): Promise<void> {
184
+
await db.schema
185
+
.createTable("images_old")
186
+
.addColumn("id", "varchar", (col) => col.primaryKey())
187
+
.addColumn("repository", "varchar", (col) => col.notNull())
188
+
.addColumn("tag", "varchar", (col) => col.notNull())
189
+
.addColumn("size", "integer", (col) => col.notNull())
190
+
.addColumn("path", "varchar", (col) => col.notNull())
191
+
.addColumn("format", "varchar", (col) => col.notNull().defaultTo("qcow2"))
192
+
.addColumn("createdAt", "varchar", (col) => col.notNull())
193
+
.addUniqueConstraint("images_repository_tag_unique", [
194
+
"repository",
195
+
"tag",
196
+
])
197
+
.execute();
198
+
199
+
await sql`
200
+
INSERT INTO images_old (id, repository, tag, size, path, format, createdAt)
201
+
SELECT id, repository, tag, size, path, format, createdAt FROM images
202
+
`.execute(db);
203
+
},
204
+
};
205
+
206
+
migrations["007"] = {
207
+
async up(db: Kysely<unknown>): Promise<void> {
208
+
await db.schema
209
+
.alterTable("images")
210
+
.addColumn("digest", "varchar")
211
+
.execute();
212
+
},
213
+
async down(db: Kysely<unknown>): Promise<void> {
214
+
await db.schema
215
+
.alterTable("images")
216
+
.dropColumn("digest")
217
+
.execute();
218
+
},
219
+
};
220
+
221
+
export const migrateToLatest = async (db: Database): Promise<void> => {
222
+
const migrator = new Migrator({ db, provider: migrationProvider });
223
+
const { error } = await migrator.migrateToLatest();
224
+
if (error) throw error;
225
+
};
+1
src/mod.ts
+1
src/mod.ts
+406
src/oras.ts
+406
src/oras.ts
···
1
+
import { createId } from "@paralleldrive/cuid2";
2
+
import { basename, dirname } from "@std/path";
3
+
import chalk from "chalk";
4
+
import { Data, Effect, pipe } from "effect";
5
+
import { IMAGE_DIR } from "./constants.ts";
6
+
import { getImage, saveImage } from "./images.ts";
7
+
import { CONFIG_DIR, failOnMissingImage } from "./mod.ts";
8
+
import { du, getCurrentArch } from "./utils.ts";
9
+
10
+
const DEFAULT_ORAS_VERSION = "1.3.0";
11
+
12
+
export class PushImageError extends Data.TaggedError("PushImageError")<{
13
+
cause?: unknown;
14
+
}> {}
15
+
16
+
export class PullImageError extends Data.TaggedError("PullImageError")<{
17
+
cause?: unknown;
18
+
}> {}
19
+
20
+
export class CreateDirectoryError
21
+
extends Data.TaggedError("CreateDirectoryError")<{
22
+
cause?: unknown;
23
+
}> {}
24
+
25
+
export class ImageAlreadyPulledError
26
+
extends Data.TaggedError("ImageAlreadyPulledError")<{
27
+
name: string;
28
+
}> {}
29
+
30
+
export async function setupOrasBinary(): Promise<void> {
31
+
Deno.env.set(
32
+
"PATH",
33
+
`${CONFIG_DIR}/bin:${Deno.env.get("PATH")}`,
34
+
);
35
+
36
+
const oras = new Deno.Command("which", {
37
+
args: ["oras"],
38
+
stdout: "null",
39
+
stderr: "null",
40
+
})
41
+
.spawn();
42
+
43
+
const orasStatus = await oras.status;
44
+
if (orasStatus.success) {
45
+
return;
46
+
}
47
+
48
+
const version = Deno.env.get("ORAS_VERSION") || DEFAULT_ORAS_VERSION;
49
+
50
+
console.log(`Downloading ORAS version ${version}...`);
51
+
52
+
const os = Deno.build.os;
53
+
let arch = "amd64";
54
+
55
+
if (Deno.build.arch === "aarch64") {
56
+
arch = "arm64";
57
+
}
58
+
59
+
if (os !== "linux" && os !== "darwin") {
60
+
console.error("Unsupported OS. Please download ORAS manually.");
61
+
Deno.exit(1);
62
+
}
63
+
64
+
// https://github.com/oras-project/oras/releases/download/v1.3.0/oras_1.3.0_darwin_amd64.tar.gz
65
+
const downloadUrl =
66
+
`https://github.com/oras-project/oras/releases/download/v${version}/oras_${version}_${os}_${arch}.tar.gz`;
67
+
68
+
console.log(`Downloading ORAS from ${chalk.greenBright(downloadUrl)}`);
69
+
70
+
const downloadProcess = new Deno.Command("curl", {
71
+
args: ["-L", downloadUrl, "-o", `oras_${version}_${os}_${arch}.tar.gz`],
72
+
stdout: "inherit",
73
+
stderr: "inherit",
74
+
cwd: "/tmp",
75
+
})
76
+
.spawn();
77
+
78
+
const status = await downloadProcess.status;
79
+
if (!status.success) {
80
+
console.error("Failed to download ORAS binary.");
81
+
Deno.exit(1);
82
+
}
83
+
84
+
console.log("Extracting ORAS binary...");
85
+
86
+
const extractProcess = new Deno.Command("tar", {
87
+
args: [
88
+
"-xzf",
89
+
`oras_${version}_${os}_${arch}.tar.gz`,
90
+
"-C",
91
+
"./",
92
+
],
93
+
stdout: "inherit",
94
+
stderr: "inherit",
95
+
cwd: "/tmp",
96
+
})
97
+
.spawn();
98
+
99
+
const extractStatus = await extractProcess.status;
100
+
if (!extractStatus.success) {
101
+
console.error("Failed to extract ORAS binary.");
102
+
Deno.exit(1);
103
+
}
104
+
105
+
await Deno.remove(`/tmp/oras_${version}_${os}_${arch}.tar.gz`);
106
+
107
+
await Deno.mkdir(`${CONFIG_DIR}/bin`, { recursive: true });
108
+
109
+
await Deno.rename(
110
+
`/tmp/oras`,
111
+
`${CONFIG_DIR}/bin/oras`,
112
+
);
113
+
await Deno.chmod(`${CONFIG_DIR}/bin/oras`, 0o755);
114
+
115
+
console.log(
116
+
`ORAS binary installed at ${
117
+
chalk.greenBright(
118
+
`${CONFIG_DIR}/bin/oras`,
119
+
)
120
+
}`,
121
+
);
122
+
}
123
+
124
+
const archiveImage = (img: { path: string }) =>
125
+
Effect.tryPromise({
126
+
try: async () => {
127
+
console.log("Archiving image for push...");
128
+
const tarProcess = new Deno.Command("tar", {
129
+
args: [
130
+
"-cSzf",
131
+
`${img.path}.tar.gz`,
132
+
"-C",
133
+
dirname(img.path),
134
+
basename(img.path),
135
+
],
136
+
stdout: "inherit",
137
+
stderr: "inherit",
138
+
}).spawn();
139
+
140
+
const tarStatus = await tarProcess.status;
141
+
if (!tarStatus.success) {
142
+
throw new Error(`Failed to create tar archive for image`);
143
+
}
144
+
return `${img.path}.tar.gz`;
145
+
},
146
+
catch: (error: unknown) =>
147
+
new PushImageError({
148
+
cause: error instanceof Error ? error.message : String(error),
149
+
}),
150
+
});
151
+
152
+
const pushToRegistry = (
153
+
img: { repository: string; tag: string; path: string },
154
+
) =>
155
+
Effect.tryPromise({
156
+
try: async () => {
157
+
console.log(`Pushing image ${img.repository}...`);
158
+
const process = new Deno.Command("oras", {
159
+
args: [
160
+
"push",
161
+
`${img.repository}:${img.tag}-${getCurrentArch()}`,
162
+
"--artifact-type",
163
+
"application/vnd.oci.image.layer.v1.tar",
164
+
"--annotation",
165
+
`org.opencontainers.image.architecture=${getCurrentArch()}`,
166
+
"--annotation",
167
+
"org.opencontainers.image.os=freebsd",
168
+
"--annotation",
169
+
"org.opencontainers.image.description=QEMU raw disk image",
170
+
basename(img.path),
171
+
],
172
+
stdout: "inherit",
173
+
stderr: "inherit",
174
+
cwd: dirname(img.path),
175
+
}).spawn();
176
+
177
+
const { code } = await process.status;
178
+
if (code !== 0) {
179
+
throw new Error(`ORAS push failed with exit code ${code}`);
180
+
}
181
+
return img.path;
182
+
},
183
+
catch: (error: unknown) =>
184
+
new PushImageError({
185
+
cause: error instanceof Error ? error.message : String(error),
186
+
}),
187
+
});
188
+
189
+
const cleanup = (path: string) =>
190
+
Effect.tryPromise({
191
+
try: () => Deno.remove(path),
192
+
catch: (error: unknown) =>
193
+
new PushImageError({
194
+
cause: error instanceof Error ? error.message : String(error),
195
+
}),
196
+
});
197
+
198
+
const createImageDirIfMissing = Effect.promise(() =>
199
+
Deno.mkdir(IMAGE_DIR, { recursive: true })
200
+
);
201
+
202
+
const checkIfImageAlreadyPulled = (image: string) =>
203
+
pipe(
204
+
getImageDigest(image),
205
+
Effect.flatMap(getImage),
206
+
Effect.flatMap((img) => {
207
+
if (img) {
208
+
return Effect.fail(
209
+
new ImageAlreadyPulledError({ name: image }),
210
+
);
211
+
}
212
+
return Effect.succeed(void 0);
213
+
}),
214
+
);
215
+
216
+
export const pullFromRegistry = (image: string) =>
217
+
pipe(
218
+
Effect.tryPromise({
219
+
try: async () => {
220
+
console.log(`Pulling image ${image}`);
221
+
const process = new Deno.Command("oras", {
222
+
args: [
223
+
"pull",
224
+
`${image}-${getCurrentArch()}`,
225
+
],
226
+
stdin: "inherit",
227
+
stdout: "inherit",
228
+
stderr: "inherit",
229
+
cwd: IMAGE_DIR,
230
+
}).spawn();
231
+
232
+
const { code } = await process.status;
233
+
if (code !== 0) {
234
+
throw new Error(`ORAS pull failed with exit code ${code}`);
235
+
}
236
+
},
237
+
catch: (error: unknown) =>
238
+
new PullImageError({
239
+
cause: error instanceof Error ? error.message : String(error),
240
+
}),
241
+
}),
242
+
);
243
+
244
+
export const getImageArchivePath = (image: string) =>
245
+
Effect.tryPromise({
246
+
try: async () => {
247
+
const process = new Deno.Command("oras", {
248
+
args: [
249
+
"manifest",
250
+
"fetch",
251
+
`${image}-${getCurrentArch()}`,
252
+
],
253
+
stdout: "piped",
254
+
stderr: "inherit",
255
+
}).spawn();
256
+
257
+
const { code, stdout } = await process.output();
258
+
if (code !== 0) {
259
+
throw new Error(`ORAS manifest fetch failed with exit code ${code}`);
260
+
}
261
+
262
+
const manifest = JSON.parse(new TextDecoder().decode(stdout));
263
+
const layers = manifest.layers;
264
+
if (!layers || layers.length === 0) {
265
+
throw new Error(`No layers found in manifest for image ${image}`);
266
+
}
267
+
268
+
if (
269
+
!layers[0].annotations ||
270
+
!layers[0].annotations["org.opencontainers.image.title"]
271
+
) {
272
+
throw new Error(
273
+
`No title annotation found for layer in image ${image}`,
274
+
);
275
+
}
276
+
277
+
const path = `${IMAGE_DIR}/${
278
+
layers[0].annotations["org.opencontainers.image.title"]
279
+
}`;
280
+
281
+
if (!(await Deno.stat(path).catch(() => false))) {
282
+
throw new Error(`Image archive not found at expected path ${path}`);
283
+
}
284
+
285
+
return path;
286
+
},
287
+
catch: (error: unknown) =>
288
+
new PullImageError({
289
+
cause: error instanceof Error ? error.message : String(error),
290
+
}),
291
+
});
292
+
293
+
const getImageDigest = (image: string) =>
294
+
Effect.tryPromise({
295
+
try: async () => {
296
+
const process = new Deno.Command("oras", {
297
+
args: [
298
+
"manifest",
299
+
"fetch",
300
+
`${image}-${getCurrentArch()}`,
301
+
],
302
+
stdout: "piped",
303
+
stderr: "inherit",
304
+
}).spawn();
305
+
306
+
const { code, stdout } = await process.output();
307
+
if (code !== 0) {
308
+
throw new Error(`ORAS manifest fetch failed with exit code ${code}`);
309
+
}
310
+
311
+
const manifest = JSON.parse(new TextDecoder().decode(stdout));
312
+
if (!manifest.layers[0] || !manifest.layers[0].digest) {
313
+
throw new Error(`No digest found in manifest for image ${image}`);
314
+
}
315
+
316
+
return manifest.layers[0].digest as string;
317
+
},
318
+
catch: (error: unknown) =>
319
+
new PullImageError({
320
+
cause: error instanceof Error ? error.message : String(error),
321
+
}),
322
+
});
323
+
324
+
const extractImage = (path: string) =>
325
+
Effect.tryPromise({
326
+
try: async () => {
327
+
console.log("Extracting image archive...");
328
+
const tarProcess = new Deno.Command("tar", {
329
+
args: [
330
+
"-xSzf",
331
+
path,
332
+
"-C",
333
+
dirname(path),
334
+
],
335
+
stdout: "inherit",
336
+
stderr: "inherit",
337
+
cwd: IMAGE_DIR,
338
+
}).spawn();
339
+
340
+
const tarStatus = await tarProcess.status;
341
+
if (!tarStatus.success) {
342
+
throw new Error(`Failed to extract tar archive for image`);
343
+
}
344
+
return path.replace(/\.tar\.gz$/, "");
345
+
},
346
+
catch: (error: unknown) =>
347
+
new PullImageError({
348
+
cause: error instanceof Error ? error.message : String(error),
349
+
}),
350
+
});
351
+
352
+
const savePulledImage = (
353
+
imagePath: string,
354
+
digest: string,
355
+
name: string,
356
+
) =>
357
+
Effect.gen(function* () {
358
+
yield* saveImage({
359
+
id: createId(),
360
+
repository: name.split(":")[0],
361
+
tag: name.split(":")[1] || "latest",
362
+
size: yield* du(imagePath),
363
+
path: imagePath,
364
+
format: imagePath.endsWith(".qcow2") ? "qcow2" : "raw",
365
+
digest,
366
+
});
367
+
return `${imagePath}.tar.gz`;
368
+
});
369
+
370
+
export const pushImage = (image: string) =>
371
+
pipe(
372
+
getImage(image),
373
+
Effect.flatMap(failOnMissingImage),
374
+
Effect.flatMap((img) =>
375
+
pipe(
376
+
archiveImage(img),
377
+
Effect.tap((archivedPath) => {
378
+
img.path = archivedPath;
379
+
return Effect.succeed(void 0);
380
+
}),
381
+
Effect.flatMap(() => pushToRegistry(img)),
382
+
Effect.flatMap(cleanup),
383
+
)
384
+
),
385
+
);
386
+
387
+
export const pullImage = (image: string) =>
388
+
pipe(
389
+
Effect.all([createImageDirIfMissing, checkIfImageAlreadyPulled(image)]),
390
+
Effect.flatMap(() => pullFromRegistry(image)),
391
+
Effect.flatMap(() => getImageArchivePath(image)),
392
+
Effect.flatMap(extractImage),
393
+
Effect.flatMap((imagePath: string) =>
394
+
Effect.all([
395
+
Effect.succeed(imagePath),
396
+
getImageDigest(image),
397
+
Effect.succeed(image),
398
+
])
399
+
),
400
+
Effect.flatMap(([imagePath, digest, image]) =>
401
+
savePulledImage(imagePath, digest, image)
402
+
),
403
+
Effect.flatMap(cleanup),
404
+
Effect.catchTag("ImageAlreadyPulledError", () =>
405
+
Effect.sync(() => console.log(`Image ${image} is already pulled.`))),
406
+
);
+55
src/subcommands/images.ts
+55
src/subcommands/images.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 { Effect, pipe } from "effect";
6
+
import type { Image } from "../db.ts";
7
+
import { type DbError, listImages } from "../images.ts";
8
+
import { humanFileSize } from "../utils.ts";
9
+
10
+
dayjs.extend(relativeTime);
11
+
dayjs.extend(utc);
12
+
13
+
const createTable = () =>
14
+
Effect.succeed(
15
+
new Table(
16
+
["REPOSITORY", "TAG", "IMAGE ID", "CREATED", "SIZE"],
17
+
),
18
+
);
19
+
20
+
const populateTable = (table: Table, images: Image[]) =>
21
+
Effect.gen(function* () {
22
+
for (const image of images) {
23
+
table.push([
24
+
image.repository,
25
+
image.tag,
26
+
image.id,
27
+
dayjs.utc(image.createdAt).local().fromNow(),
28
+
yield* humanFileSize(image.size),
29
+
]);
30
+
}
31
+
return table;
32
+
});
33
+
34
+
const displayTable = (table: Table) =>
35
+
Effect.sync(() => {
36
+
console.log(table.padding(2).toString());
37
+
});
38
+
39
+
const handleError = (error: DbError | Error) =>
40
+
Effect.sync(() => {
41
+
console.error(`Failed to fetch virtual machines: ${error}`);
42
+
Deno.exit(1);
43
+
});
44
+
45
+
const lsEffect = () =>
46
+
pipe(
47
+
Effect.all([listImages(), createTable()]),
48
+
Effect.flatMap(([images, table]) => populateTable(table, images)),
49
+
Effect.flatMap(displayTable),
50
+
Effect.catchAll(handleError),
51
+
);
52
+
53
+
export default async function () {
54
+
await Effect.runPromise(lsEffect());
55
+
}
+35
src/subcommands/login.ts
+35
src/subcommands/login.ts
···
1
+
import { setupOrasBinary } from "../oras.ts";
2
+
3
+
export default async function (
4
+
username: string,
5
+
password: string,
6
+
reqistry: string,
7
+
) {
8
+
await setupOrasBinary();
9
+
10
+
const cmd = new Deno.Command("oras", {
11
+
args: [
12
+
"login",
13
+
"--username",
14
+
username,
15
+
"--password-stdin",
16
+
reqistry,
17
+
],
18
+
stdin: "piped",
19
+
stderr: "inherit",
20
+
stdout: "inherit",
21
+
});
22
+
23
+
const process = cmd.spawn();
24
+
if (process.stdin) {
25
+
const writer = process.stdin.getWriter();
26
+
await writer.write(new TextEncoder().encode(password + "\n"));
27
+
writer.close();
28
+
}
29
+
30
+
const status = await process.status;
31
+
32
+
if (!status.success) {
33
+
Deno.exit(status.code);
34
+
}
35
+
}
+19
src/subcommands/logout.ts
+19
src/subcommands/logout.ts
···
1
+
import { setupOrasBinary } from "../oras.ts";
2
+
3
+
export default async function (registry: string) {
4
+
await setupOrasBinary();
5
+
6
+
const cmd = new Deno.Command("oras", {
7
+
args: ["logout", registry],
8
+
stderr: "inherit",
9
+
stdout: "inherit",
10
+
});
11
+
12
+
const process = cmd.spawn();
13
+
14
+
const status = await process.status;
15
+
16
+
if (!status.success) {
17
+
Deno.exit(status.code);
18
+
}
19
+
}
+19
src/subcommands/pull.ts
+19
src/subcommands/pull.ts
···
1
+
import { Effect, pipe } from "effect";
2
+
import { pullImage, setupOrasBinary } from "../oras.ts";
3
+
import { validateImage } from "../utils.ts";
4
+
5
+
export default async function (image: string): Promise<void> {
6
+
await Effect.runPromise(
7
+
pipe(
8
+
Effect.promise(() => setupOrasBinary()),
9
+
Effect.tap(() => validateImage(image)),
10
+
Effect.tap(() => pullImage(image)),
11
+
Effect.catchAll((error) =>
12
+
Effect.sync(() => {
13
+
console.error(`Failed to pull image: ${error.cause}`);
14
+
Deno.exit(1);
15
+
})
16
+
),
17
+
),
18
+
);
19
+
}
+19
src/subcommands/push.ts
+19
src/subcommands/push.ts
···
1
+
import { Effect, pipe } from "effect";
2
+
import { pushImage, setupOrasBinary } from "../oras.ts";
3
+
import { validateImage } from "../utils.ts";
4
+
5
+
export default async function (image: string): Promise<void> {
6
+
await Effect.runPromise(
7
+
pipe(
8
+
Effect.promise(() => setupOrasBinary()),
9
+
Effect.tap(() => validateImage(image)),
10
+
Effect.tap(() => pushImage(image)),
11
+
Effect.catchAll((error) =>
12
+
Effect.sync(() => {
13
+
console.error(`Failed to push image: ${error.cause}`);
14
+
Deno.exit(1);
15
+
})
16
+
),
17
+
),
18
+
);
19
+
}
+20
src/subcommands/rmi.ts
+20
src/subcommands/rmi.ts
···
1
+
import { Effect, pipe } from "effect";
2
+
import { deleteImage, getImage } from "../images.ts";
3
+
import { failOnMissingImage } from "../utils.ts";
4
+
5
+
export default async function (id: string) {
6
+
await Effect.runPromise(
7
+
pipe(
8
+
getImage(id),
9
+
Effect.flatMap(failOnMissingImage),
10
+
Effect.tap(() => deleteImage(id)),
11
+
Effect.tap(() => console.log(`Image ${id} removed successfully.`)),
12
+
Effect.catchAll((error) =>
13
+
Effect.sync(() => {
14
+
console.error(`Failed to remove image: ${error.message}`);
15
+
Deno.exit(1);
16
+
})
17
+
),
18
+
),
19
+
);
20
+
}
+73
src/subcommands/run.ts
+73
src/subcommands/run.ts
···
1
+
import { parseFlags } from "@cliffy/flags";
2
+
import { Effect, pipe } from "effect";
3
+
import type { Image } from "../db.ts";
4
+
import { getImage } from "../images.ts";
5
+
import { createBridgeNetworkIfNeeded } from "../network.ts";
6
+
import { pullImage, PullImageError, setupOrasBinary } from "../oras.ts";
7
+
import { type Options, runQemu, validateImage } from "../utils.ts";
8
+
9
+
const pullImageOnMissing = (
10
+
name: string,
11
+
): Effect.Effect<Image, Error, never> =>
12
+
pipe(
13
+
getImage(name),
14
+
Effect.flatMap((img) => {
15
+
if (img) {
16
+
return Effect.succeed(img);
17
+
}
18
+
console.log(`Image ${name} not found locally`);
19
+
return pipe(
20
+
pullImage(name),
21
+
Effect.flatMap(() => getImage(name)),
22
+
Effect.flatMap((pulledImg) =>
23
+
pulledImg ? Effect.succeed(pulledImg) : Effect.fail(
24
+
new PullImageError({ cause: "Failed to pull image" }),
25
+
)
26
+
),
27
+
);
28
+
}),
29
+
);
30
+
31
+
const runImage = (image: Image) =>
32
+
Effect.gen(function* () {
33
+
console.log(`Running image ${image.repository}...`);
34
+
const options = mergeFlags(image);
35
+
if (options.bridge) {
36
+
yield* createBridgeNetworkIfNeeded(options.bridge);
37
+
}
38
+
yield* runQemu(null, options);
39
+
});
40
+
41
+
export default async function (
42
+
image: string,
43
+
): Promise<void> {
44
+
await Effect.runPromise(
45
+
pipe(
46
+
Effect.promise(() => setupOrasBinary()),
47
+
Effect.tap(() => validateImage(image)),
48
+
Effect.flatMap(() => pullImageOnMissing(image)),
49
+
Effect.flatMap(runImage),
50
+
Effect.catchAll((error) =>
51
+
Effect.sync(() => {
52
+
console.error(`Failed to run image: ${error.cause} ${image}`);
53
+
Deno.exit(1);
54
+
})
55
+
),
56
+
),
57
+
);
58
+
}
59
+
60
+
function mergeFlags(image: Image): Options {
61
+
const { flags } = parseFlags(Deno.args);
62
+
return {
63
+
cpu: flags.cpu ? flags.cpu : "host",
64
+
cpus: flags.cpus ? flags.cpus : 2,
65
+
memory: flags.memory ? flags.memory : "2G",
66
+
image: image.path,
67
+
bridge: flags.bridge,
68
+
portForward: flags.portForward,
69
+
detach: flags.detach,
70
+
install: false,
71
+
diskFormat: image.format,
72
+
};
73
+
}
+46
src/subcommands/tag.ts
+46
src/subcommands/tag.ts
···
1
+
import { createId } from "@paralleldrive/cuid2";
2
+
import { Effect, pipe } from "effect";
3
+
import { saveImage } from "../images.ts";
4
+
import { getInstanceState, type VirtualMachine } from "../mod.ts";
5
+
import { du, extractTag } from "../utils.ts";
6
+
7
+
const failIfNoVM = (
8
+
[vm, tag]: [VirtualMachine | undefined, string],
9
+
) =>
10
+
Effect.gen(function* () {
11
+
if (!vm) {
12
+
throw new Error(`VM with name ${name} not found`);
13
+
}
14
+
if (!vm.drivePath) {
15
+
throw new Error(`VM with name ${name} has no drive attached`);
16
+
}
17
+
18
+
const size = yield* du(vm.drivePath);
19
+
20
+
return [vm, tag, size] as [VirtualMachine, string, number];
21
+
});
22
+
23
+
export default async function (name: string, image: string) {
24
+
await Effect.runPromise(
25
+
pipe(
26
+
Effect.all([getInstanceState(name), extractTag(image)]),
27
+
Effect.flatMap(failIfNoVM),
28
+
Effect.flatMap(([vm, tag, size]) =>
29
+
saveImage({
30
+
id: createId(),
31
+
repository: image.split(":")[0],
32
+
tag,
33
+
size,
34
+
path: vm.drivePath!,
35
+
format: vm.diskFormat,
36
+
})
37
+
),
38
+
Effect.catchAll((error) =>
39
+
Effect.sync(() => {
40
+
console.error(`Failed to tag image: ${error.cause}`);
41
+
Deno.exit(1);
42
+
})
43
+
),
44
+
),
45
+
);
46
+
}
+86
-9
src/utils.ts
+86
-9
src/utils.ts
···
1
1
import _ from "@es-toolkit/es-toolkit/compat";
2
2
import { createId } from "@paralleldrive/cuid2";
3
3
import chalk from "chalk";
4
-
import { Data, Effect } from "effect";
4
+
import { Data, Effect, pipe } from "effect";
5
5
import Moniker from "moniker";
6
6
import { EMPTY_DISK_THRESHOLD_KB, LOGS_DIR } from "./constants.ts";
7
+
import type { Image } from "./db.ts";
7
8
import { generateRandomMacAddress } from "./network.ts";
8
9
import { saveInstanceState, updateInstanceState } from "./state.ts";
9
10
···
15
16
cpus: number;
16
17
memory: string;
17
18
image?: string;
18
-
diskFormat: string;
19
-
size: string;
19
+
diskFormat?: string;
20
+
size?: string;
20
21
bridge?: string;
21
22
portForward?: string;
22
23
detach?: boolean;
24
+
install?: boolean;
23
25
}
24
26
25
27
class LogCommandError extends Data.TaggedError("LogCommandError")<{
26
28
cause?: unknown;
27
29
}> {}
28
30
31
+
class InvalidImageNameError extends Data.TaggedError("InvalidImageNameError")<{
32
+
image: string;
33
+
cause?: unknown;
34
+
}> {}
35
+
36
+
class NoSuchImageError extends Data.TaggedError("NoSuchImageError")<{
37
+
cause: string;
38
+
}> {}
39
+
40
+
export const getCurrentArch = (): string => {
41
+
switch (Deno.build.arch) {
42
+
case "x86_64":
43
+
return "amd64";
44
+
case "aarch64":
45
+
return "arm64";
46
+
default:
47
+
return Deno.build.arch;
48
+
}
49
+
};
50
+
29
51
export const isValidISOurl = (url?: string): boolean => {
30
52
return Boolean(
31
53
(url?.startsWith("http://") || url?.startsWith("https://")) &&
···
33
55
);
34
56
};
35
57
36
-
const du = (path: string) =>
58
+
export const humanFileSize = (blocks: number) =>
59
+
Effect.sync(() => {
60
+
const blockSize = 512; // bytes per block
61
+
let bytes = blocks * blockSize;
62
+
const thresh = 1024;
63
+
64
+
if (Math.abs(bytes) < thresh) {
65
+
return `${bytes}B`;
66
+
}
67
+
68
+
const units = ["KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
69
+
let u = -1;
70
+
71
+
do {
72
+
bytes /= thresh;
73
+
++u;
74
+
} while (Math.abs(bytes) >= thresh && u < units.length - 1);
75
+
76
+
return `${bytes.toFixed(1)}${units[u]}`;
77
+
});
78
+
79
+
export const validateImage = (
80
+
image: string,
81
+
): Effect.Effect<string, InvalidImageNameError, never> => {
82
+
const regex =
83
+
/^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/;
84
+
85
+
if (!regex.test(image)) {
86
+
return Effect.fail(
87
+
new InvalidImageNameError({
88
+
image,
89
+
cause:
90
+
"Image name does not conform to expected format. Should be in the format 'repository/name:tag'.",
91
+
}),
92
+
);
93
+
}
94
+
return Effect.succeed(image);
95
+
};
96
+
97
+
export const extractTag = (name: string) =>
98
+
pipe(
99
+
validateImage(name),
100
+
Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")),
101
+
);
102
+
103
+
export const failOnMissingImage = (
104
+
image: Image | undefined,
105
+
): Effect.Effect<Image, Error, never> =>
106
+
image
107
+
? Effect.succeed(image)
108
+
: Effect.fail(new NoSuchImageError({ cause: "No such image" }));
109
+
110
+
export const du = (
111
+
path: string,
112
+
): Effect.Effect<number, LogCommandError, never> =>
37
113
Effect.tryPromise({
38
114
try: async () => {
39
115
const cmd = new Deno.Command("du", {
···
244
320
: setupNATNetworkArgs(options.portForward),
245
321
"-device",
246
322
`e1000,netdev=net0,mac=${macAddress}`,
323
+
...(options.install ? [] : ["-snapshot"]),
247
324
"-nographic",
248
325
"-monitor",
249
326
"none",
···
298
375
memory: options.memory,
299
376
cpus: options.cpus,
300
377
cpu: options.cpu,
301
-
diskSize: options.size,
302
-
diskFormat: options.diskFormat,
378
+
diskSize: options.size || "20G",
379
+
diskFormat: options.diskFormat || "raw",
303
380
portForward: options.portForward,
304
381
isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined,
305
382
drivePath: options.image ? Deno.realPathSync(options.image) : undefined,
···
332
409
memory: options.memory,
333
410
cpus: options.cpus,
334
411
cpu: options.cpu,
335
-
diskSize: options.size,
336
-
diskFormat: options.diskFormat,
412
+
diskSize: options.size || "20G",
413
+
diskFormat: options.diskFormat || "raw",
337
414
portForward: options.portForward,
338
415
isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined,
339
416
drivePath: options.image ? Deno.realPathSync(options.image) : undefined,
···
469
546
const status = yield* Effect.tryPromise({
470
547
try: async () => {
471
548
const cmd = new Deno.Command("qemu-img", {
472
-
args: ["create", "-f", format, path!, size!],
549
+
args: ["create", "-f", format || "raw", path!, size!],
473
550
stdin: "inherit",
474
551
stdout: "inherit",
475
552
stderr: "inherit",