@kvbox/cli 0.5.8 → 0.5.9

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 CHANGED
@@ -162,13 +162,13 @@ function versionOf(srcFileUrl, injected) {
162
162
  }
163
163
 
164
164
  // src/version.ts
165
- var VERSION = versionOf(import.meta.url, true ? "0.5.8" : void 0);
165
+ var VERSION = versionOf(import.meta.url, true ? "0.5.9" : void 0);
166
166
 
167
167
  // src/commands/vm/index.ts
168
168
  import { Command as Command15 } from "commander";
169
169
 
170
170
  // src/commands/vm/connect.ts
171
- import { spawnSync } from "child_process";
171
+ import { spawnSync as spawnSync2 } from "child_process";
172
172
  import { Command as Command2 } from "commander";
173
173
 
174
174
  // src/cert.ts
@@ -466,6 +466,12 @@ var CapacitySchema = z2.object({
466
466
  reservedVcpu: z2.number().int(),
467
467
  running: z2.number().int()
468
468
  });
469
+ var RecoveryReportSchema = z2.object({
470
+ at: z2.string(),
471
+ booted: z2.array(z2.string()),
472
+ leftStopped: z2.array(z2.string()),
473
+ failed: z2.array(z2.object({ box: z2.string(), error: z2.string() }))
474
+ });
469
475
  var HealthSchema = z2.object({
470
476
  ok: z2.literal(true),
471
477
  version: z2.string(),
@@ -489,7 +495,9 @@ var HealthSchema = z2.object({
489
495
  publicIp: z2.string(),
490
496
  /** The relay's WireGuard endpoint the tunnel dials. */
491
497
  endpoint: z2.string()
492
- }).nullable().optional()
498
+ }).nullable().optional(),
499
+ /** Boxes brought back at the server's last start; null when there were none or it is still at it. Older servers omit it. */
500
+ recovery: RecoveryReportSchema.nullable().optional()
493
501
  });
494
502
  var MachineUsageSchema = z2.object({
495
503
  name: z2.string(),
@@ -739,6 +747,34 @@ function groupWithMember(gid, user, groupFile) {
739
747
  return void 0;
740
748
  }
741
749
 
750
+ // src/systemd.ts
751
+ import { spawnSync } from "child_process";
752
+ import { readFileSync as readFileSync2 } from "fs";
753
+ import { resolve } from "path";
754
+ var UNIT = "kvbox-server";
755
+ var UNIT_FILE = `/etc/systemd/system/${UNIT}.service`;
756
+ function hasUnitFor(p, file = UNIT_FILE) {
757
+ let text;
758
+ try {
759
+ text = readFileSync2(file, "utf8");
760
+ } catch {
761
+ return false;
762
+ }
763
+ const home = /^Environment="?KVBOX_HOME=([^"\n]+)"?$/m.exec(text)?.[1];
764
+ return home !== void 0 && resolve(home) === resolve(p.home);
765
+ }
766
+ function startUnitProcess() {
767
+ const res = spawnSync("sudo", ["-n", "systemctl", "start", UNIT], { encoding: "utf8" });
768
+ return { ok: res.status === 0, out: `${res.stdout ?? ""}${res.stderr ?? ""}`.trim() };
769
+ }
770
+ function unitActive() {
771
+ const res = spawnSync("systemctl", ["is-active", UNIT], { encoding: "utf8" });
772
+ return (res.stdout ?? "").trim() === "active";
773
+ }
774
+ function startsAtBoot(p) {
775
+ return hasUnitFor(p) && spawnSync("systemctl", ["is-enabled", "--quiet", UNIT]).status === 0;
776
+ }
777
+
742
778
  // src/client.ts
743
779
  var ORG_SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,31}$/;
744
780
  function orgOption(opts) {
@@ -987,17 +1023,17 @@ function sshArgs(ep, p = paths()) {
987
1023
  async function waitForSsh(ep, timeoutMs = 3e4) {
988
1024
  const { connect } = await import("net");
989
1025
  const deadline = Date.now() + timeoutMs;
990
- const attempt = () => new Promise((resolve) => {
1026
+ const attempt = () => new Promise((resolve2) => {
991
1027
  const sock = connect({ host: ep.host, port: ep.port, timeout: 1e3 });
992
1028
  sock.once("connect", () => {
993
1029
  sock.destroy();
994
- resolve(true);
1030
+ resolve2(true);
995
1031
  });
996
1032
  sock.once("timeout", () => {
997
1033
  sock.destroy();
998
- resolve(false);
1034
+ resolve2(false);
999
1035
  });
1000
- sock.once("error", () => resolve(false));
1036
+ sock.once("error", () => resolve2(false));
1001
1037
  });
1002
1038
  while (Date.now() < deadline) {
1003
1039
  if (await attempt()) return;
@@ -1006,14 +1042,15 @@ async function waitForSsh(ep, timeoutMs = 3e4) {
1006
1042
  throw new KvboxError(`box at ${ep.host}:${ep.port} did not answer within ${Math.round(timeoutMs / 1e3)}s`);
1007
1043
  }
1008
1044
  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";
1009
- async function lastErrorLine(logPath) {
1045
+ async function lastErrorLine(logPath, from = 0) {
1010
1046
  const { readFile: readFile6 } = await import("fs/promises");
1011
- const text = await readFile6(logPath, "utf8").catch(() => "");
1047
+ const text = (await readFile6(logPath).catch(() => Buffer.alloc(0))).subarray(from).toString("utf8");
1012
1048
  const lines = text.trimEnd().split("\n").slice(-20).reverse();
1013
1049
  return lines.find((l) => l.startsWith("error: "))?.slice("error: ".length);
1014
1050
  }
1015
1051
  async function startLocalServer(p = paths(), timeoutMs = 5e3) {
1016
1052
  await mkdir2(p.home, { recursive: true });
1053
+ if (hasUnitFor(p)) return startUnit(p, timeoutMs);
1017
1054
  const log = openSync(p.serverLog, "a");
1018
1055
  const launcher = serverLauncher();
1019
1056
  const child = spawn(launcher.cmd, launcher.args, { detached: true, stdio: ["ignore", log, log] });
@@ -1027,29 +1064,45 @@ async function startLocalServer(p = paths(), timeoutMs = 5e3) {
1027
1064
  });
1028
1065
  });
1029
1066
  });
1030
- const ready = (async () => {
1031
- const deadline = Date.now() + timeoutMs;
1032
- while (Date.now() < deadline) {
1033
- if (await stat(p.serverSock).then(() => true, () => false)) {
1034
- const probe = new ServerClient({ kind: "local", socketPath: p.serverSock }, { autoStart: false });
1035
- try {
1036
- await probe.health();
1037
- return;
1038
- } catch {
1039
- } finally {
1040
- await probe.close();
1041
- }
1042
- }
1043
- await sleep(100);
1044
- }
1045
- throw new KvboxError(`kvbox-server did not start within ${timeoutMs / 1e3}s; see ${p.serverLog}`);
1046
- })();
1047
- await Promise.race([ready, failed]);
1067
+ await Promise.race([serving(p, timeoutMs), failed]);
1048
1068
  child.removeAllListeners("exit");
1049
1069
  child.unref();
1050
1070
  const pid = Number.parseInt(await readFile3(p.serverPid, "utf8").catch(() => ""), 10);
1051
1071
  return { pid: Number.isNaN(pid) ? child.pid ?? -1 : pid, socketPath: p.serverSock, viaGroup: launcher.viaGroup };
1052
1072
  }
1073
+ async function startUnit(p, timeoutMs) {
1074
+ const logFrom = await stat(p.serverLog).then((s) => s.size, () => 0);
1075
+ const res = startUnitProcess();
1076
+ if (!res.ok) {
1077
+ const why = res.out || await lastErrorLine(p.serverLog, logFrom) || "systemctl start failed";
1078
+ throw new KvboxError(`kvbox-server could not start: ${why} (journalctl -u ${UNIT}; rerun install-server.sh if sudo asked for a password)`);
1079
+ }
1080
+ await serving(p, timeoutMs, async () => {
1081
+ if (unitActive()) return;
1082
+ const why = await lastErrorLine(p.serverLog, logFrom) ?? "it exited right after starting";
1083
+ throw new KvboxError(`kvbox-server could not start: ${why} (journalctl -u ${UNIT})`);
1084
+ });
1085
+ const pid = Number.parseInt(await readFile3(p.serverPid, "utf8").catch(() => ""), 10);
1086
+ return { pid: Number.isNaN(pid) ? -1 : pid, socketPath: p.serverSock, viaGroup: void 0 };
1087
+ }
1088
+ async function serving(p, timeoutMs, check) {
1089
+ const deadline = Date.now() + timeoutMs;
1090
+ while (Date.now() < deadline) {
1091
+ await check?.();
1092
+ if (await stat(p.serverSock).then(() => true, () => false)) {
1093
+ const probe = new ServerClient({ kind: "local", socketPath: p.serverSock }, { autoStart: false });
1094
+ try {
1095
+ await probe.health();
1096
+ return;
1097
+ } catch {
1098
+ } finally {
1099
+ await probe.close();
1100
+ }
1101
+ }
1102
+ await sleep(100);
1103
+ }
1104
+ throw new KvboxError(`kvbox-server did not start within ${timeoutMs / 1e3}s; see ${p.serverLog}`);
1105
+ }
1053
1106
 
1054
1107
  // src/commands/vm/new.ts
1055
1108
  import { Command, Option } from "commander";
@@ -1104,14 +1157,14 @@ var connectCommand = new Command2("connect").alias("cn").description("open a she
1104
1157
  } finally {
1105
1158
  await client.close();
1106
1159
  }
1107
- let res = spawnSync("ssh", [...sshArgs(ep), ...cmdArgs], { stdio: "inherit" });
1160
+ let res = spawnSync2("ssh", [...sshArgs(ep), ...cmdArgs], { stdio: "inherit" });
1108
1161
  if (res.error) throw new KvboxError(`could not run ssh: ${res.error.message}`);
1109
1162
  if (res.status === SSH_FAILED) {
1110
1163
  const again = await clientFor(cmd.optsWithGlobals());
1111
1164
  const retry = await renewAfterRefusal(again, cert, owner).finally(() => again.close());
1112
1165
  if (retry) {
1113
1166
  info("renewed your SSH certificate, trying again\u2026");
1114
- res = spawnSync("ssh", [...sshArgs(ep), ...cmdArgs], { stdio: "inherit" });
1167
+ res = spawnSync2("ssh", [...sshArgs(ep), ...cmdArgs], { stdio: "inherit" });
1115
1168
  if (res.error) throw new KvboxError(`could not run ssh: ${res.error.message}`);
1116
1169
  }
1117
1170
  }
@@ -1155,12 +1208,12 @@ async function runSsh(ssh, opts) {
1155
1208
  timedOut = true;
1156
1209
  child.kill("SIGTERM");
1157
1210
  }, opts.timeout * 1e3);
1158
- const status2 = await new Promise((resolve, reject) => {
1211
+ const status2 = await new Promise((resolve2, reject) => {
1159
1212
  child.once("error", (err) => reject(new KvboxError(`could not run ssh: ${err.message}`)));
1160
1213
  child.once("close", (code, signal) => {
1161
- if (timedOut) resolve(TIMEOUT_EXIT);
1162
- else if (code !== null) resolve(code);
1163
- else resolve(signal === "SIGINT" ? 130 : 1);
1214
+ if (timedOut) resolve2(TIMEOUT_EXIT);
1215
+ else if (code !== null) resolve2(code);
1216
+ else resolve2(signal === "SIGINT" ? 130 : 1);
1164
1217
  });
1165
1218
  }).finally(() => clearTimeout(timer));
1166
1219
  return { status: status2, chunks, timedOut };
@@ -1515,7 +1568,7 @@ async function restartLocalServer(p = paths()) {
1515
1568
  return (await startLocalServer(p)).pid;
1516
1569
  }
1517
1570
  function runKvboxServer(args) {
1518
- return new Promise((resolve, reject) => {
1571
+ return new Promise((resolve2, reject) => {
1519
1572
  const child = spawn3("kvbox-server", args, { stdio: ["ignore", "pipe", "pipe"] });
1520
1573
  let out = "";
1521
1574
  let err = "";
@@ -1525,7 +1578,7 @@ function runKvboxServer(args) {
1525
1578
  reject(new KvboxError(e.code === "ENOENT" ? NOT_A_HOST_HINT : `could not run kvbox-server: ${e.message}`));
1526
1579
  });
1527
1580
  child.once("close", (code) => {
1528
- if (code === 0) resolve(out.trimEnd());
1581
+ if (code === 0) resolve2(out.trimEnd());
1529
1582
  else reject(new KvboxError(err.trim().replace(/^error: /, "") || `kvbox-server ${args[0] ?? ""} exited with code ${String(code)}`, code === 2 ? 2 : 1));
1530
1583
  });
1531
1584
  });
@@ -1580,9 +1633,17 @@ var statusCommand = new Command20("status").description("show whether this machi
1580
1633
  const client = await clientFor({ ...cmd.optsWithGlobals(), local: true });
1581
1634
  try {
1582
1635
  const health = await client.health();
1583
- if (opts.json) json(health);
1636
+ const atBoot = startsAtBoot(paths());
1637
+ if (opts.json) json({ ...health, startsAtBoot: atBoot });
1584
1638
  else {
1585
1639
  info(`kvbox-server ${health.version} running, pid ${health.pid}`);
1640
+ info(atBoot ? "starts at boot: yes, and brings back the boxes that were running" : "starts at boot: no (install-server.sh sets that up)");
1641
+ const r = health.recovery;
1642
+ if (r) {
1643
+ if (r.booted.length > 0) info(`after the last restart it booted again: ${r.booted.join(", ")}`);
1644
+ if (r.leftStopped.length > 0) info(`left stopped (were in standby, their memory was lost): ${r.leftStopped.join(", ")}`);
1645
+ for (const f of r.failed) info(`did not come back: ${f.box} (${f.error})`);
1646
+ }
1586
1647
  if (health.controlPlane) info(`control plane: ${health.controlPlane.name} at ${health.controlPlane.url}, this host answers it on ${health.controlPlane.hostUrl}`);
1587
1648
  else info("control plane: none (kvbox server join <url> --token <token> puts this machine behind one)");
1588
1649
  if (health.relay) info(`relay: ${health.relay.name} lends this machine ${health.relay.publicIp} (tunnel to ${health.relay.endpoint})`);
@@ -1646,7 +1707,7 @@ function describeReport(report) {
1646
1707
  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);
1647
1708
 
1648
1709
  // src/commands/login.ts
1649
- import { spawnSync as spawnSync2 } from "child_process";
1710
+ import { spawnSync as spawnSync3 } from "child_process";
1650
1711
  import { mkdir as mkdir3, readFile as readFile5, writeFile as writeFile3 } from "fs/promises";
1651
1712
  import { hostname as osHostname } from "os";
1652
1713
  import { createInterface } from "readline/promises";
@@ -1698,7 +1759,7 @@ async function ensureSshKey(p = paths(), hostname = osHostname()) {
1698
1759
  await mkdir3(p.ssh, { recursive: true, mode: 448 });
1699
1760
  const havePrivate = await readFile5(p.sshKey).then(() => true, () => false);
1700
1761
  const args = havePrivate ? ["-y", "-f", p.sshKey] : ["-q", "-t", "ed25519", "-N", "", "-C", `kvbox@${hostname}`, "-f", p.sshKey];
1701
- const res = spawnSync2("ssh-keygen", args, { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" });
1762
+ const res = spawnSync3("ssh-keygen", args, { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" });
1702
1763
  if (res.error) {
1703
1764
  if (res.error.code === "ENOENT") throw new KvboxError("ssh-keygen not found; install OpenSSH");
1704
1765
  throw new KvboxError(`could not run ssh-keygen: ${res.error.message}`);