@kvbox/cli 0.0.0-stage → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/kvbox.js ADDED
@@ -0,0 +1,2057 @@
1
+ #!/usr/bin/env node
2
+
3
+ // ../shared/src/errors.ts
4
+ var KvboxError = class extends Error {
5
+ constructor(message, exitCode = 1) {
6
+ super(message);
7
+ this.exitCode = exitCode;
8
+ this.name = "KvboxError";
9
+ }
10
+ exitCode;
11
+ };
12
+ var AmbiguousRefError = class extends KvboxError {
13
+ constructor(kind, name, slugs) {
14
+ super(`${kind} ${name} exists in ${slugs.slice(0, -1).join(", ")} and ${slugs.at(-1)}; say which with --org ${slugs[0]} or ${slugs[0]}/${name}`, 1);
15
+ this.name = "AmbiguousRefError";
16
+ }
17
+ };
18
+
19
+ // src/output.ts
20
+ import pc from "picocolors";
21
+ var color = pc.createColors(process.stdout.isTTY === true && !process.env["NO_COLOR"]);
22
+ function json(value) {
23
+ process.stdout.write(JSON.stringify(value, null, 2) + "\n");
24
+ }
25
+ var ANSI = /\x1b\[[0-9;]*m/g;
26
+ var width = (s) => s.replace(ANSI, "").length;
27
+ function table(rows, columns) {
28
+ if (rows.length === 0) return;
29
+ const cols = columns ?? Object.keys(rows[0]);
30
+ const cells = rows.map((r) => cols.map((c) => String(r[c] ?? "")));
31
+ const widths = cols.map((c, i) => Math.max(c.length, ...cells.map((r) => width(r[i]))));
32
+ const line = (parts) => parts.map((p, i) => p + " ".repeat(Math.max(0, widths[i] - width(p)))).join(" ").trimEnd();
33
+ process.stdout.write(color.dim(line(cols.map((c) => c.toUpperCase()))) + "\n");
34
+ for (const row of cells) process.stdout.write(line(row) + "\n");
35
+ }
36
+ function info(msg) {
37
+ process.stderr.write(msg + "\n");
38
+ }
39
+ function warn(msg) {
40
+ process.stderr.write(color.yellow(msg) + "\n");
41
+ }
42
+ async function confirm(question, yes) {
43
+ if (yes) return true;
44
+ if (!process.stdin.isTTY) {
45
+ warn("refusing without -y when stdin is not a terminal");
46
+ return false;
47
+ }
48
+ const { createInterface: createInterface2 } = await import("readline/promises");
49
+ const rl = createInterface2({ input: process.stdin, output: process.stderr });
50
+ try {
51
+ const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
52
+ return answer === "y" || answer === "yes";
53
+ } finally {
54
+ rl.close();
55
+ }
56
+ }
57
+ function gb(bytes) {
58
+ const g = bytes / 1024 ** 3;
59
+ if (g >= 1) return `${Number.isInteger(g) ? g : g.toFixed(1)} GB`;
60
+ return `${Math.round(bytes / 1024 ** 2)} MB`;
61
+ }
62
+ function gbShort(bytes) {
63
+ return gb(bytes).replace(" ", "");
64
+ }
65
+ function shape(vcpu, memMib, diskBytes) {
66
+ const cpuRam = `${vcpu}\xD7${gbShort(memMib * 1024 ** 2)}`;
67
+ return diskBytes === void 0 ? cpuRam : `${cpuRam} \xB7 ${gbShort(diskBytes)} disk`;
68
+ }
69
+ function boxName(name) {
70
+ return color.cyan(name);
71
+ }
72
+ function status(s) {
73
+ if (s === "running") return color.green(s);
74
+ if (s === "standby" || s === "hibernated") return color.yellow(s);
75
+ return color.dim(s);
76
+ }
77
+ function age(iso, now = Date.now()) {
78
+ const sec = Math.max(0, Math.floor((now - Date.parse(iso)) / 1e3));
79
+ if (sec < 60) return "just now";
80
+ const min = Math.floor(sec / 60);
81
+ if (min < 60) return `${min}m ago`;
82
+ const hr = Math.floor(min / 60);
83
+ if (hr < 24) return `${hr}h ago`;
84
+ const day3 = Math.floor(hr / 24);
85
+ if (day3 < 30) return `${day3}d ago`;
86
+ const month = Math.floor(day3 / 30);
87
+ if (month < 12) return `${month}mo ago`;
88
+ return `${Math.floor(day3 / 365)}y ago`;
89
+ }
90
+ function duration(seconds) {
91
+ const s = Math.max(0, Math.round(seconds));
92
+ if (s === 0) return "-";
93
+ const d = Math.floor(s / 86400);
94
+ const h = Math.floor(s % 86400 / 3600);
95
+ const m = Math.floor(s % 3600 / 60);
96
+ const sec = s % 60;
97
+ if (d > 0) return `${d}d ${h}h`;
98
+ if (h > 0) return `${h}h ${m}m`;
99
+ if (m > 0) return `${m}m ${sec}s`;
100
+ return `${sec}s`;
101
+ }
102
+ function hours(h) {
103
+ return h < 100 ? String(Math.round(h * 10) / 10) : String(Math.round(h));
104
+ }
105
+ function slugOf(owner) {
106
+ const at = owner.indexOf("@");
107
+ return at === -1 ? owner : owner.slice(0, at);
108
+ }
109
+ function pickRef(items, text, me2, kind = "box") {
110
+ if (text.includes("/")) return items.find((i) => text === `${slugOf(i.owner)}/${i.name}` || text === `${i.owner}/${i.name}`);
111
+ const named = items.filter((i) => i.name === text);
112
+ const own = named.find((i) => slugOf(i.owner) === me2);
113
+ if (own) return own;
114
+ const orgs = me2 === void 0 ? named : named.filter((i) => i.ownerKind !== "user");
115
+ if (orgs.length <= 1) return orgs[0];
116
+ throw new AmbiguousRefError(kind, text, orgs.map((i) => slugOf(i.owner)));
117
+ }
118
+ function shortRef(ref, owner) {
119
+ return ref.startsWith(`${owner}/`) ? ref.slice(owner.length + 1) : ref;
120
+ }
121
+ var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
122
+ var SPINNER_DELAY_MS = 150;
123
+ var SPINNER_FRAME_MS = 80;
124
+ async function spin(message, work, opts = {}) {
125
+ const stream = opts.stream ?? process.stderr;
126
+ const promise = typeof work === "function" ? work() : work;
127
+ if (stream.isTTY !== true) return promise;
128
+ let frame = 0;
129
+ let drawn = false;
130
+ const draw = () => {
131
+ stream.write(`\r\x1B[K${color.cyan(SPINNER_FRAMES[frame % SPINNER_FRAMES.length])} ${message}`);
132
+ frame += 1;
133
+ drawn = true;
134
+ };
135
+ let interval;
136
+ const delay = setTimeout(() => {
137
+ draw();
138
+ interval = setInterval(draw, SPINNER_FRAME_MS);
139
+ }, opts.delayMs ?? SPINNER_DELAY_MS);
140
+ try {
141
+ return await promise;
142
+ } finally {
143
+ clearTimeout(delay);
144
+ if (interval !== void 0) clearInterval(interval);
145
+ if (drawn) stream.write("\r\x1B[K");
146
+ }
147
+ }
148
+
149
+ // src/program.ts
150
+ import { Command as Command38 } from "commander";
151
+
152
+ // ../shared/src/version.ts
153
+ import { createRequire } from "module";
154
+ function versionOf(srcFileUrl, injected) {
155
+ if (typeof injected === "string") return injected;
156
+ try {
157
+ const require2 = createRequire(srcFileUrl);
158
+ return require2("../package.json").version;
159
+ } catch {
160
+ return "0.0.0-dev";
161
+ }
162
+ }
163
+
164
+ // src/version.ts
165
+ var VERSION = versionOf(import.meta.url, true ? "0.5.6" : void 0);
166
+
167
+ // src/commands/vm/index.ts
168
+ import { Command as Command15 } from "commander";
169
+
170
+ // src/commands/vm/connect.ts
171
+ import { spawnSync } from "child_process";
172
+ import { Command as Command2 } from "commander";
173
+
174
+ // src/cert.ts
175
+ import { readFile as readFile2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
176
+
177
+ // ../shared/src/paths.ts
178
+ import { homedir } from "os";
179
+ import { join } from "path";
180
+ function kvboxHome() {
181
+ return process.env["KVBOX_HOME"] ?? join(homedir(), ".kvbox");
182
+ }
183
+ function paths(home = kvboxHome()) {
184
+ return {
185
+ home,
186
+ config: join(home, "config.json"),
187
+ /** The host's ledgers (SQLite via node:sqlite): usage events today. Boxes and snapshots stay files; see server core/db.ts. */
188
+ db: join(home, "kvbox.db"),
189
+ serverSock: join(home, "server.sock"),
190
+ serverPid: join(home, "server.pid"),
191
+ serverLog: join(home, "server.log"),
192
+ /** The CLI's login to the control plane (`kvbox login`): url, bearer token, user. Mode 600. */
193
+ cli: join(home, "cli.json"),
194
+ ssh: join(home, "ssh"),
195
+ sshKey: join(home, "ssh", "id_ed25519"),
196
+ /** The device key signed by the control plane's CA (#51); ssh loads it by itself next to `-i id_ed25519`. */
197
+ sshCert: join(home, "ssh", "id_ed25519-cert.pub"),
198
+ /** Host keys of the cloud boxes this laptop connected to, one entry per box (`connect`, #21). */
199
+ sshKnownHosts: join(home, "ssh", "known_hosts"),
200
+ /** The host's self-signed TLS identity for the control plane (`kvbox-server join`): pinned there by fingerprint. */
201
+ tls: join(home, "tls"),
202
+ tlsKey: join(home, "tls", "key.pem"),
203
+ tlsCert: join(home, "tls", "cert.pem"),
204
+ /** The host's WireGuard tunnel to its relay (`kvbox server relay`, #50): the conf net-setup.sh installs for wg-quick. Mode 600. */
205
+ relay: join(home, "relay"),
206
+ relayConf: join(home, "relay", "kvbox.conf"),
207
+ bin: join(home, "bin"),
208
+ firecracker: join(home, "bin", "firecracker"),
209
+ /** Firecracker's snapshot-editor, from the same release: merges a diff snapshot onto a full memory image. */
210
+ snapshotEditor: join(home, "bin", "snapshot-editor"),
211
+ assets: join(home, "assets"),
212
+ kernel: join(home, "assets", "vmlinux"),
213
+ /** Symlink to the current base image, assets/rootfs-<version>.ext4; boxes keep using the version they were made from. */
214
+ rootfs: join(home, "assets", "rootfs.ext4"),
215
+ baseImage: (version) => join(home, "assets", `rootfs-${version}.ext4`),
216
+ machines: join(home, "machines"),
217
+ snapshots: join(home, "snapshots"),
218
+ /** Boxes and snapshots are filed by owner: machines/<owner>/<name>. The owner is part of a box's identity. */
219
+ ownerMachines: (owner) => join(home, "machines", owner),
220
+ ownerSnapshots: (owner) => join(home, "snapshots", owner),
221
+ machine: (owner, name) => {
222
+ const dir = join(home, "machines", owner, name);
223
+ return {
224
+ dir,
225
+ meta: join(dir, "meta.json"),
226
+ /** The box's writable layer, stacked on the read-only base image inside the guest. */
227
+ overlay: join(dir, "overlay.ext4"),
228
+ /**
229
+ * The box's memory as of its last snapshot (a hard link to that snapshot's `mem`, never modified).
230
+ * The next snapshot is a diff of the pages dirtied since, merged onto this. Absent after a boot.
231
+ */
232
+ memBase: join(dir, "mem.base"),
233
+ /** `kvbox hibernate`: the box's memory image and VM state while it is hibernated; gone again after `wake` or `stop`. */
234
+ hibernateDir: join(dir, "hibernate"),
235
+ hibernateVmstate: join(dir, "hibernate", "vmstate"),
236
+ hibernateMem: join(dir, "hibernate", "mem"),
237
+ sock: join(dir, "fc.sock"),
238
+ /** Firecracker's vsock endpoint: the host connects here to reach kvbox-agent inside the box (core/agent.ts). */
239
+ vsock: join(dir, "vsock.sock"),
240
+ log: join(dir, "fc.log")
241
+ };
242
+ },
243
+ snapshot: (owner, name) => {
244
+ const dir = join(home, "snapshots", owner, name);
245
+ return {
246
+ dir,
247
+ meta: join(dir, "meta.json"),
248
+ vmstate: join(dir, "vmstate"),
249
+ mem: join(dir, "mem"),
250
+ /** Copy of the box's overlay at the moment of the snapshot; the base image is referenced by version in meta.json. */
251
+ overlay: join(dir, "overlay.ext4")
252
+ };
253
+ }
254
+ };
255
+ }
256
+
257
+ // src/session.ts
258
+ import { chmod, mkdir, readFile, rm, writeFile } from "fs/promises";
259
+ import { z } from "zod";
260
+ var SessionSchema = z.object({
261
+ url: z.string(),
262
+ token: z.string(),
263
+ user: z.string(),
264
+ /** When the SSH certificate at paths().sshCert stops being accepted (ISO); absent before the first login with a key. */
265
+ sshCertExpires: z.string().optional(),
266
+ /** Whose boxes that certificate opens (`<slug>@<realm>`, the user and their orgs); absent with `sshCertExpires`. */
267
+ sshCertPrincipals: z.array(z.string()).optional()
268
+ });
269
+ async function readSession(p = paths()) {
270
+ let text;
271
+ try {
272
+ text = await readFile(p.cli, "utf8");
273
+ } catch (err) {
274
+ if (err.code === "ENOENT") return null;
275
+ throw err;
276
+ }
277
+ try {
278
+ return SessionSchema.parse(JSON.parse(text));
279
+ } catch {
280
+ throw new KvboxError(`${p.cli} is unreadable; run: kvbox logout (then log in again)`);
281
+ }
282
+ }
283
+ async function writeSession(session, p = paths()) {
284
+ await mkdir(p.home, { recursive: true });
285
+ await writeFile(p.cli, JSON.stringify(session, null, 2) + "\n", { mode: 384 });
286
+ await chmod(p.cli, 384);
287
+ }
288
+ async function clearSession(p = paths()) {
289
+ await rm(p.cli, { force: true });
290
+ }
291
+ function hostOf(url) {
292
+ try {
293
+ return new URL(url).host;
294
+ } catch {
295
+ return url;
296
+ }
297
+ }
298
+ function isLoopback(hostname) {
299
+ return hostname === "localhost" || hostname.startsWith("127.") || hostname === "[::1]";
300
+ }
301
+ function normalizeUrl(input) {
302
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(input) ? input : `https://${input}`;
303
+ let url;
304
+ try {
305
+ url = new URL(withScheme);
306
+ } catch {
307
+ throw new KvboxError(`not a URL: ${input}`);
308
+ }
309
+ if (!/^https?:$/.test(url.protocol) || url.hostname === "" || /\s/.test(input)) throw new KvboxError(`not a URL: ${input}`);
310
+ if (url.protocol === "http:" && !isLoopback(url.hostname)) {
311
+ throw new KvboxError(`${input}: use https (plain http is only allowed for localhost)`);
312
+ }
313
+ return url.origin + url.pathname.replace(/\/+$/, "");
314
+ }
315
+
316
+ // src/cert.ts
317
+ var RENEW_BEFORE_MS = 60 * 60 * 1e3;
318
+ async function writeCert(cert, p = paths()) {
319
+ await writeFile2(p.sshCert, cert.trim() + "\n", { mode: 420 });
320
+ }
321
+ async function renewAfterRefusal(client, kept, owner, p = paths()) {
322
+ if (kept !== "kept") return false;
323
+ await ensureCert(client, p, Date.now(), { owner, force: true });
324
+ return true;
325
+ }
326
+ async function removeCert(p = paths()) {
327
+ await rm2(p.sshCert, { force: true });
328
+ }
329
+ async function ensureCert(client, p = paths(), now = Date.now(), opts = {}) {
330
+ if (client.target.kind !== "cloud") return "local";
331
+ const session = await readSession(p);
332
+ if (!session) return "local";
333
+ const expires = session.sshCertExpires ? new Date(session.sshCertExpires).getTime() : 0;
334
+ const onDisk = await readFile2(p.sshCert, "utf8").then(() => true, () => false);
335
+ const names = opts.owner === void 0 || (session.sshCertPrincipals ?? []).includes(opts.owner);
336
+ if (onDisk && expires - now > RENEW_BEFORE_MS && names && !opts.force) return "kept";
337
+ const publicKey = await readFile2(`${p.sshKey}.pub`, "utf8").catch(() => null);
338
+ if (publicKey === null) throw new KvboxError(`could not renew your SSH certificate: no key at ${p.sshKey}.pub; run: kvbox login`);
339
+ let signed;
340
+ try {
341
+ signed = await client.sshCert({ publicKey: publicKey.trim() });
342
+ } catch (err) {
343
+ throw new KvboxError(`could not renew your SSH certificate: ${err.message}`);
344
+ }
345
+ await writeCert(signed.cert, p);
346
+ await writeSession({ ...session, sshCertExpires: signed.expires, sshCertPrincipals: signed.principals }, p);
347
+ if (opts.owner !== void 0 && !signed.principals.includes(opts.owner)) {
348
+ const slug = opts.owner.split("@")[0];
349
+ const sameSlugElsewhere = signed.principals.find((pr) => pr.split("@")[0] === slug && pr !== opts.owner);
350
+ const why = sameSlugElsewhere ? `the control plane signs for ${sameSlugElsewhere} but the box's host qualifies its owner as ${opts.owner}: the control plane's name (KVBOX_CLOUD_NAME) changed after the host joined` : `you are not a member of ${slug} (a platform admin reaches org boxes over the API, not over SSH)`;
351
+ throw new KvboxError(`your SSH certificate does not name ${opts.owner}: ${why}`);
352
+ }
353
+ return "renewed";
354
+ }
355
+
356
+ // src/client.ts
357
+ import { spawn } from "child_process";
358
+ import { openSync } from "fs";
359
+ import { mkdir as mkdir2, readFile as readFile3, stat } from "fs/promises";
360
+ import { setTimeout as sleep } from "timers/promises";
361
+ import { Agent, request } from "undici";
362
+ import { z as z3 } from "zod";
363
+
364
+ // ../shared/src/api.ts
365
+ import { z as z2 } from "zod";
366
+
367
+ // ../shared/src/sizes.ts
368
+ var SIZES = {
369
+ s: { vcpu: 2, memMib: 4096, diskGib: 8 },
370
+ m: { vcpu: 4, memMib: 8192, diskGib: 16 },
371
+ l: { vcpu: 8, memMib: 16384, diskGib: 32 }
372
+ };
373
+ var SIZE_NAMES = Object.keys(SIZES);
374
+ function parseMib(text) {
375
+ const m = /^\s*(\d+(?:\.\d+)?)\s*([gm]i?b?)?\s*$/i.exec(text);
376
+ if (!m) return void 0;
377
+ const n = Number(m[1]);
378
+ const unit = (m[2] ?? "g").toLowerCase();
379
+ const mib = unit.startsWith("m") ? n : n * 1024;
380
+ return Number.isInteger(mib) && mib > 0 ? mib : void 0;
381
+ }
382
+
383
+ // ../shared/src/api.ts
384
+ var SizeSchema = z2.enum(SIZE_NAMES);
385
+ var BOX_SSH_USER = "kvbox";
386
+ var BoxStatusSchema = z2.enum(["running", "standby", "hibernated", "stopped", "unknown"]);
387
+ var MachineSchema = z2.object({
388
+ /** Host-unique short id (the tap is `kv-<id>`). Missing only on a box made before ids that has not restarted since. */
389
+ id: z2.string(),
390
+ name: z2.string(),
391
+ /** Who the box belongs to; names are unique per owner. A bare name is local to the host, `user@control-plane` later. */
392
+ owner: z2.string(),
393
+ ip: z2.string(),
394
+ mac: z2.string(),
395
+ pid: z2.number().int().nullable(),
396
+ vcpu: z2.number().int(),
397
+ memMib: z2.number().int(),
398
+ diskBytes: z2.number().int(),
399
+ // the box's own writable disk, as the guest sees it
400
+ diskUsedBytes: z2.number().int(),
401
+ // what that sparse file really occupies on the host
402
+ base: z2.string(),
403
+ // base image version the box's overlay stacks on
404
+ createdAt: z2.string(),
405
+ source: z2.string(),
406
+ // "base" or "snapshot:<name>"
407
+ status: BoxStatusSchema,
408
+ /**
409
+ * Public TCP port on the host that reaches the box's SSH while it runs (#49). Only boxes made
410
+ * through a control plane have one; the control plane adds the host's address for `connect` (#21).
411
+ */
412
+ sshPort: z2.number().int().optional(),
413
+ /**
414
+ * Where `ssh` reaches the box from outside: the host's public address and that port (#21). Only
415
+ * the control plane fills it in (a host does not know its own public address); absent for a box
416
+ * whose host reported no port or did not answer.
417
+ */
418
+ ssh: z2.object({ host: z2.string(), port: z2.number().int(), user: z2.string() }).optional(),
419
+ /** Whether the owner slug is a user or an org. Only the control plane knows; it fills it in on `GET /machines`. */
420
+ ownerKind: z2.enum(["user", "org"]).optional()
421
+ });
422
+ var CreateMachineRequest = z2.object({
423
+ name: z2.string().optional(),
424
+ /** Ignored over the local socket (the caller is the host owner); the control plane sets it over HTTPS. */
425
+ owner: z2.string().optional(),
426
+ size: SizeSchema.optional(),
427
+ from: z2.string().optional(),
428
+ force: z2.boolean().optional(),
429
+ /**
430
+ * Extra authorized_keys lines for the box, on top of the host's own key: the control plane sends
431
+ * its SSH CA as one `cert-authority …` line (#51). Written into the box's authorized_keys at every
432
+ * boot (#49); a host older than that ignores them.
433
+ */
434
+ sshKeys: z2.array(z2.string()).optional()
435
+ });
436
+ var SetSshKeysRequest = z2.object({
437
+ sshKeys: z2.array(z2.string())
438
+ });
439
+ var RescaleMachineRequest = z2.object({
440
+ size: SizeSchema.optional(),
441
+ vcpu: z2.number().int().min(1).max(64).optional(),
442
+ memMib: z2.number().int().min(256).optional(),
443
+ diskGib: z2.number().int().min(1).optional()
444
+ });
445
+ var SnapshotSchema = z2.object({
446
+ name: z2.string(),
447
+ owner: z2.string(),
448
+ source: z2.string(),
449
+ // the box it was taken from, as owner/name
450
+ base: z2.string(),
451
+ // base image version the overlay stacks on; a restore needs the same one
452
+ vcpu: z2.number().int(),
453
+ memMib: z2.number().int(),
454
+ sizeBytes: z2.number().int(),
455
+ // what vmstate + mem + overlay really occupy on the host
456
+ createdAt: z2.string()
457
+ });
458
+ var CreateSnapshotRequest = z2.object({
459
+ box: z2.string(),
460
+ name: z2.string()
461
+ });
462
+ var CapacitySchema = z2.object({
463
+ hostMemMib: z2.number().int(),
464
+ hostCores: z2.number().int(),
465
+ reservedMemMib: z2.number().int(),
466
+ reservedVcpu: z2.number().int(),
467
+ running: z2.number().int()
468
+ });
469
+ var HealthSchema = z2.object({
470
+ ok: z2.literal(true),
471
+ version: z2.string(),
472
+ pid: z2.number().int(),
473
+ /** Base image version new boxes get (null when the host has no usable base image yet). */
474
+ base: z2.string().nullable(),
475
+ /** Owner that boxes made over the local socket get (the Unix user running the server). */
476
+ owner: z2.string(),
477
+ /** The control plane this host joined (null when none). */
478
+ controlPlane: z2.object({
479
+ /** Realm name, e.g. kvbox.sh. */
480
+ name: z2.string(),
481
+ url: z2.string(),
482
+ /** Where this host answers the control plane. */
483
+ hostUrl: z2.string()
484
+ }).nullable(),
485
+ /** The relay this host reaches the internet through (#50); null when it has its own public address. Older servers omit it. */
486
+ relay: z2.object({
487
+ name: z2.string(),
488
+ /** The public IP the relay owns for this host. */
489
+ publicIp: z2.string(),
490
+ /** The relay's WireGuard endpoint the tunnel dials. */
491
+ endpoint: z2.string()
492
+ }).nullable().optional()
493
+ });
494
+ var MachineUsageSchema = z2.object({
495
+ name: z2.string(),
496
+ owner: z2.string(),
497
+ status: BoxStatusSchema,
498
+ window: z2.object({ from: z2.string(), to: z2.string() }),
499
+ seconds: z2.object({
500
+ running: z2.number().int(),
501
+ standby: z2.number().int(),
502
+ hibernated: z2.number().int(),
503
+ stopped: z2.number().int()
504
+ }),
505
+ vcpuHours: z2.number(),
506
+ ramGibHours: z2.number(),
507
+ diskGibHours: z2.number(),
508
+ cost: z2.object({ amount: z2.number(), currency: z2.string() }).optional(),
509
+ /** Stops the server did not witness (Firecracker died, host rebooted) and dated to when it noticed. */
510
+ estimatedStops: z2.number().int(),
511
+ historyFrom: z2.string().nullable()
512
+ });
513
+ var UsageStatusSchema = z2.enum([...BoxStatusSchema.options, "removed"]);
514
+ var BoxUsageSchema = MachineUsageSchema.omit({ window: true, status: true }).extend({
515
+ /** The host's box id; a name removed and recreated appears twice, with two ids. */
516
+ id: z2.string(),
517
+ status: UsageStatusSchema,
518
+ /** Through a control plane: whether the owner slug is a user's or an org's (what `kvbox ls` shows). */
519
+ ownerKind: z2.enum(["user", "org"]).optional()
520
+ });
521
+ var UsageReportSchema = z2.object({
522
+ window: z2.object({ from: z2.string(), to: z2.string() }),
523
+ boxes: z2.array(BoxUsageSchema),
524
+ totals: z2.object({
525
+ seconds: MachineUsageSchema.shape.seconds,
526
+ vcpuHours: z2.number(),
527
+ ramGibHours: z2.number(),
528
+ diskGibHours: z2.number(),
529
+ cost: z2.object({ amount: z2.number(), currency: z2.string() }).optional()
530
+ })
531
+ });
532
+ var UsageEventRowSchema = z2.object({
533
+ id: z2.number().int(),
534
+ boxId: z2.string(),
535
+ owner: z2.string(),
536
+ name: z2.string(),
537
+ at: z2.string(),
538
+ state: z2.enum(["running", "standby", "hibernated", "stopped"]),
539
+ vcpu: z2.number().int(),
540
+ memMib: z2.number().int(),
541
+ diskGib: z2.number().int(),
542
+ by: z2.string()
543
+ });
544
+ var UsageEventsResponseSchema = z2.object({
545
+ ledger: z2.string().min(1),
546
+ events: z2.array(UsageEventRowSchema),
547
+ next: z2.number().int()
548
+ });
549
+ var ApiErrorSchema = z2.object({
550
+ error: z2.string()
551
+ });
552
+ var DEFAULT_CLOUD_URL = "https://api.kvbox.sh";
553
+ var CloudUserSchema = z2.object({
554
+ /** The handle: what hosts file boxes under (`<username>@<control plane>`). Lowercase, no `@` or `/`. */
555
+ username: z2.string(),
556
+ /** Display name, free text ("Alexandru Ghiura"). */
557
+ name: z2.string(),
558
+ /** Login identity, unique. */
559
+ email: z2.string(),
560
+ role: z2.enum(["admin", "user"]),
561
+ createdAt: z2.string()
562
+ });
563
+ var ORG_ROLES = ["owner", "admin", "member"];
564
+ var OrgRoleSchema = z2.enum(ORG_ROLES);
565
+ var CloudOrgSchema = z2.object({
566
+ slug: z2.string(),
567
+ /** Display name; the slug never changes, this may. */
568
+ name: z2.string(),
569
+ createdAt: z2.string()
570
+ });
571
+ var OrgMembershipSchema = CloudOrgSchema.extend({ role: OrgRoleSchema });
572
+ var OrgMemberSchema = z2.object({
573
+ username: z2.string(),
574
+ name: z2.string(),
575
+ role: OrgRoleSchema,
576
+ addedAt: z2.string()
577
+ });
578
+ var OrgDetailSchema = CloudOrgSchema.extend({ members: z2.array(OrgMemberSchema) });
579
+ var CreateOrgRequestSchema = z2.object({
580
+ slug: z2.string().min(1),
581
+ name: z2.string().min(1)
582
+ });
583
+ var RenameOrgRequestSchema = z2.object({ name: z2.string().min(1) });
584
+ var AddMemberRequestSchema = z2.object({
585
+ username: z2.string().min(1),
586
+ role: OrgRoleSchema.default("member")
587
+ });
588
+ var SetRoleRequestSchema = z2.object({ role: OrgRoleSchema });
589
+ var MeResponseSchema = CloudUserSchema.extend({ orgs: z2.array(OrgMembershipSchema) });
590
+ var LoginRequestSchema = z2.object({
591
+ email: z2.string().min(1),
592
+ password: z2.string().min(1),
593
+ publicKey: z2.string().optional()
594
+ });
595
+ var SignupRequestSchema = z2.object({
596
+ username: z2.string().min(1),
597
+ name: z2.string().min(1),
598
+ email: z2.string().min(1),
599
+ password: z2.string().min(1)
600
+ });
601
+ var LoginResponseSchema = z2.object({
602
+ token: z2.string(),
603
+ user: CloudUserSchema,
604
+ sshCert: z2.string().optional(),
605
+ sshCertExpires: z2.string().optional(),
606
+ /** Whose boxes the certificate opens: `<slug>@<realm>` for the user and each org they are in (orgs phase 4). */
607
+ sshCertPrincipals: z2.array(z2.string()).optional()
608
+ });
609
+ var SshCertRequestSchema = z2.object({
610
+ publicKey: z2.string().min(1)
611
+ });
612
+ var SshCertResponseSchema = z2.object({
613
+ cert: z2.string(),
614
+ expires: z2.string(),
615
+ principals: z2.array(z2.string())
616
+ });
617
+ var SshCaResponseSchema = z2.object({
618
+ publicKey: z2.string(),
619
+ /** CAs replaced by `ca rotate` (#55), newest first; a box still holding one of these has not had `kvbox server trust-ca` run on its host. */
620
+ retired: z2.array(z2.object({ publicKey: z2.string(), retiredAt: z2.string() })).optional()
621
+ });
622
+ var HostSshCaResponseSchema = z2.object({
623
+ publicKey: z2.string()
624
+ });
625
+ var CloudCaSchema = z2.object({
626
+ controlPlane: z2.string(),
627
+ publicKey: z2.string(),
628
+ fingerprint: z2.string()
629
+ });
630
+ var TrustCaRequestSchema = z2.object({
631
+ publicKey: z2.string().min(1)
632
+ });
633
+ var SshKeysAppliedSchema = z2.enum(["now", "start", "wake", "resume", "later"]);
634
+ var TrustCaReportSchema = z2.object({
635
+ publicKey: z2.string(),
636
+ fingerprint: z2.string(),
637
+ /** The guest trusts the CA now. */
638
+ updated: z2.array(z2.string()),
639
+ /** meta.json has the line; the guest gets it at `when`. */
640
+ pending: z2.array(z2.object({ box: z2.string(), when: SshKeysAppliedSchema, why: z2.string().optional() })),
641
+ failed: z2.array(z2.object({ box: z2.string(), error: z2.string() })),
642
+ /** Local boxes, untouched: they trust the host's key only. */
643
+ local: z2.number().int()
644
+ });
645
+ var HostJoinRequestSchema = z2.object({
646
+ token: z2.string().min(1),
647
+ name: z2.string().min(1),
648
+ url: z2.string().url(),
649
+ fingerprint: z2.string().regex(/^([0-9A-F]{2}:){31}[0-9A-F]{2}$/),
650
+ publicAddress: z2.string().min(1)
651
+ });
652
+ var HostJoinResponseSchema = z2.object({
653
+ hostSecret: z2.string(),
654
+ controlPlane: z2.object({ name: z2.string() })
655
+ });
656
+ var CloudHostSchema = z2.object({
657
+ name: z2.string(),
658
+ url: z2.string(),
659
+ publicAddress: z2.string(),
660
+ joinedAt: z2.string(),
661
+ lastSeenAt: z2.string().nullable(),
662
+ capacity: CapacitySchema.nullable()
663
+ });
664
+
665
+ // src/kvm.ts
666
+ import { accessSync, constants, readFileSync, statSync } from "fs";
667
+ import { userInfo } from "os";
668
+ var KVM_DEVICE = "/dev/kvm";
669
+ function serverLauncher(opts = {}) {
670
+ const device = opts.device ?? KVM_DEVICE;
671
+ const plain = { cmd: "kvbox-server", args: ["start"] };
672
+ try {
673
+ accessSync(device, constants.R_OK | constants.W_OK);
674
+ return plain;
675
+ } catch (err) {
676
+ const code = err.code;
677
+ if (code !== "EACCES" && code !== "EPERM") return plain;
678
+ }
679
+ let gid;
680
+ try {
681
+ gid = statSync(device).gid;
682
+ } catch {
683
+ return plain;
684
+ }
685
+ const group = groupWithMember(gid, opts.user ?? userInfo().username, opts.groupFile ?? "/etc/group");
686
+ if (group === void 0) return plain;
687
+ return { cmd: "sg", args: [group, "-c", "kvbox-server start"], viaGroup: group };
688
+ }
689
+ function groupWithMember(gid, user, groupFile) {
690
+ let text;
691
+ try {
692
+ text = readFileSync(groupFile, "utf8");
693
+ } catch {
694
+ return void 0;
695
+ }
696
+ for (const line of text.split("\n")) {
697
+ const [name, , id, members = ""] = line.split(":");
698
+ if (name === void 0 || id === void 0 || Number.parseInt(id, 10) !== gid) continue;
699
+ return members.split(",").includes(user) ? name : void 0;
700
+ }
701
+ return void 0;
702
+ }
703
+
704
+ // src/client.ts
705
+ var ORG_SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,31}$/;
706
+ function orgOption(opts) {
707
+ if (opts.org === void 0 || opts.org === "") return void 0;
708
+ if (!ORG_SLUG_RE.test(opts.org)) throw new KvboxError(`${opts.org}: not an org slug (lowercase letters, digits, '.', '_' or '-')`);
709
+ return opts.org;
710
+ }
711
+ async function resolveTarget(opts, p = paths()) {
712
+ if (!opts.local) {
713
+ const session = await readSession(p);
714
+ if (session) {
715
+ const org = orgOption(opts);
716
+ return { kind: "cloud", url: session.url, token: session.token, user: session.user, ...org === void 0 ? {} : { org } };
717
+ }
718
+ }
719
+ return { kind: "local", socketPath: p.serverSock };
720
+ }
721
+ var ServerClient = class {
722
+ constructor(target, opts = {}) {
723
+ this.target = target;
724
+ this.autoStart = opts.autoStart ?? true;
725
+ if (target.kind === "local") {
726
+ this.dispatcher = new Agent({ connect: { socketPath: target.socketPath } });
727
+ this.base = "http://kvbox";
728
+ } else {
729
+ this.dispatcher = new Agent();
730
+ this.base = target.url;
731
+ }
732
+ }
733
+ target;
734
+ dispatcher;
735
+ base;
736
+ autoStart;
737
+ startedOnce = false;
738
+ async call(method, path, schema, body) {
739
+ const headers = {};
740
+ if (body !== void 0) headers["content-type"] = "application/json";
741
+ if (this.target.kind === "cloud" && this.target.token !== "") headers["authorization"] = `Bearer ${this.target.token}`;
742
+ if (this.target.kind === "cloud" && this.target.org !== void 0) headers["x-kvbox-org"] = this.target.org;
743
+ let res;
744
+ try {
745
+ res = await request(this.base + path, {
746
+ dispatcher: this.dispatcher,
747
+ method,
748
+ headers,
749
+ body: body === void 0 ? null : JSON.stringify(body)
750
+ });
751
+ } catch (err) {
752
+ const code = err.code ?? err.cause;
753
+ if (this.target.kind === "local") {
754
+ if (this.autoStart && !this.startedOnce) {
755
+ this.startedOnce = true;
756
+ await spin("starting kvbox-server\u2026", startLocalServer(paths(), 5e3));
757
+ return this.call(method, path, schema, body);
758
+ }
759
+ throw new KvboxError(`kvbox-server is not running (${String(code)}); start it with: kvbox server start`);
760
+ }
761
+ throw new KvboxError(cloudDown(this.target.url, String(code)));
762
+ }
763
+ const text = await res.body.text();
764
+ const where = this.target.kind === "cloud" ? hostOf(this.target.url) : "kvbox-server";
765
+ if (res.statusCode >= 300) {
766
+ const parsed = ApiErrorSchema.safeParse(safeJson(text) ?? {});
767
+ if (!parsed.success) throw new KvboxError(`${where} answered ${res.statusCode}`);
768
+ if (this.target.kind === "cloud" && this.target.token !== "") {
769
+ if (res.statusCode === 401) throw new KvboxError("session expired: kvbox login");
770
+ if (res.statusCode === 404 && this.target.org !== void 0 && parsed.data.error === `org ${this.target.org}: not found`) {
771
+ throw new KvboxError(`${parsed.data.error} (you are not in it, or it is gone)`);
772
+ }
773
+ }
774
+ throw new KvboxError(parsed.data.error, res.statusCode === 501 ? 2 : 1);
775
+ }
776
+ if (text === "") return schema.parse(void 0);
777
+ const json2 = safeJson(text);
778
+ if (json2 === void 0) throw new KvboxError(`${where} answered ${res.statusCode} with something that is not JSON`);
779
+ return schema.parse(json2);
780
+ }
781
+ health() {
782
+ return this.call("GET", "/health", HealthSchema);
783
+ }
784
+ capacity() {
785
+ return this.call("GET", "/capacity", CapacitySchema);
786
+ }
787
+ /** Through a control plane: every box the user can see, or the `--org` org's. */
788
+ listMachines() {
789
+ return this.call("GET", "/machines", z3.array(MachineSchema));
790
+ }
791
+ createMachine(req) {
792
+ return this.call("POST", "/machines", MachineSchema, req);
793
+ }
794
+ stopMachine(name) {
795
+ return this.call("POST", `/machines/${encodeURIComponent(name)}/stop`, MachineSchema);
796
+ }
797
+ startMachine(name) {
798
+ return this.call("POST", `/machines/${encodeURIComponent(name)}/start`, MachineSchema);
799
+ }
800
+ pauseMachine(name) {
801
+ return this.call("POST", `/machines/${encodeURIComponent(name)}/pause`, MachineSchema);
802
+ }
803
+ resumeMachine(name) {
804
+ return this.call("POST", `/machines/${encodeURIComponent(name)}/resume`, MachineSchema);
805
+ }
806
+ hibernateMachine(name) {
807
+ return this.call("POST", `/machines/${encodeURIComponent(name)}/hibernate`, MachineSchema);
808
+ }
809
+ wakeMachine(name) {
810
+ return this.call("POST", `/machines/${encodeURIComponent(name)}/wake`, MachineSchema);
811
+ }
812
+ updateMachine(name) {
813
+ return this.call("POST", `/machines/${encodeURIComponent(name)}/update`, MachineSchema);
814
+ }
815
+ rescaleMachine(name, req) {
816
+ return this.call("POST", `/machines/${encodeURIComponent(name)}/rescale`, MachineSchema, req);
817
+ }
818
+ /** `since`: an ISO date, already validated; the server counts from the box's creation without it. */
819
+ machineUsage(name, since) {
820
+ const query = since === void 0 ? "" : `?since=${encodeURIComponent(since)}`;
821
+ return this.call("GET", `/machines/${encodeURIComponent(name)}/usage${query}`, MachineUsageSchema);
822
+ }
823
+ /** Every box of the caller over the window (#41 on a host, #42 through a control plane); dates as the user wrote them. */
824
+ usageReport(since, until) {
825
+ const q = new URLSearchParams();
826
+ if (since !== void 0) q.set("since", since);
827
+ if (until !== void 0) q.set("until", until);
828
+ const query = q.size === 0 ? "" : `?${q.toString()}`;
829
+ return this.call("GET", `/usage${query}`, UsageReportSchema);
830
+ }
831
+ deleteMachine(name) {
832
+ return this.call("DELETE", `/machines/${encodeURIComponent(name)}`, z3.undefined());
833
+ }
834
+ listSnapshots() {
835
+ return this.call("GET", "/snapshots", z3.array(SnapshotSchema));
836
+ }
837
+ createSnapshot(req) {
838
+ return this.call("POST", "/snapshots", SnapshotSchema, req);
839
+ }
840
+ deleteSnapshot(name) {
841
+ return this.call("DELETE", `/snapshots/${encodeURIComponent(name)}`, z3.undefined());
842
+ }
843
+ // ---- control plane only (`kvbox login`, `logout`, `whoami`) ----
844
+ login(req) {
845
+ return this.call("POST", "/login", LoginResponseSchema, req);
846
+ }
847
+ logout() {
848
+ return this.call("POST", "/logout", z3.unknown()).then(() => void 0);
849
+ }
850
+ me() {
851
+ return this.call("GET", "/me", MeResponseSchema);
852
+ }
853
+ // ---- orgs (`kvbox org …`) ----
854
+ listOrgs() {
855
+ return this.call("GET", "/orgs", z3.array(OrgMembershipSchema));
856
+ }
857
+ createOrg(req) {
858
+ return this.call("POST", "/orgs", CloudOrgSchema, req);
859
+ }
860
+ getOrg(slug) {
861
+ return this.call("GET", `/orgs/${encodeURIComponent(slug)}`, OrgDetailSchema);
862
+ }
863
+ deleteOrg(slug) {
864
+ return this.call("DELETE", `/orgs/${encodeURIComponent(slug)}`, z3.undefined());
865
+ }
866
+ addMember(slug, req) {
867
+ return this.call("POST", `/orgs/${encodeURIComponent(slug)}/members`, z3.undefined(), req);
868
+ }
869
+ setRole(slug, username, role) {
870
+ return this.call("PATCH", `/orgs/${encodeURIComponent(slug)}/members/${encodeURIComponent(username)}`, z3.unknown(), { role }).then(() => void 0);
871
+ }
872
+ removeMember(slug, username) {
873
+ return this.call("DELETE", `/orgs/${encodeURIComponent(slug)}/members/${encodeURIComponent(username)}`, z3.undefined());
874
+ }
875
+ /** Sign this machine's key again (#51); the key must be one the user logged in with. */
876
+ sshCert(req) {
877
+ return this.call("POST", "/ssh/cert", SshCertResponseSchema, req);
878
+ }
879
+ sshCa() {
880
+ return this.call("GET", "/ssh/ca", SshCaResponseSchema);
881
+ }
882
+ /** This host's socket only (#55): the CA its control plane says boxes should trust. */
883
+ cloudCa() {
884
+ return this.call("GET", "/ssh/cloud-ca", CloudCaSchema);
885
+ }
886
+ /** This host's socket only (#55): make every cloud box on it trust this CA. */
887
+ trustCa(req) {
888
+ return this.call("POST", "/ssh/trust-ca", TrustCaReportSchema, req);
889
+ }
890
+ async close() {
891
+ await this.dispatcher.close();
892
+ }
893
+ };
894
+ function cloudDown(url, code) {
895
+ const host = hostOf(url);
896
+ const hint = host.endsWith("kvbox.sh") ? "check status.kvbox.sh" : "use --local for this machine, or kvbox logout";
897
+ return `${host} is not available or is down (${code}). You are logged in there, so kvbox talks to it instead of this machine; ${hint}`;
898
+ }
899
+ function safeJson(text) {
900
+ try {
901
+ return JSON.parse(text);
902
+ } catch {
903
+ return void 0;
904
+ }
905
+ }
906
+ async function wakeUp(client, box, name) {
907
+ switch (box.status) {
908
+ case "standby":
909
+ info(`box ${name} is in standby; resuming it`);
910
+ return spin(`resuming ${name}\u2026`, client.resumeMachine(name));
911
+ case "hibernated":
912
+ info(`box ${name} is hibernated; waking it`);
913
+ return spin(`waking ${name} (loading its memory from disk)\u2026`, client.wakeMachine(name));
914
+ case "stopped":
915
+ info(`box ${name} is stopped; starting it`);
916
+ return spin(`booting ${name}\u2026`, client.startMachine(name));
917
+ default:
918
+ return box;
919
+ }
920
+ }
921
+ var me = (client) => client.target.kind === "cloud" ? client.target.user : void 0;
922
+ async function clientFor(opts, client = {}) {
923
+ return new ServerClient(await resolveTarget(opts), client);
924
+ }
925
+ function sshEndpoint(box, target) {
926
+ if (target.kind === "local") return { host: box.ip, port: 22, user: BOX_SSH_USER };
927
+ if (box.ssh === void 0) throw new KvboxError(`box ${box.name} has no public SSH port`);
928
+ return { ...box.ssh, alias: `${box.id}.${box.ssh.host}` };
929
+ }
930
+ function sshArgs(ep, p = paths()) {
931
+ const hostKey = ep.alias === void 0 ? ["-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"] : ["-o", "StrictHostKeyChecking=accept-new", "-o", `UserKnownHostsFile=${p.sshKnownHosts}`, "-o", `HostKeyAlias=${ep.alias}`];
932
+ return [
933
+ "-i",
934
+ p.sshKey,
935
+ "-p",
936
+ String(ep.port),
937
+ ...hostKey,
938
+ "-o",
939
+ "LogLevel=ERROR",
940
+ "-o",
941
+ "SetEnv=LC_ALL=C.UTF-8",
942
+ `${ep.user}@${ep.host}`
943
+ ];
944
+ }
945
+ async function waitForSsh(ep, timeoutMs = 3e4) {
946
+ const { connect } = await import("net");
947
+ const deadline = Date.now() + timeoutMs;
948
+ const attempt = () => new Promise((resolve) => {
949
+ const sock = connect({ host: ep.host, port: ep.port, timeout: 1e3 });
950
+ sock.once("connect", () => {
951
+ sock.destroy();
952
+ resolve(true);
953
+ });
954
+ sock.once("timeout", () => {
955
+ sock.destroy();
956
+ resolve(false);
957
+ });
958
+ sock.once("error", () => resolve(false));
959
+ });
960
+ while (Date.now() < deadline) {
961
+ if (await attempt()) return;
962
+ await new Promise((r) => setTimeout(r, 250));
963
+ }
964
+ throw new KvboxError(`box at ${ep.host}:${ep.port} did not answer within ${Math.round(timeoutMs / 1e3)}s`);
965
+ }
966
+ var NOT_A_HOST_HINT = "kvbox-server is not installed, so this machine is not a kvbox host. Set it up with install-server.sh, or log in to the cloud with: kvbox login";
967
+ async function lastErrorLine(logPath) {
968
+ const { readFile: readFile6 } = await import("fs/promises");
969
+ const text = await readFile6(logPath, "utf8").catch(() => "");
970
+ const lines = text.trimEnd().split("\n").slice(-20).reverse();
971
+ return lines.find((l) => l.startsWith("error: "))?.slice("error: ".length);
972
+ }
973
+ async function startLocalServer(p = paths(), timeoutMs = 5e3) {
974
+ await mkdir2(p.home, { recursive: true });
975
+ const log = openSync(p.serverLog, "a");
976
+ const launcher = serverLauncher();
977
+ const child = spawn(launcher.cmd, launcher.args, { detached: true, stdio: ["ignore", log, log] });
978
+ const failed = new Promise((_, reject) => {
979
+ child.once("error", (err) => {
980
+ reject(new KvboxError(err.code === "ENOENT" && launcher.viaGroup === void 0 ? NOT_A_HOST_HINT : `could not start kvbox-server: ${err.message}`));
981
+ });
982
+ child.once("exit", (code) => {
983
+ void lastErrorLine(p.serverLog).then((why) => {
984
+ reject(new KvboxError(why === void 0 ? `kvbox-server exited with code ${String(code)} before listening; see ${p.serverLog}` : `kvbox-server could not start: ${why}`));
985
+ });
986
+ });
987
+ });
988
+ const ready = (async () => {
989
+ const deadline = Date.now() + timeoutMs;
990
+ while (Date.now() < deadline) {
991
+ if (await stat(p.serverSock).then(() => true, () => false)) {
992
+ const probe = new ServerClient({ kind: "local", socketPath: p.serverSock }, { autoStart: false });
993
+ try {
994
+ await probe.health();
995
+ return;
996
+ } catch {
997
+ } finally {
998
+ await probe.close();
999
+ }
1000
+ }
1001
+ await sleep(100);
1002
+ }
1003
+ throw new KvboxError(`kvbox-server did not start within ${timeoutMs / 1e3}s; see ${p.serverLog}`);
1004
+ })();
1005
+ await Promise.race([ready, failed]);
1006
+ child.removeAllListeners("exit");
1007
+ child.unref();
1008
+ const pid = Number.parseInt(await readFile3(p.serverPid, "utf8").catch(() => ""), 10);
1009
+ return { pid: Number.isNaN(pid) ? child.pid ?? -1 : pid, socketPath: p.serverSock, viaGroup: launcher.viaGroup };
1010
+ }
1011
+
1012
+ // src/commands/vm/new.ts
1013
+ import { Command, Option } from "commander";
1014
+ var newCommand = new Command("new").description("boot a new box from the base image or a snapshot").argument("[name]", "box name (box1, box2, \u2026 when omitted)").addOption(new Option("-s, --size <size>", "box size (default: server config, s)").choices(SIZE_NAMES)).option("-f, --from <snapshot>", "start from a snapshot instead of the base image").option("--force", "skip the capacity guard").option("--json", "print the box as JSON").action(async (name, opts, cmd) => {
1015
+ const client = await clientFor(cmd.optsWithGlobals());
1016
+ try {
1017
+ const what = opts.from !== void 0 ? `restoring ${name ?? "a new box"} from snapshot ${opts.from}\u2026` : `booting ${name ?? "a new box"}\u2026`;
1018
+ const box = await spin(what, client.createMachine({
1019
+ ...name !== void 0 ? { name } : {},
1020
+ ...opts.size !== void 0 ? { size: opts.size } : {},
1021
+ ...opts.from !== void 0 ? { from: opts.from } : {},
1022
+ ...opts.force ? { force: true } : {}
1023
+ }));
1024
+ if (opts.json) {
1025
+ json(box);
1026
+ return;
1027
+ }
1028
+ const user = client.target.kind === "cloud" ? client.target.user : void 0;
1029
+ process.stdout.write(`${box.name}
1030
+ `);
1031
+ info(`${color.green("\u2713")} ${describe(box, user)}`);
1032
+ if (opts.from !== void 0) info(` connect with: kvbox connect ${displayName(box, user)} (already up: everything that ran in the snapshot is running)`);
1033
+ else info(` connect with: kvbox connect ${displayName(box, user)} (SSH is up a couple of seconds after boot)`);
1034
+ } finally {
1035
+ await client.close();
1036
+ }
1037
+ });
1038
+ function displayName(box, user) {
1039
+ return user !== void 0 && slugOf(box.owner) !== user ? `${slugOf(box.owner)}/${box.name}` : box.name;
1040
+ }
1041
+ function describe(box, user) {
1042
+ const from = box.source.startsWith("snapshot:") ? ` from snapshot ${shortRef(box.source.slice("snapshot:".length), box.owner)}` : "";
1043
+ return `box ${color.bold(displayName(box, user))} is ${box.status} at ${box.ip}${from}: ${box.vcpu} vCPU, ${gb(box.memMib * 1024 ** 2)} RAM, ${gb(box.diskBytes)} disk`;
1044
+ }
1045
+
1046
+ // src/commands/vm/connect.ts
1047
+ var SSH_FAILED = 255;
1048
+ var connectCommand = new Command2("connect").alias("cn").description("open a shell in a box over SSH (or run a command: kvbox connect <name> -- <cmd>)").argument("<name>", "box name").argument("[cmd...]", "command to run instead of a shell").passThroughOptions().action(async (name, cmdArgs, _opts, cmd) => {
1049
+ const client = await clientFor(cmd.optsWithGlobals());
1050
+ let ep;
1051
+ let cert;
1052
+ let owner;
1053
+ try {
1054
+ let box = pickRef(await client.listMachines(), name, me(client));
1055
+ if (!box) throw new KvboxError(`box ${name}: not found (kvbox ls)`);
1056
+ cert = await ensureCert(client, void 0, void 0, { owner: box.owner });
1057
+ owner = box.owner;
1058
+ const wasRunning = box.status === "running";
1059
+ if (!wasRunning) box = await wakeUp(client, box, displayName(box, me(client)));
1060
+ ep = sshEndpoint(box, client.target);
1061
+ if (!wasRunning) await spin(`waiting for ${name} to accept SSH\u2026`, waitForSsh(ep));
1062
+ } finally {
1063
+ await client.close();
1064
+ }
1065
+ let res = spawnSync("ssh", [...sshArgs(ep), ...cmdArgs], { stdio: "inherit" });
1066
+ if (res.error) throw new KvboxError(`could not run ssh: ${res.error.message}`);
1067
+ if (res.status === SSH_FAILED) {
1068
+ const again = await clientFor(cmd.optsWithGlobals());
1069
+ const retry = await renewAfterRefusal(again, cert, owner).finally(() => again.close());
1070
+ if (retry) {
1071
+ info("renewed your SSH certificate, trying again\u2026");
1072
+ res = spawnSync("ssh", [...sshArgs(ep), ...cmdArgs], { stdio: "inherit" });
1073
+ if (res.error) throw new KvboxError(`could not run ssh: ${res.error.message}`);
1074
+ }
1075
+ }
1076
+ process.exit(res.status ?? 1);
1077
+ });
1078
+
1079
+ // src/commands/vm/exec.ts
1080
+ import { spawn as spawn2 } from "child_process";
1081
+ import { Command as Command3, InvalidArgumentError } from "commander";
1082
+ function shellQuote(s) {
1083
+ return `'${s.replace(/'/g, `'\\''`)}'`;
1084
+ }
1085
+ function remoteCommand(cmdArgs, env) {
1086
+ const cmd = cmdArgs.join(" ");
1087
+ if (env.length === 0) return cmd;
1088
+ const assignments = env.map((entry) => {
1089
+ const eq = entry.indexOf("=");
1090
+ const key = eq > 0 ? entry.slice(0, eq) : "";
1091
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new KvboxError(`--env ${entry}: expected KEY=VALUE`);
1092
+ return `${key}=${shellQuote(entry.slice(eq + 1))}`;
1093
+ });
1094
+ return `export ${assignments.join(" ")}; ${cmd}`;
1095
+ }
1096
+ var TIMEOUT_EXIT = 124;
1097
+ var SSH_FAILED2 = 255;
1098
+ function collect(value, previous) {
1099
+ return [...previous ?? [], value];
1100
+ }
1101
+ function positiveSeconds(value) {
1102
+ const n = Number(value);
1103
+ if (!Number.isFinite(n) || n <= 0) throw new InvalidArgumentError("expected a number of seconds > 0");
1104
+ return n;
1105
+ }
1106
+ async function runSsh(ssh, opts) {
1107
+ const child = spawn2("ssh", ssh, { stdio: ["inherit", opts.json ? "pipe" : "inherit", opts.json ? "pipe" : "inherit"] });
1108
+ const chunks = [];
1109
+ child.stdout?.on("data", (c) => chunks.push(c));
1110
+ child.stderr?.on("data", (c) => chunks.push(c));
1111
+ let timedOut = false;
1112
+ const timer = opts.timeout === void 0 ? void 0 : setTimeout(() => {
1113
+ timedOut = true;
1114
+ child.kill("SIGTERM");
1115
+ }, opts.timeout * 1e3);
1116
+ const status2 = await new Promise((resolve, reject) => {
1117
+ child.once("error", (err) => reject(new KvboxError(`could not run ssh: ${err.message}`)));
1118
+ child.once("close", (code, signal) => {
1119
+ if (timedOut) resolve(TIMEOUT_EXIT);
1120
+ else if (code !== null) resolve(code);
1121
+ else resolve(signal === "SIGINT" ? 130 : 1);
1122
+ });
1123
+ }).finally(() => clearTimeout(timer));
1124
+ return { status: status2, chunks, timedOut };
1125
+ }
1126
+ var execCommand = new Command3("exec").description("run a command in a box and exit with its status (kvbox exec <name> -- <cmd>)").argument("<name>", "box name").argument("<cmd...>", "command, passed to the box's shell as one string (pipes, &&, cd work)").option("-e, --env <KEY=VALUE>", "set an environment variable for the command (repeatable)", collect).option("--timeout <seconds>", "kill the command after N seconds and exit 124", positiveSeconds).option("--tty", "allocate a pseudo-terminal (for interactive tools like htop)").option("--json", 'capture output and print {"output": "...", "exit_code": N}').action(async (name, cmdArgs, opts, cmd) => {
1127
+ const client = await clientFor(cmd.optsWithGlobals());
1128
+ let ep;
1129
+ let cert;
1130
+ let owner;
1131
+ try {
1132
+ let box = pickRef(await client.listMachines(), name, me(client));
1133
+ if (!box) throw new KvboxError(`box ${name}: not found (kvbox ls)`);
1134
+ if (box.status === "stopped") throw new KvboxError(`box ${name} is stopped (kvbox start ${name})`);
1135
+ cert = await ensureCert(client, void 0, void 0, { owner: box.owner });
1136
+ owner = box.owner;
1137
+ const wasRunning = box.status === "running";
1138
+ if (!wasRunning) box = await wakeUp(client, box, displayName(box, me(client)));
1139
+ ep = sshEndpoint(box, client.target);
1140
+ if (!wasRunning) await spin(`waiting for ${name} to accept SSH\u2026`, waitForSsh(ep));
1141
+ } finally {
1142
+ await client.close();
1143
+ }
1144
+ const remote = remoteCommand(cmdArgs, opts.env ?? []);
1145
+ const stdinIsTty = process.stdin.isTTY === true;
1146
+ const ssh = [
1147
+ ...opts.tty ? ["-t"] : [],
1148
+ // With a terminal on stdin nothing is sent (the command would otherwise wait on it);
1149
+ // from a pipe or file, stdin is forwarded and its EOF reaches the command.
1150
+ ...stdinIsTty && !opts.tty ? ["-n"] : [],
1151
+ ...sshArgs(ep),
1152
+ // ssh joins everything after the host into the remote command line.
1153
+ remote
1154
+ ];
1155
+ let { status: status2, chunks, timedOut } = await runSsh(ssh, opts);
1156
+ if (status2 === SSH_FAILED2 && !timedOut) {
1157
+ const again = await clientFor(cmd.optsWithGlobals());
1158
+ if (await renewAfterRefusal(again, cert, owner).finally(() => again.close())) {
1159
+ if (opts.json) process.stderr.write(Buffer.concat(chunks));
1160
+ process.stderr.write("renewed your SSH certificate, trying again\u2026\n");
1161
+ ({ status: status2, chunks, timedOut } = await runSsh(ssh, opts));
1162
+ }
1163
+ }
1164
+ if (status2 === SSH_FAILED2 && !timedOut) {
1165
+ if (opts.json) process.stderr.write(Buffer.concat(chunks));
1166
+ throw new KvboxError(`box ${name}: could not run the command over SSH (box still booting?)`, SSH_FAILED2);
1167
+ }
1168
+ if (opts.json) {
1169
+ process.stdout.write(JSON.stringify({ output: Buffer.concat(chunks).toString("utf8"), exit_code: status2 }) + "\n");
1170
+ }
1171
+ process.exit(status2);
1172
+ });
1173
+
1174
+ // src/commands/vm/hibernate.ts
1175
+ import { Command as Command4 } from "commander";
1176
+ var hibernateCommand = new Command4("hibernate").description("save a box's memory to disk and free its RAM (kvbox wake continues it where it left off)").argument("<name>", "box name").option("--json", "print the box as JSON").action(async (name, opts, cmd) => {
1177
+ const client = await clientFor(cmd.optsWithGlobals());
1178
+ try {
1179
+ const box = await spin(`hibernating ${name} (saving its memory to disk)\u2026`, client.hibernateMachine(name));
1180
+ if (opts.json) json(box);
1181
+ else info(`${color.green("\u2713")} box ${name} hibernated (memory saved to disk, RAM freed; kvbox wake ${name} continues it where it left off)`);
1182
+ } finally {
1183
+ await client.close();
1184
+ }
1185
+ });
1186
+
1187
+ // src/commands/vm/ls.ts
1188
+ import { Command as Command5, Option as Option2 } from "commander";
1189
+ function ownerCell(b, me2) {
1190
+ const slug = slugOf(b.owner);
1191
+ if (slug === me2) return "private";
1192
+ return b.ownerKind === void 0 ? slug : `${slug} ${color.dim(b.ownerKind)}`;
1193
+ }
1194
+ function addressOf(b) {
1195
+ if (b.ssh !== void 0) return `${b.ssh.host}:${b.ssh.port}`;
1196
+ return b.ip === "" ? "-" : b.ip;
1197
+ }
1198
+ var lsCommand = new Command5("ls").description("list boxes with status and address (yours and your orgs'; --org <slug> for one org)").addOption(new Option2("-a, --all", "kept for older scripts: the listing covers everything you can see anyway").hideHelp()).option("--json", "print as JSON").action(async (opts, cmd) => {
1199
+ const client = await clientFor(cmd.optsWithGlobals());
1200
+ try {
1201
+ const boxes = await client.listMachines();
1202
+ if (opts.json) {
1203
+ json(boxes);
1204
+ return;
1205
+ }
1206
+ const org = client.target.kind === "cloud" ? client.target.org : void 0;
1207
+ if (boxes.length === 0) {
1208
+ info(org === void 0 ? "no boxes yet; create one with: kvbox new" : `no boxes in ${org} yet; create one with: kvbox new --org ${org}`);
1209
+ return;
1210
+ }
1211
+ if (org !== void 0) info(`boxes of ${org}`);
1212
+ table(lsRows(boxes, client.target.kind === "cloud" ? { me: client.target.user } : void 0));
1213
+ } finally {
1214
+ await client.close();
1215
+ }
1216
+ });
1217
+ function lsRows(boxes, cloud) {
1218
+ return boxes.map((b) => ({
1219
+ ...cloud ? { owner: ownerCell(b, cloud.me) } : {},
1220
+ name: boxName(b.name),
1221
+ status: status(b.status),
1222
+ address: addressOf(b),
1223
+ size: b.status === "unknown" ? "-" : `${shape(b.vcpu, b.memMib, b.diskBytes)} ${color.dim(`(${gbShort(b.diskUsedBytes)} used)`)}`,
1224
+ created: age(b.createdAt)
1225
+ }));
1226
+ }
1227
+
1228
+ // src/commands/vm/pause.ts
1229
+ import { Command as Command6 } from "commander";
1230
+ var pauseCommand = new Command6("pause").description("freeze a box in place (memory and processes kept, no CPU used; kvbox resume continues it instantly)").argument("<name>", "box name").option("--json", "print the box as JSON").action(async (name, opts, cmd) => {
1231
+ const client = await clientFor(cmd.optsWithGlobals());
1232
+ try {
1233
+ const box = await spin(`pausing ${name}\u2026`, client.pauseMachine(name));
1234
+ if (opts.json) json(box);
1235
+ else info(`${color.green("\u2713")} box ${name} is in standby (frozen in place; kvbox resume ${name} continues it)`);
1236
+ } finally {
1237
+ await client.close();
1238
+ }
1239
+ });
1240
+
1241
+ // src/commands/vm/rescale.ts
1242
+ import { Command as Command7, Option as Option3 } from "commander";
1243
+ var rescaleCommand = new Command7("rescale").description("change a box's vCPUs, memory or disk (e.g. --vcpu 4 --mem 16GB); a running box restarts").argument("<name>", "box name").addOption(new Option3("-s, --size <size>", "a preset to start from").choices(SIZE_NAMES)).option("--vcpu <n>", "number of vCPUs").option("--mem <size>", "memory, e.g. 16GB or 4096MB").option("--disk <size>", "disk size, e.g. 32GB (grow only)").option("--json", "print the box as JSON").action(async (name, opts, cmd) => {
1244
+ const req = {
1245
+ ...opts.size !== void 0 ? { size: opts.size } : {},
1246
+ ...opts.vcpu !== void 0 ? { vcpu: parseCount(opts.vcpu) } : {},
1247
+ ...opts.mem !== void 0 ? { memMib: parseSize("--mem", opts.mem) } : {},
1248
+ ...opts.disk !== void 0 ? { diskGib: parseGib("--disk", opts.disk) } : {}
1249
+ };
1250
+ if (Object.keys(req).length === 0) throw new KvboxError("nothing to change: give --size, --vcpu, --mem or --disk");
1251
+ const client = await clientFor(cmd.optsWithGlobals());
1252
+ try {
1253
+ const before = pickRef(await client.listMachines(), name, me(client));
1254
+ if (!before) throw new KvboxError(`box ${name}: not found (kvbox ls)`);
1255
+ const box = await spin(`rescaling ${name}\u2026`, client.rescaleMachine(displayName(before, me(client)), req));
1256
+ if (opts.json) {
1257
+ json(box);
1258
+ return;
1259
+ }
1260
+ const same = box.vcpu === before.vcpu && box.memMib === before.memMib && box.diskBytes === before.diskBytes;
1261
+ if (same) {
1262
+ info(`box ${name} already has that shape; nothing changed`);
1263
+ return;
1264
+ }
1265
+ info(`${color.green("\u2713")} ${describe(box)}`);
1266
+ if (before.status === "running") info(" it was restarted: processes that were running are gone, files are intact");
1267
+ } finally {
1268
+ await client.close();
1269
+ }
1270
+ });
1271
+ function parseCount(text) {
1272
+ const n = Number(text);
1273
+ if (!Number.isInteger(n) || n < 1) throw new KvboxError(`--vcpu ${text}: give a whole number of vCPUs`);
1274
+ return n;
1275
+ }
1276
+ function parseSize(flag, text) {
1277
+ const mib = parseMib(text);
1278
+ if (mib === void 0) throw new KvboxError(`${flag} ${text}: give a size like 16GB or 4096MB`);
1279
+ return mib;
1280
+ }
1281
+ function parseGib(flag, text) {
1282
+ const mib = parseSize(flag, text);
1283
+ if (mib % 1024 !== 0) throw new KvboxError(`${flag} ${text}: disk sizes are whole GB`);
1284
+ return mib / 1024;
1285
+ }
1286
+
1287
+ // src/commands/vm/resume.ts
1288
+ import { Command as Command8 } from "commander";
1289
+ var resumeCommand = new Command8("resume").description("continue a box that is in standby (kvbox pause)").argument("<name>", "box name").option("--json", "print the box as JSON").action(async (name, opts, cmd) => {
1290
+ const client = await clientFor(cmd.optsWithGlobals());
1291
+ try {
1292
+ const box = await spin(`resuming ${name}\u2026`, client.resumeMachine(name));
1293
+ if (opts.json) json(box);
1294
+ else info(`${color.green("\u2713")} box ${name} resumed`);
1295
+ } finally {
1296
+ await client.close();
1297
+ }
1298
+ });
1299
+
1300
+ // src/commands/vm/rm.ts
1301
+ import { Command as Command9 } from "commander";
1302
+ var rmCommand = new Command9("rm").description("destroy a box (its disk is deleted; snapshots are kept)").argument("<name>", "box name").option("-y, --yes", "do not ask for confirmation").action(async (name, opts, cmd) => {
1303
+ const client = await clientFor(cmd.optsWithGlobals());
1304
+ try {
1305
+ const box = pickRef(await client.listMachines(), name, me(client));
1306
+ if (!box) throw new KvboxError(`box ${name}: not found (kvbox ls)`);
1307
+ const ref = displayName(box, me(client));
1308
+ if (!await confirm(`Destroy box ${ref} and its disk?`, opts.yes ?? false)) {
1309
+ info("cancelled");
1310
+ process.exitCode = 1;
1311
+ return;
1312
+ }
1313
+ await spin(`destroying ${ref}\u2026`, client.deleteMachine(ref));
1314
+ info(`${color.green("\u2713")} box ${ref} destroyed`);
1315
+ } finally {
1316
+ await client.close();
1317
+ }
1318
+ });
1319
+
1320
+ // src/commands/vm/start.ts
1321
+ import { Command as Command10 } from "commander";
1322
+ var startCommand = new Command10("start").description("boot a stopped box again (same name, IP and disk)").argument("<name>", "box name").option("--json", "print the box as JSON").action(async (name, opts, cmd) => {
1323
+ const client = await clientFor(cmd.optsWithGlobals());
1324
+ try {
1325
+ const box = await spin(`booting ${name}\u2026`, client.startMachine(name));
1326
+ if (opts.json) json(box);
1327
+ else info(`${color.green("\u2713")} ${describe(box)}`);
1328
+ } finally {
1329
+ await client.close();
1330
+ }
1331
+ });
1332
+
1333
+ // src/commands/vm/stop.ts
1334
+ import { Command as Command11 } from "commander";
1335
+ var stopCommand = new Command11("stop").description("shut a box down (its disk is kept; start it again with kvbox start)").argument("<name>", "box name").option("--json", "print the box as JSON").action(async (name, opts, cmd) => {
1336
+ const client = await clientFor(cmd.optsWithGlobals());
1337
+ try {
1338
+ const box = await spin(`shutting ${name} down\u2026`, client.stopMachine(name));
1339
+ if (opts.json) json(box);
1340
+ else info(`${color.green("\u2713")} box ${name} stopped (disk kept; kvbox start ${name} boots it again)`);
1341
+ } finally {
1342
+ await client.close();
1343
+ }
1344
+ });
1345
+
1346
+ // src/commands/vm/update.ts
1347
+ import { Command as Command12 } from "commander";
1348
+ var updateCommand = new Command12("update").description("move a box onto the current base image (its files stay; a running box restarts)").argument("<name>", "box name").option("--json", "print the box as JSON").action(async (name, opts, cmd) => {
1349
+ const client = await clientFor(cmd.optsWithGlobals());
1350
+ try {
1351
+ const before = pickRef(await client.listMachines(), name, me(client));
1352
+ if (!before) throw new KvboxError(`box ${name}: not found (kvbox ls)`);
1353
+ const box = await spin(`updating ${name}\u2026`, client.updateMachine(displayName(before, me(client))));
1354
+ if (opts.json) {
1355
+ json(box);
1356
+ return;
1357
+ }
1358
+ if (box.base === before.base) {
1359
+ info(`box ${name} is already on base image ${box.base}`);
1360
+ } else {
1361
+ const restarted = before.status === "running" ? " (restarted: processes that were running are gone, files are intact)" : "";
1362
+ info(`${color.green("\u2713")} box ${color.bold(name)} now runs on base image ${box.base} (was ${before.base})${restarted}`);
1363
+ }
1364
+ } finally {
1365
+ await client.close();
1366
+ }
1367
+ });
1368
+
1369
+ // src/commands/vm/usage.ts
1370
+ import { Command as Command13 } from "commander";
1371
+ var usageCommand = new Command13("usage").description("how long a box ran, slept and sat stopped, as vCPU-hours, RAM and disk GiB-hours (and cost, when the host prices them)").argument("<name>", "box name").option("--since <date>", "count from this date instead of the box's creation, e.g. 2026-10-01").option("--json", "print as JSON").action(async (name, opts, cmd) => {
1372
+ let since;
1373
+ if (opts.since !== void 0) {
1374
+ const t = Date.parse(opts.since);
1375
+ if (Number.isNaN(t)) throw new KvboxError(`--since ${opts.since}: not a date; write it like 2026-10-19 or 2026-10-19T08:00:00Z`);
1376
+ since = new Date(t).toISOString();
1377
+ }
1378
+ const client = await clientFor(cmd.optsWithGlobals());
1379
+ try {
1380
+ const u = await client.machineUsage(name, since);
1381
+ if (opts.json) json(u);
1382
+ else process.stdout.write(block(u));
1383
+ } finally {
1384
+ await client.close();
1385
+ }
1386
+ });
1387
+ var day = (iso) => iso.slice(0, 10);
1388
+ var SYMBOLS = { EUR: "\u20AC", USD: "$", GBP: "\xA3" };
1389
+ function money(amount, currency) {
1390
+ const n = amount.toFixed(2);
1391
+ const sym = SYMBOLS[currency.toUpperCase()];
1392
+ return sym === void 0 ? `${n} ${currency}` : `${sym}${n}`;
1393
+ }
1394
+ function block(u) {
1395
+ const estimated = u.estimatedStops === 0 ? "" : color.dim(` (${u.estimatedStops} stop time${u.estimatedStops === 1 ? "" : "s"} estimated)`);
1396
+ const rows = [
1397
+ ["name", boxName(u.name)],
1398
+ ["status", status(u.status)],
1399
+ ["window", `${day(u.window.from)} to ${day(u.window.to)}`],
1400
+ ["running", duration(u.seconds.running) + estimated],
1401
+ ["standby", duration(u.seconds.standby)],
1402
+ ["hibernated", duration(u.seconds.hibernated)],
1403
+ ["stopped", duration(u.seconds.stopped)],
1404
+ ["vcpu-h", hours(u.vcpuHours)],
1405
+ ["ram GiB-h", hours(u.ramGibHours)],
1406
+ ["disk GiB-h", hours(u.diskGibHours)]
1407
+ ];
1408
+ if (u.cost !== void 0) rows.push(["cost", `${money(u.cost.amount, u.cost.currency)} ${color.dim("(estimated)")}`]);
1409
+ if (u.historyFrom !== null && u.historyFrom > u.window.from) {
1410
+ rows.push(["history", color.dim(`from ${u.historyFrom.slice(0, 16).replace("T", " ")} UTC; the box is older, but its time before that was not recorded`)]);
1411
+ }
1412
+ const w = Math.max(...rows.map(([k]) => k.length));
1413
+ return rows.map(([k, v]) => `${" ".repeat(w - k.length)}${k}: ${v}`).join("\n") + "\n";
1414
+ }
1415
+
1416
+ // src/commands/vm/wake.ts
1417
+ import { Command as Command14 } from "commander";
1418
+ var wakeCommand = new Command14("wake").description("continue a hibernated box where it left off (same name, IP and disk)").argument("<name>", "box name").option("--json", "print the box as JSON").action(async (name, opts, cmd) => {
1419
+ const client = await clientFor(cmd.optsWithGlobals());
1420
+ try {
1421
+ const box = await spin(`waking ${name} (loading its memory from disk)\u2026`, client.wakeMachine(name));
1422
+ if (opts.json) json(box);
1423
+ else info(`${color.green("\u2713")} box ${name} is awake, running where it left off`);
1424
+ } finally {
1425
+ await client.close();
1426
+ }
1427
+ });
1428
+
1429
+ // src/commands/vm/index.ts
1430
+ var vmCommand = new Command15("vm").enablePositionalOptions().description("manage boxes (each one is a Firecracker microVM)").addCommand(newCommand).addCommand(lsCommand).addCommand(connectCommand).addCommand(execCommand).addCommand(stopCommand).addCommand(startCommand).addCommand(pauseCommand).addCommand(resumeCommand).addCommand(hibernateCommand).addCommand(wakeCommand).addCommand(updateCommand).addCommand(rescaleCommand).addCommand(usageCommand).addCommand(rmCommand);
1431
+ for (const cmd of vmCommand.commands) cmd.option("--org <slug>", "act in this org (- for your personal boxes)");
1432
+ var vmAliases = [newCommand, lsCommand, connectCommand, execCommand, stopCommand, startCommand, pauseCommand, resumeCommand, hibernateCommand, wakeCommand, rmCommand];
1433
+
1434
+ // src/commands/server/index.ts
1435
+ import { Command as Command23 } from "commander";
1436
+
1437
+ // src/commands/server/join.ts
1438
+ import { Command as Command16 } from "commander";
1439
+
1440
+ // src/commands/server/daemon.ts
1441
+ import { spawn as spawn3 } from "child_process";
1442
+ import { readFile as readFile4 } from "fs/promises";
1443
+ import { setTimeout as sleep2 } from "timers/promises";
1444
+ async function runningPid(p = paths()) {
1445
+ let pid;
1446
+ try {
1447
+ pid = Number.parseInt(await readFile4(p.serverPid, "utf8"), 10);
1448
+ } catch {
1449
+ return null;
1450
+ }
1451
+ return alive(pid) ? pid : null;
1452
+ }
1453
+ function alive(pid) {
1454
+ try {
1455
+ process.kill(pid, 0);
1456
+ return true;
1457
+ } catch {
1458
+ return false;
1459
+ }
1460
+ }
1461
+ async function stopLocalServer(pid, timeoutMs = 1e4) {
1462
+ process.kill(pid, "SIGTERM");
1463
+ const deadline = Date.now() + timeoutMs;
1464
+ while (alive(pid)) {
1465
+ if (Date.now() > deadline) throw new KvboxError(`kvbox-server (pid ${pid}) did not stop within ${timeoutMs / 1e3}s`);
1466
+ await sleep2(100);
1467
+ }
1468
+ }
1469
+ async function restartLocalServer(p = paths()) {
1470
+ const pid = await runningPid(p);
1471
+ if (pid !== null) await stopLocalServer(pid);
1472
+ return (await startLocalServer(p)).pid;
1473
+ }
1474
+ function runKvboxServer(args) {
1475
+ return new Promise((resolve, reject) => {
1476
+ const child = spawn3("kvbox-server", args, { stdio: ["ignore", "pipe", "pipe"] });
1477
+ let out = "";
1478
+ let err = "";
1479
+ child.stdout.on("data", (chunk) => out += chunk.toString());
1480
+ child.stderr.on("data", (chunk) => err += chunk.toString());
1481
+ child.once("error", (e) => {
1482
+ reject(new KvboxError(e.code === "ENOENT" ? NOT_A_HOST_HINT : `could not run kvbox-server: ${e.message}`));
1483
+ });
1484
+ child.once("close", (code) => {
1485
+ if (code === 0) resolve(out.trimEnd());
1486
+ else reject(new KvboxError(err.trim().replace(/^error: /, "") || `kvbox-server ${args[0] ?? ""} exited with code ${String(code)}`, code === 2 ? 2 : 1));
1487
+ });
1488
+ });
1489
+ }
1490
+
1491
+ // src/commands/server/join.ts
1492
+ var joinCommand = new Command16("join").description("put this machine behind a control plane (needs a one-time token minted there) and restart its server").argument("<control-plane-url>", "the control plane, e.g. https://api.kvbox.sh").requiredOption("--token <token>", "one-time join token from the control plane").option("--advertise <host-or-ip>", "address the control plane reaches this machine at (default: its default-route address)").option("--name <name>", "how the control plane lists this machine (default: hostname)").action(async (url, opts) => {
1493
+ const args = ["join", url, "--token", opts.token];
1494
+ if (opts.advertise !== void 0) args.push("--advertise", opts.advertise);
1495
+ if (opts.name !== void 0) args.push("--name", opts.name);
1496
+ for (const line of (await runKvboxServer(args)).split("\n")) info(line);
1497
+ const pid = await spin("restarting kvbox-server\u2026", restartLocalServer(paths()));
1498
+ info(`kvbox-server restarted (pid ${pid}); it answers the control plane now`);
1499
+ });
1500
+
1501
+ // src/commands/server/leave.ts
1502
+ import { Command as Command17 } from "commander";
1503
+ var leaveCommand = new Command17("leave").description("take this machine out from behind its control plane and restart its server").action(async () => {
1504
+ const p = paths();
1505
+ for (const line of (await runKvboxServer(["leave"])).split("\n")) info(line);
1506
+ if (await runningPid(p) === null) return;
1507
+ const pid = await spin("restarting kvbox-server\u2026", restartLocalServer(p));
1508
+ info(`kvbox-server restarted (pid ${pid}); only this machine talks to it now`);
1509
+ });
1510
+
1511
+ // src/commands/server/relay.ts
1512
+ import { Command as Command18 } from "commander";
1513
+ var relayCommand = new Command18("relay").description("reach the internet through a relay (paste what `kvbox-relay add` printed on the relay VM); `off` takes the tunnel down").argument("<join-string>", "the line `kvbox-relay add` printed, or `off`").action(async (joinString) => {
1514
+ for (const line of (await runKvboxServer(["relay", joinString])).split("\n")) info(line);
1515
+ });
1516
+
1517
+ // src/commands/server/start.ts
1518
+ import { Command as Command19 } from "commander";
1519
+ var startCommand2 = new Command19("start").description("start the local kvbox-server in the background").action(async (_opts, cmd) => {
1520
+ const client = await clientFor({ ...cmd.optsWithGlobals(), local: true }, { autoStart: false });
1521
+ try {
1522
+ const health = await client.health();
1523
+ info(`kvbox-server ${health.version} is already running (pid ${health.pid})`);
1524
+ return;
1525
+ } catch {
1526
+ } finally {
1527
+ await client.close();
1528
+ }
1529
+ const { pid, socketPath, viaGroup } = await startLocalServer(paths());
1530
+ info(`kvbox-server started (pid ${pid}), listening on ${socketPath}`);
1531
+ if (viaGroup !== void 0) info(`started through the ${viaGroup} group, which this shell does not have yet (log out and back in to get it)`);
1532
+ });
1533
+
1534
+ // src/commands/server/status.ts
1535
+ import { Command as Command20 } from "commander";
1536
+ var statusCommand = new Command20("status").description("show whether this machine's server is running, its control plane and which base image new boxes get").option("--json", "print as JSON").action(async (opts, cmd) => {
1537
+ const client = await clientFor({ ...cmd.optsWithGlobals(), local: true });
1538
+ try {
1539
+ const health = await client.health();
1540
+ if (opts.json) json(health);
1541
+ else {
1542
+ info(`kvbox-server ${health.version} running, pid ${health.pid}`);
1543
+ if (health.controlPlane) info(`control plane: ${health.controlPlane.name} at ${health.controlPlane.url}, this host answers it on ${health.controlPlane.hostUrl}`);
1544
+ else info("control plane: none (kvbox server join <url> --token <token> puts this machine behind one)");
1545
+ if (health.relay) info(`relay: ${health.relay.name} lends this machine ${health.relay.publicIp} (tunnel to ${health.relay.endpoint})`);
1546
+ if (health.base === null) info("base image: none usable yet (run install-server.sh)");
1547
+ else info(`base image: ${health.base} (what kvbox new uses; kvbox vm update <box> moves an older box onto it)`);
1548
+ }
1549
+ } finally {
1550
+ await client.close();
1551
+ }
1552
+ });
1553
+
1554
+ // src/commands/server/stop.ts
1555
+ import { Command as Command21 } from "commander";
1556
+ var stopCommand2 = new Command21("stop").description("stop the local server (running boxes are not affected)").action(async () => {
1557
+ const pid = await runningPid(paths());
1558
+ if (pid === null) throw new KvboxError("kvbox-server is not running");
1559
+ await stopLocalServer(pid);
1560
+ info(`kvbox-server stopped (pid ${pid})`);
1561
+ });
1562
+
1563
+ // src/commands/server/trust-ca.ts
1564
+ import { Command as Command22 } from "commander";
1565
+ var trustCaCommand = new Command22("trust-ca").description("make every cloud box on this machine trust a new SSH CA (after `ca rotate` on the control plane)").argument("[public-key]", "the CA public key (or its cert-authority line); or --from-cloud").option("--from-cloud", "ask the control plane this host joined for its current CA, show the fingerprint, then ask").option("-y, --yes", "do not ask for confirmation").option("--json", "print the report as JSON").action(async (publicKey, opts, cmd) => {
1566
+ if (publicKey === void 0 && !opts.fromCloud) throw new KvboxError("give the CA public key, or --from-cloud to fetch it from the control plane this host joined");
1567
+ if (publicKey !== void 0 && opts.fromCloud) throw new KvboxError("either a public key or --from-cloud, not both");
1568
+ const client = await clientFor({ ...cmd.optsWithGlobals(), local: true });
1569
+ try {
1570
+ let key = publicKey;
1571
+ if (key === void 0) {
1572
+ const ca = await client.cloudCa();
1573
+ info(`${ca.controlPlane} says its SSH CA is ${ca.fingerprint}`);
1574
+ if (!await confirm(`make every cloud box on this machine trust it (in place of the CA it trusts now)?`, opts.yes ?? false)) {
1575
+ throw new KvboxError("cancelled", 130);
1576
+ }
1577
+ key = ca.publicKey;
1578
+ }
1579
+ const report = await client.trustCa({ publicKey: key });
1580
+ if (opts.json) json(report);
1581
+ else describeReport(report);
1582
+ } finally {
1583
+ await client.close();
1584
+ }
1585
+ });
1586
+ var WHEN = {
1587
+ start: "is stopped: it trusts the CA from its next start",
1588
+ wake: "is hibernated: it trusts the CA from its next wake",
1589
+ resume: "is in standby: it trusts the CA from its next resume",
1590
+ later: "is running but could not take it now; it trusts the CA from its next stop and start"
1591
+ };
1592
+ function describeReport(report) {
1593
+ info(`CA ${report.fingerprint}`);
1594
+ for (const box of report.updated) info(`box ${box} now trusts the CA`);
1595
+ for (const p of report.pending) info(`box ${p.box} ${WHEN[p.when]}${p.why ? ` (${p.why})` : ""}`);
1596
+ for (const f of report.failed) warn(`box ${f.box}: ${f.error}`);
1597
+ const parts = [`${report.updated.length} box(es) updated now`, `${report.pending.length} pending`, `${report.failed.length} failed`];
1598
+ if (report.local > 0) parts.push(`${report.local} local box(es) untouched (they trust this host's key only)`);
1599
+ info(parts.join(", "));
1600
+ }
1601
+
1602
+ // src/commands/server/index.ts
1603
+ var serverCommand = new Command23("server").description("run or manage the kvbox server").addCommand(startCommand2).addCommand(statusCommand).addCommand(stopCommand2).addCommand(joinCommand).addCommand(leaveCommand).addCommand(relayCommand).addCommand(trustCaCommand);
1604
+
1605
+ // src/commands/login.ts
1606
+ import { spawnSync as spawnSync2 } from "child_process";
1607
+ import { mkdir as mkdir3, readFile as readFile5, writeFile as writeFile3 } from "fs/promises";
1608
+ import { hostname as osHostname } from "os";
1609
+ import { createInterface } from "readline/promises";
1610
+ import { Writable } from "stream";
1611
+ import { Command as Command24 } from "commander";
1612
+ var loginCommand = new Command24("login").description("log in to the kvbox cloud (or your own control plane) and target it from now on").argument("[url]", `control plane URL (default: ${DEFAULT_CLOUD_URL})`).option("-e, --email <email>", "account email (prompted otherwise)").option("--password-stdin", "read the password from the first line of stdin (for scripts)").action(async (urlArg, opts) => {
1613
+ const url = normalizeUrl(urlArg ?? DEFAULT_CLOUD_URL);
1614
+ const creds = await readCredentials(opts);
1615
+ const session = await runLogin(url, creds, paths());
1616
+ info(`logged in to ${hostOf(url)} as ${session.user}`);
1617
+ });
1618
+ async function readCredentials(opts, io = { stdin: process.stdin, isTTY: process.stdin.isTTY === true }) {
1619
+ const noTerminal = () => new KvboxError("stdin is not a terminal: pass --email and --password-stdin");
1620
+ let email = opts.email;
1621
+ if (email === void 0) {
1622
+ if (!io.isTTY) throw noTerminal();
1623
+ email = await prompt("email: ", { input: process.stdin, output: process.stderr });
1624
+ }
1625
+ let password;
1626
+ if (opts.passwordStdin) password = await firstLine(io.stdin);
1627
+ else if (io.isTTY) password = await promptSecret("password: ", { input: process.stdin, output: process.stderr });
1628
+ else throw noTerminal();
1629
+ if (email === "") throw new KvboxError("email is empty");
1630
+ if (password === "") throw new KvboxError("password is empty");
1631
+ return { email, password };
1632
+ }
1633
+ async function runLogin(url, creds, p = paths(), hostname = osHostname()) {
1634
+ const publicKey = await ensureSshKey(p, hostname);
1635
+ const client = new ServerClient({ kind: "cloud", url, token: "" }, { autoStart: false });
1636
+ let res;
1637
+ try {
1638
+ res = await client.login({ email: creds.email, password: creds.password, publicKey });
1639
+ } finally {
1640
+ await client.close();
1641
+ }
1642
+ const session = { url, token: res.token, user: res.user.username };
1643
+ if (res.sshCert !== void 0 && res.sshCertExpires !== void 0) {
1644
+ await writeCert(res.sshCert, p);
1645
+ session.sshCertExpires = res.sshCertExpires;
1646
+ if (res.sshCertPrincipals !== void 0) session.sshCertPrincipals = res.sshCertPrincipals;
1647
+ }
1648
+ await writeSession(session, p);
1649
+ return session;
1650
+ }
1651
+ async function ensureSshKey(p = paths(), hostname = osHostname()) {
1652
+ const pubPath = `${p.sshKey}.pub`;
1653
+ const pub = await readFile5(pubPath, "utf8").catch(() => null);
1654
+ if (pub !== null) return pub.trim();
1655
+ await mkdir3(p.ssh, { recursive: true, mode: 448 });
1656
+ const havePrivate = await readFile5(p.sshKey).then(() => true, () => false);
1657
+ const args = havePrivate ? ["-y", "-f", p.sshKey] : ["-q", "-t", "ed25519", "-N", "", "-C", `kvbox@${hostname}`, "-f", p.sshKey];
1658
+ const res = spawnSync2("ssh-keygen", args, { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" });
1659
+ if (res.error) {
1660
+ if (res.error.code === "ENOENT") throw new KvboxError("ssh-keygen not found; install OpenSSH");
1661
+ throw new KvboxError(`could not run ssh-keygen: ${res.error.message}`);
1662
+ }
1663
+ if (res.status !== 0) throw new KvboxError(`ssh-keygen failed: ${res.stderr.trim()}`);
1664
+ if (havePrivate) await writeFile3(pubPath, res.stdout.trim() + "\n");
1665
+ return (await readFile5(pubPath, "utf8")).trim();
1666
+ }
1667
+ async function prompt(question, io) {
1668
+ const rl = createInterface({ input: io.input, output: io.output });
1669
+ return (await ask(rl, question)).trim();
1670
+ }
1671
+ async function promptSecret(question, io) {
1672
+ const sink = new Writable({ write: (_chunk, _enc, cb) => cb() });
1673
+ const rl = createInterface({ input: io.input, output: sink, terminal: true });
1674
+ io.output.write(question);
1675
+ try {
1676
+ return await ask(rl, question);
1677
+ } finally {
1678
+ io.output.write("\n");
1679
+ }
1680
+ }
1681
+ async function ask(rl, question) {
1682
+ let onClose = () => {
1683
+ };
1684
+ const closed = new Promise((_, reject) => {
1685
+ onClose = () => reject(new KvboxError("cancelled", 130));
1686
+ rl.once("close", onClose);
1687
+ });
1688
+ try {
1689
+ return await Promise.race([rl.question(question).catch(cancelled), closed]);
1690
+ } finally {
1691
+ rl.off("close", onClose);
1692
+ rl.close();
1693
+ }
1694
+ }
1695
+ function cancelled(err) {
1696
+ if (err.code === "ABORT_ERR") throw new KvboxError("cancelled", 130);
1697
+ throw err;
1698
+ }
1699
+ async function firstLine(stream) {
1700
+ const rl = createInterface({ input: stream });
1701
+ try {
1702
+ for await (const line of rl) return line.trim();
1703
+ return "";
1704
+ } finally {
1705
+ rl.close();
1706
+ }
1707
+ }
1708
+
1709
+ // src/commands/logout.ts
1710
+ import { Command as Command25 } from "commander";
1711
+ var logoutCommand = new Command25("logout").description("log out of the control plane; commands target this machine's kvbox-server again").action(async () => {
1712
+ const p = paths();
1713
+ const session = await readSession(p).catch(() => null);
1714
+ switch (await runLogout(p)) {
1715
+ case "none":
1716
+ info("not logged in");
1717
+ break;
1718
+ case "unreadable":
1719
+ info(`logged out (${p.cli} was unreadable and is gone)`);
1720
+ break;
1721
+ case "cleared":
1722
+ info(`logged out of ${hostOf(session?.url ?? "")}`);
1723
+ }
1724
+ });
1725
+ async function runLogout(p = paths()) {
1726
+ let session;
1727
+ try {
1728
+ session = await readSession(p);
1729
+ } catch {
1730
+ await clearSession(p);
1731
+ await removeCert(p);
1732
+ return "unreadable";
1733
+ }
1734
+ if (!session) return "none";
1735
+ const client = new ServerClient({ kind: "cloud", url: session.url, token: session.token }, { autoStart: false });
1736
+ try {
1737
+ await client.logout();
1738
+ } catch {
1739
+ } finally {
1740
+ await client.close();
1741
+ }
1742
+ await clearSession(p);
1743
+ await removeCert(p);
1744
+ return "cleared";
1745
+ }
1746
+
1747
+ // src/commands/whoami.ts
1748
+ import { Command as Command26 } from "commander";
1749
+ var whoamiCommand = new Command26("whoami").description("show which account and control plane commands target (or local mode)").option("--json", "print as JSON").action(async (opts) => {
1750
+ const session = await readSession();
1751
+ if (opts.json) {
1752
+ json(session ? { user: session.user, url: session.url, sshCertExpires: session.sshCertExpires ?? null } : null);
1753
+ return;
1754
+ }
1755
+ process.stdout.write(describeSession(session) + "\n");
1756
+ });
1757
+ function describeSession(session) {
1758
+ if (!session) return "not logged in (local mode)";
1759
+ const lines = [`${session.user} on ${hostOf(session.url)}`];
1760
+ if (session.sshCertExpires) lines.push(`certificate valid until ${session.sshCertExpires.slice(0, 10)}`);
1761
+ return lines.join("\n");
1762
+ }
1763
+
1764
+ // src/commands/snapshots/index.ts
1765
+ import { Command as Command30 } from "commander";
1766
+
1767
+ // src/commands/snapshots/ls.ts
1768
+ import { Command as Command27, Option as Option4 } from "commander";
1769
+ var lsCommand2 = new Command27("ls").description("list snapshots (yours and your orgs'; --org <slug> for one org)").addOption(new Option4("-a, --all", "kept for older scripts: the listing covers everything you can see anyway").hideHelp()).option("--json", "print as JSON").action(async (opts, cmd) => {
1770
+ const client = await clientFor(cmd.optsWithGlobals());
1771
+ try {
1772
+ const cloud = client.target.kind === "cloud";
1773
+ const snaps = await client.listSnapshots();
1774
+ if (opts.json) {
1775
+ json(snaps);
1776
+ return;
1777
+ }
1778
+ if (snaps.length === 0) {
1779
+ info("no snapshots yet; freeze a running box with: kvbox snapshots new <box> <name>");
1780
+ return;
1781
+ }
1782
+ const org = client.target.kind === "cloud" ? client.target.org : void 0;
1783
+ if (org !== void 0) info(`snapshots of ${org}`);
1784
+ table(
1785
+ snaps.map((s) => ({
1786
+ ...cloud ? { owner: slugOf(s.owner) === client.target.user ? "private" : slugOf(s.owner) } : {},
1787
+ name: boxName(s.name),
1788
+ "from box": shortRef(s.source, s.owner),
1789
+ shape: shape(s.vcpu, s.memMib),
1790
+ "on disk": gbShort(s.sizeBytes),
1791
+ created: age(s.createdAt)
1792
+ }))
1793
+ );
1794
+ } finally {
1795
+ await client.close();
1796
+ }
1797
+ });
1798
+
1799
+ // src/commands/snapshots/new.ts
1800
+ import { Command as Command28 } from "commander";
1801
+ var newCommand2 = new Command28("new").description("freeze a running box (memory + disk) into a snapshot; the box keeps running").argument("<box>", "box name").argument("<name>", "snapshot name").option("--json", "print the snapshot as JSON").action(async (box, name, opts, cmd) => {
1802
+ const client = await clientFor(cmd.optsWithGlobals());
1803
+ try {
1804
+ const snap = await spin(`saving snapshot ${name} from box ${box} (memory + disk)\u2026`, client.createSnapshot({ box, name }));
1805
+ if (opts.json) json(snap);
1806
+ else info(`${color.green("\u2713")} snapshot ${name} saved from box ${box} (${gb(snap.sizeBytes)} on disk); start a warm clone with: kvbox new --from ${name}`);
1807
+ } finally {
1808
+ await client.close();
1809
+ }
1810
+ });
1811
+
1812
+ // src/commands/snapshots/rm.ts
1813
+ import { Command as Command29 } from "commander";
1814
+ var rmCommand2 = new Command29("rm").description("delete a snapshot (boxes made from it are not affected)").argument("<name>", "snapshot name").option("-y, --yes", "do not ask for confirmation").action(async (name, opts, cmd) => {
1815
+ if (!await confirm(`Delete snapshot ${name}?`, opts.yes ?? false)) {
1816
+ info("cancelled");
1817
+ process.exitCode = 1;
1818
+ return;
1819
+ }
1820
+ const client = await clientFor(cmd.optsWithGlobals());
1821
+ try {
1822
+ await spin(`deleting snapshot ${name}\u2026`, client.deleteSnapshot(name));
1823
+ info(`${color.green("\u2713")} snapshot ${name} deleted`);
1824
+ } finally {
1825
+ await client.close();
1826
+ }
1827
+ });
1828
+
1829
+ // src/commands/snapshots/index.ts
1830
+ var snapshotsCommand = new Command30("snapshots").aliases(["snapshot", "snap"]).description("freeze running boxes into snapshots and manage them").addCommand(newCommand2).addCommand(lsCommand2).addCommand(rmCommand2);
1831
+ for (const cmd of snapshotsCommand.commands) cmd.option("--org <slug>", "act in this org (- for your personal boxes)");
1832
+
1833
+ // src/commands/org/index.ts
1834
+ import { Command as Command36 } from "commander";
1835
+
1836
+ // src/commands/org/ls.ts
1837
+ import { Command as Command31 } from "commander";
1838
+
1839
+ // src/commands/org/context.ts
1840
+ var NOT_LOGGED_IN = "orgs live on the control plane: kvbox login first";
1841
+ async function cloudClientFor(opts) {
1842
+ const client = await clientFor(opts, { autoStart: false });
1843
+ if (client.target.kind === "local") {
1844
+ await client.close();
1845
+ throw new KvboxError(NOT_LOGGED_IN);
1846
+ }
1847
+ return client;
1848
+ }
1849
+ function orgFor(opts, target) {
1850
+ if (target.kind === "local") throw new KvboxError(NOT_LOGGED_IN);
1851
+ const slug = opts.org ?? target.org;
1852
+ if (slug === void 0) throw new KvboxError("no org: pass --org <slug>");
1853
+ return slug;
1854
+ }
1855
+
1856
+ // src/commands/org/ls.ts
1857
+ var lsCommand3 = new Command31("ls").description("list the orgs you belong to and your role in each").option("--json", "print as JSON").action(async (opts, cmd) => {
1858
+ const client = await cloudClientFor(cmd.optsWithGlobals());
1859
+ try {
1860
+ const orgs = await client.listOrgs();
1861
+ if (opts.json) {
1862
+ json(orgs);
1863
+ return;
1864
+ }
1865
+ if (orgs.length === 0) {
1866
+ info("you are in no org yet; make one with: kvbox org new <slug>");
1867
+ return;
1868
+ }
1869
+ table(orgRows(orgs));
1870
+ } finally {
1871
+ await client.close();
1872
+ }
1873
+ });
1874
+ function orgRows(orgs) {
1875
+ return orgs.map((o) => ({ org: boxName(o.slug), name: o.name, role: o.role, since: age(o.createdAt) }));
1876
+ }
1877
+
1878
+ // src/commands/org/members.ts
1879
+ import { Command as Command32, Option as Option5 } from "commander";
1880
+ var lsCommand4 = new Command32("ls").description("list an org's members and their roles").option("-o, --org <slug>", "the org (or --org before the command)").option("--json", "print as JSON").action(async (opts, cmd) => {
1881
+ const client = await cloudClientFor(cmd.optsWithGlobals());
1882
+ try {
1883
+ const org = await client.getOrg(orgFor(opts, client.target));
1884
+ if (opts.json) {
1885
+ json(org.members);
1886
+ return;
1887
+ }
1888
+ info(`${org.slug} (${org.name})`);
1889
+ table(org.members.map((m) => ({ user: boxName(m.username), name: m.name, role: m.role, since: age(m.addedAt) })));
1890
+ } finally {
1891
+ await client.close();
1892
+ }
1893
+ });
1894
+ var addCommand = new Command32("add").description("add a user to an org (admins and owners; only an owner can add an owner)").argument("<user>", "their username").addOption(new Option5("-r, --role <role>", "their role").choices(ORG_ROLES).default("member")).option("-o, --org <slug>", "the org (or --org before the command)").action(async (user, opts, cmd) => {
1895
+ const client = await cloudClientFor(cmd.optsWithGlobals());
1896
+ try {
1897
+ const slug = orgFor(opts, client.target);
1898
+ await client.addMember(slug, { username: user, role: opts.role });
1899
+ info(`${color.green("\u2713")} ${user} is now a ${opts.role} of ${slug}`);
1900
+ } finally {
1901
+ await client.close();
1902
+ }
1903
+ });
1904
+ var rmCommand3 = new Command32("rm").description("remove a member (or leave: remove yourself)").argument("<user>", "their username").option("-o, --org <slug>", "the org (or --org before the command)").option("-y, --yes", "do not ask for confirmation").action(async (user, opts, cmd) => {
1905
+ const client = await cloudClientFor(cmd.optsWithGlobals());
1906
+ try {
1907
+ const slug = orgFor(opts, client.target);
1908
+ if (!await confirm(`Remove ${user} from ${slug}?`, opts.yes ?? false)) {
1909
+ info("cancelled");
1910
+ process.exitCode = 1;
1911
+ return;
1912
+ }
1913
+ const self = client.target.kind === "cloud" && client.target.user === user;
1914
+ await client.removeMember(slug, user);
1915
+ info(`${color.green("\u2713")} ${self ? `you left ${slug}` : `${user} removed from ${slug}`}`);
1916
+ } finally {
1917
+ await client.close();
1918
+ }
1919
+ });
1920
+ var membersCommand = new Command32("members").description("who is in an org and what they may do").addCommand(lsCommand4).addCommand(addCommand).addCommand(rmCommand3);
1921
+
1922
+ // src/commands/org/new.ts
1923
+ import { Command as Command33 } from "commander";
1924
+ var newCommand3 = new Command33("new").description("create an org; you become its owner (the slug is permanent, the name is not)").argument("<slug>", "the org's slug: lowercase, like a username (acme)").option("-n, --name <name>", "display name (default: the slug)").option("--json", "print the org as JSON").action(async (slug, opts, cmd) => {
1925
+ const client = await cloudClientFor(cmd.optsWithGlobals());
1926
+ try {
1927
+ const org = await client.createOrg({ slug, name: opts.name ?? slug });
1928
+ if (opts.json) {
1929
+ json(org);
1930
+ return;
1931
+ }
1932
+ info(`${color.green("\u2713")} org ${color.bold(org.slug)} created (${org.name}); you are its owner`);
1933
+ info(` work in it with: kvbox org use ${org.slug} (then bare box names are ${org.slug}'s)`);
1934
+ } finally {
1935
+ await client.close();
1936
+ }
1937
+ });
1938
+
1939
+ // src/commands/org/rm.ts
1940
+ import { Command as Command34 } from "commander";
1941
+ var rmCommand4 = new Command34("rm").description("delete an org (owners only; remove its boxes and snapshots first)").argument("<slug>", "the org").option("-y, --yes", "do not ask for confirmation").action(async (slug, opts, cmd) => {
1942
+ if (!await confirm(`Delete org ${slug}? Its members lose access; its slug stays taken.`, opts.yes ?? false)) {
1943
+ info("cancelled");
1944
+ process.exitCode = 1;
1945
+ return;
1946
+ }
1947
+ const client = await cloudClientFor(cmd.optsWithGlobals());
1948
+ try {
1949
+ await client.deleteOrg(slug);
1950
+ info(`${color.green("\u2713")} org ${slug} deleted`);
1951
+ } finally {
1952
+ await client.close();
1953
+ }
1954
+ });
1955
+
1956
+ // src/commands/org/role.ts
1957
+ import { Argument, Command as Command35 } from "commander";
1958
+ var roleCommand = new Command35("role").description("change a member's role (owners only)").argument("<user>", "their username").addArgument(new Argument("<role>", "owner, admin or member").choices(ORG_ROLES)).option("-o, --org <slug>", "the org (default: the current context)").action(async (user, role, opts, cmd) => {
1959
+ const client = await cloudClientFor(cmd.optsWithGlobals());
1960
+ try {
1961
+ const slug = orgFor(opts, client.target);
1962
+ await client.setRole(slug, user, role);
1963
+ info(`${color.green("\u2713")} ${user} is now a ${role} of ${slug}`);
1964
+ } finally {
1965
+ await client.close();
1966
+ }
1967
+ });
1968
+
1969
+ // src/commands/org/index.ts
1970
+ var orgCommand = new Command36("org").description("orgs: shared slugs that own boxes, with owners, admins and members").addCommand(lsCommand3).addCommand(newCommand3).addCommand(membersCommand).addCommand(roleCommand).addCommand(rmCommand4);
1971
+
1972
+ // src/commands/usage.ts
1973
+ import { Command as Command37 } from "commander";
1974
+ var usageCommand2 = new Command37("usage").description("what your boxes used this month (or --since/--until): running time, vCPU-h, RAM and disk GiB-h, cost").option("--since <date>", "count from this date, e.g. 2026-10-01 (default: the 1st of this month)").option("--until <date>", "count up to this date (default: now)").option("--org <slug>", "one org's boxes only").option("--json", "print as JSON").action(async (opts, cmd) => {
1975
+ for (const [name, value] of [
1976
+ ["since", opts.since],
1977
+ ["until", opts.until]
1978
+ ]) {
1979
+ if (value !== void 0 && Number.isNaN(Date.parse(value))) throw new KvboxError(`--${name} ${value}: not a date; write it like 2026-10-19 or 2026-10-19T08:00:00Z`);
1980
+ }
1981
+ const client = await clientFor(cmd.optsWithGlobals());
1982
+ try {
1983
+ if (client.target.kind === "local" && cmd.optsWithGlobals().org) throw new KvboxError("--org needs a control plane: kvbox login, or drop --org");
1984
+ const report = await client.usageReport(opts.since, opts.until);
1985
+ if (opts.json) {
1986
+ json(report);
1987
+ return;
1988
+ }
1989
+ const cloud = client.target.kind === "cloud" ? { me: client.target.user } : void 0;
1990
+ info(`${day2(report.window.from)} to ${day2(report.window.to)}`);
1991
+ if (report.boxes.length === 0) {
1992
+ info("no usage in this window");
1993
+ return;
1994
+ }
1995
+ table(usageRows(report, cloud));
1996
+ } finally {
1997
+ await client.close();
1998
+ }
1999
+ });
2000
+ var day2 = (iso) => iso.slice(0, 10);
2001
+ function ownerCell2(b, me2) {
2002
+ const slug = slugOf(b.owner);
2003
+ if (slug === me2) return "private";
2004
+ return b.ownerKind === void 0 ? slug : `${slug} ${color.dim(b.ownerKind)}`;
2005
+ }
2006
+ function usageRows(report, cloud) {
2007
+ const priced = report.totals.cost !== void 0;
2008
+ const rows = report.boxes.map((b) => ({
2009
+ ...cloud ? { owner: ownerCell2(b, cloud.me) } : {},
2010
+ name: boxName(b.name),
2011
+ status: b.status === "removed" ? color.dim("removed") : status(b.status),
2012
+ running: duration(b.seconds.running),
2013
+ "vcpu-h": hours(b.vcpuHours),
2014
+ "ram GiB-h": hours(b.ramGibHours),
2015
+ "disk GiB-h": hours(b.diskGibHours),
2016
+ ...priced ? { cost: money(b.cost?.amount ?? 0, report.totals.cost.currency) } : {}
2017
+ }));
2018
+ const t = report.totals;
2019
+ rows.push({
2020
+ ...cloud ? { owner: "" } : {},
2021
+ name: color.bold("total"),
2022
+ status: "",
2023
+ running: duration(t.seconds.running),
2024
+ "vcpu-h": hours(t.vcpuHours),
2025
+ "ram GiB-h": hours(t.ramGibHours),
2026
+ "disk GiB-h": hours(t.diskGibHours),
2027
+ ...priced ? { cost: `${money(t.cost.amount, t.cost.currency)} ${color.dim("(estimated)")}` } : {}
2028
+ });
2029
+ return rows;
2030
+ }
2031
+
2032
+ // src/program.ts
2033
+ function buildProgram() {
2034
+ const program = new Command38("kvbox").description("self-hosted Firecracker microVMs with Docker-like ergonomics").version(VERSION, "-v, --version").option("--local", "use this machine's kvbox-server over its socket, ignoring kvbox login").option("--org <slug>", "the org: where kvbox new creates, and which box a name means when it is not one of yours; before the command here, or after it").showHelpAfterError().enablePositionalOptions();
2035
+ program.addCommand(vmCommand);
2036
+ program.addCommand(snapshotsCommand);
2037
+ program.addCommand(serverCommand);
2038
+ program.addCommand(loginCommand);
2039
+ program.addCommand(logoutCommand);
2040
+ program.addCommand(whoamiCommand);
2041
+ program.addCommand(orgCommand);
2042
+ program.addCommand(usageCommand2);
2043
+ for (const cmd of vmAliases) program.addCommand(cmd);
2044
+ return program;
2045
+ }
2046
+
2047
+ // src/index.ts
2048
+ try {
2049
+ await buildProgram().parseAsync(process.argv);
2050
+ } catch (err) {
2051
+ if (err instanceof KvboxError) {
2052
+ process.stderr.write(color.red(`error: ${err.message}`) + "\n");
2053
+ process.exit(err.exitCode);
2054
+ }
2055
+ throw err;
2056
+ }
2057
+ //# sourceMappingURL=kvbox.js.map