@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 +75 -10
- package/dist/api.js +1 -1
- package/dist/args.js +1 -1
- package/dist/commands/api-keys.js +20 -37
- package/dist/commands/connect.js +12 -8
- package/dist/commands/db.js +123 -81
- package/dist/commands/migrate.js +20 -20
- package/dist/commands/projects.js +39 -37
- package/dist/commands/schema.js +184 -19
- package/dist/commands/status.js +16 -13
- package/dist/commands/usage.d.ts +13 -0
- package/dist/commands/usage.js +164 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +9 -2
- package/dist/index.js +47 -11
- package/dist/utils/errors.d.ts +29 -0
- package/dist/utils/errors.js +65 -0
- package/dist/utils/logger.js +1 -1
- package/dist/utils/prompts.d.ts +10 -0
- package/dist/utils/prompts.js +15 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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.
|
|
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,
|
|
14
|
-
import { prompt,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/connect.js
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/commands/db.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
154
|
-
process.exit(1);
|
|
162
|
+
emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
|
|
155
163
|
}
|
|
156
|
-
|
|
164
|
+
// Don't block on an interactive prompt headlessly.
|
|
165
|
+
let branchName = name;
|
|
157
166
|
if (!branchName) {
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
183
|
+
s?.stop();
|
|
170
184
|
if (!res.ok) {
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
process.exit(1);
|
|
211
|
+
emitError("MISSING_ARG", "Branch name is required.", "Usage: bata db branch delete <name>");
|
|
189
212
|
}
|
|
190
|
-
|
|
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
|
|
203
|
-
|
|
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
|
|
209
|
-
|
|
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
|
|
236
|
+
s?.stop();
|
|
214
237
|
if (!res.ok) {
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
|
269
|
-
rows
|
|
270
|
-
row_count: rowCount
|
|
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) =>
|
|
306
|
+
table(columns.map((c) => c.toUpperCase()), rows.map((r) => columns.map((col) => fmtCell(r[col]))));
|
|
277
307
|
log();
|
|
278
|
-
log(` ${colors.dim(`${rowCount
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|