@ghalex/kvbox-server 0.1.3 → 0.2.0

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.
@@ -35,7 +35,8 @@ function paths(home = kvboxHome()) {
35
35
  serverSock: join(home, "server.sock"),
36
36
  serverPid: join(home, "server.pid"),
37
37
  serverLog: join(home, "server.log"),
38
- hosts: join(home, "hosts.json"),
38
+ /** The CLI's login to the control plane (`kvbox login`): url, bearer token, user. Mode 600. */
39
+ cli: join(home, "cli.json"),
39
40
  ssh: join(home, "ssh"),
40
41
  sshKey: join(home, "ssh", "id_ed25519"),
41
42
  bin: join(home, "bin"),
@@ -278,9 +279,16 @@ import { rm as rm3, writeFile as writeFile5 } from "node:fs/promises";
278
279
  import { createAdaptorServer } from "@hono/node-server";
279
280
 
280
281
  // src/core/state.ts
282
+ import { randomBytes } from "node:crypto";
281
283
  import { mkdir, readFile as readFile2, readdir, writeFile as writeFile2 } from "node:fs/promises";
282
284
  import { z as z2 } from "zod";
283
285
  var MachineMetaSchema = z2.object({
286
+ /**
287
+ * Host-unique, minted at `kvbox new` (fresh or restored) and kept for the box's life; the tap is
288
+ * `kv-<id>`. Absent only on a box that predates ids and is still running on its `kv-<name>` tap:
289
+ * `migrateBoxIds` gives stopped ones theirs at server start, a running one gets it when it next starts.
290
+ */
291
+ id: z2.string().optional(),
284
292
  name: z2.string(),
285
293
  /** Part of the box's identity: machines/<owner>/<name>. See refs.ts. */
286
294
  owner: z2.string(),
@@ -303,6 +311,12 @@ var MachineMetaSchema = z2.object({
303
311
  /** `kvbox pause`: Firecracker is alive but the VM is frozen (standby). Absent: not paused. */
304
312
  paused: z2.boolean().optional()
305
313
  });
314
+ function newBoxId(taken) {
315
+ for (; ; ) {
316
+ const id = randomBytes(3).toString("hex");
317
+ if (!taken.has(id)) return id;
318
+ }
319
+ }
306
320
  var SnapshotMetaSchema = z2.object({
307
321
  name: z2.string(),
308
322
  owner: z2.string(),
@@ -377,57 +391,6 @@ function pidAlive(pid) {
377
391
  import { existsSync as existsSync2 } from "node:fs";
378
392
  import { mkdir as mkdir2, readdir as readdir2, readFile as readFile3, rename, writeFile as writeFile3 } from "node:fs/promises";
379
393
  import { join as join2 } from "node:path";
380
- async function migrateOwnerLayout(owner, p = paths()) {
381
- const done = { boxes: [], snapshots: [], conflicts: [] };
382
- for (const name of await oldDepthEntries(p.machines)) {
383
- const from = join2(p.machines, name);
384
- const to = p.machine(owner, name).dir;
385
- if (existsSync2(to)) {
386
- done.conflicts.push(`machines/${name}`);
387
- continue;
388
- }
389
- await patchJson(join2(from, "meta.json"), (meta) => ({
390
- ...meta,
391
- owner,
392
- // a restored box names its snapshot: that moved under the same owner
393
- source: typeof meta["source"] === "string" && meta["source"].startsWith("snapshot:") && !meta["source"].includes("/") ? `snapshot:${owner}/${meta["source"].slice("snapshot:".length)}` : meta["source"]
394
- }));
395
- await mkdir2(p.ownerMachines(owner), { recursive: true, mode: 448 });
396
- await rename(from, to);
397
- done.boxes.push(name);
398
- }
399
- for (const name of await oldDepthEntries(p.snapshots)) {
400
- const from = join2(p.snapshots, name);
401
- const to = p.snapshot(owner, name).dir;
402
- if (existsSync2(to)) {
403
- done.conflicts.push(`snapshots/${name}`);
404
- continue;
405
- }
406
- await patchJson(join2(from, "meta.json"), (meta) => ({
407
- ...meta,
408
- owner,
409
- source: typeof meta["source"] === "string" && !meta["source"].includes("/") ? `${owner}/${meta["source"]}` : meta["source"]
410
- }));
411
- await mkdir2(p.ownerSnapshots(owner), { recursive: true, mode: 448 });
412
- await rename(from, to);
413
- done.snapshots.push(name);
414
- }
415
- return done;
416
- }
417
- async function oldDepthEntries(root) {
418
- let entries;
419
- try {
420
- entries = await readdir2(root, { withFileTypes: true });
421
- } catch (err) {
422
- if (err.code === "ENOENT") return [];
423
- throw err;
424
- }
425
- return entries.filter((e) => e.isDirectory() && existsSync2(join2(root, e.name, "meta.json"))).map((e) => e.name).sort();
426
- }
427
- async function patchJson(file, patch) {
428
- const raw = JSON.parse(await readFile3(file, "utf8"));
429
- await writeFile3(file, JSON.stringify(patch(raw), null, 2) + "\n");
430
- }
431
394
 
432
395
  // src/core/db.ts
433
396
  import { chmodSync, closeSync, mkdirSync, openSync } from "node:fs";
@@ -509,6 +472,91 @@ function usageEventsFor(boxId, since, p = paths()) {
509
472
  const rows = openDb(p).prepare("select * from usage_events where box_id = ? and at >= ? order by at, id").all(boxId, since);
510
473
  return rows.map(fromRaw);
511
474
  }
475
+ function rekeyUsageEvents(from, to, p = paths()) {
476
+ return Number(openDb(p).prepare("update usage_events set box_id = ? where box_id = ?").run(to, from).changes);
477
+ }
478
+
479
+ // src/core/migrate.ts
480
+ async function migrateOwnerLayout(owner, p = paths()) {
481
+ const done = { boxes: [], snapshots: [], conflicts: [] };
482
+ for (const name of await oldDepthEntries(p.machines)) {
483
+ const from = join2(p.machines, name);
484
+ const to = p.machine(owner, name).dir;
485
+ if (existsSync2(to)) {
486
+ done.conflicts.push(`machines/${name}`);
487
+ continue;
488
+ }
489
+ await patchJson(join2(from, "meta.json"), (meta) => ({
490
+ ...meta,
491
+ owner,
492
+ // a restored box names its snapshot: that moved under the same owner
493
+ source: typeof meta["source"] === "string" && meta["source"].startsWith("snapshot:") && !meta["source"].includes("/") ? `snapshot:${owner}/${meta["source"].slice("snapshot:".length)}` : meta["source"]
494
+ }));
495
+ await mkdir2(p.ownerMachines(owner), { recursive: true, mode: 448 });
496
+ await rename(from, to);
497
+ done.boxes.push(name);
498
+ }
499
+ for (const name of await oldDepthEntries(p.snapshots)) {
500
+ const from = join2(p.snapshots, name);
501
+ const to = p.snapshot(owner, name).dir;
502
+ if (existsSync2(to)) {
503
+ done.conflicts.push(`snapshots/${name}`);
504
+ continue;
505
+ }
506
+ await patchJson(join2(from, "meta.json"), (meta) => ({
507
+ ...meta,
508
+ owner,
509
+ source: typeof meta["source"] === "string" && !meta["source"].includes("/") ? `${owner}/${meta["source"]}` : meta["source"]
510
+ }));
511
+ await mkdir2(p.ownerSnapshots(owner), { recursive: true, mode: 448 });
512
+ await rename(from, to);
513
+ done.snapshots.push(name);
514
+ }
515
+ return done;
516
+ }
517
+ async function oldDepthEntries(root) {
518
+ let entries;
519
+ try {
520
+ entries = await readdir2(root, { withFileTypes: true });
521
+ } catch (err) {
522
+ if (err.code === "ENOENT") return [];
523
+ throw err;
524
+ }
525
+ return entries.filter((e) => e.isDirectory() && existsSync2(join2(root, e.name, "meta.json"))).map((e) => e.name).sort();
526
+ }
527
+ async function patchJson(file, patch) {
528
+ const raw = JSON.parse(await readFile3(file, "utf8"));
529
+ await writeFile3(file, JSON.stringify(patch(raw), null, 2) + "\n");
530
+ }
531
+ async function migrateBoxIds(p = paths()) {
532
+ const metas = [];
533
+ for (const ref of await listMachineRefs(p)) {
534
+ try {
535
+ metas.push(await readMachineMeta(ref, p));
536
+ } catch {
537
+ continue;
538
+ }
539
+ }
540
+ const taken = new Set(metas.flatMap((m) => m.id === void 0 ? [] : [m.id]));
541
+ const done = [];
542
+ for (const meta of metas) {
543
+ if (meta.id !== void 0) {
544
+ rekeyUsageEvents(refKey(meta), meta.id, p);
545
+ continue;
546
+ }
547
+ if (pidAlive(meta.pid)) continue;
548
+ await adoptBoxId(meta, taken, p);
549
+ done.push(refKey(meta));
550
+ }
551
+ return done;
552
+ }
553
+ async function adoptBoxId(meta, taken, p = paths()) {
554
+ if (meta.id !== void 0) return meta;
555
+ const withId2 = { ...meta, id: newBoxId(taken) };
556
+ await writeMachineMeta(withId2, p);
557
+ rekeyUsageEvents(refKey(meta), withId2.id, p);
558
+ return withId2;
559
+ }
512
560
 
513
561
  // src/core/machines.ts
514
562
  import { spawn } from "node:child_process";
@@ -752,6 +800,12 @@ var NAME_RE = /^[a-z0-9][a-z0-9-]{0,11}$/;
752
800
  var AUTO_PREFIX = "box";
753
801
  var GUEST_CID = 3;
754
802
  var OVERLAY_REL = "overlay.ext4";
803
+ var tapKey = (meta) => meta.id ?? meta.name;
804
+ var usageKey = (meta) => meta.id ?? refKey(meta);
805
+ var idsInUse = (metas) => new Set(metas.flatMap((m) => m.id === void 0 ? [] : [m.id]));
806
+ async function withId(meta, p) {
807
+ return meta.id !== void 0 ? meta : adoptBoxId(meta, idsInUse(await readAllMeta(p)), p);
808
+ }
755
809
  function diskStats(disk) {
756
810
  try {
757
811
  const st = statSync(disk);
@@ -773,7 +827,7 @@ function diskGibOf(meta, p) {
773
827
  }
774
828
  function recordState(meta, state, by, p, at = (/* @__PURE__ */ new Date()).toISOString()) {
775
829
  try {
776
- insertUsageEvent({ boxId: refKey(meta), owner: meta.owner, name: meta.name, at, state, by, vcpu: meta.vcpu, memMib: meta.memMib, diskGib: diskGibOf(meta, p) }, p);
830
+ insertUsageEvent({ boxId: usageKey(meta), owner: meta.owner, name: meta.name, at, state, by, vcpu: meta.vcpu, memMib: meta.memMib, diskGib: diskGibOf(meta, p) }, p);
777
831
  } catch (err) {
778
832
  console.error(`warning: usage ledger: ${err.message}`);
779
833
  }
@@ -792,12 +846,13 @@ function toMachine(meta, p = paths()) {
792
846
  const alive = pidAlive(meta.pid);
793
847
  if (!alive && meta.pid !== null) {
794
848
  try {
795
- closeOpenRun(refKey(meta), (/* @__PURE__ */ new Date()).toISOString(), p);
849
+ closeOpenRun(usageKey(meta), (/* @__PURE__ */ new Date()).toISOString(), p);
796
850
  } catch (err) {
797
851
  console.error(`warning: usage ledger: ${err.message}`);
798
852
  }
799
853
  }
800
854
  return {
855
+ id: meta.id,
801
856
  name: meta.name,
802
857
  owner: meta.owner,
803
858
  ip: meta.ip,
@@ -1014,6 +1069,7 @@ async function createMachine(req, owner, p = paths()) {
1014
1069
  const pubkeyB64 = await readPubkeyB64(p);
1015
1070
  const existing = await readAllMeta(p);
1016
1071
  const name = pickName(req.name, new Set(existing.filter((m2) => m2.owner === owner).map((m2) => m2.name)));
1072
+ const id = newBoxId(idsInUse(existing));
1017
1073
  const ip = nextFreeIp(cfg.subnet, [...existing.map((m2) => m2.ip), cfg.gateway]);
1018
1074
  const mac = macFor(ip);
1019
1075
  const m = p.machine(owner, name);
@@ -1024,16 +1080,16 @@ async function createMachine(req, owner, p = paths()) {
1024
1080
  let tapCreated = false;
1025
1081
  try {
1026
1082
  await createOverlay(m.overlay, size.diskGib);
1027
- const tap = await createTap(name, cfg);
1083
+ const tap = await createTap(id, cfg);
1028
1084
  tapCreated = true;
1029
- const spec = { name, owner, ip, mac, vcpu: size.vcpu, memMib: size.memMib, base: base.version };
1085
+ const spec = { id, name, owner, ip, mac, vcpu: size.vcpu, memMib: size.memMib, base: base.version };
1030
1086
  const pid = await launch(spec, tap, cfg, pubkeyB64, p);
1031
1087
  const meta = { ...spec, pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: "base" };
1032
1088
  await writeMachineMeta(meta, p);
1033
1089
  recordState(meta, "running", "new", p, meta.createdAt);
1034
1090
  return toMachine(meta, p);
1035
1091
  } catch (err) {
1036
- if (tapCreated) await deleteTap(name, cfg).catch(() => void 0);
1092
+ if (tapCreated) await deleteTap(id, cfg).catch(() => void 0);
1037
1093
  await rm(m.dir, { recursive: true, force: true });
1038
1094
  throw err;
1039
1095
  }
@@ -1062,6 +1118,7 @@ async function restoreMachine(fromText, owner, req, cfg, p) {
1062
1118
  await assertRoomFor(snap.vcpu, snap.memMib, req.force ?? false, p);
1063
1119
  const existing = await readAllMeta(p);
1064
1120
  const name = pickName(req.name, new Set(existing.filter((m2) => m2.owner === owner).map((m2) => m2.name)));
1121
+ const id = newBoxId(idsInUse(existing));
1065
1122
  const ip = nextFreeIp(cfg.subnet, [...existing.map((m2) => m2.ip), cfg.gateway]);
1066
1123
  const mac = macFor(ip);
1067
1124
  const m = p.machine(owner, name);
@@ -1073,23 +1130,23 @@ async function restoreMachine(fromText, owner, req, cfg, p) {
1073
1130
  try {
1074
1131
  await run(["cp", "--sparse=always", "--reflink=auto", s.overlay, m.overlay]);
1075
1132
  await link(s.mem, m.memBase);
1076
- const tap = await createTap(name, cfg, { detached: true });
1133
+ const tap = await createTap(id, cfg, { detached: true });
1077
1134
  tapCreated = true;
1078
- const spec = { name, owner, ip, mac, vcpu: snap.vcpu, memMib: snap.memMib, base: snap.base };
1135
+ const spec = { id, name, owner, ip, mac, vcpu: snap.vcpu, memMib: snap.memMib, base: snap.base };
1079
1136
  pid = await loadSnapshot(spec, { vmstate: s.vmstate, mem: s.mem, label: shown(from) }, tap, cfg, p);
1080
1137
  await resumeVm(m.sock);
1081
1138
  await introduce(spec, m.vsock, cfg, {
1082
1139
  silent: `nothing answered on the restored box's control channel for 3 s; ${tooOldToRestore(shown(from))}`,
1083
1140
  noAnswer: "the restored box was discarded. kvbox-agent inside it took the request but gave no answer; try again, and if it keeps happening look at the box's journal (kvbox exec <box> -- sudo journalctl -u kvbox-agent) on a box started from the same snapshot."
1084
1141
  });
1085
- await attachTap(name, cfg);
1142
+ await attachTap(id, cfg);
1086
1143
  const meta = { ...spec, pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: `snapshot:${refKey(from)}`, memSnapshots: 1 };
1087
1144
  await writeMachineMeta(meta, p);
1088
1145
  recordState(meta, "running", "new", p, meta.createdAt);
1089
1146
  return toMachine(meta, p);
1090
1147
  } catch (err) {
1091
1148
  if (pid !== void 0) killQuietly(pid);
1092
- if (tapCreated) await deleteTap(name, cfg).catch(() => void 0);
1149
+ if (tapCreated) await deleteTap(id, cfg).catch(() => void 0);
1093
1150
  await rm(m.dir, { recursive: true, force: true });
1094
1151
  throw err;
1095
1152
  }
@@ -1147,7 +1204,7 @@ async function waitDead(pid, graceMs) {
1147
1204
  }
1148
1205
  }
1149
1206
  async function cleanupProcess(meta, cfg, p) {
1150
- await deleteTap(meta.name, cfg);
1207
+ await deleteTap(tapKey(meta), cfg);
1151
1208
  const m = p.machine(meta.owner, meta.name);
1152
1209
  await rm(m.sock, { force: true });
1153
1210
  await rm(m.vsock, { force: true });
@@ -1166,7 +1223,7 @@ async function stopMachine(ref, p = paths()) {
1166
1223
  return toMachine(stopped, p);
1167
1224
  }
1168
1225
  async function startMachine(ref, p = paths()) {
1169
- const meta = await readMeta(ref, p);
1226
+ const meta = await withId(await readMeta(ref, p), p);
1170
1227
  const cfg = await loadConfig(p);
1171
1228
  if (pidAlive(meta.pid)) throw new KvboxError(`box ${shown(ref)} is already ${meta.paused ? "up, in standby (kvbox resume " + shown(ref) + ")" : "running"}`);
1172
1229
  if (isHibernated(meta, p)) {
@@ -1175,7 +1232,7 @@ async function startMachine(ref, p = paths()) {
1175
1232
  assertAssets(cfg);
1176
1233
  const pubkeyB64 = await readPubkeyB64(p);
1177
1234
  await ensureHostNetwork(cfg);
1178
- const tap = await createTap(ref.name, cfg);
1235
+ const tap = await createTap(meta.id, cfg);
1179
1236
  try {
1180
1237
  const pid = await launch(meta, tap, cfg, pubkeyB64, p);
1181
1238
  const started = { ...meta, pid };
@@ -1183,7 +1240,7 @@ async function startMachine(ref, p = paths()) {
1183
1240
  recordState(started, "running", "start", p);
1184
1241
  return toMachine(started, p);
1185
1242
  } catch (err) {
1186
- await deleteTap(ref.name, cfg).catch(() => void 0);
1243
+ await deleteTap(meta.id, cfg).catch(() => void 0);
1187
1244
  throw err;
1188
1245
  }
1189
1246
  }
@@ -1250,7 +1307,7 @@ async function hibernateMachine(ref, p = paths()) {
1250
1307
  return toMachine(hibernated, p);
1251
1308
  }
1252
1309
  async function wakeMachine(ref, p = paths()) {
1253
- const meta = await readMeta(ref, p);
1310
+ const meta = await withId(await readMeta(ref, p), p);
1254
1311
  const cfg = await loadConfig(p);
1255
1312
  const status = statusOf(meta, p);
1256
1313
  if (status === "running" || status === "standby") throw new KvboxError(`${stateMessage(ref, status)}, not hibernated`);
@@ -1261,20 +1318,20 @@ async function wakeMachine(ref, p = paths()) {
1261
1318
  }
1262
1319
  const m = p.machine(ref.owner, ref.name);
1263
1320
  await ensureHostNetwork(cfg);
1264
- const tap = await createTap(ref.name, cfg, { detached: true });
1321
+ const tap = await createTap(meta.id, cfg, { detached: true });
1265
1322
  let pid;
1266
1323
  try {
1267
- const spec = { name: meta.name, owner: meta.owner, ip: meta.ip, mac: meta.mac, vcpu: meta.vcpu, memMib: meta.memMib, base: meta.base };
1324
+ const spec = { id: meta.id, name: meta.name, owner: meta.owner, ip: meta.ip, mac: meta.mac, vcpu: meta.vcpu, memMib: meta.memMib, base: meta.base };
1268
1325
  pid = await loadSnapshot(spec, { vmstate: m.hibernateVmstate, mem: m.hibernateMem, label: `box ${shown(ref)}'s memory image` }, tap, cfg, p);
1269
1326
  await resumeVm(m.sock);
1270
1327
  await introduce(spec, m.vsock, cfg, {
1271
1328
  silent: `nothing answered on the box's control channel for 3 s; it stays hibernated, try kvbox wake ${shown(ref)} again`,
1272
1329
  noAnswer: `the box stays hibernated. kvbox-agent inside it took the request but gave no answer; try kvbox wake ${shown(ref)} again`
1273
1330
  });
1274
- await attachTap(ref.name, cfg);
1331
+ await attachTap(meta.id, cfg);
1275
1332
  } catch (err) {
1276
1333
  if (pid !== void 0) killQuietly(pid);
1277
- await deleteTap(ref.name, cfg).catch(() => void 0);
1334
+ await deleteTap(meta.id, cfg).catch(() => void 0);
1278
1335
  throw err;
1279
1336
  }
1280
1337
  await rm(m.memBase, { force: true });
@@ -1404,7 +1461,7 @@ async function machineUsage(ref, since, p = paths()) {
1404
1461
  const box = await getMachine(ref, p);
1405
1462
  const cfg = await loadConfig(p);
1406
1463
  const now = (/* @__PURE__ */ new Date()).toISOString();
1407
- const boxId = refKey(ref);
1464
+ const boxId = box.id ?? refKey(ref);
1408
1465
  let events = usageEventsFor(boxId, box.createdAt, p);
1409
1466
  if (events.length === 0) {
1410
1467
  insertUsageEvent(
@@ -1419,7 +1476,7 @@ async function machineUsage(ref, since, p = paths()) {
1419
1476
  async function seedUsageLedger(p = paths()) {
1420
1477
  let seeded = 0;
1421
1478
  for (const box of await listMachines(null, p)) {
1422
- const boxId = `${box.owner}/${box.name}`;
1479
+ const boxId = box.id ?? `${box.owner}/${box.name}`;
1423
1480
  if (usageEventsFor(boxId, box.createdAt, p).length > 0) continue;
1424
1481
  insertUsageEvent(
1425
1482
  { boxId, owner: box.owner, name: box.name, at: (/* @__PURE__ */ new Date()).toISOString(), state: box.status, by: "upgrade", vcpu: box.vcpu, memMib: box.memMib, diskGib: Math.round(box.diskBytes / GIB) },
@@ -1446,7 +1503,7 @@ function versionOf(srcFileUrl, injected) {
1446
1503
  }
1447
1504
 
1448
1505
  // src/version.ts
1449
- var VERSION = versionOf(import.meta.url, true ? "0.1.3" : void 0);
1506
+ var VERSION = versionOf(import.meta.url, true ? "0.2.0" : void 0);
1450
1507
 
1451
1508
  // src/caller.ts
1452
1509
  function resolveCaller() {
@@ -1473,6 +1530,8 @@ import { z as z3 } from "zod";
1473
1530
  var SizeSchema = z3.enum(SIZE_NAMES);
1474
1531
  var BoxStatusSchema = z3.enum(["running", "standby", "hibernated", "stopped"]);
1475
1532
  var MachineSchema = z3.object({
1533
+ /** Host-unique short id (the tap is `kv-<id>`). Missing only on a box made before ids that has not restarted since. */
1534
+ id: z3.string().optional(),
1476
1535
  name: z3.string(),
1477
1536
  /** Who the box belongs to; names are unique per owner. A bare name is local to the host, `user@control-plane` later. */
1478
1537
  owner: z3.string(),
@@ -1562,6 +1621,39 @@ var MachineUsageSchema = z3.object({
1562
1621
  var ApiErrorSchema = z3.object({
1563
1622
  error: z3.string()
1564
1623
  });
1624
+ var CloudUserSchema = z3.object({
1625
+ name: z3.string(),
1626
+ role: z3.enum(["admin", "user"]),
1627
+ createdAt: z3.string()
1628
+ });
1629
+ var LoginRequestSchema = z3.object({
1630
+ name: z3.string().min(1),
1631
+ password: z3.string().min(1),
1632
+ publicKey: z3.string().optional()
1633
+ });
1634
+ var LoginResponseSchema = z3.object({
1635
+ token: z3.string(),
1636
+ user: CloudUserSchema
1637
+ });
1638
+ var HostJoinRequestSchema = z3.object({
1639
+ token: z3.string().min(1),
1640
+ name: z3.string().min(1),
1641
+ url: z3.string().url(),
1642
+ fingerprint: z3.string().regex(/^([0-9A-F]{2}:){31}[0-9A-F]{2}$/),
1643
+ publicAddress: z3.string().min(1)
1644
+ });
1645
+ var HostJoinResponseSchema = z3.object({
1646
+ hostSecret: z3.string(),
1647
+ controlPlane: z3.object({ name: z3.string() })
1648
+ });
1649
+ var CloudHostSchema = z3.object({
1650
+ name: z3.string(),
1651
+ url: z3.string(),
1652
+ publicAddress: z3.string(),
1653
+ joinedAt: z3.string(),
1654
+ lastSeenAt: z3.string().nullable(),
1655
+ capacity: CapacitySchema.nullable()
1656
+ });
1565
1657
 
1566
1658
  // src/routes/machines.ts
1567
1659
  function machineRoutes() {
@@ -1780,6 +1872,8 @@ async function listen(opts = {}) {
1780
1872
  console.error(`boxes and snapshots now belong to an owner: moved ${moved.boxes.length} box(es) and ${moved.snapshots.length} snapshot(s) under ${cfg.owner}/`);
1781
1873
  }
1782
1874
  for (const c of moved.conflicts) console.error(`warning: ${c} left in place: ${cfg.owner} already has one with that name`);
1875
+ const withIds = await migrateBoxIds(p);
1876
+ if (withIds.length > 0) console.error(`boxes now have an id of their own: ${withIds.length} box(es) made before this version got theirs (${withIds.join(", ")})`);
1783
1877
  const seeded = await seedUsageLedger(p);
1784
1878
  if (seeded > 0) console.error(`usage: started keeping time for ${seeded} box(es) made before this version; kvbox vm usage counts from now for them`);
1785
1879
  const socketPath = opts.socketPath ?? p.serverSock;