@kvbox/cli 0.5.7 → 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
@@ -147,7 +147,7 @@ async function spin(message, work, opts = {}) {
147
147
  }
148
148
 
149
149
  // src/program.ts
150
- import { Command as Command38 } from "commander";
150
+ import { Command as Command39 } from "commander";
151
151
 
152
152
  // ../shared/src/version.ts
153
153
  import { createRequire } from "module";
@@ -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.7" : 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) {
@@ -773,11 +809,11 @@ var ServerClient = class {
773
809
  base;
774
810
  autoStart;
775
811
  startedOnce = false;
776
- async call(method, path, schema, body) {
812
+ async call(method, path, schema, body, org = this.target.kind === "cloud" ? this.target.org : void 0) {
777
813
  const headers = {};
778
814
  if (body !== void 0) headers["content-type"] = "application/json";
779
815
  if (this.target.kind === "cloud" && this.target.token !== "") headers["authorization"] = `Bearer ${this.target.token}`;
780
- if (this.target.kind === "cloud" && this.target.org !== void 0) headers["x-kvbox-org"] = this.target.org;
816
+ if (this.target.kind === "cloud" && org !== void 0) headers["x-kvbox-org"] = org;
781
817
  let res;
782
818
  try {
783
819
  res = await request(this.base + path, {
@@ -866,9 +902,9 @@ var ServerClient = class {
866
902
  const query = q.size === 0 ? "" : `?${q.toString()}`;
867
903
  return this.call("GET", `/usage${query}`, UsageReportSchema);
868
904
  }
869
- /** The prepaid balance of the caller's slug, or of `--org`'s (#76); a control plane only. */
870
- balance() {
871
- return this.call("GET", "/billing/balance", BalanceSchema);
905
+ /** The prepaid balance of the caller's slug, or of `org` (default: `--org`'s) (#76); a control plane only. */
906
+ balance(org) {
907
+ return this.call("GET", "/billing/balance", BalanceSchema, void 0, org);
872
908
  }
873
909
  deleteMachine(name) {
874
910
  return this.call("DELETE", `/machines/${encodeURIComponent(name)}`, z3.undefined());
@@ -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}`);
@@ -2086,9 +2147,38 @@ function usageRows(report, cloud) {
2086
2147
  return rows;
2087
2148
  }
2088
2149
 
2150
+ // src/commands/credit.ts
2151
+ import { Command as Command38 } from "commander";
2152
+ var creditCommand = new Command38("credit").description("the prepaid credit left on your account and on each of your orgs").option("--org <slug>", "one org only").option("--json", "print as JSON").action(async (opts, cmd) => {
2153
+ const client = await clientFor(cmd.optsWithGlobals(), { autoStart: false });
2154
+ try {
2155
+ if (client.target.kind === "local") throw new KvboxError("credit lives on the control plane: kvbox login first");
2156
+ const me2 = client.target.user;
2157
+ const balances = client.target.org !== void 0 ? [await client.balance()] : await Promise.all([void 0, ...(await client.listOrgs()).map((o) => o.slug)].map((org) => client.balance(org)));
2158
+ if (opts.json) {
2159
+ json(balances);
2160
+ return;
2161
+ }
2162
+ table(balances.map((b) => creditRow(b, me2)));
2163
+ } finally {
2164
+ await client.close();
2165
+ }
2166
+ });
2167
+ function creditRow(b, me2) {
2168
+ const left = money(b.balance, b.currency);
2169
+ return {
2170
+ owner: b.slug === me2 ? "private" : boxName(b.slug),
2171
+ credit: b.balance > 0 ? left : color.red(left),
2172
+ "topped up": money(b.topUps, b.currency),
2173
+ spent: money(b.spent, b.currency),
2174
+ // Suspended (#76): the sweep hibernated the boxes at zero; a top-up brings them back.
2175
+ state: b.suspendedAt ? color.red("suspended") : b.since === null ? color.dim("never topped up") : "ok"
2176
+ };
2177
+ }
2178
+
2089
2179
  // src/program.ts
2090
2180
  function buildProgram() {
2091
- 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();
2181
+ const program = new Command39("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();
2092
2182
  program.addCommand(vmCommand);
2093
2183
  program.addCommand(snapshotsCommand);
2094
2184
  program.addCommand(serverCommand);
@@ -2097,6 +2187,7 @@ function buildProgram() {
2097
2187
  program.addCommand(whoamiCommand);
2098
2188
  program.addCommand(orgCommand);
2099
2189
  program.addCommand(usageCommand2);
2190
+ program.addCommand(creditCommand);
2100
2191
  for (const cmd of vmAliases) program.addCommand(cmd);
2101
2192
  return program;
2102
2193
  }