@easybits.cloud/cli 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +33 -10
  2. package/dist/index.js +262 -57
  3. package/package.json +4 -3
package/README.md CHANGED
@@ -17,7 +17,7 @@ Requires Node 22+.
17
17
 
18
18
  ```bash
19
19
  easybits login # opens your browser (OAuth2 + PKCE); session renews itself
20
- easybits login eb_sk_live_YOUR_KEY # or save an API key instead
20
+ easybits login - < key.txt # or save an API key, read from stdin (never in argv)
21
21
  easybits usage
22
22
  easybits logout
23
23
  ```
@@ -25,13 +25,14 @@ easybits logout
25
25
  Running a command without a session in a terminal starts the login automatically;
26
26
  without a terminal (agents, CI) it exits `3`. Precedence: `EASYBITS_API_KEY` env var >
27
27
  `--token <key>` > browser session > saved API key. Keys: https://www.easybits.cloud/dash/developer.
28
+ In CI and agents, `EASYBITS_API_KEY` needs no login at all.
28
29
 
29
30
  ## Commands
30
31
 
31
32
  ```bash
32
33
  # Sandboxes (alias: sb)
33
34
  easybits sandboxes ls
34
- easybits sandboxes create --template node --name scratch
35
+ easybits sandboxes create --template node --name scratch --dotenv .env
35
36
  easybits sandboxes get sb_abc123
36
37
  easybits sandboxes exec sb_abc123 -- npm test
37
38
  easybits sandboxes logs sb_abc123 --unit myapp --lines 100
@@ -41,7 +42,7 @@ easybits sandboxes files write sb_abc123 /data/work/app.js ./app.js
41
42
  easybits sandboxes suspend sb_abc123
42
43
  easybits sandboxes resume sb_abc123
43
44
  easybits sandboxes snapshot sb_abc123 --name before-upgrade
44
- easybits sandboxes destroy sb_abc123
45
+ easybits sandboxes destroy sb_abc123 --yes
45
46
 
46
47
  # Hosting: permanent machines (alias: deploy)
47
48
  easybits machines ls
@@ -50,7 +51,7 @@ easybits machines releases sb_abc123
50
51
  easybits machines logs sb_abc123 --grep ERROR
51
52
  easybits machines rollback sb_abc123 rel_789
52
53
  easybits machines secrets ls sb_abc123
53
- easybits machines secrets set sb_abc123 DATABASE_URL=postgres://...
54
+ easybits machines secrets set sb_abc123 --dotenv .env.production # - reads stdin
54
55
  easybits machines secrets unset sb_abc123 DATABASE_URL
55
56
  easybits init --port 3000 # GitHub Actions workflow that deploys on every push
56
57
 
@@ -58,24 +59,25 @@ easybits init --port 3000 # GitHub Actions workflow that deploys on e
58
59
  easybits domains ls sb_abc123
59
60
  easybits domains add sb_abc123 shop.example.com --port 3000
60
61
  easybits domains verify sb_abc123 shop.example.com
61
- easybits domains rm sb_abc123 shop.example.com
62
+ easybits domains rm sb_abc123 shop.example.com --yes
62
63
 
63
64
  # Databases
64
65
  easybits db ls
65
66
  easybits db create leads
67
+ easybits db tables leads # tables, row counts, columns
66
68
  easybits db query leads "SELECT * FROM leads" --json
67
- easybits db rm leads
69
+ easybits db rm leads --yes
68
70
 
69
71
  # Agents
70
72
  easybits agents ls
71
- easybits agents create --template goose --name helper
73
+ easybits agents create --template goose --name helper --dotenv .env
72
74
  easybits agents message ag_123 "hello"
73
- easybits agents destroy ag_123
75
+ easybits agents destroy ag_123 --yes
74
76
 
75
77
  # Files, websites, account
76
78
  easybits files ls
77
79
  easybits files upload ./report.pdf
78
- easybits files delete FILE_ID
80
+ easybits files delete FILE_ID --yes
79
81
  easybits websites ls
80
82
  easybits usage
81
83
 
@@ -86,7 +88,18 @@ easybits ssh-key # public key for sandbox SSH
86
88
  easybits docs cli # a docs section as markdown (--en for English)
87
89
  ```
88
90
 
89
- Every command has help with examples: `easybits <command> <subcommand> --help`.
91
+ Every command has help with examples: `easybits <command> <subcommand> --help`
92
+ (or `easybits help <command> <subcommand>`). A typo gets a "Did you mean" suggestion.
93
+
94
+ **Destructive commands** (`db rm`, `agents destroy`, `sandboxes destroy`, `domains rm`,
95
+ `files delete`) ask before acting in a terminal: `[y/N]`, or typing the name/id for
96
+ irreversible ones (a database, an agent, a permanent sandbox). Without a terminal they
97
+ never prompt: pass `--yes` / `-y`, or they exit `2`.
98
+
99
+ **Secrets** go in a dotenv file or on stdin, never in argv (visible in `ps` and shell
100
+ history): `--dotenv <path>` (`KEY=VALUE` lines, `-` = stdin) on `sandboxes create|exec|resume`,
101
+ `agents create` and `machines secrets set`. `--env K=V` stays for non-secret values and wins
102
+ over the file. (It is not called `--env-file` because Node 22 intercepts that flag name.)
90
103
 
91
104
  ## SSH over 443
92
105
 
@@ -103,11 +116,21 @@ Host *.ghosty
103
116
  - `--json`: stdout is JSON only, errors included: `{"error":"…","code":3,"hint":"…"}` (`code` = exit code).
104
117
  - Exit codes: `0` ok · `1` API error · `2` usage error · `3` no session, expired or rejected.
105
118
  - Login for a person: `easybits login --json` prints `{"event":"login_url","url":…}` first (show it to them), then `{"event":"logged_in","email":…}`.
119
+ - Destructive commands need `--yes` (no TTY means no prompt: exit `2` with the exact command to rerun).
106
120
  - `sandboxes exec` without `--json` exits with the remote command's code; with `--json` it exits 0 and reports `exitCode`.
107
121
  - Skill: `npx skills add https://easybits.cloud` (includes `easybits-cli`).
108
122
 
109
123
  Full reference: https://www.easybits.cloud/docs#cli · https://www.easybits.cloud/en/docs/cli.md
110
124
 
125
+ ## Changes in 0.6.0
126
+
127
+ - **Breaking for scripts:** `db rm`, `agents destroy`, `sandboxes destroy`, `domains rm` and
128
+ `files delete` now require `--yes` when not running in a terminal (exit `2` otherwise).
129
+ - `--dotenv <path|->` for env vars and secrets; `easybits login -` / `--with-token` reads the key from stdin.
130
+ `login <api-key>` and `secrets set KEY=VALUE` still work but are no longer advertised.
131
+ - `easybits db tables <db>`: tables with row counts and columns (`--json`: `[{name, rows, columns:[{name,type,pk}]}]`).
132
+ - "Did you mean" for unknown commands and subcommands; `easybits help <command> <subcommand>`.
133
+
111
134
  ## License
112
135
 
113
136
  MIT
package/dist/index.js CHANGED
@@ -6,6 +6,9 @@ import { parseArgs } from "util";
6
6
  // src/commands/account.ts
7
7
  import { resolveBaseUrl as resolveBaseUrl3 } from "@easybits.cloud/sdk";
8
8
 
9
+ // src/args.ts
10
+ import { existsSync, readFileSync } from "fs";
11
+
9
12
  // src/errors.ts
10
13
  import { EasybitsError } from "@easybits.cloud/sdk";
11
14
  var EXIT = { OK: 0, API: 1, USAGE: 2, AUTH: 3 };
@@ -34,7 +37,7 @@ function notLoggedIn() {
34
37
  return new CliError(
35
38
  "Not logged in.",
36
39
  EXIT.AUTH,
37
- "Run: easybits login (or: easybits login <api-key>, or set EASYBITS_API_KEY)",
40
+ "Run: easybits login (or: easybits login - < key-file, or set EASYBITS_API_KEY)",
38
41
  "not_logged_in"
39
42
  );
40
43
  }
@@ -117,6 +120,34 @@ async function readStdin() {
117
120
  for await (const c of process.stdin) chunks.push(c);
118
121
  return Buffer.concat(chunks);
119
122
  }
123
+ var ENV_FILE_OPTION = {
124
+ dotenv: { type: "string", value: "path", description: "KEY=VALUE lines (dotenv); - reads stdin. Use it for secrets" }
125
+ };
126
+ async function readEnvFile(path, usage2) {
127
+ let text;
128
+ if (path === "-") {
129
+ if (process.stdin.isTTY) throw usageError("--dotenv - expects KEY=VALUE lines on stdin.", usage2);
130
+ text = (await readStdin()).toString("utf8");
131
+ } else {
132
+ if (!existsSync(path)) throw usageError(`File not found: ${path}`, usage2);
133
+ text = readFileSync(path, "utf8");
134
+ }
135
+ const out = {};
136
+ for (const [n, raw] of text.split(/\r?\n/).entries()) {
137
+ const line = raw.trim();
138
+ if (!line || line.startsWith("#")) continue;
139
+ const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(.*)$/.exec(line);
140
+ if (!m) throw usageError(`${path === "-" ? "stdin" : path}:${n + 1}: expected KEY=VALUE.`, usage2);
141
+ let v = m[2];
142
+ if (v.length >= 2 && (v[0] === '"' || v[0] === "'") && v.endsWith(v[0])) v = v.slice(1, -1);
143
+ out[m[1]] = v;
144
+ }
145
+ return out;
146
+ }
147
+ async function envFrom(ctx, usage2) {
148
+ const file = str(ctx, "dotenv");
149
+ return { ...file ? await readEnvFile(file, usage2) : {}, ...pairs(list(ctx, "env"), usage2) };
150
+ }
120
151
 
121
152
  // src/output.ts
122
153
  function emit(ctx, data, human) {
@@ -156,7 +187,7 @@ import { EasybitsClient, resolveBaseUrl as resolveBaseUrl2 } from "@easybits.clo
156
187
  import { createHash, randomBytes } from "crypto";
157
188
  import { createServer } from "http";
158
189
  import { spawn } from "child_process";
159
- import { chmodSync, readFileSync, writeFileSync } from "fs";
190
+ import { chmodSync, readFileSync as readFileSync2, writeFileSync } from "fs";
160
191
  import { homedir } from "os";
161
192
  import { join } from "path";
162
193
  import { resolveBaseUrl } from "@easybits.cloud/sdk";
@@ -165,7 +196,7 @@ var LOGIN_TIMEOUT_MS = 5 * 6e4;
165
196
  var RC_PATH = join(homedir(), ".easybitsrc");
166
197
  function readRc() {
167
198
  try {
168
- return JSON.parse(readFileSync(RC_PATH, "utf8"));
199
+ return JSON.parse(readFileSync2(RC_PATH, "utf8"));
169
200
  } catch {
170
201
  return {};
171
202
  }
@@ -350,20 +381,28 @@ var login = {
350
381
  name: "login",
351
382
  group: "Account",
352
383
  summary: "Sign in with your browser (or save an API key)",
353
- synopsis: "login [api-key]",
384
+ synopsis: "login [-]",
354
385
  leaf: {
355
386
  summary: "Sign in with your browser (OAuth2 + PKCE), or save an API key instead",
356
- usage: "easybits login [api-key] [--no-browser]",
387
+ usage: "easybits login [-|--with-token] [--no-browser]",
357
388
  options: {
358
- "no-browser": { type: "boolean", description: "Only print the sign-in URL (e.g. for an agent to relay it)" }
389
+ "no-browser": { type: "boolean", description: "Only print the sign-in URL (e.g. for an agent to relay it)" },
390
+ "with-token": { type: "boolean", description: "Read an API key from stdin instead of the browser (same as -)" }
359
391
  },
360
392
  examples: [
361
- "easybits login # opens the browser; waits for you",
362
- 'easybits login --json # agents: prints {"event":"login_url"} first',
363
- "easybits login eb_sk_live_xxxxxxxx # API key instead of the browser (validated first)"
393
+ "easybits login # opens the browser; waits for you",
394
+ 'easybits login --json # agents: prints {"event":"login_url"} first',
395
+ "easybits login - < key.txt # API key from stdin (validated first)",
396
+ "printenv EB_KEY | easybits login --with-token",
397
+ "EASYBITS_API_KEY=\u2026 easybits usage # CI/agents: the env var needs no login at all"
364
398
  ],
365
399
  async run(ctx) {
366
- const key = ctx.args[0];
400
+ let key = ctx.args[0];
401
+ if (key === "-" || bool(ctx, "with-token")) {
402
+ if (process.stdin.isTTY) throw usageError("Pipe the API key on stdin: easybits login - < key.txt", this.usage);
403
+ key = (await readStdin()).toString("utf8").trim();
404
+ if (!key) throw usageError("Empty API key on stdin.", this.usage);
405
+ }
367
406
  if (key) {
368
407
  const email2 = await fetchEmail(key);
369
408
  const { oauth: _drop, ...rest } = readRc();
@@ -528,8 +567,49 @@ var mcp = {
528
567
  };
529
568
 
530
569
  // src/commands/files.ts
531
- import { existsSync, readFileSync as readFileSync2, statSync } from "fs";
570
+ import { existsSync as existsSync2, readFileSync as readFileSync3, statSync } from "fs";
532
571
  import { basename, extname } from "path";
572
+
573
+ // src/prompt.ts
574
+ import { createInterface } from "readline/promises";
575
+ var YES_OPTION = {
576
+ yes: { type: "boolean", short: "y", description: "Skip the confirmation (required without a terminal)" }
577
+ };
578
+ function sameCommand() {
579
+ const argv2 = process.argv.slice(2).map((a) => /^[\w@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`);
580
+ return `easybits ${argv2.join(" ")}`;
581
+ }
582
+ async function ask(question) {
583
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
584
+ try {
585
+ return (await rl.question(question)).trim();
586
+ } catch {
587
+ process.stderr.write("\n");
588
+ return "";
589
+ } finally {
590
+ rl.close();
591
+ }
592
+ }
593
+ function requireYesIfHeadless(ctx) {
594
+ if (bool(ctx, "yes") || interactive(ctx)) return;
595
+ throw new CliError("--yes required when not running interactively.", EXIT.USAGE, `Run: ${sameCommand()} --yes`, "usage");
596
+ }
597
+ async function confirm(ctx, action, opts = {}) {
598
+ if (bool(ctx, "yes")) return;
599
+ requireYesIfHeadless(ctx);
600
+ if (opts.typeName) {
601
+ process.stderr.write(`${action}
602
+ This cannot be undone.
603
+ `);
604
+ const answer2 = await ask(`Type "${opts.typeName}" to confirm: `);
605
+ if (answer2 !== opts.typeName) throw new CliError("Aborted: the name did not match.", EXIT.USAGE, void 0, "aborted");
606
+ return;
607
+ }
608
+ const answer = await ask(`${action} [y/N] `);
609
+ if (!/^y(es)?$/i.test(answer)) throw new CliError("Aborted.", EXIT.USAGE, void 0, "aborted");
610
+ }
611
+
612
+ // src/commands/files.ts
533
613
  var MIME = {
534
614
  pdf: "application/pdf",
535
615
  png: "image/png",
@@ -580,14 +660,14 @@ var files = {
580
660
  examples: ["easybits files upload ./report.pdf", "easybits files upload ./logo.png --json"],
581
661
  async run(ctx) {
582
662
  const path = need(ctx, 0, "path", this.usage);
583
- if (!existsSync(path)) throw usageError(`File not found: ${path}`, this.usage);
663
+ if (!existsSync2(path)) throw usageError(`File not found: ${path}`, this.usage);
584
664
  const size = statSync(path).size;
585
665
  const contentType = MIME[extname(path).slice(1).toLowerCase()] || "application/octet-stream";
586
666
  const eb = await getClient(ctx);
587
667
  const data = await eb.uploadFile({ fileName: basename(path), contentType, size });
588
668
  const res = await fetch(data.putUrl, {
589
669
  method: "PUT",
590
- body: readFileSync2(path),
670
+ body: readFileSync3(path),
591
671
  headers: { "Content-Type": contentType }
592
672
  });
593
673
  if (!res.ok) throw new CliError(`Upload failed: storage answered ${res.status}`, 1, void 0, "upload_failed", res.status);
@@ -597,10 +677,13 @@ var files = {
597
677
  delete: {
598
678
  aliases: ["rm"],
599
679
  summary: "Delete a file (soft delete, 7-day trash)",
600
- usage: "easybits files delete <file-id>",
601
- examples: ["easybits files delete 6650f0c2a1b2c3d4e5f60718"],
680
+ usage: "easybits files delete <file-id> [--yes]",
681
+ options: { ...YES_OPTION },
682
+ examples: ["easybits files delete 6650f0c2a1b2c3d4e5f60718", "easybits files rm 6650f0c2a1b2c3d4e5f60718 --yes"],
602
683
  async run(ctx) {
603
684
  const id = need(ctx, 0, "file-id", this.usage);
685
+ requireYesIfHeadless(ctx);
686
+ await confirm(ctx, `Delete file ${id}? (recoverable from trash for 7 days)`);
604
687
  const eb = await getClient(ctx);
605
688
  const r = await eb.deleteFile(id);
606
689
  emit(ctx, r, () => console.log("Deleted"));
@@ -610,7 +693,7 @@ var files = {
610
693
  };
611
694
 
612
695
  // src/commands/init.ts
613
- import { existsSync as existsSync2, mkdirSync, writeFileSync as writeFileSync2 } from "fs";
696
+ import { existsSync as existsSync3, mkdirSync, writeFileSync as writeFileSync2 } from "fs";
614
697
  var init = {
615
698
  name: "init",
616
699
  group: "Hosting",
@@ -627,7 +710,7 @@ var init = {
627
710
  async run(ctx) {
628
711
  const appDir = str(ctx, "app-dir") ?? "/srv/app";
629
712
  const port = str(ctx, "port") ?? "3000";
630
- if (!existsSync2("package.json")) {
713
+ if (!existsSync3("package.json")) {
631
714
  throw usageError("No package.json here. Run this at the root of your app's repo.", this.usage);
632
715
  }
633
716
  const workflow = `# Despliegue a EasyBits en cada push a main.
@@ -804,7 +887,7 @@ From then on, every push to main deploys.`));
804
887
  // src/commands/ssh.ts
805
888
  import { homedir as homedir2 } from "os";
806
889
  import { join as join2 } from "path";
807
- import { existsSync as existsSync3, readFileSync as readFileSync3, mkdirSync as mkdirSync2 } from "fs";
890
+ import { existsSync as existsSync4, readFileSync as readFileSync4, mkdirSync as mkdirSync2 } from "fs";
808
891
  import { execFileSync } from "child_process";
809
892
  var SSH_CONFIG_SNIPPET = `Host *.ghosty
810
893
  ProxyCommand easybits ssh-proxy %h
@@ -822,14 +905,14 @@ var sshKey = {
822
905
  const dir = join2(homedir2(), ".ssh");
823
906
  const key = join2(dir, "easybits_ed25519");
824
907
  const pub = `${key}.pub`;
825
- if (!existsSync3(pub)) {
908
+ if (!existsSync4(pub)) {
826
909
  mkdirSync2(dir, { recursive: true, mode: 448 });
827
910
  execFileSync("ssh-keygen", ["-t", "ed25519", "-N", "", "-f", key, "-C", "easybits"], {
828
911
  stdio: ["ignore", "ignore", "inherit"]
829
912
  });
830
913
  console.error(`easybits: key created at ${key}`);
831
914
  }
832
- const publicKey = readFileSync3(pub, "utf8").trim();
915
+ const publicKey = readFileSync4(pub, "utf8").trim();
833
916
  if (ctx.json) process.stdout.write(JSON.stringify({ publicKey, path: pub }, null, 2) + "\n");
834
917
  else process.stdout.write(publicKey + "\n");
835
918
  }
@@ -881,7 +964,7 @@ var sshProxy = {
881
964
  };
882
965
 
883
966
  // src/commands/sandboxes.ts
884
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
967
+ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
885
968
  function sandboxRecord(s) {
886
969
  return {
887
970
  sandboxId: s.sandboxId,
@@ -943,23 +1026,25 @@ var sandboxes = {
943
1026
  create: {
944
1027
  aliases: ["new"],
945
1028
  summary: "Create a sandbox and wait until it is running",
946
- usage: "easybits sandboxes create [--template ubuntu] [--name <name>] [--timeout <s>] [--size s|m|l|xl] [--env K=V]...",
1029
+ usage: "easybits sandboxes create [--template ubuntu] [--name <name>] [--timeout <s>] [--size s|m|l|xl] [--dotenv <path>] [--env K=V]...",
947
1030
  options: {
948
1031
  template: { type: "string", value: "name", description: "Base template (default ubuntu): ubuntu, python, node, code-interpreter\u2026" },
949
1032
  name: { type: "string", value: "name", description: "Human label (lets you `ssh <name>.ghosty`)" },
950
1033
  timeout: { type: "string", value: "seconds", description: "Time to live before auto-destroy" },
951
1034
  size: { type: "string", value: "s|m|l|xl", description: "VM size class (gated by plan)" },
952
- env: { type: "string", multiple: true, value: "K=V", description: "Environment variable (repeatable)" },
1035
+ env: { type: "string", multiple: true, value: "K=V", description: "Environment variable, non-secret (repeatable)" },
1036
+ ...ENV_FILE_OPTION,
953
1037
  "no-wait": { type: "boolean", description: "Return right away instead of waiting for running" }
954
1038
  },
955
1039
  examples: [
956
1040
  "easybits sandboxes create --template node --name scratch",
957
- "ID=$(easybits sb create --template python --json | jq -r .sandboxId)"
1041
+ "ID=$(easybits sb create --template python --json | jq -r .sandboxId)",
1042
+ "easybits sb create --template node --dotenv .env"
958
1043
  ],
959
1044
  async run(ctx) {
960
1045
  const size = str(ctx, "size");
961
1046
  if (size && !["s", "m", "l", "xl"].includes(size)) throw usageError("--size must be s, m, l or xl.", this.usage);
962
- const env = pairs(list(ctx, "env"), this.usage);
1047
+ const env = await envFrom(ctx, this.usage);
963
1048
  const eb = await getClient(ctx);
964
1049
  const sb = await eb.sandboxes.create({
965
1050
  template: str(ctx, "template") ?? "ubuntu",
@@ -985,14 +1070,16 @@ var sandboxes = {
985
1070
  },
986
1071
  exec: {
987
1072
  summary: "Run a shell command inside a sandbox",
988
- usage: "easybits sandboxes exec <sandbox-id> [--cwd <dir>] [--timeout <s>] -- <command...>",
1073
+ usage: "easybits sandboxes exec <sandbox-id> [--cwd <dir>] [--timeout <s>] [--dotenv <path>] -- <command...>",
989
1074
  options: {
990
1075
  cwd: { type: "string", value: "dir", description: "Working directory" },
991
1076
  timeout: { type: "string", value: "seconds", description: "Kill the command after this long" },
992
- env: { type: "string", multiple: true, value: "K=V", description: "Environment variable (repeatable)" }
1077
+ env: { type: "string", multiple: true, value: "K=V", description: "Environment variable, non-secret (repeatable)" },
1078
+ ...ENV_FILE_OPTION
993
1079
  },
994
1080
  examples: [
995
1081
  "easybits sandboxes exec sb_abc123 -- uname -a",
1082
+ "easybits sb exec sb_abc123 --dotenv .env -- node migrate.js",
996
1083
  "easybits sb exec sb_abc123 --cwd /data/work -- npm test",
997
1084
  "easybits sb exec sb_abc123 --json -- 'ls -la /' | jq .exitCode"
998
1085
  ],
@@ -1000,7 +1087,7 @@ var sandboxes = {
1000
1087
  need(ctx, 0, "sandbox-id", this.usage);
1001
1088
  const command = ctx.args.slice(1).join(" ");
1002
1089
  if (!command) throw usageError("Missing <command>.", this.usage);
1003
- const env = pairs(list(ctx, "env"), this.usage);
1090
+ const env = await envFrom(ctx, this.usage);
1004
1091
  const sb = await sandbox(ctx, this.usage);
1005
1092
  const r = await sb.exec(command, {
1006
1093
  cwd: str(ctx, "cwd"),
@@ -1086,8 +1173,8 @@ var sandboxes = {
1086
1173
  let buf;
1087
1174
  if (inline != null) buf = Buffer.from(inline, "utf8");
1088
1175
  else if (local) {
1089
- if (!existsSync4(local)) throw usageError(`File not found: ${local}`, FILES_USAGE);
1090
- buf = readFileSync4(local);
1176
+ if (!existsSync5(local)) throw usageError(`File not found: ${local}`, FILES_USAGE);
1177
+ buf = readFileSync5(local);
1091
1178
  } else if (!process.stdin.isTTY) buf = await readStdin();
1092
1179
  else throw usageError("Give a local file, --content, or pipe data on stdin.", FILES_USAGE);
1093
1180
  const r = await sb.files.write(path, buf.toString("base64"), { encoding: "base64" });
@@ -1108,11 +1195,14 @@ var sandboxes = {
1108
1195
  },
1109
1196
  resume: {
1110
1197
  summary: "Wake a suspended sandbox",
1111
- usage: "easybits sandboxes resume <sandbox-id> [--env K=V]...",
1112
- options: { env: { type: "string", multiple: true, value: "K=V", description: "Rewrite env on wake (repeatable)" } },
1198
+ usage: "easybits sandboxes resume <sandbox-id> [--dotenv <path>] [--env K=V]...",
1199
+ options: {
1200
+ env: { type: "string", multiple: true, value: "K=V", description: "Rewrite env on wake, non-secret (repeatable)" },
1201
+ ...ENV_FILE_OPTION
1202
+ },
1113
1203
  examples: ["easybits sandboxes resume sb_abc123"],
1114
1204
  async run(ctx) {
1115
- const env = pairs(list(ctx, "env"), this.usage);
1205
+ const env = await envFrom(ctx, this.usage);
1116
1206
  const r = await (await sandbox(ctx, this.usage)).resume(Object.keys(env).length ? { env } : {});
1117
1207
  emit(ctx, r, () => console.log(`Resumed ${r.sandboxId ?? ctx.args[0]} (${r.status ?? "running"})`));
1118
1208
  }
@@ -1120,10 +1210,19 @@ var sandboxes = {
1120
1210
  destroy: {
1121
1211
  aliases: ["rm", "delete"],
1122
1212
  summary: "Destroy a sandbox (irreversible)",
1123
- usage: "easybits sandboxes destroy <sandbox-id>",
1124
- examples: ["easybits sandboxes destroy sb_abc123"],
1213
+ usage: "easybits sandboxes destroy <sandbox-id> [--yes]",
1214
+ options: { ...YES_OPTION },
1215
+ examples: ["easybits sandboxes destroy sb_abc123", "easybits sb rm sb_abc123 --yes"],
1125
1216
  async run(ctx) {
1126
- const r = await (await sandbox(ctx, this.usage)).destroy();
1217
+ need(ctx, 0, "sandbox-id", this.usage);
1218
+ requireYesIfHeadless(ctx);
1219
+ const sb = await sandbox(ctx, this.usage);
1220
+ await confirm(
1221
+ ctx,
1222
+ sb.persistent ? `Destroy permanent sandbox ${sb.sandboxId}${sb.name ? ` (${sb.name})` : ""} and its disk?` : `Destroy sandbox ${sb.sandboxId}?`,
1223
+ sb.persistent ? { typeName: sb.sandboxId } : {}
1224
+ );
1225
+ const r = await sb.destroy();
1127
1226
  emit(ctx, { ...r, sandboxId: ctx.args[0] }, () => console.log(`Destroyed ${ctx.args[0]}`));
1128
1227
  }
1129
1228
  },
@@ -1141,7 +1240,7 @@ var sandboxes = {
1141
1240
  };
1142
1241
 
1143
1242
  // src/commands/machines.ts
1144
- var SECRETS_USAGE = "easybits machines secrets <ls|set|unset> <machine-id> [KEY=VALUE... | NAME]";
1243
+ var SECRETS_USAGE = "easybits machines secrets <ls|set|unset> <machine-id> [--dotenv <path> | NAME]";
1145
1244
  var machines = {
1146
1245
  name: "machines",
1147
1246
  aliases: ["deploy", "machine"],
@@ -1232,9 +1331,11 @@ var machines = {
1232
1331
  secrets: {
1233
1332
  summary: "App env vars stored encrypted in your vault",
1234
1333
  usage: SECRETS_USAGE,
1334
+ options: { ...ENV_FILE_OPTION },
1235
1335
  examples: [
1236
1336
  "easybits machines secrets ls sb_abc123",
1237
- "easybits machines secrets set sb_abc123 DATABASE_URL=postgres://\u2026 API_KEY=xyz",
1337
+ "easybits machines secrets set sb_abc123 --dotenv .env.production",
1338
+ `printf 'API_KEY=%s\\n' "$API_KEY" | easybits machines secrets set sb_abc123 --dotenv -`,
1238
1339
  "easybits machines secrets unset sb_abc123 API_KEY"
1239
1340
  ],
1240
1341
  async run(ctx) {
@@ -1248,8 +1349,9 @@ var machines = {
1248
1349
  console.log(`In vault: ${r.inVault.length ? r.inVault.join(", ") : "(none)"}`);
1249
1350
  });
1250
1351
  } else if (action === "set") {
1251
- const values = pairs(ctx.args.slice(2), SECRETS_USAGE);
1252
- if (!Object.keys(values).length) throw usageError("Give at least one KEY=VALUE.", SECRETS_USAGE);
1352
+ const file = str(ctx, "dotenv");
1353
+ const values = { ...file ? await readEnvFile(file, SECRETS_USAGE) : {}, ...pairs(ctx.args.slice(2), SECRETS_USAGE) };
1354
+ if (!Object.keys(values).length) throw usageError("Give --dotenv <path> (or - for stdin).", SECRETS_USAGE);
1253
1355
  const r = await eb.machines.setSecrets(id, values);
1254
1356
  emit(ctx, r, () => console.log(`Set ${Object.keys(values).join(", ")}. Injected now: ${r.secretNames.join(", ")}`));
1255
1357
  } else if (action === "unset") {
@@ -1321,10 +1423,14 @@ var domains = {
1321
1423
  rm: {
1322
1424
  aliases: ["remove", "delete"],
1323
1425
  summary: "Detach a domain",
1324
- usage: "easybits domains rm <sandbox-id> <domain>",
1325
- examples: ["easybits domains rm sb_abc123 shop.example.com"],
1426
+ usage: "easybits domains rm <sandbox-id> <domain> [--yes]",
1427
+ options: { ...YES_OPTION },
1428
+ examples: ["easybits domains rm sb_abc123 shop.example.com", "easybits domains rm sb_abc123 shop.example.com --yes"],
1326
1429
  async run(ctx) {
1430
+ need(ctx, 0, "sandbox-id", this.usage);
1327
1431
  const domain = need(ctx, 1, "domain", this.usage);
1432
+ requireYesIfHeadless(ctx);
1433
+ await confirm(ctx, `Detach ${domain} from ${ctx.args[0]}? It stops serving right away.`);
1328
1434
  const r = await (await sandbox2(ctx, this.usage)).removeDomain(domain);
1329
1435
  emit(ctx, r, () => console.log(`Removed ${domain}`));
1330
1436
  }
@@ -1380,16 +1486,55 @@ var db = {
1380
1486
  rm: {
1381
1487
  aliases: ["delete", "drop"],
1382
1488
  summary: "Delete a database (irreversible)",
1383
- usage: "easybits db rm <db-id|name>",
1384
- examples: ["easybits db rm leads"],
1489
+ usage: "easybits db rm <db-id|name> [--yes]",
1490
+ options: { ...YES_OPTION },
1491
+ examples: ["easybits db rm leads # asks you to type the name", "easybits db rm leads --yes # scripts and agents"],
1385
1492
  async run(ctx) {
1386
1493
  const ref = need(ctx, 0, "db-id|name", this.usage);
1494
+ requireYesIfHeadless(ctx);
1387
1495
  const eb = await getClient(ctx);
1388
1496
  const hit = await findDb(eb, ref);
1497
+ await confirm(ctx, `Delete database ${hit.name} (${hit.id}) and all its data?`, { typeName: hit.name });
1389
1498
  const r = await eb.deleteDatabase(hit.id);
1390
1499
  emit(ctx, { ...r, id: hit.id, name: hit.name }, () => console.log(`Deleted ${hit.name} (${hit.id})`));
1391
1500
  }
1392
1501
  },
1502
+ tables: {
1503
+ aliases: ["inspect", "schema"],
1504
+ summary: "List tables with row counts and columns",
1505
+ usage: "easybits db tables <db-id|name>",
1506
+ examples: ["easybits db tables leads", "easybits db tables leads --json"],
1507
+ // Patrón de `turso db inspect` / `.tables` + `.schema` de sqlite3, en una sola vista.
1508
+ async run(ctx) {
1509
+ const ref = need(ctx, 0, "db-id|name", this.usage);
1510
+ const eb = await getClient(ctx);
1511
+ const hit = await findDb(eb, ref);
1512
+ const q = (sql, args) => eb.queryDatabase(hit.id, sql, args);
1513
+ const names = (await q("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_litestream_%' ORDER BY name")).rows.map((r) => String(r[0]));
1514
+ const ident = (n) => `"${n.replace(/"/g, '""')}"`;
1515
+ const tables = [];
1516
+ for (const name of names) {
1517
+ const info2 = await q(`PRAGMA table_info(${ident(name)})`);
1518
+ const col = (k) => info2.cols.indexOf(k);
1519
+ const columns = info2.rows.map((r) => ({ name: String(r[col("name")]), type: String(r[col("type")] ?? ""), pk: Number(r[col("pk")]) > 0 }));
1520
+ const count = await q(`SELECT count(*) FROM ${ident(name)}`);
1521
+ tables.push({ name, rows: Number(count.rows[0]?.[0] ?? 0), columns });
1522
+ }
1523
+ emit(
1524
+ ctx,
1525
+ tables,
1526
+ () => table(
1527
+ tables.map((t) => ({
1528
+ name: t.name,
1529
+ rows: t.rows,
1530
+ columns: t.columns.map((c) => `${c.name}${c.type ? ` ${c.type}` : ""}${c.pk ? " PK" : ""}`).join(", ")
1531
+ })),
1532
+ [["name", "TABLE"], ["rows", "ROWS"], ["columns", "COLUMNS"]],
1533
+ `No tables in ${hit.name}. Create one: easybits db query ${hit.name} 'CREATE TABLE \u2026'`
1534
+ )
1535
+ );
1536
+ }
1537
+ },
1393
1538
  query: {
1394
1539
  aliases: ["sql"],
1395
1540
  summary: "Run one SQL statement (by database id or name)",
@@ -1452,25 +1597,28 @@ var agents = {
1452
1597
  },
1453
1598
  create: {
1454
1599
  summary: "Create an agent from a template",
1455
- usage: "easybits agents create --template <template> [--name <name>] [--env K=V]... [--timeout <s>]",
1600
+ usage: "easybits agents create --template <template> [--name <name>] [--dotenv <path>] [--env K=V]... [--timeout <s>]",
1456
1601
  options: {
1457
1602
  template: { type: "string", value: "template", description: "Agent template (see: easybits docs agents)" },
1458
1603
  name: { type: "string", value: "name", description: "Label" },
1459
- env: { type: "string", multiple: true, value: "K=V", description: "Env for the agent (repeatable)" },
1604
+ env: { type: "string", multiple: true, value: "K=V", description: "Env for the agent, non-secret (repeatable)" },
1605
+ ...ENV_FILE_OPTION,
1460
1606
  timeout: { type: "string", value: "seconds", description: "Lifetime before auto-destroy" }
1461
1607
  },
1462
1608
  examples: [
1463
1609
  "easybits agents create --template goose --name helper",
1464
- "easybits agents create --template chat-anthropic --env ANTHROPIC_API_KEY=sk-ant-\u2026 --json"
1610
+ "easybits agents create --template chat-anthropic --dotenv .env --json",
1611
+ "op read op://vault/anthropic/env | easybits agents create --template chat-anthropic --dotenv -"
1465
1612
  ],
1466
1613
  async run(ctx) {
1467
1614
  const template = str(ctx, "template");
1468
1615
  if (!template) throw usageError("Missing --template.", this.usage);
1616
+ const env = await envFrom(ctx, this.usage);
1469
1617
  const eb = await getClient(ctx);
1470
1618
  const a = await eb.createAgent({
1471
1619
  template,
1472
1620
  name: str(ctx, "name"),
1473
- env: pairs(list(ctx, "env"), this.usage),
1621
+ env,
1474
1622
  timeoutSeconds: int(ctx, "timeout", this.usage)
1475
1623
  });
1476
1624
  emit(ctx, a, () => {
@@ -1505,10 +1653,13 @@ var agents = {
1505
1653
  destroy: {
1506
1654
  aliases: ["rm", "delete"],
1507
1655
  summary: "Destroy an agent and its sandbox",
1508
- usage: "easybits agents destroy <agent-id>",
1509
- examples: ["easybits agents destroy ag_123"],
1656
+ usage: "easybits agents destroy <agent-id> [--yes]",
1657
+ options: { ...YES_OPTION },
1658
+ examples: ["easybits agents destroy ag_123 # asks you to type the id", "easybits agents destroy ag_123 --yes"],
1510
1659
  async run(ctx) {
1511
1660
  const id = need(ctx, 0, "agent-id", this.usage);
1661
+ requireYesIfHeadless(ctx);
1662
+ await confirm(ctx, `Destroy agent ${id} and its sandbox (including /data)?`, { typeName: id });
1512
1663
  const eb = await getClient(ctx);
1513
1664
  const r = await eb.destroyAgent(id);
1514
1665
  emit(ctx, { ...r, agentId: id }, () => console.log(`Destroyed ${id}`));
@@ -1587,10 +1738,36 @@ function findCommand(name) {
1587
1738
  return COMMANDS.find((c) => c.name === name || c.aliases?.includes(name));
1588
1739
  }
1589
1740
 
1741
+ // src/suggest.ts
1742
+ function levenshtein(a, b) {
1743
+ if (a === b) return 0;
1744
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
1745
+ for (let i = 1; i <= a.length; i++) {
1746
+ const cur = [i];
1747
+ for (let j = 1; j <= b.length; j++) {
1748
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
1749
+ }
1750
+ prev = cur;
1751
+ }
1752
+ return prev[b.length];
1753
+ }
1754
+ function suggest(input, candidates, max = 2) {
1755
+ const needle = input.toLowerCase();
1756
+ const scored = [...new Set(candidates)].map((c) => ({ c, d: levenshtein(needle, c.toLowerCase()) })).filter(({ c, d }) => d <= max || needle.length >= 2 && c.toLowerCase().startsWith(needle)).sort((x, y) => x.d - y.d || x.c.localeCompare(y.c));
1757
+ return scored.map(({ c }) => c);
1758
+ }
1759
+ function didYouMean(input, candidates, prefix, canonical = (n) => n) {
1760
+ const hits = [...new Set(suggest(input, candidates).map(canonical))].slice(0, 3);
1761
+ if (!hits.length) return void 0;
1762
+ return `Did you mean this?
1763
+ ${hits.map((h) => ` ${prefix}${h}`).join("\n")}`;
1764
+ }
1765
+
1590
1766
  // src/help.ts
1591
1767
  var GLOBAL_FLAGS = `Global flags:
1592
1768
  --json Machine output: JSON on stdout, errors included
1593
- --token <key> API key for this call (EASYBITS_API_KEY wins if set)
1769
+ --token <key> API key for this call (prefer EASYBITS_API_KEY: argv leaks to ps)
1770
+ -y, --yes On rm/destroy: skip the confirmation (required without a TTY)
1594
1771
  -h, --help Help (also: easybits <command> --help)
1595
1772
  -v, --version Print the CLI version
1596
1773
 
@@ -1649,7 +1826,7 @@ var BANNER = " \x1B[38;2;244;245;251m\u2860\u2812\u2812\u28E4\x1B[38;
1649
1826
 
1650
1827
  // src/index.ts
1651
1828
  import { EasybitsError as EasybitsError2 } from "@easybits.cloud/sdk";
1652
- var VERSION = true ? "0.5.1" : "dev";
1829
+ var VERSION = true ? "0.6.0" : "dev";
1653
1830
  var GLOBAL_OPTIONS = {
1654
1831
  json: { type: "boolean" },
1655
1832
  token: { type: "string" },
@@ -1692,16 +1869,44 @@ function parse(argv2, skip, leaf) {
1692
1869
  throw usageError(e.message.split("\n")[0].split(". To specify")[0].replace(/\.?$/, "."), leaf?.usage);
1693
1870
  }
1694
1871
  }
1872
+ function findSub(cmd, name) {
1873
+ return Object.entries(cmd.subs ?? {}).find(([n, l]) => n === name || l.aliases?.includes(name));
1874
+ }
1875
+ function unknownCommand(name) {
1876
+ const e = usageError(`Unknown command "${name}".`);
1877
+ const names = COMMANDS.flatMap((c) => [c.name, ...c.aliases ?? []]);
1878
+ const hint = didYouMean(name, names, "easybits ", (n) => findCommand(n)?.name ?? n);
1879
+ if (hint) e.hint = `${hint}
1880
+ Run: easybits --help`;
1881
+ return e;
1882
+ }
1883
+ function unknownSub(cmd, name) {
1884
+ const e = usageError(`Unknown subcommand "${cmd.name} ${name}".`, `easybits ${cmd.name} <${Object.keys(cmd.subs ?? {}).join("|")}>`);
1885
+ const names = Object.entries(cmd.subs ?? {}).flatMap(([n, l]) => [n, ...l.aliases ?? []]);
1886
+ const hint = didYouMean(name, names, `easybits ${cmd.name} `, (n) => findSub(cmd, n)?.[0] ?? n);
1887
+ if (hint) e.hint = `${hint}
1888
+ ${e.hint}`;
1889
+ return e;
1890
+ }
1695
1891
  async function main(argv2) {
1696
1892
  const pos = positionalIndexes(argv2);
1697
1893
  const cmdName = pos.length ? argv2[pos[0]] : void 0;
1698
1894
  if (!cmdName || cmdName === "help") {
1699
- const topic = cmdName === "help" && pos[1] != null ? findCommand(argv2[pos[1]]) : void 0;
1700
- const { values: values2 } = parse(argv2, pos.slice(0, 2), void 0);
1895
+ const topicName = cmdName === "help" && pos[1] != null ? argv2[pos[1]] : void 0;
1896
+ const topic = topicName ? findCommand(topicName) : void 0;
1897
+ if (topicName && !topic) throw unknownCommand(topicName);
1898
+ const subName = topic?.subs && pos[2] != null ? argv2[pos[2]] : void 0;
1899
+ const sub = topic && subName ? findSub(topic, subName) : void 0;
1900
+ if (topic && subName && !sub) throw unknownSub(topic, subName);
1901
+ const { values: values2 } = parse(argv2, pos.slice(0, sub ? 3 : 2), void 0);
1701
1902
  if (values2.version) {
1702
1903
  console.log(VERSION);
1703
1904
  return;
1704
1905
  }
1906
+ if (sub) {
1907
+ console.log(leafHelp(sub[1]));
1908
+ return;
1909
+ }
1705
1910
  if (topic) {
1706
1911
  console.log(commandHelp(topic));
1707
1912
  return;
@@ -1711,17 +1916,17 @@ async function main(argv2) {
1711
1916
  return;
1712
1917
  }
1713
1918
  const cmd = findCommand(cmdName);
1714
- if (!cmd) throw usageError(`Unknown command "${cmdName}".`);
1919
+ if (!cmd) throw unknownCommand(cmdName);
1715
1920
  let leaf = cmd.leaf;
1716
1921
  const skip = [pos[0]];
1717
1922
  if (cmd.subs) {
1718
1923
  const subName = pos[1] != null ? argv2[pos[1]] : void 0;
1719
- const entry = subName ? Object.entries(cmd.subs).find(([n, l]) => n === subName || l.aliases?.includes(subName)) : void 0;
1924
+ const entry = subName ? findSub(cmd, subName) : void 0;
1720
1925
  if (entry) {
1721
1926
  leaf = entry[1];
1722
1927
  skip.push(pos[1]);
1723
1928
  } else if (subName) {
1724
- throw usageError(`Unknown subcommand "${cmd.name} ${subName}".`, `easybits ${cmd.name} <${Object.keys(cmd.subs).join("|")}>`);
1929
+ throw unknownSub(cmd, subName);
1725
1930
  } else if (cmd.defaultSub && !argv2.includes("--help") && !argv2.includes("-h")) {
1726
1931
  leaf = cmd.subs[cmd.defaultSub];
1727
1932
  } else if (!argv2.includes("--help") && !argv2.includes("-h")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easybits.cloud/cli",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Official CLI for EasyBits, the cloud for AI agents — sandboxes, hosting, domains, databases, agents and files from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,13 +29,14 @@
29
29
  "build": "tsup",
30
30
  "dev": "tsup --watch",
31
31
  "prepublishOnly": "npm run build",
32
- "typecheck": "tsc --noEmit"
32
+ "typecheck": "tsc --noEmit",
33
+ "test": "node --experimental-strip-types --no-warnings --test src/*.test.ts"
33
34
  },
34
35
  "publishConfig": {
35
36
  "access": "public"
36
37
  },
37
38
  "dependencies": {
38
- "@easybits.cloud/sdk": "^0.35.3"
39
+ "@easybits.cloud/sdk": "^0.36.0"
39
40
  },
40
41
  "devDependencies": {
41
42
  "@types/node": "^22.0.0",