@kvbox/server 0.5.18 → 0.5.19

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.
@@ -76,6 +76,8 @@ function paths(home = kvboxHome()) {
76
76
  * The next snapshot is a diff of the pages dirtied since, merged onto this. Absent after a boot.
77
77
  */
78
78
  memBase: join(dir, "mem.base"),
79
+ /** `kvbox vm fork` (#79): the source's VM state, captured here and gone once the box runs. Its memory is `memBase`. */
80
+ forkVmstate: join(dir, "fork.vmstate"),
79
81
  /** `kvbox hibernate`: the box's memory image and VM state while it is hibernated; gone again after `wake` or `stop`. */
80
82
  hibernateDir: join(dir, "hibernate"),
81
83
  hibernateVmstate: join(dir, "hibernate", "vmstate"),
@@ -214,8 +216,9 @@ function groupUsage(rows, window, pricing, live = /* @__PURE__ */ new Map()) {
214
216
  const status = live.get(id) ?? (removed ? "removed" : last.state);
215
217
  out.push({ id, name: last.name, owner: last.owner, status, ...totals });
216
218
  }
217
- return out.sort((a, b) => a.owner.localeCompare(b.owner) || a.name.localeCompare(b.name) || (a.historyFrom ?? "").localeCompare(b.historyFrom ?? ""));
219
+ return out.sort(usageOrder);
218
220
  }
221
+ var usageOrder = (a, b) => a.owner.localeCompare(b.owner) || a.name.localeCompare(b.name) || (a.historyFrom ?? "").localeCompare(b.historyFrom ?? "");
219
222
 
220
223
  // src/core/refs.ts
221
224
  import { userInfo } from "node:os";
@@ -257,6 +260,11 @@ function parseRef(text, caller) {
257
260
  }
258
261
  return { owner, name, display: owner === caller ? name : text };
259
262
  }
263
+ function ownerFor(slug, caller) {
264
+ if (!LOCAL_OWNER_RE.test(slug)) throw new KvboxError(`invalid owner "${slug}": a bare slug, without '@' or '/'`);
265
+ const realm = realmOf(caller);
266
+ return realm === null ? slug : `${slug}@${realm}`;
267
+ }
260
268
  function refKey(ref) {
261
269
  return `${ref.owner}/${ref.name}`;
262
270
  }
@@ -382,7 +390,7 @@ var MachineSchema = z3.object({
382
390
  // base image version the box's overlay stacks on
383
391
  createdAt: z3.string(),
384
392
  source: z3.string(),
385
- // "base" or "snapshot:<name>"
393
+ // "base", "snapshot:<owner/name>" or "fork:<owner/name>" (#79)
386
394
  status: BoxStatusSchema,
387
395
  /**
388
396
  * Public TCP port on the host that reaches the box's SSH while it runs (#49). Only boxes made
@@ -418,6 +426,20 @@ var CreateMachineRequest = z3.object({
418
426
  /** Seconds without network traffic before the box is hibernated (#47); 0: never. Absent: the host's default. */
419
427
  autoHibernateSec: z3.number().int().min(0).optional()
420
428
  });
429
+ var ForkMachineRequest = z3.object({
430
+ name: z3.string().optional(),
431
+ /**
432
+ * The new box's slug, bare (`acme`): the control plane sends the target of `--org` here; the host
433
+ * qualifies it with the caller's realm. Absent: the source's owner. A CLI leaves it out.
434
+ */
435
+ owner: z3.string().optional(),
436
+ /** A cold fork only: a warm one keeps the source's vCPU and memory. */
437
+ size: SizeSchema.optional(),
438
+ force: z3.boolean().optional(),
439
+ /** As in CreateMachineRequest: the control plane's CA line for the new box's owner. */
440
+ sshKeys: z3.array(z3.string()).optional(),
441
+ autoHibernateSec: z3.number().int().min(0).optional()
442
+ });
421
443
  var SetSshKeysRequest = z3.object({
422
444
  sshKeys: z3.array(z3.string())
423
445
  });
@@ -533,7 +555,12 @@ var BoxUsageSchema = MachineUsageSchema.omit({ window: true, status: true }).ext
533
555
  id: z3.string(),
534
556
  status: UsageStatusSchema,
535
557
  /** Through a control plane: whether the owner slug is a user's or an org's (what `kvbox ls` shows). */
536
- ownerKind: z3.enum(["user", "org"]).optional()
558
+ ownerKind: z3.enum(["user", "org"]).optional(),
559
+ /**
560
+ * Through a control plane: the username that made the box (#60), absent when the box is gone from
561
+ * the index (removed), was found by reconcile, or its maker was deleted. The ledger does not record it.
562
+ */
563
+ createdBy: z3.string().optional()
537
564
  });
538
565
  var UsageReportSchema = z3.object({
539
566
  window: z3.object({ from: z3.string(), to: z3.string() }),
@@ -929,8 +956,8 @@ async function requireKvm(device = KVM_DEVICE, groupFile = "/etc/group") {
929
956
 
930
957
  // src/core/machines.ts
931
958
  import { spawn } from "node:child_process";
932
- import { closeSync as closeSync2, existsSync as existsSync2, openSync as openSync2, statSync } from "node:fs";
933
- import { link, mkdir as mkdir3, readFile as readFile5, realpath, rm, truncate, writeFile as writeFile3 } from "node:fs/promises";
959
+ import { closeSync as closeSync2, existsSync as existsSync3, openSync as openSync2, statSync } from "node:fs";
960
+ import { link as link2, mkdir as mkdir4, readFile as readFile5, realpath, rm as rm2, truncate, writeFile as writeFile3 } from "node:fs/promises";
934
961
  import { basename } from "node:path";
935
962
  import { setTimeout as sleep } from "node:timers/promises";
936
963
 
@@ -1352,6 +1379,10 @@ function locked(fn) {
1352
1379
  return (...args) => withBoxLock(args[0], () => fn(...args));
1353
1380
  }
1354
1381
 
1382
+ // src/core/snapshots.ts
1383
+ import { existsSync } from "node:fs";
1384
+ import { link, mkdir as mkdir3, readdir as readdir2, rename as rename2, rm, stat as stat2 } from "node:fs/promises";
1385
+
1355
1386
  // src/core/firecracker.ts
1356
1387
  import { Agent, request as request2 } from "undici";
1357
1388
  var FirecrackerClient = class {
@@ -1461,9 +1492,137 @@ var FirecrackerClient = class {
1461
1492
  }
1462
1493
  };
1463
1494
 
1495
+ // src/core/snapshots.ts
1496
+ var NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
1497
+ function toSnapshot(meta) {
1498
+ return {
1499
+ name: meta.name,
1500
+ owner: meta.owner,
1501
+ source: meta.source,
1502
+ base: meta.base,
1503
+ vcpu: meta.vcpu,
1504
+ memMib: meta.memMib,
1505
+ sizeBytes: meta.sizeBytes,
1506
+ createdAt: meta.createdAt
1507
+ };
1508
+ }
1509
+ async function listSnapshots(scope, p = paths()) {
1510
+ const out = [];
1511
+ for (const ref of await listSnapshotRefs(p)) {
1512
+ if (!inScope(ref.owner, scope)) continue;
1513
+ try {
1514
+ out.push(toSnapshot(await readSnapshotMeta(ref, p)));
1515
+ } catch (err) {
1516
+ console.error(`warning: skipping snapshots/${refKey(ref)}: ${err.message}`);
1517
+ }
1518
+ }
1519
+ return out;
1520
+ }
1521
+ async function usedBytes(dir) {
1522
+ let total = 0;
1523
+ for (const entry of await readdir2(dir)) {
1524
+ total += (await stat2(`${dir}/${entry}`)).blocks * 512;
1525
+ }
1526
+ return total;
1527
+ }
1528
+ async function readBox(ref, p) {
1529
+ try {
1530
+ return await readMachineMeta(ref, p);
1531
+ } catch (err) {
1532
+ if (err.code === "ENOENT") throw new NotFoundError(`box ${shown(ref)}`);
1533
+ throw err;
1534
+ }
1535
+ }
1536
+ async function saveSnapshot(req, owner, p = paths()) {
1537
+ if (!NAME_RE.test(req.name)) {
1538
+ throw new KvboxError(`invalid snapshot name "${req.name}": 1-64 chars, lowercase letters, digits, '.', '_' and '-', starting with a letter or digit`);
1539
+ }
1540
+ const ref = parseRef(req.box, owner);
1541
+ return withBoxLock(ref, () => snapshotBox(ref, req, owner, p));
1542
+ }
1543
+ async function snapshotBox(ref, req, owner, p) {
1544
+ const box = await readBox(ref, p);
1545
+ if (!vmAlive(box)) {
1546
+ if (existsSync(p.machine(box.owner, box.name).hibernateVmstate)) {
1547
+ throw new KvboxError(`box ${req.box} is hibernated; a snapshot freezes a running box (kvbox wake ${req.box} first)`);
1548
+ }
1549
+ throw new KvboxError(`box ${req.box} is not running; a snapshot freezes a running box (kvbox start ${req.box} first)`);
1550
+ }
1551
+ const s = p.snapshot(owner, req.name);
1552
+ if (existsSync(s.dir)) throw new KvboxError(`snapshot ${req.name} already exists`);
1553
+ const cfg = await loadConfig(p);
1554
+ await mkdir3(s.dir, { recursive: true, mode: 448 });
1555
+ try {
1556
+ await captureBox(box, s, cfg, p);
1557
+ const meta = {
1558
+ name: req.name,
1559
+ owner,
1560
+ source: refKey(box),
1561
+ base: box.base,
1562
+ vcpu: box.vcpu,
1563
+ memMib: box.memMib,
1564
+ sizeBytes: await usedBytes(s.dir),
1565
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1566
+ };
1567
+ await writeSnapshotMeta(meta, p);
1568
+ return toSnapshot(meta);
1569
+ } catch (err) {
1570
+ await rm(s.dir, { recursive: true, force: true });
1571
+ throw err;
1572
+ }
1573
+ }
1574
+ async function captureBox(box, to, cfg, p) {
1575
+ const m = p.machine(box.owner, box.name);
1576
+ const fc = new FirecrackerClient(m.sock);
1577
+ let lineageTouched = false;
1578
+ try {
1579
+ const since = box.memSnapshots ?? 0;
1580
+ const mode = since === 0 || existsSync(m.memBase) ? "Diff" : "Full";
1581
+ let failure2;
1582
+ await fc.pause();
1583
+ try {
1584
+ await fc.snapshotCreate(to.vmstate, to.mem, mode);
1585
+ lineageTouched = true;
1586
+ await run(["cp", "--sparse=always", "--reflink=auto", m.overlay, to.overlay]);
1587
+ } catch (err) {
1588
+ failure2 = err;
1589
+ } finally {
1590
+ if (!box.paused) await fc.resume();
1591
+ }
1592
+ if (lineageTouched) {
1593
+ await writeMachineMeta({ ...box, memSnapshots: since + 1 }, p);
1594
+ }
1595
+ if (failure2 !== void 0) throw failure2;
1596
+ if (mode === "Full") {
1597
+ await run(["fallocate", "--dig-holes", to.mem]);
1598
+ } else if (since > 0) {
1599
+ await mergeOntoPrevious(m.memBase, to.mem, cfg.snapshotEditor ?? p.snapshotEditor);
1600
+ }
1601
+ await rm(m.memBase, { force: true });
1602
+ await link(to.mem, m.memBase);
1603
+ } catch (err) {
1604
+ if (lineageTouched) await rm(m.memBase, { force: true }).catch(() => void 0);
1605
+ throw err;
1606
+ } finally {
1607
+ await fc.close();
1608
+ }
1609
+ }
1610
+ async function mergeOntoPrevious(previous, diffFile, editor) {
1611
+ if (!existsSync(editor)) throw new KvboxError(`snapshot-editor missing at ${editor}; rerun install-server.sh`);
1612
+ const merged = `${diffFile}.merged`;
1613
+ await run(["cp", "--sparse=always", "--reflink=auto", previous, merged]);
1614
+ await run([editor, "edit-memory", "rebase", "--memory-path", merged, "--diff-path", diffFile]);
1615
+ await rename2(merged, diffFile);
1616
+ }
1617
+ async function deleteSnapshot(ref, p = paths()) {
1618
+ const dir = p.snapshot(ref.owner, ref.name).dir;
1619
+ if (!existsSync(dir)) throw new NotFoundError(`snapshot ${shown(ref)}`);
1620
+ await rm(dir, { recursive: true, force: true });
1621
+ }
1622
+
1464
1623
  // src/core/network.ts
1465
1624
  import { randomInt } from "node:crypto";
1466
- import { existsSync } from "node:fs";
1625
+ import { existsSync as existsSync2 } from "node:fs";
1467
1626
  import { fileURLToPath } from "node:url";
1468
1627
  var TAP_PREFIX = "kv-";
1469
1628
  var PRIVILEGED_SCRIPT = "/usr/local/lib/kvbox/net-setup.sh";
@@ -1473,15 +1632,15 @@ function tapName(box) {
1473
1632
  function netSetupScript() {
1474
1633
  const override = process.env["KVBOX_NET_SETUP"];
1475
1634
  if (override) return override;
1476
- if (existsSync(PRIVILEGED_SCRIPT)) return PRIVILEGED_SCRIPT;
1635
+ if (existsSync2(PRIVILEGED_SCRIPT)) return PRIVILEGED_SCRIPT;
1477
1636
  for (const rel of ["../scripts/net-setup.sh", "../../scripts/net-setup.sh"]) {
1478
1637
  const candidate = fileURLToPath(new URL(rel, import.meta.url));
1479
- if (existsSync(candidate)) return candidate;
1638
+ if (existsSync2(candidate)) return candidate;
1480
1639
  }
1481
1640
  throw new KvboxError("net-setup.sh not found next to kvbox-server");
1482
1641
  }
1483
1642
  function bridgeExists(bridge) {
1484
- return existsSync(`/sys/class/net/${bridge}`);
1643
+ return existsSync2(`/sys/class/net/${bridge}`);
1485
1644
  }
1486
1645
  function netRun(cfg, args) {
1487
1646
  return run([netSetupScript(), ...args], {
@@ -1576,7 +1735,7 @@ function macFor(ip) {
1576
1735
  }
1577
1736
 
1578
1737
  // src/core/machines.ts
1579
- var NAME_RE = /^[a-z0-9][a-z0-9-]{0,11}$/;
1738
+ var NAME_RE2 = /^[a-z0-9][a-z0-9-]{0,11}$/;
1580
1739
  var AUTO_PREFIX = "box";
1581
1740
  var GUEST_CID = 3;
1582
1741
  var OVERLAY_REL = "overlay.ext4";
@@ -1633,7 +1792,7 @@ function recordState(meta, state, by, p, at = (/* @__PURE__ */ new Date()).toISO
1633
1792
  }
1634
1793
  }
1635
1794
  function isHibernated(meta, p) {
1636
- return !vmAlive(meta) && existsSync2(p.machine(meta.owner, meta.name).hibernateVmstate);
1795
+ return !vmAlive(meta) && existsSync3(p.machine(meta.owner, meta.name).hibernateVmstate);
1637
1796
  }
1638
1797
  function statusOf(meta, p = paths()) {
1639
1798
  if (vmAlive(meta)) return meta.paused ? "standby" : "running";
@@ -1692,7 +1851,7 @@ async function getMachine(ref, p = paths()) {
1692
1851
  }
1693
1852
  function pickName(requested, taken) {
1694
1853
  if (requested !== void 0) {
1695
- if (!NAME_RE.test(requested)) {
1854
+ if (!NAME_RE2.test(requested)) {
1696
1855
  throw new KvboxError(`invalid box name "${requested}": 1-12 chars, lowercase letters, digits and '-', starting with a letter or digit`);
1697
1856
  }
1698
1857
  if (taken.has(requested)) throw new KvboxError(`box ${requested} already exists`);
@@ -1739,7 +1898,7 @@ async function currentBase(cfg, p) {
1739
1898
  }
1740
1899
  function baseFileFor(meta, p) {
1741
1900
  const file = p.baseImage(meta.base);
1742
- if (!existsSync2(file)) throw new KvboxError(`box ${meta.name} runs on base image ${meta.base}, which is gone from ${p.assets}`);
1901
+ if (!existsSync3(file)) throw new KvboxError(`box ${meta.name} runs on base image ${meta.base}, which is gone from ${p.assets}`);
1743
1902
  return file;
1744
1903
  }
1745
1904
  var overlayCapable = /* @__PURE__ */ new Map();
@@ -1774,8 +1933,8 @@ async function tailOf(file, lines = 5) {
1774
1933
  }
1775
1934
  async function spawnFirecracker(ref, cfg, p) {
1776
1935
  const m = p.machine(ref.owner, ref.name);
1777
- await rm(m.sock, { force: true });
1778
- await rm(m.vsock, { force: true });
1936
+ await rm2(m.sock, { force: true });
1937
+ await rm2(m.vsock, { force: true });
1779
1938
  const logFd = openSync2(m.log, "a");
1780
1939
  let child;
1781
1940
  try {
@@ -1790,7 +1949,7 @@ async function spawnFirecracker(ref, cfg, p) {
1790
1949
  child.unref();
1791
1950
  const pid = child.pid;
1792
1951
  if (pid === void 0) throw new KvboxError("could not start firecracker");
1793
- if (!await waitFor(() => existsSync2(m.sock), 3e3)) {
1952
+ if (!await waitFor(() => existsSync3(m.sock), 3e3)) {
1794
1953
  killQuietly(pid);
1795
1954
  throw new KvboxError(`firecracker did not open its API socket; last log lines:
1796
1955
  ${await tailOf(m.log)}`);
@@ -1861,7 +2020,7 @@ async function readHostPubkey(p) {
1861
2020
  }
1862
2021
  function assertAssets(cfg) {
1863
2022
  for (const [what, file] of [["kernel", cfg.kernel], ["base image", cfg.rootfs], ["firecracker", cfg.firecracker]]) {
1864
- if (!existsSync2(file)) throw new KvboxError(`${what} missing at ${file}; run install-server.sh`);
2023
+ if (!existsSync3(file)) throw new KvboxError(`${what} missing at ${file}; run install-server.sh`);
1865
2024
  }
1866
2025
  }
1867
2026
  async function createMachine(req, owner, p = paths()) {
@@ -1872,33 +2031,43 @@ async function createMachine(req, owner, p = paths()) {
1872
2031
  const size = SIZES[sizeName];
1873
2032
  assertAssets(cfg);
1874
2033
  await assertRoomFor(size.vcpu, size.memMib, req.force ?? false, p);
1875
- const cloud = isCloudBox(owner);
1876
- const sshKeys = cloud && req.sshKeys !== void 0 && req.sshKeys.length > 0 ? req.sshKeys : void 0;
1877
- const keysB64 = authorizedKeysB64(await readHostPubkey(p), sshKeys ?? []);
2034
+ const sshKeys = isCloudBox(owner) && req.sshKeys !== void 0 && req.sshKeys.length > 0 ? req.sshKeys : void 0;
2035
+ authorizedKeysB64(await readHostPubkey(p), sshKeys ?? []);
1878
2036
  const existing = await readAllMeta(p);
1879
- const name = pickName(req.name, new Set(existing.filter((m2) => m2.owner === owner).map((m2) => m2.name)));
2037
+ const name = pickName(req.name, new Set(existing.filter((m) => m.owner === owner).map((m) => m.name)));
1880
2038
  const id = newBoxId(idsInUse(existing));
1881
- const ip = nextFreeIp(cfg.subnet, [...existing.map((m2) => m2.ip), cfg.gateway]);
1882
- const mac = macFor(ip);
1883
- const m = p.machine(owner, name);
2039
+ const ip = nextFreeIp(cfg.subnet, [...existing.map((m) => m.ip), cfg.gateway]);
1884
2040
  const base = await currentBase(cfg, p);
1885
2041
  await assertOverlayCapable(base.file, base.version);
2042
+ return bootInto(
2043
+ { id, name, owner, ip, mac: macFor(ip), vcpu: size.vcpu, memMib: size.memMib, base: base.version, sshKeys },
2044
+ // The base image is shared read-only by every box; only the box's own writes land in its overlay.
2045
+ (m) => createOverlay(m.overlay, size.diskGib),
2046
+ { source: "base", autoHibernateSec: autoHibernateFor(req, cfg) },
2047
+ cfg,
2048
+ p
2049
+ );
2050
+ }
2051
+ async function bootInto(box, prepare, origin, cfg, p) {
2052
+ const { id, ip, owner, name } = box;
2053
+ const keysB64 = authorizedKeysB64(await readHostPubkey(p), box.sshKeys ?? []);
2054
+ const m = p.machine(owner, name);
1886
2055
  await ensureHostNetwork(cfg);
1887
- await mkdir3(m.dir, { recursive: true, mode: 448 });
2056
+ await mkdir4(m.dir, { recursive: true, mode: 448 });
1888
2057
  let tapCreated = false;
1889
2058
  let pid;
1890
2059
  let sshPort;
1891
2060
  let drawn;
1892
2061
  try {
1893
- drawn = cloud ? await drawPorts(1, p) : void 0;
2062
+ drawn = isCloudBox(owner) ? await drawPorts(1, p) : void 0;
1894
2063
  sshPort = drawn?.ports[0];
1895
- await createOverlay(m.overlay, size.diskGib);
2064
+ await prepare(m);
1896
2065
  const tap = await createTap(id, cfg);
1897
2066
  tapCreated = true;
1898
- const spec = { id, name, owner, ip, mac, vcpu: size.vcpu, memMib: size.memMib, base: base.version, sshKeys, sshPort };
2067
+ const spec = { ...box, sshPort };
1899
2068
  pid = await launch(spec, tap, cfg, keysB64, p);
1900
2069
  await openPorts(spec, cfg);
1901
- const meta = { ...spec, pid, bootId: currentBootId(), createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: "base", autoHibernateSec: autoHibernateFor(req, cfg) };
2070
+ const meta = { ...spec, pid, bootId: currentBootId(), createdAt: (/* @__PURE__ */ new Date()).toISOString(), ...origin };
1902
2071
  await writeMachineMeta(meta, p);
1903
2072
  recordState(meta, "running", "new", p, meta.createdAt);
1904
2073
  return toMachine(meta, p);
@@ -1906,7 +2075,7 @@ async function createMachine(req, owner, p = paths()) {
1906
2075
  if (pid !== void 0) killQuietly(pid);
1907
2076
  await closePorts({ id, ip, sshPort }, cfg);
1908
2077
  if (tapCreated) await deleteTap(id, cfg).catch(() => void 0);
1909
- await rm(m.dir, { recursive: true, force: true });
2078
+ await rm2(m.dir, { recursive: true, force: true });
1910
2079
  throw err;
1911
2080
  } finally {
1912
2081
  drawn?.release();
@@ -1930,44 +2099,56 @@ async function restoreMachine(fromText, owner, req, cfg, p) {
1930
2099
  throw new KvboxError(`--size cannot be combined with --from: a restored box has the snapshot's size (${shown(from)}: ${snap.vcpu} vCPU, ${snap.memMib} MiB)`);
1931
2100
  }
1932
2101
  assertAssets(cfg);
1933
- if (!existsSync2(p.baseImage(snap.base))) {
2102
+ if (!existsSync3(p.baseImage(snap.base))) {
1934
2103
  throw new KvboxError(`snapshot ${shown(from)} stacks on base image ${snap.base}, which is gone from ${p.assets}`);
1935
2104
  }
1936
2105
  await assertRoomFor(snap.vcpu, snap.memMib, req.force ?? false, p);
1937
- const cloud = isCloudBox(owner);
1938
- const sshKeys = cloud && req.sshKeys !== void 0 && req.sshKeys.length > 0 ? req.sshKeys : void 0;
2106
+ const sshKeys = isCloudBox(owner) && req.sshKeys !== void 0 && req.sshKeys.length > 0 ? req.sshKeys : void 0;
1939
2107
  authorizedKeysB64(await readHostPubkey(p), sshKeys ?? []);
1940
2108
  const existing = await readAllMeta(p);
1941
- const name = pickName(req.name, new Set(existing.filter((m2) => m2.owner === owner).map((m2) => m2.name)));
2109
+ const name = pickName(req.name, new Set(existing.filter((m) => m.owner === owner).map((m) => m.name)));
1942
2110
  const id = newBoxId(idsInUse(existing));
1943
- const ip = nextFreeIp(cfg.subnet, [...existing.map((m2) => m2.ip), cfg.gateway]);
1944
- const mac = macFor(ip);
1945
- const m = p.machine(owner, name);
2111
+ const ip = nextFreeIp(cfg.subnet, [...existing.map((m) => m.ip), cfg.gateway]);
1946
2112
  const s = p.snapshot(from.owner, from.name);
2113
+ return restoreInto(
2114
+ { id, name, owner, ip, mac: macFor(ip), vcpu: snap.vcpu, memMib: snap.memMib, base: snap.base, sshKeys },
2115
+ async (m) => {
2116
+ await run(["cp", "--sparse=always", "--reflink=auto", s.overlay, m.overlay]);
2117
+ await link2(s.mem, m.memBase);
2118
+ return { vmstate: s.vmstate, mem: s.mem, label: shown(from) };
2119
+ },
2120
+ { source: `snapshot:${refKey(from)}`, autoHibernateSec: autoHibernateFor(req, cfg) },
2121
+ cfg,
2122
+ p
2123
+ );
2124
+ }
2125
+ async function restoreInto(box, prepare, origin, cfg, p, keysOrFail) {
2126
+ const { id, ip, owner, name } = box;
2127
+ const m = p.machine(owner, name);
1947
2128
  await ensureHostNetwork(cfg);
1948
- await mkdir3(m.dir, { recursive: true, mode: 448 });
2129
+ await mkdir4(m.dir, { recursive: true, mode: 448 });
1949
2130
  let tapCreated = false;
1950
2131
  let pid;
1951
2132
  let sshPort;
1952
2133
  let drawn;
1953
2134
  try {
1954
- drawn = cloud ? await drawPorts(1, p) : void 0;
2135
+ drawn = isCloudBox(owner) ? await drawPorts(1, p) : void 0;
1955
2136
  sshPort = drawn?.ports[0];
1956
- await run(["cp", "--sparse=always", "--reflink=auto", s.overlay, m.overlay]);
1957
- await link(s.mem, m.memBase);
2137
+ const image = await prepare(m);
1958
2138
  const tap = await createTap(id, cfg, { detached: true });
1959
2139
  tapCreated = true;
1960
- const spec = { id, name, owner, ip, mac, vcpu: snap.vcpu, memMib: snap.memMib, base: snap.base, sshKeys, sshPort };
1961
- pid = await loadSnapshot(spec, { vmstate: s.vmstate, mem: s.mem, label: shown(from) }, tap, cfg, p);
2140
+ const spec = { ...box, sshPort };
2141
+ pid = await loadSnapshot(spec, image, tap, cfg, p);
1962
2142
  await resumeVm(m.sock);
1963
2143
  await introduce(spec, m.vsock, cfg, {
1964
2144
  silent: `nothing answered on the restored box's control channel for 3 s; the restored box was discarded, try again`,
1965
2145
  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."
1966
2146
  });
1967
- const stale = await pushAuthorizedKeys({ owner, name, sshKeys }, m.vsock, p);
2147
+ const stale = await pushAuthorizedKeys(box, m.vsock, p);
2148
+ if (stale && keysOrFail) throw new KvboxError(`${keysOrFail}: ${stale}`);
1968
2149
  await attachTap(id, cfg);
1969
2150
  await openPorts(spec, cfg);
1970
- const meta = { ...spec, pid, bootId: currentBootId(), createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: `snapshot:${refKey(from)}`, memSnapshots: 1, sshKeysStale: stale ? true : void 0, autoHibernateSec: autoHibernateFor(req, cfg) };
2151
+ const meta = { ...spec, pid, bootId: currentBootId(), createdAt: (/* @__PURE__ */ new Date()).toISOString(), ...origin, memSnapshots: 1, sshKeysStale: stale ? true : void 0 };
1971
2152
  await writeMachineMeta(meta, p);
1972
2153
  recordState(meta, "running", "new", p, meta.createdAt);
1973
2154
  return toMachine(meta, p);
@@ -1975,12 +2156,71 @@ async function restoreMachine(fromText, owner, req, cfg, p) {
1975
2156
  if (pid !== void 0) killQuietly(pid);
1976
2157
  await closePorts({ id, ip, sshPort }, cfg);
1977
2158
  if (tapCreated) await deleteTap(id, cfg).catch(() => void 0);
1978
- await rm(m.dir, { recursive: true, force: true });
2159
+ await rm2(m.dir, { recursive: true, force: true });
1979
2160
  throw err;
1980
2161
  } finally {
1981
2162
  drawn?.release();
1982
2163
  }
1983
2164
  }
2165
+ var forkMachine = locked(async function forkMachine2(ref, owner, req, p = paths()) {
2166
+ const src = await readMeta(ref, p);
2167
+ const cfg = await loadConfig(p);
2168
+ const status = statusOf(src, p);
2169
+ if (status === "hibernated") throw new KvboxError(`${stateMessage(ref, status)}; a fork copies a running or a stopped box (kvbox wake ${shown(ref)} first)`);
2170
+ const warm = status !== "stopped";
2171
+ const srcDisk = p.machine(src.owner, src.name).overlay;
2172
+ let shape = { vcpu: src.vcpu, memMib: src.memMib };
2173
+ let growTo;
2174
+ if (req.size !== void 0) {
2175
+ if (warm) {
2176
+ throw new KvboxError(`--size cannot be combined with forking a running box: the copy keeps its memory, so its size (${shown(ref)}: ${src.vcpu} vCPU, ${src.memMib} MiB); kvbox vm rescale the copy afterwards`);
2177
+ }
2178
+ if (!isSizeName(req.size)) throw new KvboxError(`unknown size "${req.size}"; use one of ${Object.keys(SIZES).join(", ")}`);
2179
+ const preset = SIZES[req.size];
2180
+ const diskGib = Math.round(statSync(srcDisk).size / 1024 ** 3);
2181
+ if (preset.diskGib < diskGib) throw new KvboxError(`box ${shown(ref)} has a ${diskGib} GB disk and a disk cannot shrink; size ${req.size} has ${preset.diskGib} GB`);
2182
+ shape = { vcpu: preset.vcpu, memMib: preset.memMib };
2183
+ growTo = preset.diskGib > diskGib ? preset.diskGib : void 0;
2184
+ }
2185
+ assertAssets(cfg);
2186
+ baseFileFor(src, p);
2187
+ await assertRoomFor(shape.vcpu, shape.memMib, req.force ?? false, p);
2188
+ const sshKeys = isCloudBox(owner) && req.sshKeys !== void 0 && req.sshKeys.length > 0 ? req.sshKeys : void 0;
2189
+ authorizedKeysB64(await readHostPubkey(p), sshKeys ?? []);
2190
+ const existing = await readAllMeta(p);
2191
+ const name = pickName(req.name, new Set(existing.filter((m) => m.owner === owner).map((m) => m.name)));
2192
+ const id = newBoxId(idsInUse(existing));
2193
+ const ip = nextFreeIp(cfg.subnet, [...existing.map((m) => m.ip), cfg.gateway]);
2194
+ const box = { id, name, owner, ip, mac: macFor(ip), ...shape, base: src.base, sshKeys };
2195
+ const origin = { source: `fork:${refKey(src)}`, autoHibernateSec: (req.autoHibernateSec ?? src.autoHibernateSec) || void 0 };
2196
+ if (!warm) {
2197
+ return bootInto(
2198
+ box,
2199
+ async (m) => {
2200
+ await run(["cp", "--sparse=always", "--reflink=auto", srcDisk, m.overlay]);
2201
+ if (growTo !== void 0) await growDisk(m.overlay, growTo);
2202
+ },
2203
+ origin,
2204
+ cfg,
2205
+ p
2206
+ );
2207
+ }
2208
+ const machine = await restoreInto(
2209
+ box,
2210
+ async (m) => {
2211
+ await captureBox(src, { vmstate: m.forkVmstate, mem: m.memBase, overlay: m.overlay }, cfg, p);
2212
+ return { vmstate: m.forkVmstate, mem: m.memBase, label: `box ${shown(ref)}` };
2213
+ },
2214
+ origin,
2215
+ cfg,
2216
+ p,
2217
+ // The copy froze the source slug's CA line; left in place, that slug's members could open a box of
2218
+ // another slug. Such a fork is discarded rather than kept stale; a cold fork boots with the new line.
2219
+ owner === src.owner ? void 0 : `box ${shown(ref)} was not forked into ${owner}: the copy would still let ${src.owner} in (stop the box and fork it cold, which boots with ${owner}'s keys)`
2220
+ );
2221
+ await rm2(p.machine(owner, name).forkVmstate, { force: true });
2222
+ return machine;
2223
+ });
1984
2224
  var AGENT_REPLY_MS = 2e4;
1985
2225
  async function introduce(spec, vsock, cfg, failure2) {
1986
2226
  const id = { name: spec.name, ip: spec.ip, prefixLen: prefixLenOf(cfg.subnet), gateway: cfg.gateway, mac: spec.mac, time: Math.floor(Date.now() / 1e3) };
@@ -2047,8 +2287,8 @@ async function cleanupProcess(meta, cfg, p) {
2047
2287
  await closePorts(meta, cfg);
2048
2288
  await deleteTap(meta.id, cfg);
2049
2289
  const m = p.machine(meta.owner, meta.name);
2050
- await rm(m.sock, { force: true });
2051
- await rm(m.vsock, { force: true });
2290
+ await rm2(m.sock, { force: true });
2291
+ await rm2(m.vsock, { force: true });
2052
2292
  }
2053
2293
  var stopMachine = locked(async function stopMachine2(ref, p = paths()) {
2054
2294
  const meta = await readMeta(ref, p);
@@ -2056,8 +2296,8 @@ var stopMachine = locked(async function stopMachine2(ref, p = paths()) {
2056
2296
  const m = p.machine(ref.owner, ref.name);
2057
2297
  if (!vmAlive(meta) && !isHibernated(meta, p)) throw new KvboxError(`box ${shown(ref)} is already stopped`);
2058
2298
  await shutdown(meta, cfg, p, 1e4);
2059
- await rm(m.hibernateDir, { recursive: true, force: true });
2060
- await rm(m.memBase, { force: true });
2299
+ await rm2(m.hibernateDir, { recursive: true, force: true });
2300
+ await rm2(m.memBase, { force: true });
2061
2301
  const stopped = { ...meta, pid: null, memSnapshots: void 0, paused: void 0 };
2062
2302
  await writeMachineMeta(stopped, p);
2063
2303
  recordState(stopped, "stopped", "stop", p);
@@ -2175,15 +2415,15 @@ var hibernateMachine = locked(async function hibernateMachine2(ref, p = paths(),
2175
2415
  if (status === "hibernated") throw new KvboxError(`${stateMessage(ref, status)} already`);
2176
2416
  if (status === "stopped") throw new KvboxError(`${stateMessage(ref, status)}; only a running box can be hibernated (there is no memory to keep)`);
2177
2417
  const m = p.machine(ref.owner, ref.name);
2178
- await rm(m.hibernateDir, { recursive: true, force: true });
2179
- await mkdir3(m.hibernateDir, { recursive: true, mode: 448 });
2418
+ await rm2(m.hibernateDir, { recursive: true, force: true });
2419
+ await mkdir4(m.hibernateDir, { recursive: true, mode: 448 });
2180
2420
  const fc = new FirecrackerClient(m.sock);
2181
2421
  try {
2182
2422
  try {
2183
2423
  await fc.pause();
2184
2424
  await fc.snapshotCreate(m.hibernateVmstate, m.hibernateMem, "Full");
2185
2425
  } catch (err) {
2186
- await rm(m.hibernateDir, { recursive: true, force: true });
2426
+ await rm2(m.hibernateDir, { recursive: true, force: true });
2187
2427
  if (!meta.paused) await fc.resume().catch(() => void 0);
2188
2428
  throw err;
2189
2429
  }
@@ -2194,7 +2434,7 @@ var hibernateMachine = locked(async function hibernateMachine2(ref, p = paths(),
2194
2434
  await waitFor(() => !pidAlive(meta.pid), 5e3, 50);
2195
2435
  await cleanupProcess(meta, cfg, p);
2196
2436
  await run(["fallocate", "--dig-holes", m.hibernateMem]);
2197
- await rm(m.memBase, { force: true });
2437
+ await rm2(m.memBase, { force: true });
2198
2438
  const hibernated = { ...meta, pid: null, memSnapshots: void 0, paused: void 0 };
2199
2439
  await writeMachineMeta(hibernated, p);
2200
2440
  recordState(hibernated, "hibernated", by, p);
@@ -2207,7 +2447,7 @@ var wakeMachine = locked(async function wakeMachine2(ref, p = paths()) {
2207
2447
  if (status === "running" || status === "standby") throw new KvboxError(`${stateMessage(ref, status)}, not hibernated`);
2208
2448
  if (status === "stopped") throw new KvboxError(`${stateMessage(ref, status)}, not hibernated; kvbox start ${shown(ref)} boots it`);
2209
2449
  assertAssets(cfg);
2210
- if (!existsSync2(p.baseImage(meta.base))) {
2450
+ if (!existsSync3(p.baseImage(meta.base))) {
2211
2451
  throw new KvboxError(`box ${shown(ref)} stacks on base image ${meta.base}, which is gone from ${p.assets}`);
2212
2452
  }
2213
2453
  const m = p.machine(ref.owner, ref.name);
@@ -2232,9 +2472,9 @@ var wakeMachine = locked(async function wakeMachine2(ref, p = paths()) {
2232
2472
  await deleteTap(meta.id, cfg).catch(() => void 0);
2233
2473
  throw err;
2234
2474
  }
2235
- await rm(m.memBase, { force: true });
2236
- await link(m.hibernateMem, m.memBase);
2237
- await rm(m.hibernateDir, { recursive: true, force: true });
2475
+ await rm2(m.memBase, { force: true });
2476
+ await link2(m.hibernateMem, m.memBase);
2477
+ await rm2(m.hibernateDir, { recursive: true, force: true });
2238
2478
  const running = { ...meta, pid, bootId: currentBootId(), memSnapshots: 1, paused: void 0, sshKeysStale: stale ? true : void 0 };
2239
2479
  await writeMachineMeta(running, p);
2240
2480
  recordState(running, "running", "wake", p);
@@ -2244,7 +2484,7 @@ async function applyAtNextBoot(meta, changes, by, cfg, p, whileStopped) {
2244
2484
  const wasRunning = vmAlive(meta);
2245
2485
  if (wasRunning) {
2246
2486
  await shutdown(meta, cfg, p, 1e4);
2247
- await rm(p.machine(meta.owner, meta.name).memBase, { force: true });
2487
+ await rm2(p.machine(meta.owner, meta.name).memBase, { force: true });
2248
2488
  }
2249
2489
  const updated = { ...meta, ...changes, pid: null, memSnapshots: void 0, paused: void 0 };
2250
2490
  await writeMachineMeta(updated, p);
@@ -2366,7 +2606,7 @@ var destroyMachine = locked(async function destroyMachine2(ref, p = paths()) {
2366
2606
  const cfg = await loadConfig(p);
2367
2607
  await shutdown(meta, cfg, p, 2e3);
2368
2608
  recordState(meta, "stopped", "rm", p);
2369
- await rm(p.machine(ref.owner, ref.name).dir, { recursive: true, force: true });
2609
+ await rm2(p.machine(ref.owner, ref.name).dir, { recursive: true, force: true });
2370
2610
  });
2371
2611
  var RECOVERY_CONCURRENCY = 2;
2372
2612
  async function settleDeadBoxes(p = paths()) {
@@ -2381,7 +2621,7 @@ async function settleDeadBoxes(p = paths()) {
2381
2621
  } catch (err) {
2382
2622
  console.error(`warning: usage ledger: ${err.message}`);
2383
2623
  }
2384
- await rm(p.machine(meta.owner, meta.name).memBase, { force: true });
2624
+ await rm2(p.machine(meta.owner, meta.name).memBase, { force: true });
2385
2625
  if (meta.paused) {
2386
2626
  await writeMachineMeta({ ...meta, pid: null, bootId: void 0, memSnapshots: void 0, paused: void 0 }, p);
2387
2627
  leftStopped.push(refKey(meta));
@@ -2430,7 +2670,7 @@ async function bootRecovered(settled, p = paths()) {
2430
2670
  }
2431
2671
 
2432
2672
  // src/core/relay.ts
2433
- import { chmod as chmod3, mkdir as mkdir4, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
2673
+ import { chmod as chmod3, mkdir as mkdir5, rm as rm3, writeFile as writeFile4 } from "node:fs/promises";
2434
2674
 
2435
2675
  // ../shared/src/relay.ts
2436
2676
  import { z as z5 } from "zod";
@@ -2494,7 +2734,7 @@ async function useRelay(joinString, p = paths()) {
2494
2734
  `${cfg.controlPlane.name} reaches this host at ${cfg.relay.publicIp}, not ${join2.publicIp}; run kvbox server leave first, then join again`
2495
2735
  );
2496
2736
  }
2497
- await mkdir4(p.relay, { recursive: true, mode: 448 });
2737
+ await mkdir5(p.relay, { recursive: true, mode: 448 });
2498
2738
  await writeFile4(p.relayConf, relayConf(join2), { mode: 384 });
2499
2739
  await chmod3(p.relayConf, 384);
2500
2740
  await netSetup(cfg, ["relay", "up", p.relayConf, String(cfg.controlPlane?.port ?? DEFAULT_HTTPS_PORT)]);
@@ -2510,7 +2750,7 @@ async function dropRelay(p = paths()) {
2510
2750
  throw new KvboxError(`${cfg.controlPlane.name} reaches this host through the relay (${relay.publicIp}); run kvbox server leave first`);
2511
2751
  }
2512
2752
  await netSetup(cfg, ["relay", "down"]);
2513
- await rm2(p.relayConf, { force: true });
2753
+ await rm3(p.relayConf, { force: true });
2514
2754
  const { relay: _gone, ...rest } = cfg;
2515
2755
  await saveConfig(rest, p);
2516
2756
  return relay;
@@ -2685,7 +2925,7 @@ function versionOf(srcFileUrl, injected) {
2685
2925
  }
2686
2926
 
2687
2927
  // src/version.ts
2688
- var VERSION = versionOf(import.meta.url, true ? "0.5.18" : void 0);
2928
+ var VERSION = versionOf(import.meta.url, true ? "0.5.19" : void 0);
2689
2929
 
2690
2930
  // src/caller.ts
2691
2931
  import { createHash as createHash2, timingSafeEqual } from "node:crypto";
@@ -3053,6 +3293,12 @@ function machineRoutes() {
3053
3293
  const body = CreateMachineRequest.parse(await c.req.json().catch(() => ({})));
3054
3294
  return c.json(await createMachine(body, c.get("owner")), 201);
3055
3295
  });
3296
+ r.post("/:name/fork", async (c) => {
3297
+ const body = ForkMachineRequest.parse(await c.req.json().catch(() => ({})));
3298
+ const caller = c.get("owner");
3299
+ const into = body.owner === void 0 ? caller : ownerFor(body.owner, caller);
3300
+ return c.json(await forkMachine(parseRef(c.req.param("name"), caller), into, body), 201);
3301
+ });
3056
3302
  r.post("/:name/stop", async (c) => c.json(await stopMachine(parseRef(c.req.param("name"), c.get("owner")))));
3057
3303
  r.post("/:name/start", async (c) => c.json(await startMachine(parseRef(c.req.param("name"), c.get("owner")))));
3058
3304
  r.post("/:name/pause", async (c) => c.json(await pauseMachine(parseRef(c.req.param("name"), c.get("owner")))));
@@ -3104,131 +3350,6 @@ function machineRoutes() {
3104
3350
 
3105
3351
  // src/routes/snapshots.ts
3106
3352
  import { Hono as Hono4 } from "hono";
3107
-
3108
- // src/core/snapshots.ts
3109
- import { existsSync as existsSync3 } from "node:fs";
3110
- import { link as link2, mkdir as mkdir5, readdir as readdir2, rename as rename2, rm as rm3, stat as stat2 } from "node:fs/promises";
3111
- var NAME_RE2 = /^[a-z0-9][a-z0-9._-]{0,63}$/;
3112
- function toSnapshot(meta) {
3113
- return {
3114
- name: meta.name,
3115
- owner: meta.owner,
3116
- source: meta.source,
3117
- base: meta.base,
3118
- vcpu: meta.vcpu,
3119
- memMib: meta.memMib,
3120
- sizeBytes: meta.sizeBytes,
3121
- createdAt: meta.createdAt
3122
- };
3123
- }
3124
- async function listSnapshots(scope, p = paths()) {
3125
- const out = [];
3126
- for (const ref of await listSnapshotRefs(p)) {
3127
- if (!inScope(ref.owner, scope)) continue;
3128
- try {
3129
- out.push(toSnapshot(await readSnapshotMeta(ref, p)));
3130
- } catch (err) {
3131
- console.error(`warning: skipping snapshots/${refKey(ref)}: ${err.message}`);
3132
- }
3133
- }
3134
- return out;
3135
- }
3136
- async function usedBytes(dir) {
3137
- let total = 0;
3138
- for (const entry of await readdir2(dir)) {
3139
- total += (await stat2(`${dir}/${entry}`)).blocks * 512;
3140
- }
3141
- return total;
3142
- }
3143
- async function readBox(ref, p) {
3144
- try {
3145
- return await readMachineMeta(ref, p);
3146
- } catch (err) {
3147
- if (err.code === "ENOENT") throw new NotFoundError(`box ${shown(ref)}`);
3148
- throw err;
3149
- }
3150
- }
3151
- async function saveSnapshot(req, owner, p = paths()) {
3152
- if (!NAME_RE2.test(req.name)) {
3153
- throw new KvboxError(`invalid snapshot name "${req.name}": 1-64 chars, lowercase letters, digits, '.', '_' and '-', starting with a letter or digit`);
3154
- }
3155
- const ref = parseRef(req.box, owner);
3156
- return withBoxLock(ref, () => snapshotBox(ref, req, owner, p));
3157
- }
3158
- async function snapshotBox(ref, req, owner, p) {
3159
- const box = await readBox(ref, p);
3160
- if (!vmAlive(box)) {
3161
- if (existsSync3(p.machine(box.owner, box.name).hibernateVmstate)) {
3162
- throw new KvboxError(`box ${req.box} is hibernated; a snapshot freezes a running box (kvbox wake ${req.box} first)`);
3163
- }
3164
- throw new KvboxError(`box ${req.box} is not running; a snapshot freezes a running box (kvbox start ${req.box} first)`);
3165
- }
3166
- const s = p.snapshot(owner, req.name);
3167
- if (existsSync3(s.dir)) throw new KvboxError(`snapshot ${req.name} already exists`);
3168
- const cfg = await loadConfig(p);
3169
- const m = p.machine(box.owner, box.name);
3170
- await mkdir5(s.dir, { recursive: true, mode: 448 });
3171
- const fc = new FirecrackerClient(m.sock);
3172
- let lineageTouched = false;
3173
- try {
3174
- const since = box.memSnapshots ?? 0;
3175
- const mode = since === 0 || existsSync3(m.memBase) ? "Diff" : "Full";
3176
- let failure2;
3177
- await fc.pause();
3178
- try {
3179
- await fc.snapshotCreate(s.vmstate, s.mem, mode);
3180
- lineageTouched = true;
3181
- await run(["cp", "--sparse=always", "--reflink=auto", m.overlay, s.overlay]);
3182
- } catch (err) {
3183
- failure2 = err;
3184
- } finally {
3185
- if (!box.paused) await fc.resume();
3186
- }
3187
- if (lineageTouched) {
3188
- await writeMachineMeta({ ...box, memSnapshots: since + 1 }, p);
3189
- }
3190
- if (failure2 !== void 0) throw failure2;
3191
- if (mode === "Full") {
3192
- await run(["fallocate", "--dig-holes", s.mem]);
3193
- } else if (since > 0) {
3194
- await mergeOntoPrevious(m.memBase, s.mem, cfg.snapshotEditor ?? p.snapshotEditor);
3195
- }
3196
- await rm3(m.memBase, { force: true });
3197
- await link2(s.mem, m.memBase);
3198
- const meta = {
3199
- name: req.name,
3200
- owner,
3201
- source: refKey(box),
3202
- base: box.base,
3203
- vcpu: box.vcpu,
3204
- memMib: box.memMib,
3205
- sizeBytes: await usedBytes(s.dir),
3206
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
3207
- };
3208
- await writeSnapshotMeta(meta, p);
3209
- return toSnapshot(meta);
3210
- } catch (err) {
3211
- if (lineageTouched) await rm3(m.memBase, { force: true }).catch(() => void 0);
3212
- await rm3(s.dir, { recursive: true, force: true });
3213
- throw err;
3214
- } finally {
3215
- await fc.close();
3216
- }
3217
- }
3218
- async function mergeOntoPrevious(previous, diffFile, editor) {
3219
- if (!existsSync3(editor)) throw new KvboxError(`snapshot-editor missing at ${editor}; rerun install-server.sh`);
3220
- const merged = `${diffFile}.merged`;
3221
- await run(["cp", "--sparse=always", "--reflink=auto", previous, merged]);
3222
- await run([editor, "edit-memory", "rebase", "--memory-path", merged, "--diff-path", diffFile]);
3223
- await rename2(merged, diffFile);
3224
- }
3225
- async function deleteSnapshot(ref, p = paths()) {
3226
- const dir = p.snapshot(ref.owner, ref.name).dir;
3227
- if (!existsSync3(dir)) throw new NotFoundError(`snapshot ${shown(ref)}`);
3228
- await rm3(dir, { recursive: true, force: true });
3229
- }
3230
-
3231
- // src/routes/snapshots.ts
3232
3353
  function snapshotRoutes() {
3233
3354
  const r = new Hono4();
3234
3355
  r.get("/", async (c) => c.json(await listSnapshots(c.get("listScope"))));