@kvbox/cli 0.5.24 → 0.5.25

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/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # @kvbox/cli
2
+
3
+ The `kvbox` command: Firecracker microVMs with Docker-like ergonomics. Every box is a real VM with its own kernel, boots in seconds, and can be snapshotted warm and cloned.
4
+
5
+ ```
6
+ npm i -g @kvbox/cli
7
+ kvbox login # use kvbox.sh, or point it at your own host
8
+ kvbox new dev --size m # boot a box
9
+ kvbox connect dev # shell in over SSH
10
+ kvbox exec dev -- uname -a
11
+ kvbox snapshots new dev base # memory + disk, the box keeps running
12
+ kvbox new api --from base # a warm clone
13
+ kvbox volume create data --size 50GB --box dev # a disk that outlives the box, at /mnt/data
14
+ kvbox ls / kvbox rm dev
15
+ ```
16
+
17
+ This is a thin client, any OS. It talks to [kvbox.sh](https://kvbox.sh) or to a host of your own running [`@kvbox/server`](https://www.npmjs.com/package/@kvbox/server). Every listing command takes `--json`.
18
+
19
+ Docs: [kvbox.sh/docs](https://kvbox.sh/docs). Agent skill: [kvbox.sh/kvbox/SKILL.md](https://kvbox.sh/kvbox/SKILL.md).
package/dist/kvbox.js CHANGED
@@ -149,7 +149,7 @@ async function spin(message, work, opts = {}) {
149
149
  }
150
150
 
151
151
  // src/program.ts
152
- import { Command as Command57 } from "commander";
152
+ import { Command as Command61 } from "commander";
153
153
 
154
154
  // ../shared/src/version.ts
155
155
  import { createRequire } from "module";
@@ -164,7 +164,7 @@ function versionOf(srcFileUrl, injected) {
164
164
  }
165
165
 
166
166
  // src/version.ts
167
- var VERSION = versionOf(import.meta.url, true ? "0.5.24" : void 0);
167
+ var VERSION = versionOf(import.meta.url, true ? "0.5.25" : void 0);
168
168
 
169
169
  // src/commands/vm/index.ts
170
170
  import { Command as Command26 } from "commander";
@@ -262,6 +262,9 @@ function paths(home = kvboxHome()) {
262
262
  disk: join(dir, "disk.ext4")
263
263
  };
264
264
  },
265
+ /** Each owner's environment variables as the control plane last sent them (#88): env/<owner>.json, mode 600. */
266
+ env: join(home, "env"),
267
+ ownerEnv: (owner) => join(home, "env", `${owner}.json`),
265
268
  snapshot: (owner, name) => {
266
269
  const dir = join(home, "snapshots", owner, name);
267
270
  return {
@@ -505,6 +508,26 @@ var CreateVolumeRequest = z2.object({
505
508
  var AttachVolumeRequest = z2.object({
506
509
  box: z2.string()
507
510
  });
511
+ var EnvVarSchema = z2.object({
512
+ name: z2.string(),
513
+ value: z2.string().optional(),
514
+ secret: z2.boolean(),
515
+ updatedAt: z2.string()
516
+ });
517
+ var SetEnvRequest = z2.object({
518
+ value: z2.string(),
519
+ secret: z2.boolean().optional()
520
+ });
521
+ var EnvChangeSchema = z2.object({
522
+ owner: z2.string(),
523
+ name: z2.string(),
524
+ boxes: z2.number().int()
525
+ });
526
+ var HostEnvRequest = z2.object({
527
+ version: z2.number().int().min(0),
528
+ vars: z2.array(z2.object({ name: z2.string(), value: z2.string() }))
529
+ });
530
+ var HostEnvVersionsSchema = z2.array(z2.object({ owner: z2.string(), version: z2.number().int() }));
508
531
  var CreateMachineRequest = z2.object({
509
532
  name: z2.string().optional(),
510
533
  /** Ignored over the local socket (the caller is the host owner); the control plane sets it over HTTPS. */
@@ -1137,6 +1160,16 @@ var ServerClient = class {
1137
1160
  deleteSnapshot(name) {
1138
1161
  return this.call("DELETE", `/snapshots/${encodeURIComponent(name)}`, z3.undefined());
1139
1162
  }
1163
+ // ---- environment variables (#88), a control plane only: the caller's slug, or `--org`'s ----
1164
+ listEnv() {
1165
+ return this.call("GET", "/env", z3.array(EnvVarSchema));
1166
+ }
1167
+ setEnv(name, req) {
1168
+ return this.call("PUT", `/env/${encodeURIComponent(name)}`, EnvChangeSchema, req);
1169
+ }
1170
+ removeEnv(name) {
1171
+ return this.call("DELETE", `/env/${encodeURIComponent(name)}`, EnvChangeSchema);
1172
+ }
1140
1173
  // ---- volumes (#52) ----
1141
1174
  listVolumes(opts = {}) {
1142
1175
  return this.call("GET", `/volumes${allQuery(opts)}`, z3.array(VolumeSchema), void 0, opts.org);
@@ -2419,7 +2452,13 @@ var cancelled = () => new KvboxError("cancelled", 130);
2419
2452
  function keysIn(chunk) {
2420
2453
  const keys = [];
2421
2454
  for (let i = 0; i < chunk.length; ) {
2422
- if (chunk[i] === ESC && (chunk[i + 1] === "[" || chunk[i + 1] === "O") && i + 2 < chunk.length) {
2455
+ if (chunk[i] === ESC && chunk[i + 1] === "[" && i + 2 < chunk.length) {
2456
+ let j = i + 2;
2457
+ while (j < chunk.length && /[0-9;?]/.test(chunk[j])) j += 1;
2458
+ const end = j < chunk.length ? j + 1 : j;
2459
+ keys.push(chunk.slice(i, end));
2460
+ i = end;
2461
+ } else if (chunk[i] === ESC && chunk[i + 1] === "O" && i + 2 < chunk.length) {
2423
2462
  keys.push(chunk.slice(i, i + 3));
2424
2463
  i += 3;
2425
2464
  } else {
@@ -2496,7 +2535,8 @@ async function text(message, opts = {}) {
2496
2535
  const hint = opts.default !== void 0 ? ` ${color.dim(`(${opts.default})`)}` : "";
2497
2536
  const question = `${color.green("?")} ${color.bold(message)}${hint} ${color.dim("\u203A")} `;
2498
2537
  const rl = createInterface({ input: process.stdin, output: out });
2499
- const raw = (await askLine(rl, question)).trim();
2538
+ const line = await askLine(rl, question);
2539
+ const raw = opts.trim === false ? line : line.trim();
2500
2540
  const answer = raw === "" && opts.default !== void 0 ? opts.default : raw;
2501
2541
  const problem = opts.validate?.(answer);
2502
2542
  out.write(`${ESC}[${rows(question + raw)}A\r${ESC}[J`);
@@ -2509,6 +2549,44 @@ async function text(message, opts = {}) {
2509
2549
  `);
2510
2550
  }
2511
2551
  }
2552
+ async function hiddenText(message) {
2553
+ const out = process.stderr;
2554
+ const inp = process.stdin;
2555
+ const question = `${color.green("?")} ${color.bold(message)} ${color.dim("(hidden) \u203A")} `;
2556
+ out.write(question);
2557
+ inp.setRawMode(true);
2558
+ inp.resume();
2559
+ inp.setEncoding("utf8");
2560
+ let chars = [];
2561
+ let done = false;
2562
+ try {
2563
+ return await new Promise((resolve3, reject) => {
2564
+ const onData = (chunk) => {
2565
+ for (const key of keysIn(chunk)) {
2566
+ if (key === ENTER || key === "\n") {
2567
+ inp.off("data", onData);
2568
+ done = true;
2569
+ resolve3(chars.join(""));
2570
+ return;
2571
+ }
2572
+ if (key === CTRL_C || key === CTRL_D) {
2573
+ inp.off("data", onData);
2574
+ reject(cancelled());
2575
+ return;
2576
+ }
2577
+ if (key === "\x7F" || key === "\b") chars = chars.slice(0, -1);
2578
+ else if (key.length === 1 && (key >= " " || key === " ")) chars.push(key);
2579
+ }
2580
+ };
2581
+ inp.on("data", onData);
2582
+ });
2583
+ } finally {
2584
+ inp.setRawMode(false);
2585
+ inp.pause();
2586
+ out.write(`\r${ESC}[J${done ? `${settled(message, "\u2022\u2022\u2022\u2022\u2022")}
2587
+ ` : "\n"}`);
2588
+ }
2589
+ }
2512
2590
  async function askLine(rl, question) {
2513
2591
  let onClose = () => {
2514
2592
  };
@@ -3145,7 +3223,8 @@ var nextjsTemplate = {
3145
3223
  * The source lands in a staging directory and is installed and built there while the old app
3146
3224
  * keeps serving; only then is it swapped into place and the service restarted, so a failed
3147
3225
  * install or build leaves the running app alone. The unit is rewritten every time, so a changed
3148
- * package manager or start script takes effect. /srv/app is the kvbox user's (image v0.8.0+).
3226
+ * package manager or start script takes effect. /srv/app is the kvbox user's (image v0.8.0+). The
3227
+ * build runs in the deploy's SSH session, so it sees the owner's variables (`kvbox env`) too.
3149
3228
  */
3150
3229
  remote(built) {
3151
3230
  const c = commands(built.pm);
@@ -3157,8 +3236,12 @@ var nextjsTemplate = {
3157
3236
  "[Service]",
3158
3237
  "User=kvbox",
3159
3238
  `WorkingDirectory=${APP_DIR}`,
3160
- `ExecStart=/usr/bin/env ${c.start}`,
3161
- `Environment=NODE_ENV=production PORT=${PORT}`,
3239
+ // PORT on the command line: the proxied address points at it, and nothing in the environment
3240
+ // file below may move the app off it (env's PORT, say, copied from a .env).
3241
+ `ExecStart=/usr/bin/env PORT=${PORT} ${c.start}`,
3242
+ "Environment=NODE_ENV=production",
3243
+ // The box owner's variables (#88, base v0.10.0+; the `-` makes an older box start without them).
3244
+ "EnvironmentFile=-/etc/kvbox/env",
3162
3245
  "Restart=always",
3163
3246
  "RestartSec=2",
3164
3247
  "",
@@ -3677,12 +3760,128 @@ var rmCommand6 = new Command55("rm").alias("remove").description("delete a volum
3677
3760
  var volumeCommand = new Command56("volume").aliases(["volumes", "vol"]).description("disks of their own: create one, attach it to a box at /mnt/<name>, move it to another box later").addCommand(createCommand).addCommand(lsCommand6).addCommand(attachCommand).addCommand(detachCommand).addCommand(rmCommand6);
3678
3761
  for (const cmd of volumeCommand.commands) cmd.option("--org <slug>", "act in this org (- for your personal volumes)");
3679
3762
 
3763
+ // src/commands/env/index.ts
3764
+ import { Command as Command60 } from "commander";
3765
+
3766
+ // src/commands/env/ls.ts
3767
+ import { Command as Command57 } from "commander";
3768
+
3769
+ // src/commands/env/shared.ts
3770
+ function requireCloud2(client) {
3771
+ if (client.target.kind === "local") throw new KvboxError("kvbox env needs kvbox.sh: kvbox login first");
3772
+ return client.target.org ?? client.target.user ?? "you";
3773
+ }
3774
+ function reach(boxes, verb) {
3775
+ if (boxes === 0) return verb === "get" ? "no boxes yet, every new one gets it" : "no boxes yet";
3776
+ return boxes === 1 ? `1 box ${verb}s it when it next starts` : `${boxes} boxes ${verb} it when they next start`;
3777
+ }
3778
+
3779
+ // src/commands/env/ls.ts
3780
+ var lsCommand7 = new Command57("ls").alias("list").description("list the variables (a secret shows as (sealed))").option("--json", "print as JSON").action(async (opts, cmd) => {
3781
+ const client = await clientFor(cmd.optsWithGlobals(), { autoStart: false });
3782
+ try {
3783
+ const owner = requireCloud2(client);
3784
+ const vars = await client.listEnv();
3785
+ if (opts.json) {
3786
+ json(vars);
3787
+ return;
3788
+ }
3789
+ if (vars.length === 0) {
3790
+ info(`no variables for ${owner} yet; set one with: kvbox env set NAME value`);
3791
+ return;
3792
+ }
3793
+ table(vars.map((v) => ({ name: v.name, value: v.secret ? color.dim("(sealed)") : v.value, updated: age(v.updatedAt) })));
3794
+ } finally {
3795
+ await client.close();
3796
+ }
3797
+ });
3798
+
3799
+ // src/commands/env/rm.ts
3800
+ import { Command as Command58 } from "commander";
3801
+ var rmCommand7 = new Command58("rm").alias("remove").description("remove variables; boxes lose them when they next start").argument("<names...>", "variable names").option("-y, --yes", "do not ask for confirmation").option("--json", "print as JSON").action(async (names, opts, cmd) => {
3802
+ const client = await clientFor(cmd.optsWithGlobals(), { autoStart: false });
3803
+ try {
3804
+ const owner = requireCloud2(client);
3805
+ if (!await confirm(`Remove ${names.join(", ")} from ${owner}'s variables?`, opts.yes ?? false)) {
3806
+ info("cancelled");
3807
+ process.exitCode = 1;
3808
+ return;
3809
+ }
3810
+ const changes = [];
3811
+ const failed = [];
3812
+ for (const name of names) {
3813
+ try {
3814
+ const c = await spin(`removing ${name}\u2026`, client.removeEnv(name));
3815
+ changes.push(c);
3816
+ if (!opts.json) info(`${color.green("\u2713")} removed ${c.name} from ${owner}: ${reach(c.boxes, "lose")}`);
3817
+ } catch (err) {
3818
+ failed.push({ name, err });
3819
+ }
3820
+ }
3821
+ if (changes.length === 0 && failed.length > 0) throw failed[0].err;
3822
+ if (opts.json) json(changes);
3823
+ for (const f of failed) warn(`${color.red("\u2717")} ${f.name}: ${f.err.message}`);
3824
+ if (failed.length > 0) process.exitCode = 1;
3825
+ } finally {
3826
+ await client.close();
3827
+ }
3828
+ });
3829
+
3830
+ // src/commands/env/set.ts
3831
+ import { Command as Command59 } from "commander";
3832
+
3833
+ // ../shared/src/env.ts
3834
+ var ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
3835
+ var ENV_RESERVED = ["PATH", "HOME", "USER", "SHELL"];
3836
+ var ENV_MAX_VALUE_BYTES = 4096;
3837
+ function envNameProblem(name) {
3838
+ if (!ENV_NAME_RE.test(name)) return `${JSON.stringify(name)} is not a variable name: letters, digits and _, not starting with a digit`;
3839
+ if (ENV_RESERVED.includes(name)) return `${name} is the box's own and cannot be set`;
3840
+ if (name.startsWith("KVBOX_")) return `${name}: names starting with KVBOX_ are kept for kvbox`;
3841
+ return null;
3842
+ }
3843
+ function envValueProblem(value) {
3844
+ if (/[\n\r\0]/.test(value)) return "a value is one line: no line breaks (base64 a multi-line value such as a key file)";
3845
+ if (Buffer.byteLength(value, "utf8") > ENV_MAX_VALUE_BYTES) return `a value is at most ${ENV_MAX_VALUE_BYTES / 1024} KB`;
3846
+ return null;
3847
+ }
3848
+
3849
+ // src/commands/env/set.ts
3850
+ var setCommand2 = new Command59("set").description("add or change a variable; boxes get it when they next start").argument("[name]", "variable name, e.g. DATABASE_URL (asked for when left out)").argument("[value]", "its value (asked for when left out; hidden with --secret)").option("--secret", "write-only: the value is never shown again, by any command").option("--json", "print as JSON").action(async (nameArg, valueArg, opts, cmd) => {
3851
+ const client = await clientFor(cmd.optsWithGlobals(), { autoStart: false });
3852
+ try {
3853
+ const owner = requireCloud2(client);
3854
+ const ask = interactive();
3855
+ if ((nameArg === void 0 || valueArg === void 0) && !ask) throw new KvboxError("give the name and the value: kvbox env set NAME VALUE (or run it in a terminal to be asked)");
3856
+ const name = nameArg ?? await text("Variable name", { validate: (a) => envNameProblem(a) ?? void 0 });
3857
+ const nameProblem = envNameProblem(name);
3858
+ if (nameProblem !== null) throw new KvboxError(nameProblem);
3859
+ const value = valueArg ?? (opts.secret ? await hiddenText(`Value for ${name}`) : await text(`Value for ${name}`, { trim: false }));
3860
+ const valueProblem = envValueProblem(value);
3861
+ if (valueProblem !== null) throw new KvboxError(valueProblem);
3862
+ const change = await spin(`setting ${name}\u2026`, client.setEnv(name, { value, secret: opts.secret ?? false }));
3863
+ if (opts.json) {
3864
+ json(change);
3865
+ return;
3866
+ }
3867
+ const what = opts.secret ? `${name} (secret)` : name;
3868
+ info(`${color.green("\u2713")} set ${what} for ${owner}: ${reach(change.boxes, "get")}`);
3869
+ } finally {
3870
+ await client.close();
3871
+ }
3872
+ });
3873
+
3874
+ // src/commands/env/index.ts
3875
+ var envCommand = new Command60("env").description("environment variables every box of yours gets when it starts (secrets too)").addCommand(lsCommand7, { isDefault: true }).addCommand(setCommand2).addCommand(rmCommand7);
3876
+ for (const cmd of envCommand.commands) cmd.option("--org <slug>", "the org's variables instead of yours");
3877
+
3680
3878
  // src/program.ts
3681
3879
  function buildProgram() {
3682
- const program = new Command57("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();
3880
+ const program = new Command61("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();
3683
3881
  program.addCommand(vmCommand);
3684
3882
  program.addCommand(snapshotsCommand);
3685
3883
  program.addCommand(volumeCommand);
3884
+ program.addCommand(envCommand);
3686
3885
  program.addCommand(serverCommand);
3687
3886
  program.addCommand(loginCommand);
3688
3887
  program.addCommand(logoutCommand);