@batadata/cli 0.1.0 → 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.
Files changed (44) hide show
  1. package/README.md +155 -0
  2. package/dist/api.js +1 -2
  3. package/dist/args.js +1 -2
  4. package/dist/commands/api-keys.js +20 -38
  5. package/dist/commands/auth.js +0 -1
  6. package/dist/commands/connect.js +12 -9
  7. package/dist/commands/create.js +0 -1
  8. package/dist/commands/db.js +123 -82
  9. package/dist/commands/dev.js +0 -1
  10. package/dist/commands/generate.js +0 -1
  11. package/dist/commands/migrate.js +20 -21
  12. package/dist/commands/projects.js +39 -38
  13. package/dist/commands/schema.js +184 -20
  14. package/dist/commands/status.js +16 -14
  15. package/dist/commands/usage.d.ts +13 -0
  16. package/dist/commands/usage.js +164 -0
  17. package/dist/config.d.ts +3 -0
  18. package/dist/config.js +9 -3
  19. package/dist/index.js +48 -13
  20. package/dist/utils/errors.d.ts +29 -0
  21. package/dist/utils/errors.js +65 -0
  22. package/dist/utils/logger.js +1 -2
  23. package/dist/utils/open.js +0 -1
  24. package/dist/utils/prompts.d.ts +10 -0
  25. package/dist/utils/prompts.js +15 -1
  26. package/package.json +1 -1
  27. package/dist/api.js.map +0 -1
  28. package/dist/args.js.map +0 -1
  29. package/dist/commands/api-keys.js.map +0 -1
  30. package/dist/commands/auth.js.map +0 -1
  31. package/dist/commands/connect.js.map +0 -1
  32. package/dist/commands/create.js.map +0 -1
  33. package/dist/commands/db.js.map +0 -1
  34. package/dist/commands/dev.js.map +0 -1
  35. package/dist/commands/generate.js.map +0 -1
  36. package/dist/commands/migrate.js.map +0 -1
  37. package/dist/commands/projects.js.map +0 -1
  38. package/dist/commands/schema.js.map +0 -1
  39. package/dist/commands/status.js.map +0 -1
  40. package/dist/config.js.map +0 -1
  41. package/dist/index.js.map +0 -1
  42. package/dist/utils/logger.js.map +0 -1
  43. package/dist/utils/open.js.map +0 -1
  44. package/dist/utils/prompts.js.map +0 -1
@@ -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,19 +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
  }
316
- //# sourceMappingURL=db.js.map
@@ -25,4 +25,3 @@ export async function dev() {
25
25
  log(` ${colors.cyan("bata generate -w")} Watch mode for type generation`);
26
26
  log();
27
27
  }
28
- //# sourceMappingURL=dev.js.map
@@ -80,4 +80,3 @@ export async function generate(args) {
80
80
  }
81
81
  });
82
82
  }
83
- //# sourceMappingURL=generate.js.map
@@ -1,38 +1,37 @@
1
- import { colors, log, heading } from "../utils/logger.js";
2
- function comingSoon(cmd) {
3
- heading(`Migrate: ${cmd}`);
4
- log(` ${colors.yellow("!")} ${colors.dim("Coming soon.")} Migrations are not yet implemented.`);
5
- log();
6
- log(` ${colors.dim("Track progress at")} ${colors.cyan("https://github.com/zvndev")}`);
7
- log();
1
+ import { colors, log } from "../utils/logger.js";
2
+ import { emitError } from "../utils/errors.js";
3
+ const NOT_IMPLEMENTED_HINT = "Use `bata schema check <file.sql> --fail-on breaking` to gate migrations today.";
4
+ /**
5
+ * Unimplemented migrate subcommand. Never exits 0 for a no-op: emits the
6
+ * NOT_IMPLEMENTED envelope (exit 3) so agents/CI see a real failure, with a
7
+ * hint pointing at the migration-gating capability that exists today.
8
+ */
9
+ function notImplemented(cmd) {
10
+ emitError("NOT_IMPLEMENTED", `Migrate ${cmd} is not yet implemented.`, NOT_IMPLEMENTED_HINT);
8
11
  }
9
12
  export async function handleMigrate(args) {
10
13
  const sub = args[0];
11
14
  switch (sub) {
12
15
  case "create":
13
- comingSoon("create");
14
- break;
16
+ notImplemented("create");
15
17
  case "deploy":
16
- comingSoon("deploy");
17
- break;
18
+ notImplemented("deploy");
18
19
  case "status":
19
- comingSoon("status");
20
- break;
20
+ notImplemented("status");
21
21
  case "reset":
22
- comingSoon("reset");
23
- break;
22
+ notImplemented("reset");
24
23
  default:
25
24
  log();
26
25
  log(` ${colors.bold("bata migrate")} ${colors.dim("— database migration management")}`);
27
26
  log();
28
27
  log(` ${colors.dim("Commands:")}`);
29
- log(` ${colors.cyan("create")} Create a new migration`);
30
- log(` ${colors.cyan("deploy")} Deploy pending migrations`);
31
- log(` ${colors.cyan("status")} Show migration status`);
32
- log(` ${colors.cyan("reset")} Reset database (dangerous)`);
28
+ log(` ${colors.cyan("create")} Create a new migration ${colors.dim("(not implemented)")}`);
29
+ log(` ${colors.cyan("deploy")} Deploy pending migrations ${colors.dim("(not implemented)")}`);
30
+ log(` ${colors.cyan("status")} Show migration status ${colors.dim("(not implemented)")}`);
31
+ log(` ${colors.cyan("reset")} Reset database ${colors.dim("(not implemented)")}`);
33
32
  log();
34
- log(` ${colors.yellow("!")} ${colors.dim("All migration commands are coming soon.")}`);
33
+ log(` ${colors.yellow("!")} ${colors.dim("Migration commands are not yet implemented. To gate a migration today:")}`);
34
+ log(` ${colors.cyan("bata schema check <file.sql> --fail-on breaking")}`);
35
35
  log();
36
36
  }
37
37
  }
38
- //# sourceMappingURL=migrate.js.map
@@ -1,7 +1,8 @@
1
1
  import { api, apiError, resolveTeamId, asList } from "../api.js";
2
2
  import { requireToken, loadConfig, saveConfig, isJsonMode } from "../config.js";
3
- import { colors, log, json, success, error, spinner, table, kvList, heading } from "../utils/logger.js";
4
- import { prompt, confirm, select } from "../utils/prompts.js";
3
+ import { colors, log, json, success, spinner, table, kvList, heading } from "../utils/logger.js";
4
+ import { prompt, confirmDestructive, select } from "../utils/prompts.js";
5
+ import { emitError } from "../utils/errors.js";
5
6
  function projectCreatedAt(p) {
6
7
  return p.created_at ?? p.createdAt ?? "";
7
8
  }
@@ -38,18 +39,19 @@ export async function list() {
38
39
  const config = loadConfig();
39
40
  const jsonMode = isJsonMode();
40
41
  const s = jsonMode ? null : spinner("Fetching projects");
42
+ // GET /v1/projects requires a team_id — resolve it identically to `create`
43
+ // (falls back to the user's first team) so the API-key path doesn't 400.
41
44
  const teamId = await resolveTeamId(token);
42
- const query = {};
43
- if (teamId)
44
- query.team_id = teamId;
45
- const res = await api.get("/v1/projects", token, query);
45
+ if (!teamId) {
46
+ s?.stop();
47
+ emitError("NO_TEAM", "No team found for this credential.", "This API key isn't attached to a team. Run `bata login` or check `bata whoami`.");
48
+ }
49
+ const res = await api.get("/v1/projects", token, { team_id: teamId });
46
50
  if (!res.ok) {
47
51
  s?.stop();
48
- if (jsonMode)
49
- json({ error: apiError(res, "Failed to fetch projects") });
50
- else
51
- error(apiError(res, "Failed to fetch projects"));
52
- process.exit(1);
52
+ emitError(res.status === 401 || res.status === 403 ? "INVALID_KEY"
53
+ : res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE"
54
+ : "CLI_ERROR", apiError(res, "Failed to fetch projects"), "");
53
55
  }
54
56
  s?.stop();
55
57
  const projects = asList(res.data);
@@ -88,8 +90,7 @@ export async function create() {
88
90
  heading("Create a new project");
89
91
  const name = await prompt("Project name");
90
92
  if (!name) {
91
- error("Project name is required.");
92
- process.exit(1);
93
+ emitError("MISSING_ARG", "Project name is required.", "");
93
94
  }
94
95
  const region = await select("Select a region", REGIONS);
95
96
  const s = spinner("Creating project");
@@ -101,8 +102,9 @@ export async function create() {
101
102
  const res = await api.post("/v1/projects", body, token);
102
103
  if (!res.ok) {
103
104
  s.stop();
104
- error(apiError(res, "Failed to create project"));
105
- process.exit(1);
105
+ emitError(res.status === 401 || res.status === 403 ? "INVALID_KEY"
106
+ : res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE"
107
+ : "CLI_ERROR", apiError(res, "Failed to create project"), "");
106
108
  }
107
109
  s.stop();
108
110
  const project = res.data;
@@ -126,12 +128,7 @@ export async function info(projectId) {
126
128
  const jsonMode = isJsonMode();
127
129
  const id = projectId || config.defaultProject;
128
130
  if (!id) {
129
- const msg = "No project specified. Pass a project ID or set a default with bata projects create.";
130
- if (jsonMode)
131
- json({ error: msg });
132
- else
133
- error(msg);
134
- process.exit(1);
131
+ emitError("NO_PROJECT", "No project specified.", "Pass a project ID or set a default with bata projects create.");
135
132
  }
136
133
  const s = jsonMode ? null : spinner("Fetching project details");
137
134
  const teamId = await resolveTeamId(token);
@@ -141,11 +138,10 @@ export async function info(projectId) {
141
138
  const res = await api.get(`/v1/projects/${id}`, token, query);
142
139
  if (!res.ok) {
143
140
  s?.stop();
144
- if (jsonMode)
145
- json({ error: apiError(res, "Project not found") });
146
- else
147
- error(apiError(res, "Project not found"));
148
- process.exit(1);
141
+ emitError(res.status === 404 ? "NOT_FOUND"
142
+ : res.status === 401 || res.status === 403 ? "INVALID_KEY"
143
+ : res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE"
144
+ : "CLI_ERROR", apiError(res, "Project not found"), "");
149
145
  }
150
146
  // Also fetch connection info
151
147
  const connRes = await api.get(`/v1/connection-info/${id}`, token);
@@ -204,36 +200,44 @@ export async function info(projectId) {
204
200
  log();
205
201
  }
206
202
  export async function deleteProject(projectId) {
203
+ const jsonMode = isJsonMode();
207
204
  const token = requireToken();
208
205
  const config = loadConfig();
209
206
  const id = projectId || config.defaultProject;
210
207
  if (!id) {
211
- error("No project specified.");
212
- process.exit(1);
208
+ emitError("NO_PROJECT", "No project specified.", "Pass a project ID: bata projects delete <id>");
213
209
  }
214
- const ok = await confirm(`Delete project ${colors.cyan(id)}? This cannot be undone.`, false);
210
+ // Skip the prompt headlessly (--yes / --json / no TTY) so agents and CI can
211
+ // actually delete — and never report success without deleting.
212
+ const ok = await confirmDestructive(`Delete project ${colors.cyan(id)}? This cannot be undone.`);
215
213
  if (!ok) {
216
214
  log();
217
215
  log(" Aborted.");
218
216
  log();
219
217
  return;
220
218
  }
221
- const s = spinner("Deleting project");
219
+ const s = jsonMode ? null : spinner("Deleting project");
222
220
  const teamId = await resolveTeamId(token);
223
221
  const query = {};
224
222
  if (teamId)
225
223
  query.team_id = teamId;
226
224
  const res = await api.del(`/v1/projects/${id}`, token, query);
227
225
  if (!res.ok) {
228
- s.stop();
229
- error(apiError(res, "Failed to delete project"));
230
- process.exit(1);
226
+ s?.stop();
227
+ emitError(res.status === 404 ? "NOT_FOUND"
228
+ : res.status === 401 || res.status === 403 ? "INVALID_KEY"
229
+ : res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE"
230
+ : "CLI_ERROR", apiError(res, "Failed to delete project"), "");
231
231
  }
232
232
  // Clear default project if it was this one
233
233
  if (config.defaultProject === id) {
234
234
  saveConfig({ defaultProject: undefined });
235
235
  }
236
- s.stop();
236
+ s?.stop();
237
+ if (jsonMode) {
238
+ json({ project: { id }, deleted: true });
239
+ return;
240
+ }
237
241
  log();
238
242
  success(`Project ${id} deleted.`);
239
243
  log();
@@ -251,9 +255,6 @@ export async function handleProjects(args) {
251
255
  case undefined:
252
256
  return list();
253
257
  default:
254
- error(`Unknown subcommand: projects ${sub}`);
255
- log(` ${colors.dim("Available:")} list, create, info, delete`);
256
- process.exit(1);
258
+ emitError("INVALID_FLAG", `Unknown subcommand: projects ${sub}`, "Available: list, create, info, delete");
257
259
  }
258
260
  }
259
- //# sourceMappingURL=projects.js.map