@batadata/cli 0.1.1 → 0.1.2

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 CHANGED
@@ -2,7 +2,11 @@
2
2
 
3
3
  Command-line interface for [BataDB](https://batadata.com) — a serverless Postgres
4
4
  platform. Create projects, open `psql`, run queries, manage branches and API keys,
5
- and generate types, all from your terminal.
5
+ inspect cost, and generate types, all from your terminal.
6
+
7
+ Every command also runs **headlessly** with a single API key and `--json` output,
8
+ so an AI coding agent can provision and operate a database with no human in the
9
+ loop. See [Headless / agent use](#headless--agent-use).
6
10
 
7
11
  ## Install
8
12
 
@@ -36,6 +40,7 @@ control plane with `--api-url <url>` or `BATA_API_URL`.
36
40
  bata create my-app # create a project and wait until it's ready
37
41
  bata status # list all projects and their status
38
42
  bata connect my-app # open psql (auto-wakes the compute if suspended)
43
+ bata usage # per-dimension cost for the current billing period
39
44
  ```
40
45
 
41
46
  ## Commands
@@ -45,33 +50,93 @@ bata connect my-app # open psql (auto-wakes the compute if suspended)
45
50
  | `create <name>` | Create a project and wait for it to be ready |
46
51
  | `connect <name>` | Open `psql` to a project (auto-wakes if suspended) |
47
52
  | `status` | Show all projects and their status |
53
+ | `usage` | Show per-dimension cost (compute, storage, transfer) for the current period |
48
54
  | `login` / `logout` / `whoami` | Manage your session |
49
55
  | `api-keys` | Create / list / revoke API keys |
50
56
  | `projects` | `list`, `create`, `info`, `delete` |
51
57
  | `db url` | Print a connection string |
52
- | `db connect` | Open `psql` to your database |
53
- | `db query` | Run a SQL query |
58
+ | `db connect` | Open `psql` to your database (interactive only) |
59
+ | `db query <sql>` | Run a SQL query and print the rows |
54
60
  | `db branches` | List database branches |
55
61
  | `db branch create` / `db branch delete` | Manage branches |
56
62
  | `db studio` | Open the table browser in your browser |
63
+ | `schema check <file>` | Check a proposed schema change against live query traffic |
57
64
  | `generate` | Generate types from your database schema (`--watch` for watch mode) |
58
65
  | `dev` | Print the local development setup guide |
59
66
 
60
- `schema` and `migrate` are reserved and currently print a "coming soon" notice.
67
+ `schema check` is implemented today. The remaining `schema` subcommands
68
+ (`init`, `push`, `pull`, `diff`) and all of `migrate` are not implemented yet —
69
+ they exit `3` (`NOT_IMPLEMENTED`) and point you at `schema check` for migration
70
+ safety.
61
71
 
62
72
  Run `bata --help` for the full, authoritative command list, or `bata --version`.
63
73
 
64
74
  ## Headless / agent use
65
75
 
66
- The CLI is built to be scriptable:
76
+ Every command runs headlessly with just `BATA_API_KEY` set — no `bata login`,
77
+ no interactive prompts, no human in the loop. This is what makes BataDB
78
+ agent-native: an AI coding agent can provision a database, run SQL, branch, and
79
+ clean up entirely on its own.
80
+
81
+ - **`--json`** — machine-readable output. The error envelope is JSON too and
82
+ always the same shape: `{ "error", "code", "hint" }`.
83
+ - **Exit codes** — a stable contract so a script can branch on the failure mode:
84
+
85
+ | Code | Meaning |
86
+ |------|---------|
87
+ | `0` | Success |
88
+ | `1` | Generic error (`CLI_ERROR`) |
89
+ | `2` | Gate tripped (`schema check --fail-on`) |
90
+ | `3` | Not implemented (`NOT_IMPLEMENTED`) |
91
+ | `4` | Auth / credentials (`NO_CREDENTIALS`, `INVALID_KEY`) |
92
+ | `5` | Not found / bad input (`NO_PROJECT`, `BRANCH_NOT_FOUND`, `INVALID_FLAG`, `INTERACTIVE_ONLY`) |
93
+ | `6` | Upstream / transient (`API_UNAVAILABLE`, `TIMEOUT`) — safe to retry |
94
+
95
+ - **`--yes` / `-y`** — skip confirmation prompts on destructive commands.
96
+
97
+ `db connect` and `connect` open an interactive `psql` session and exit `5`
98
+ (`INTERACTIVE_ONLY`) when run headlessly. Agents should use `db url` for a
99
+ connection string or `db query <sql>` to execute SQL.
100
+
101
+ ### A full agent workflow
102
+
103
+ Provision, query, branch, check cost, and tear down — no human, no login:
104
+
105
+ ```bash
106
+ export BATA_API_KEY=bata_xxx
107
+
108
+ # 1. Create a project and wait until it's ready
109
+ bata create my-agent-app --json
110
+
111
+ # 2. Run SQL headlessly (rows come back as JSON objects)
112
+ bata db query "SELECT now()" --json
113
+
114
+ # 3. Branch the database (copy-on-write)
115
+ bata db branch create preview --json
116
+
117
+ # 4. Inspect cost for the current period
118
+ bata usage --json
119
+
120
+ # 5. Clean up
121
+ bata projects delete --yes --json
122
+ ```
123
+
124
+ `db branch create` returns immediately with `"ready": false` — the branch row
125
+ exists but its compute may still be provisioning. Poll `bata db branches --json`
126
+ until the branch reports a ready status before connecting to it.
127
+
128
+ ### Cost truth
67
129
 
68
- - **`--json`** machine-readable output on read commands. Errors are emitted as
69
- JSON too, e.g. `{"error":"Not authenticated","code":"NO_CREDENTIALS","hint":"..."}`.
70
- - **Exit codes** — `0` on success, `1` on error (auth failures, unknown commands).
71
- - **`--yes` / `-y`** skip confirmation prompts.
130
+ `bata usage` reports cost per dimension and is deliberately honest about what is
131
+ and isn't metered. Transfer (egress) is **not metered yet**, so it never shows a
132
+ priced `$0`in `--json` it reports `{ "metered": false, "value": null,
133
+ "cost_cents": null, "status": "not_metered_yet" }`, and in the human view it
134
+ reads `Not metered yet`. You only ever see a dollar figure for a dimension we
135
+ genuinely meter end to end (compute and storage today).
72
136
 
73
137
  ```bash
74
- bata status --json | jq '.[].name'
138
+ bata usage --json | jq '.projects[].transfer.status' # "not_metered_yet"
139
+ bata status --json | jq '.projects[].name' # list project names
75
140
  ```
76
141
 
77
142
  ## Global options
package/dist/api.js CHANGED
@@ -14,7 +14,7 @@ export async function request(method, path, options = {}) {
14
14
  }
15
15
  const headers = {
16
16
  "Content-Type": "application/json",
17
- "User-Agent": "@batadata/cli 0.1.0",
17
+ "User-Agent": "@batadata/cli 0.1.2",
18
18
  };
19
19
  if (options.token) {
20
20
  headers["Authorization"] = `Bearer ${options.token}`;
package/dist/args.js CHANGED
@@ -36,6 +36,6 @@ export function parseGlobalFlags(argv) {
36
36
  rest.push(arg);
37
37
  }
38
38
  }
39
- setRuntime({ json: flags.json, apiKey: flags.apiKey, apiUrl: flags.apiUrl });
39
+ setRuntime({ json: flags.json, apiKey: flags.apiKey, apiUrl: flags.apiUrl, yes: flags.yes });
40
40
  return { rest, flags };
41
41
  }
@@ -10,8 +10,15 @@
10
10
  */
11
11
  import { api, apiError } from "../api.js";
12
12
  import { requireToken, isJsonMode } from "../config.js";
13
- import { colors, log, json, success, error, warn, spinner, table, kvList, heading } from "../utils/logger.js";
14
- import { prompt, confirm } from "../utils/prompts.js";
13
+ import { colors, log, json, success, warn, spinner, table, kvList, heading } from "../utils/logger.js";
14
+ import { prompt, confirmDestructive } from "../utils/prompts.js";
15
+ import { emitError } from "../utils/errors.js";
16
+ /** Map an API-key endpoint failure to the right coded exit. */
17
+ function apiKeyError(res, fallbackMsg) {
18
+ emitError(res.status === 401 || res.status === 403 ? "INVALID_KEY"
19
+ : res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE"
20
+ : "CLI_ERROR", fallbackMsg, "");
21
+ }
15
22
  function parseFlag(args, name) {
16
23
  for (let i = 0; i < args.length; i++) {
17
24
  if (args[i] === name && args[i + 1])
@@ -60,13 +67,7 @@ async function create(args) {
60
67
  const res = await api.post("/v1/api-keys", { name }, token);
61
68
  s?.stop();
62
69
  if (!res.ok) {
63
- if (jsonMode) {
64
- json({ error: apiError(res, "Failed to create API key") });
65
- }
66
- else {
67
- error(apiError(res, "Failed to create API key"));
68
- }
69
- process.exit(1);
70
+ apiKeyError(res, apiError(res, "Failed to create API key"));
70
71
  }
71
72
  const k = res.data;
72
73
  if (jsonMode) {
@@ -104,13 +105,7 @@ async function list(args) {
104
105
  const res = await api.get("/v1/api-keys", token);
105
106
  s?.stop();
106
107
  if (!res.ok) {
107
- if (jsonMode) {
108
- json({ error: apiError(res, "Failed to list API keys") });
109
- }
110
- else {
111
- error(apiError(res, "Failed to list API keys"));
112
- }
113
- process.exit(1);
108
+ apiKeyError(res, apiError(res, "Failed to list API keys"));
114
109
  }
115
110
  const keys = Array.isArray(res.data) ? res.data : [];
116
111
  if (jsonMode) {
@@ -145,32 +140,22 @@ async function list(args) {
145
140
  async function revoke(args) {
146
141
  const token = requireToken();
147
142
  const jsonMode = isJsonMode();
148
- const skipConfirm = args.includes("--yes") || args.includes("-y");
149
143
  const id = positional(args)[0];
150
144
  if (!id) {
151
- const msg = "API key ID is required. Usage: bata api-keys revoke <id> [--yes]";
152
- if (jsonMode)
153
- json({ error: msg });
154
- else
155
- error(msg);
156
- process.exit(1);
145
+ emitError("MISSING_ARG", "API key ID is required.", "Usage: bata api-keys revoke <id> [--yes]");
157
146
  }
158
- if (!skipConfirm && !jsonMode && process.stdin.isTTY) {
159
- const ok = await confirm(`Revoke API key ${colors.cyan(id)}? This cannot be undone.`, false);
160
- if (!ok) {
161
- log(" Aborted.");
162
- return;
163
- }
147
+ // Shared headless-skip rule (--yes / --json / no TTY) so the prompt can't
148
+ // block an agent and the behavior can't drift from branch/project deletes.
149
+ const ok = await confirmDestructive(`Revoke API key ${colors.cyan(id)}? This cannot be undone.`);
150
+ if (!ok) {
151
+ log(" Aborted.");
152
+ return;
164
153
  }
165
154
  const s = jsonMode ? null : spinner("Revoking API key");
166
155
  const res = await api.del(`/v1/api-keys/${id}`, token);
167
156
  s?.stop();
168
157
  if (!res.ok) {
169
- if (jsonMode)
170
- json({ error: apiError(res, "Failed to revoke API key") });
171
- else
172
- error(apiError(res, "Failed to revoke API key"));
173
- process.exit(1);
158
+ apiKeyError(res, apiError(res, "Failed to revoke API key"));
174
159
  }
175
160
  if (jsonMode) {
176
161
  json({ id, revoked: true });
@@ -215,8 +200,6 @@ export async function handleApiKeys(args) {
215
200
  case "revoke":
216
201
  return revoke(rest);
217
202
  default:
218
- error(`Unknown subcommand: api-keys ${sub}`);
219
- log(` ${colors.dim("Available:")} create, list, revoke`);
220
- process.exit(1);
203
+ emitError("INVALID_FLAG", `Unknown subcommand: api-keys ${sub}`, "Available: create, list, revoke");
221
204
  }
222
205
  }
@@ -6,9 +6,15 @@
6
6
  */
7
7
  import { execSync, spawn } from "node:child_process";
8
8
  import { api } from "../api.js";
9
- import { requireToken, loadConfig } from "../config.js";
9
+ import { requireToken, loadConfig, isJsonMode } from "../config.js";
10
10
  import { colors, log, error, spinner, info as logInfo } from "../utils/logger.js";
11
+ import { emitError } from "../utils/errors.js";
11
12
  export async function connect(args) {
13
+ // psql is an interactive session — there's no headless equivalent. Don't spawn
14
+ // it in --json or non-TTY contexts; point agents at the headless surfaces.
15
+ if (isJsonMode() || !process.stdin.isTTY) {
16
+ emitError("INTERACTIVE_ONLY", "bata connect opens an interactive psql session and can't run headlessly.", "Use `bata db url` for a connection string or `bata db query <sql>` for headless execution.");
17
+ }
12
18
  const token = requireToken();
13
19
  const config = loadConfig();
14
20
  // Accept project name as argument, or use default
@@ -23,21 +29,19 @@ export async function connect(args) {
23
29
  const projRes = await api.get("/v1/projects", token, query);
24
30
  s.stop();
25
31
  if (!projRes.ok) {
26
- error("Failed to fetch projects.");
27
- process.exit(1);
32
+ emitError(projRes.status === 401 || projRes.status === 403 ? "INVALID_KEY"
33
+ : projRes.status >= 500 || projRes.status === 0 ? "API_UNAVAILABLE"
34
+ : "CLI_ERROR", "Failed to fetch projects.", "");
28
35
  }
29
36
  const projects = Array.isArray(projRes.data) ? projRes.data : [];
30
37
  const found = projects.find((p) => p.name === projectName || p.id === projectName);
31
38
  if (!found) {
32
- error(`Project "${projectName}" not found.`);
33
- process.exit(1);
39
+ emitError("NOT_FOUND", `Project "${projectName}" not found.`, "");
34
40
  }
35
41
  projectId = found.id;
36
42
  }
37
43
  if (!projectId) {
38
- error("No project specified. Usage: bata connect <project-name>");
39
- log(` Or set a default with ${colors.cyan("bata create <name>")}`);
40
- process.exit(1);
44
+ emitError("NO_PROJECT", "No project specified.", "Usage: bata connect <project-name> (or set a default with bata create <name>)");
41
45
  }
42
46
  const s = spinner("Fetching connection info");
43
47
  const connRes = await api.get(`/v1/connection-info/${projectId}`, token);
@@ -2,8 +2,9 @@ import { execSync, spawn } from "node:child_process";
2
2
  import { api, apiError } from "../api.js";
3
3
  import { requireToken, loadConfig, isJsonMode } from "../config.js";
4
4
  import { colors, log, json, error, spinner, table, heading, info as logInfo } from "../utils/logger.js";
5
- import { prompt, confirm } from "../utils/prompts.js";
5
+ import { prompt, confirmDestructive } from "../utils/prompts.js";
6
6
  import { openBrowser } from "../utils/open.js";
7
+ import { emitError } from "../utils/errors.js";
7
8
  async function getConnectionInfo(projectId, token) {
8
9
  // reveal=true so the returned string is actually usable (the owner is asking).
9
10
  const res = await api.get(`/v1/connection-info/${projectId}`, token, { reveal: "true" });
@@ -29,20 +30,39 @@ function formatDate(iso) {
29
30
  const d = new Date(iso);
30
31
  return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
31
32
  }
33
+ /**
34
+ * Resolve a project's primary branch (the one to run a query against). Lists
35
+ * branches the same way `db branches` does (GET /v1/projects/:id) and returns
36
+ * the primary, falling back to the first branch. Returns null if the project
37
+ * has no branches or the lookup failed.
38
+ */
39
+ async function getPrimaryBranch(projectId, token, teamId) {
40
+ const query = {};
41
+ if (teamId)
42
+ query.team_id = teamId;
43
+ const res = await api.get(`/v1/projects/${projectId}`, token, query);
44
+ if (!res.ok)
45
+ return null;
46
+ const list = res.data.branches ?? [];
47
+ return list.find((b) => b.is_primary) ?? list[0] ?? null;
48
+ }
32
49
  export async function connect() {
50
+ // psql is an interactive session — there's no headless equivalent. Don't spawn
51
+ // it in --json or non-TTY contexts; point agents at the headless surfaces.
52
+ if (isJsonMode() || !process.stdin.isTTY) {
53
+ emitError("INTERACTIVE_ONLY", "bata db connect opens an interactive psql session and can't run headlessly.", "Use `bata db url` for a connection string or `bata db query <sql>` for headless execution.");
54
+ }
33
55
  const token = requireToken();
34
56
  const config = loadConfig();
35
57
  const projectId = config.defaultProject;
36
58
  if (!projectId) {
37
- error("No default project. Run bata projects create or set one with bata projects info <id>.");
38
- process.exit(1);
59
+ emitError("NO_PROJECT", "No default project.", "Run bata projects create or set one with bata projects info <id>.");
39
60
  }
40
61
  const s = spinner("Fetching connection info");
41
62
  const conn = await getConnectionInfo(projectId, token);
42
63
  s.stop();
43
64
  if (!conn || !conn.connection_uri) {
44
- error("Could not fetch connection string for this project.");
45
- process.exit(1);
65
+ emitError("NOT_FOUND", "Could not fetch connection string for this project.", "");
46
66
  }
47
67
  log();
48
68
  logInfo(`Connecting to project ${colors.cyan(projectId)}`);
@@ -70,17 +90,11 @@ export async function url() {
70
90
  const config = loadConfig();
71
91
  const projectId = config.defaultProject;
72
92
  if (!projectId) {
73
- error("No default project set.");
74
- process.exit(1);
93
+ emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
75
94
  }
76
95
  const conn = await getConnectionInfo(projectId, token);
77
96
  if (!conn || !conn.connection_uri) {
78
- const msg = "Could not fetch connection string.";
79
- if (isJsonMode())
80
- json({ error: msg });
81
- else
82
- error(msg);
83
- process.exit(1);
97
+ emitError("NOT_FOUND", "Could not fetch connection string.", "");
84
98
  }
85
99
  if (isJsonMode()) {
86
100
  json({ direct: conn.connection_uri, pooled: conn.pooled_uri ?? null });
@@ -95,12 +109,7 @@ export async function branches() {
95
109
  const jsonMode = isJsonMode();
96
110
  const projectId = config.defaultProject;
97
111
  if (!projectId) {
98
- const msg = "No default project set.";
99
- if (jsonMode)
100
- json({ error: msg });
101
- else
102
- error(msg);
103
- process.exit(1);
112
+ emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
104
113
  }
105
114
  const s = jsonMode ? null : spinner("Fetching branches");
106
115
  const query = {};
@@ -109,11 +118,10 @@ export async function branches() {
109
118
  const res = await api.get(`/v1/projects/${projectId}`, token, query);
110
119
  s?.stop();
111
120
  if (!res.ok) {
112
- if (jsonMode)
113
- json({ error: apiError(res, "Failed to fetch branches") });
114
- else
115
- error(apiError(res, "Failed to fetch project branches"));
116
- process.exit(1);
121
+ emitError(res.status === 401 || res.status === 403 ? "INVALID_KEY"
122
+ : res.status === 404 ? "NO_PROJECT"
123
+ : res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE"
124
+ : "CLI_ERROR", apiError(res, "Failed to fetch branches"), "");
117
125
  }
118
126
  const branchList = res.data.branches || [];
119
127
  if (jsonMode) {
@@ -146,19 +154,25 @@ export async function branches() {
146
154
  log();
147
155
  }
148
156
  export async function branchCreate(name) {
157
+ const jsonMode = isJsonMode();
149
158
  const token = requireToken();
150
159
  const config = loadConfig();
151
160
  const projectId = config.defaultProject;
152
161
  if (!projectId) {
153
- error("No default project set.");
154
- process.exit(1);
162
+ emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
155
163
  }
156
- const branchName = name || (await prompt("Branch name"));
164
+ // Don't block on an interactive prompt headlessly.
165
+ let branchName = name;
157
166
  if (!branchName) {
158
- error("Branch name is required.");
159
- process.exit(1);
167
+ if (jsonMode || !process.stdin.isTTY) {
168
+ emitError("MISSING_ARG", "Branch name is required.", "Usage: bata db branch create <name>");
169
+ }
170
+ branchName = await prompt("Branch name");
171
+ }
172
+ if (!branchName) {
173
+ emitError("MISSING_ARG", "Branch name is required.", "Usage: bata db branch create <name>");
160
174
  }
161
- const s = spinner(`Creating branch ${colors.cyan(branchName)}`);
175
+ const s = jsonMode ? null : spinner(`Creating branch ${colors.cyan(branchName)}`);
162
176
  const body = {
163
177
  name: branchName,
164
178
  project_id: projectId,
@@ -166,54 +180,66 @@ export async function branchCreate(name) {
166
180
  if (config.defaultTeam)
167
181
  body.team_id = config.defaultTeam;
168
182
  const res = await api.post("/v1/branches", body, token);
169
- s.stop();
183
+ s?.stop();
170
184
  if (!res.ok) {
171
- error(apiError(res, "Failed to create branch"));
172
- process.exit(1);
185
+ emitError(res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE" : "CLI_ERROR", apiError(res, "Failed to create branch"), "");
186
+ }
187
+ if (jsonMode) {
188
+ json({
189
+ branch: { id: res.data.id, name: res.data.name ?? branchName, project_id: projectId },
190
+ // The branch row exists immediately, but its compute may still be
191
+ // provisioning — poll `db branches` for status before connecting.
192
+ ready: false,
193
+ poll: "bata db branches --json",
194
+ });
195
+ return;
173
196
  }
174
197
  log();
175
198
  log(` ${colors.green(">")} Branch ${colors.cyan(branchName)} created`);
199
+ log(` ${colors.dim("Poll readiness with")} ${colors.cyan("bata db branches")}`);
176
200
  log();
177
201
  }
178
202
  export async function branchDelete(name) {
203
+ const jsonMode = isJsonMode();
179
204
  const token = requireToken();
180
205
  const config = loadConfig();
181
206
  const projectId = config.defaultProject;
182
207
  if (!projectId) {
183
- error("No default project set.");
184
- process.exit(1);
208
+ emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
185
209
  }
186
210
  if (!name) {
187
- error("Branch name is required. Usage: bata db branch delete <name>");
188
- process.exit(1);
211
+ emitError("MISSING_ARG", "Branch name is required.", "Usage: bata db branch delete <name>");
189
212
  }
190
- const ok = await confirm(`Delete branch ${colors.cyan(name)}?`, false);
213
+ // Skip the prompt headlessly (--yes / --json / no TTY) so agents and CI can
214
+ // actually delete — and never report success without deleting.
215
+ const ok = await confirmDestructive(`Delete branch ${colors.cyan(name)}?`);
191
216
  if (!ok) {
192
217
  log(" Aborted.");
193
218
  return;
194
219
  }
195
- const s = spinner(`Deleting branch ${name}`);
220
+ const s = jsonMode ? null : spinner(`Deleting branch ${name}`);
196
221
  // We need to find the branch ID first
197
222
  const query = {};
198
223
  if (config.defaultTeam)
199
224
  query.team_id = config.defaultTeam;
200
225
  const projRes = await api.get(`/v1/projects/${projectId}`, token, query);
201
226
  if (!projRes.ok || !projRes.data.branches) {
202
- s.stop();
203
- error("Failed to fetch branches.");
204
- process.exit(1);
227
+ s?.stop();
228
+ emitError(projRes.status >= 500 || projRes.status === 0 ? "API_UNAVAILABLE" : "CLI_ERROR", apiError(projRes, "Failed to fetch branches."), "");
205
229
  }
206
230
  const branch = projRes.data.branches.find((b) => b.name === name);
207
231
  if (!branch) {
208
- s.stop();
209
- error(`Branch "${name}" not found.`);
210
- process.exit(1);
232
+ s?.stop();
233
+ emitError("BRANCH_NOT_FOUND", `Branch "${name}" not found.`, "List branches with: bata db branches --json");
211
234
  }
212
235
  const res = await api.del(`/v1/branches/${branch.id}`, token, query);
213
- s.stop();
236
+ s?.stop();
214
237
  if (!res.ok) {
215
- error("Failed to delete branch.");
216
- process.exit(1);
238
+ emitError(res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE" : "CLI_ERROR", apiError(res, "Failed to delete branch."), "");
239
+ }
240
+ if (jsonMode) {
241
+ json({ branch: { id: branch.id, name, project_id: projectId }, deleted: true });
242
+ return;
217
243
  }
218
244
  log();
219
245
  log(` ${colors.green(">")} Branch ${colors.cyan(name)} deleted`);
@@ -234,55 +260,76 @@ export async function studio() {
234
260
  export async function query(sql) {
235
261
  const jsonMode = isJsonMode();
236
262
  if (!sql) {
237
- const msg = 'SQL query is required. Usage: bata db query "SELECT 1"';
238
- if (jsonMode)
239
- json({ error: msg });
240
- else
241
- error(msg);
242
- process.exit(1);
263
+ emitError("MISSING_ARG", "SQL query is required.", 'Usage: bata db query "SELECT 1"');
243
264
  }
244
265
  const token = requireToken();
245
266
  const config = loadConfig();
246
267
  const projectId = config.defaultProject;
247
268
  if (!projectId) {
248
- const msg = "No default project set.";
249
- if (jsonMode)
250
- json({ error: msg });
251
- else
252
- error(msg);
253
- process.exit(1);
269
+ emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
254
270
  }
255
271
  const s = jsonMode ? null : spinner("Running query");
256
- const res = await api.post(`/v1/query/${projectId}`, { sql }, token);
272
+ // Resolve the primary branch — /v1/sql/execute is keyed on branch_id, not
273
+ // project. (The old code POSTed to /v1/query/:projectId, which 404s.)
274
+ const branch = await getPrimaryBranch(projectId, token, config.defaultTeam);
275
+ if (!branch) {
276
+ s?.stop();
277
+ emitError("BRANCH_NOT_FOUND", "No branch found for this project to run the query against.", "Check the project with: bata db branches --json");
278
+ }
279
+ const res = await api.post("/v1/sql/execute", { branch_id: branch.id, query: sql }, token);
257
280
  s?.stop();
258
281
  if (!res.ok) {
259
- if (jsonMode)
260
- json({ error: apiError(res, "Query failed") });
261
- else
262
- error(apiError(res, "Query failed"));
263
- process.exit(1);
282
+ const code = res.status === 401 || res.status === 403 ? "INVALID_KEY"
283
+ : res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE"
284
+ : "CLI_ERROR";
285
+ emitError(code, apiError(res, "Query failed"), "");
264
286
  }
265
- const { columns, rows, rowCount } = res.data;
287
+ // The endpoint returns 200 even when the SQL itself errored (it executed and
288
+ // failed). Treat that as a real error, not a silent success.
289
+ if (res.data.error) {
290
+ emitError("CLI_ERROR", res.data.error, "");
291
+ }
292
+ const columns = res.data.columns ?? [];
293
+ const rows = res.data.rows ?? [];
294
+ const rowCount = res.data.rowCount ?? rows.length;
266
295
  if (jsonMode) {
267
296
  json({
268
- columns: columns ?? [],
269
- rows: rows ?? [],
270
- row_count: rowCount ?? rows?.length ?? 0,
297
+ columns,
298
+ rows, // column-keyed objects, exactly as the server returned them
299
+ row_count: rowCount,
300
+ command: res.data.command ?? null,
271
301
  });
272
302
  return;
273
303
  }
274
- if (columns && rows) {
304
+ if (columns.length && rows.length) {
275
305
  log();
276
- table(columns.map((c) => c.toUpperCase()), rows.map((r) => r.map(String)));
306
+ table(columns.map((c) => c.toUpperCase()), rows.map((r) => columns.map((col) => fmtCell(r[col]))));
277
307
  log();
278
- log(` ${colors.dim(`${rowCount ?? rows.length} row(s)`)}`);
308
+ log(` ${colors.dim(`${rowCount} row(s)`)}`);
309
+ }
310
+ else if (rows.length) {
311
+ // Rows with no field metadata (rare) — render whatever keys came back.
312
+ const keys = Object.keys(rows[0]);
313
+ log();
314
+ table(keys.map((k) => k.toUpperCase()), rows.map((r) => keys.map((k) => fmtCell(r[k]))));
315
+ log();
316
+ log(` ${colors.dim(`${rowCount} row(s)`)}`);
279
317
  }
280
318
  else {
281
319
  log();
282
- log(` ${colors.dim("Query executed successfully.")}`);
320
+ const cmd = res.data.command ? `${res.data.command} ` : "";
321
+ log(` ${colors.dim(`${cmd}OK — ${rowCount} row(s) affected.`)}`);
283
322
  }
284
323
  log();
285
324
  }
325
+ /** Render a column-keyed value for the table (null → empty, objects → JSON). */
326
+ function fmtCell(v) {
327
+ if (v === null || v === undefined)
328
+ return "";
329
+ if (typeof v === "object")
330
+ return JSON.stringify(v);
331
+ return String(v);
332
+ }
286
333
  export async function handleDb(args) {
287
334
  const sub = args[0];
288
335
  switch (sub) {
@@ -298,18 +345,13 @@ export async function handleDb(args) {
298
345
  return branchCreate(args[2]);
299
346
  if (action === "delete")
300
347
  return branchDelete(args[2]);
301
- error(`Unknown: db branch ${action || ""}`);
302
- log(` ${colors.dim("Available:")} create, delete`);
303
- process.exit(1);
304
- break;
348
+ emitError("INVALID_FLAG", `Unknown: db branch ${action || ""}`, "Available: create, delete");
305
349
  }
306
350
  case "studio":
307
351
  return studio();
308
352
  case "query":
309
353
  return query(args.slice(1).join(" "));
310
354
  default:
311
- error(`Unknown subcommand: db ${sub || ""}`);
312
- log(` ${colors.dim("Available:")} connect, url, branches, branch, studio, query`);
313
- process.exit(1);
355
+ emitError("INVALID_FLAG", `Unknown subcommand: db ${sub || ""}`, "Available: connect, url, branches, branch, studio, query");
314
356
  }
315
357
  }