@batadata/cli 0.1.1 → 0.1.3

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.
@@ -0,0 +1,164 @@
1
+ /**
2
+ * bata usage — Cost truth for the current billing period.
3
+ *
4
+ * Calls the control-plane current-usage endpoint (GET /v1/billing/current-usage)
5
+ * and renders per-dimension cost for the credential's team.
6
+ *
7
+ * HONESTY RULE (the whole point of the cost-truth wedge):
8
+ * A dimension that is NOT actually metered must render "Not metered yet" and
9
+ * NEVER a priced $0. Today transfer is not metered end-to-end, so it reports
10
+ * { metered: false, value: null, cost_cents: null, status: "not_metered_yet" }.
11
+ * We will only show a dollar figure for a dimension we genuinely meter.
12
+ */
13
+ import { api, apiError } from "../api.js";
14
+ import { requireToken, isJsonMode } from "../config.js";
15
+ import { colors, log, json, spinner, table, heading, kvList } from "../utils/logger.js";
16
+ import { emitError } from "../utils/errors.js";
17
+ /**
18
+ * Decide whether a dimension is truly metered. A dimension is metered only when
19
+ * the API explicitly says so (`metered === true`). A missing `metered` field is
20
+ * treated as not-metered for transfer (the API doesn't meter it yet); compute
21
+ * and storage default to metered because those ARE wired up end-to-end.
22
+ */
23
+ function dimensionView(dim, value, unit, meteredDefault) {
24
+ const metered = dim?.metered ?? meteredDefault;
25
+ if (!metered) {
26
+ return { metered: false, value: null, unit, cost_cents: null, status: "not_metered_yet" };
27
+ }
28
+ return {
29
+ metered: true,
30
+ value,
31
+ unit,
32
+ cost_cents: dim?.costCents ?? null,
33
+ status: "metered",
34
+ };
35
+ }
36
+ function dollars(cents) {
37
+ if (cents === null)
38
+ return colors.dim("Not metered yet");
39
+ return `$${(cents / 100).toFixed(cents % 100 === 0 ? 2 : 4)}`;
40
+ }
41
+ function valueLabel(d) {
42
+ if (!d.metered || d.value === null)
43
+ return colors.dim("—");
44
+ return `${d.value} ${d.unit}`;
45
+ }
46
+ function parseProjectFlag(args) {
47
+ for (let i = 0; i < args.length; i++) {
48
+ if (args[i] === "--project" && args[i + 1])
49
+ return args[i + 1];
50
+ if (args[i].startsWith("--project="))
51
+ return args[i].slice("--project=".length);
52
+ }
53
+ return undefined;
54
+ }
55
+ export async function usage(args) {
56
+ const jsonMode = isJsonMode();
57
+ const token = requireToken();
58
+ const projectFilter = parseProjectFlag(args);
59
+ const s = jsonMode ? null : spinner("Fetching usage");
60
+ // current-usage derives the team from the credential — no team_id needed.
61
+ const res = await api.get("/v1/billing/current-usage", token);
62
+ s?.stop();
63
+ if (!res.ok) {
64
+ emitError(res.status === 401 || res.status === 403 ? "INVALID_KEY"
65
+ : res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE"
66
+ : "CLI_ERROR", apiError(res, "Failed to fetch usage"), "");
67
+ }
68
+ const data = res.data ?? {};
69
+ let projects = data.projects ?? [];
70
+ if (projectFilter) {
71
+ projects = projects.filter((p) => p.projectId === projectFilter || p.projectName === projectFilter);
72
+ if (projects.length === 0) {
73
+ emitError("NOT_FOUND", `No usage for project "${projectFilter}".`, "Check the project id/name with: bata projects list --json");
74
+ }
75
+ }
76
+ // Build per-project, per-dimension views. compute + storage are metered;
77
+ // transfer is NOT metered end-to-end yet → "Not metered yet", never $0.
78
+ const projectViews = projects.map((p) => {
79
+ const compute = dimensionView(p.compute, p.compute?.hours ?? null, "CU-hr", true);
80
+ const storage = dimensionView(p.storage, p.storage?.gb ?? null, "GB", true);
81
+ const transfer = dimensionView(p.transfer, p.transfer?.gb ?? null, "GB", false);
82
+ return { project: p, compute, storage, transfer };
83
+ });
84
+ // Recommendations: top-level + any per-project ones, normalized with an action.
85
+ const recommendations = [
86
+ ...(data.recommendations ?? []),
87
+ ...projects
88
+ .filter((p) => p.recommendation)
89
+ .map((p) => ({ ...p.recommendation, projectId: p.projectId })),
90
+ ]
91
+ .filter((r) => r && (r.message || r.action))
92
+ .map((r) => ({
93
+ kind: r.kind ?? "suspend_idle",
94
+ message: r.message ?? "",
95
+ action: r.action ?? null,
96
+ }));
97
+ if (jsonMode) {
98
+ json({
99
+ period_start: data.periodStart ?? null,
100
+ plan: data.plan ?? null,
101
+ pricing: data.pricing ?? null,
102
+ projects: projectViews.map(({ project, compute, storage, transfer }) => ({
103
+ project_id: project.projectId,
104
+ project_name: project.projectName,
105
+ status: project.status ?? null,
106
+ // Honest envelope: metered dimensions carry a number, un-metered ones
107
+ // carry nulls and status:"not_metered_yet" — never a priced $0.
108
+ compute,
109
+ storage,
110
+ transfer,
111
+ active_computes: project.activeComputes?.length ?? 0,
112
+ })),
113
+ totals: data.totals
114
+ ? {
115
+ estimated_cost_cents: data.totals.estimatedCostCents ?? 0,
116
+ compute_hours: data.totals.computeHours ?? null,
117
+ storage_gb: data.totals.storageGb ?? null,
118
+ // transfer is not metered yet → null + status, not 0
119
+ transfer: { metered: false, value: null, unit: "GB", cost_cents: null, status: "not_metered_yet" },
120
+ active_computes: data.totals.activeComputes ?? 0,
121
+ }
122
+ : null,
123
+ recommendations,
124
+ });
125
+ return;
126
+ }
127
+ // ── Human output ──
128
+ heading("Usage — current period");
129
+ const planName = data.plan?.name ?? "free";
130
+ const base = data.plan?.monthlyBaseCents ?? 0;
131
+ kvList([
132
+ ["Plan", `${planName}${base ? ` (${dollars(base)}/mo base)` : ""}`],
133
+ ["Period start", data.periodStart ? new Date(data.periodStart).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : "-"],
134
+ ]);
135
+ log();
136
+ if (projectViews.length === 0) {
137
+ log(` ${colors.dim("No projects with usage in this period.")}`);
138
+ log();
139
+ return;
140
+ }
141
+ for (const { project, compute, storage, transfer } of projectViews) {
142
+ log(` ${colors.bold(project.projectName)} ${colors.dim(project.projectId)}`);
143
+ table(["DIMENSION", "USAGE", "COST"], [
144
+ ["Compute", valueLabel(compute), dollars(compute.cost_cents)],
145
+ ["Storage", valueLabel(storage), dollars(storage.cost_cents)],
146
+ ["Transfer", valueLabel(transfer), dollars(transfer.cost_cents)],
147
+ ]);
148
+ log();
149
+ }
150
+ if (data.totals) {
151
+ const total = data.totals.estimatedCostCents ?? 0;
152
+ log(` ${colors.bold("Total estimated:")} ${dollars(total + base)} ${colors.dim("(metered dimensions only — transfer not metered yet)")}`);
153
+ log();
154
+ }
155
+ if (recommendations.length) {
156
+ log(` ${colors.bold("Recommendations")}`);
157
+ for (const r of recommendations) {
158
+ log(` ${colors.yellow("·")} ${r.message}`);
159
+ if (r.action)
160
+ log(` ${colors.cyan(r.action)}`);
161
+ }
162
+ log();
163
+ }
164
+ }
package/dist/config.d.ts CHANGED
@@ -13,9 +13,12 @@ interface RuntimeContext {
13
13
  apiKey?: string;
14
14
  apiUrl?: string;
15
15
  json: boolean;
16
+ yes: boolean;
16
17
  }
17
18
  export declare function setRuntime(ctx: Partial<RuntimeContext>): void;
18
19
  export declare function isJsonMode(): boolean;
20
+ /** Was `--yes` / `-y` passed? (Skips destructive confirmation prompts.) */
21
+ export declare function isYes(): boolean;
19
22
  export declare function loadConfig(): BataConfig;
20
23
  export declare function saveConfig(config: Partial<BataConfig>): void;
21
24
  export declare function clearConfig(): void;
package/dist/config.js CHANGED
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import * as os from "node:os";
4
4
  const CONFIG_PATH = path.join(os.homedir(), ".batarc");
5
5
  const DEFAULT_API_URL = "https://api.batadata.com";
6
- const runtime = { json: false };
6
+ const runtime = { json: false, yes: false };
7
7
  export function setRuntime(ctx) {
8
8
  if (ctx.apiKey !== undefined)
9
9
  runtime.apiKey = ctx.apiKey;
@@ -11,10 +11,16 @@ export function setRuntime(ctx) {
11
11
  runtime.apiUrl = ctx.apiUrl;
12
12
  if (ctx.json !== undefined)
13
13
  runtime.json = ctx.json;
14
+ if (ctx.yes !== undefined)
15
+ runtime.yes = ctx.yes;
14
16
  }
15
17
  export function isJsonMode() {
16
18
  return runtime.json;
17
19
  }
20
+ /** Was `--yes` / `-y` passed? (Skips destructive confirmation prompts.) */
21
+ export function isYes() {
22
+ return runtime.yes;
23
+ }
18
24
  export function loadConfig() {
19
25
  try {
20
26
  const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
@@ -70,7 +76,8 @@ export function requireToken() {
70
76
  "\x1b[36m--api-key\x1b[0m, set \x1b[36mBATA_API_KEY\x1b[0m, or run " +
71
77
  "\x1b[36mbata login\x1b[0m.");
72
78
  }
73
- process.exit(1);
79
+ // Exit 4 = auth/credentials per the documented exit-code contract.
80
+ process.exit(4);
74
81
  }
75
82
  return token;
76
83
  }
package/dist/index.js CHANGED
@@ -10,10 +10,12 @@ import { dev } from "./commands/dev.js";
10
10
  import { create } from "./commands/create.js";
11
11
  import { status } from "./commands/status.js";
12
12
  import { connect } from "./commands/connect.js";
13
+ import { usage } from "./commands/usage.js";
13
14
  import { parseGlobalFlags } from "./args.js";
14
15
  import { isJsonMode } from "./config.js";
15
16
  import { colors, log, banner } from "./utils/logger.js";
16
- const VERSION = "0.1.1";
17
+ import { exitCodeFor, isRetryable } from "./utils/errors.js";
18
+ const VERSION = "0.1.3";
17
19
  function help() {
18
20
  banner();
19
21
  log(` ${colors.bold("Usage")}`);
@@ -23,6 +25,7 @@ function help() {
23
25
  log(` ${colors.cyan("create <name>")} Create a project and wait for it to be ready`);
24
26
  log(` ${colors.cyan("connect <name>")} Open psql to a project (auto-wakes if suspended)`);
25
27
  log(` ${colors.cyan("status")} Show all projects and their status`);
28
+ log(` ${colors.cyan("usage")} Per-dimension cost for the current period`);
26
29
  log();
27
30
  log(` ${colors.bold("Auth")}`);
28
31
  log(` ${colors.cyan("login")} Log in to BataDB`);
@@ -40,11 +43,11 @@ function help() {
40
43
  log(` ${colors.bold("Database")}`);
41
44
  log(` ${colors.cyan("db connect")} Open psql to your database`);
42
45
  log(` ${colors.cyan("db url")} Print connection string`);
43
- log(` ${colors.cyan("db branches")} List database branches`);
46
+ log(` ${colors.cyan("db branches")} List database branches ${colors.dim("(STATUS shows compute readiness)")}`);
44
47
  log(` ${colors.cyan("db branch create")} Create a new branch`);
45
48
  log(` ${colors.cyan("db branch delete")} Delete a branch`);
46
49
  log(` ${colors.cyan("db studio")} Open table browser in browser`);
47
- log(` ${colors.cyan("db query")} Run a SQL query`);
50
+ log(` ${colors.cyan("db query")} Run a SQL query ${colors.dim("(--branch <id> to target a branch)")}`);
48
51
  log();
49
52
  log(` ${colors.bold("Schema & Types")}`);
50
53
  log(` ${colors.cyan("generate")} Generate types from database schema`);
@@ -63,9 +66,25 @@ function help() {
63
66
  log(` ${colors.dim("--help, -h")} Show this help message`);
64
67
  log(` ${colors.dim("--version, -v")} Show version`);
65
68
  log();
66
- log(` ${colors.bold("Headless / agents")}`);
69
+ log(` ${colors.bold("Agents")} ${colors.dim("(headless, machine-readable)")}`);
67
70
  log(` ${colors.dim("Every command works with just")} ${colors.cyan("BATA_API_KEY")} ${colors.dim("set — no login needed.")}`);
68
- log(` ${colors.dim("Mint a key with")} ${colors.cyan("bata api-keys create")}${colors.dim(".")}`);
71
+ log(` ${colors.dim("Mint a key with")} ${colors.cyan("bata api-keys create --json")}${colors.dim(".")}`);
72
+ log();
73
+ log(` ${colors.cyan("schema check <file.sql> --fail-on breaking")} Gate a migration (exit 2 if breaking)`);
74
+ log(` ${colors.cyan("db url --json")} Print connection string as JSON`);
75
+ log(` ${colors.cyan("db query <sql> --json")} Run SQL headlessly, rows as JSON objects`);
76
+ log(` ${colors.cyan("usage --json")} Per-dimension cost (honest: un-metered → null)`);
77
+ log();
78
+ log(` ${colors.dim("All errors in --json mode share one envelope:")} ${colors.dim('{ "error", "code", "hint" }')} ${colors.dim("on stderr.")}`);
79
+ log();
80
+ log(` ${colors.bold("Exit codes")}`);
81
+ log(` ${colors.dim("0")} success`);
82
+ log(` ${colors.dim("1")} generic error ${colors.dim("(CLI_ERROR)")}`);
83
+ log(` ${colors.dim("2")} gate tripped ${colors.dim("(schema check --fail-on)")}`);
84
+ log(` ${colors.dim("3")} not implemented ${colors.dim("(NOT_IMPLEMENTED)")}`);
85
+ log(` ${colors.dim("4")} auth / credentials ${colors.dim("(NO_CREDENTIALS, INVALID_KEY)")}`);
86
+ log(` ${colors.dim("5")} not-found / bad input ${colors.dim("(NO_PROJECT, BRANCH_NOT_FOUND, INVALID_FLAG, INTERACTIVE_ONLY)")}`);
87
+ log(` ${colors.dim("6")} upstream / transient ${colors.dim("(API_UNAVAILABLE, TIMEOUT, COMPUTE_STARTING — retryable)")}`);
69
88
  log();
70
89
  log(` ${colors.dim("Documentation:")} ${colors.cyan("https://www.npmjs.com/package/@batadata/cli")}`);
71
90
  log();
@@ -99,6 +118,9 @@ async function main() {
99
118
  case "connect":
100
119
  await connect(rest);
101
120
  break;
121
+ case "usage":
122
+ await usage(rest);
123
+ break;
102
124
  // Auth
103
125
  case "login":
104
126
  await login();
@@ -144,25 +166,42 @@ async function main() {
144
166
  await dev();
145
167
  break;
146
168
  default:
147
- log();
148
- log(` ${colors.red("Error:")} Unknown command ${colors.white(command)}`);
149
- log();
150
- log(` Run ${colors.cyan("bata --help")} to see available commands.`);
151
- log();
152
- process.exit(1);
169
+ if (isJsonMode()) {
170
+ log(JSON.stringify({
171
+ error: `Unknown command "${command}".`,
172
+ code: "INVALID_FLAG",
173
+ hint: "Run `bata --help` to see available commands.",
174
+ }, null, 2));
175
+ }
176
+ else {
177
+ log();
178
+ log(` ${colors.red("Error:")} Unknown command ${colors.white(command)}`);
179
+ log();
180
+ log(` Run ${colors.cyan("bata --help")} to see available commands.`);
181
+ log();
182
+ }
183
+ process.exit(exitCodeFor("INVALID_FLAG"));
153
184
  }
154
185
  }
155
186
  catch (err) {
156
187
  const message = err instanceof Error ? err.message : String(err);
188
+ // Network/transport throws (e.g. the request timed out, DNS/connection
189
+ // refused) are upstream/transient — surface them with a retryable code so
190
+ // agents know to back off rather than treating it as a hard CLI bug.
191
+ const timedOut = /timed out/i.test(message);
192
+ const transient = timedOut
193
+ || /ENOTFOUND|ECONNRESET|EAI_AGAIN|socket hang up/i.test(message)
194
+ || isRetryable({ message });
195
+ const code = transient ? (timedOut ? "TIMEOUT" : "API_UNAVAILABLE") : "CLI_ERROR";
157
196
  if (isJsonMode()) {
158
- log(JSON.stringify({ error: message, code: "CLI_ERROR" }, null, 2));
197
+ log(JSON.stringify({ error: message, code, hint: "" }, null, 2));
159
198
  }
160
199
  else {
161
200
  log();
162
201
  log(` ${colors.red("Error:")} ${message}`);
163
202
  log();
164
203
  }
165
- process.exit(1);
204
+ process.exit(exitCodeFor(code));
166
205
  }
167
206
  }
168
207
  main();
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Shared error / exit-code contract for the whole CLI.
3
+ *
4
+ * Every command emits ONE JSON error envelope in --json mode:
5
+ * { "error": string, "code": SCREAMING_SNAKE, "hint": string }
6
+ * Errors always go to stderr (via error()); the envelope is the only thing on
7
+ * stdout in --json mode, so machine consumers can parse stdout cleanly.
8
+ *
9
+ * ── Exit-code contract (documented + surfaced in `bata --help`) ──
10
+ * 0 success
11
+ * 1 generic CLI_ERROR (genuinely-unknown failure)
12
+ * 2 gate tripped (schema check --fail-on)
13
+ * 3 NOT_IMPLEMENTED (coming-soon command — never a silent no-op exit 0)
14
+ * 4 auth / creds — NO_CREDENTIALS, INVALID_KEY
15
+ * 5 not-found/bad-input — NO_PROJECT, BRANCH_NOT_FOUND, INVALID_FLAG, INTERACTIVE_ONLY, ...
16
+ * 6 upstream/transient — API_UNAVAILABLE, TIMEOUT, COMPUTE_STARTING (retryable)
17
+ */
18
+ export type ErrorCode = "CLI_ERROR" | "NOT_IMPLEMENTED" | "NO_CREDENTIALS" | "INVALID_KEY" | "NO_PROJECT" | "BRANCH_NOT_FOUND" | "INVALID_FLAG" | "INTERACTIVE_ONLY" | "NO_TEAM" | "EMPTY_INPUT" | "FILE_NOT_FOUND" | "MISSING_ARG" | "NOT_FOUND" | "API_UNAVAILABLE" | "TIMEOUT" | "COMPUTE_STARTING" | "GATE_TRIPPED";
19
+ /**
20
+ * Decide whether a failed request is upstream/transient (retryable, exit 6)
21
+ * rather than a hard CLI error. An agent that sees exit 6 should back off and
22
+ * retry; exit 1 means "don't bother, it won't fix itself".
23
+ *
24
+ * Retryable signals:
25
+ * - HTTP 503 (compute waking up) or 0 (no response / connection refused)
26
+ * - HTTP >= 500 (any upstream blip)
27
+ * - an error `code` of COMPUTE_STARTING / API_UNAVAILABLE / TIMEOUT
28
+ * - a connection-refused / ECONNREFUSED transport message
29
+ *
30
+ * Genuine SQL errors (syntax, etc.) carry none of these and stay exit 1.
31
+ */
32
+ export declare function isRetryable(opts: {
33
+ status?: number;
34
+ code?: string;
35
+ message?: string;
36
+ }): boolean;
37
+ /** Map an error code to its documented process exit code. */
38
+ export declare function exitCodeFor(code: string): number;
39
+ /**
40
+ * Emit the single error envelope and exit with the code that matches `code`.
41
+ * In --json mode: `{ error, code, hint }` to stdout-as-JSON (errors stay clean).
42
+ * In human mode: a red "Error:" line to stderr plus a dim hint.
43
+ *
44
+ * Promoted from schema.ts so every command shares one contract. Pass an explicit
45
+ * `exitCode` only to override the table (rare — e.g. forcing 1 on an unknown).
46
+ */
47
+ export declare function emitError(code: ErrorCode | string, message: string, hint?: string, exitCode?: number): never;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Shared error / exit-code contract for the whole CLI.
3
+ *
4
+ * Every command emits ONE JSON error envelope in --json mode:
5
+ * { "error": string, "code": SCREAMING_SNAKE, "hint": string }
6
+ * Errors always go to stderr (via error()); the envelope is the only thing on
7
+ * stdout in --json mode, so machine consumers can parse stdout cleanly.
8
+ *
9
+ * ── Exit-code contract (documented + surfaced in `bata --help`) ──
10
+ * 0 success
11
+ * 1 generic CLI_ERROR (genuinely-unknown failure)
12
+ * 2 gate tripped (schema check --fail-on)
13
+ * 3 NOT_IMPLEMENTED (coming-soon command — never a silent no-op exit 0)
14
+ * 4 auth / creds — NO_CREDENTIALS, INVALID_KEY
15
+ * 5 not-found/bad-input — NO_PROJECT, BRANCH_NOT_FOUND, INVALID_FLAG, INTERACTIVE_ONLY, ...
16
+ * 6 upstream/transient — API_UNAVAILABLE, TIMEOUT, COMPUTE_STARTING (retryable)
17
+ */
18
+ import { isJsonMode } from "../config.js";
19
+ import { colors, log, json, error } from "./logger.js";
20
+ /**
21
+ * Decide whether a failed request is upstream/transient (retryable, exit 6)
22
+ * rather than a hard CLI error. An agent that sees exit 6 should back off and
23
+ * retry; exit 1 means "don't bother, it won't fix itself".
24
+ *
25
+ * Retryable signals:
26
+ * - HTTP 503 (compute waking up) or 0 (no response / connection refused)
27
+ * - HTTP >= 500 (any upstream blip)
28
+ * - an error `code` of COMPUTE_STARTING / API_UNAVAILABLE / TIMEOUT
29
+ * - a connection-refused / ECONNREFUSED transport message
30
+ *
31
+ * Genuine SQL errors (syntax, etc.) carry none of these and stay exit 1.
32
+ */
33
+ export function isRetryable(opts) {
34
+ const { status, code, message } = opts;
35
+ if (status === 503 || status === 0)
36
+ return true;
37
+ if (typeof status === "number" && status >= 500)
38
+ return true;
39
+ if (code === "COMPUTE_STARTING" || code === "API_UNAVAILABLE" || code === "TIMEOUT")
40
+ return true;
41
+ if (message && /ECONNREFUSED|connection refused/i.test(message))
42
+ return true;
43
+ return false;
44
+ }
45
+ /** Map an error code to its documented process exit code. */
46
+ export function exitCodeFor(code) {
47
+ switch (code) {
48
+ case "NOT_IMPLEMENTED":
49
+ return 3;
50
+ case "NO_CREDENTIALS":
51
+ case "INVALID_KEY":
52
+ return 4;
53
+ case "NO_PROJECT":
54
+ case "BRANCH_NOT_FOUND":
55
+ case "INVALID_FLAG":
56
+ case "INTERACTIVE_ONLY":
57
+ case "NO_TEAM":
58
+ case "EMPTY_INPUT":
59
+ case "FILE_NOT_FOUND":
60
+ case "MISSING_ARG":
61
+ case "NOT_FOUND":
62
+ return 5;
63
+ case "API_UNAVAILABLE":
64
+ case "TIMEOUT":
65
+ case "COMPUTE_STARTING":
66
+ return 6;
67
+ case "GATE_TRIPPED":
68
+ return 2;
69
+ default:
70
+ return 1; // CLI_ERROR / unknown
71
+ }
72
+ }
73
+ /**
74
+ * Emit the single error envelope and exit with the code that matches `code`.
75
+ * In --json mode: `{ error, code, hint }` to stdout-as-JSON (errors stay clean).
76
+ * In human mode: a red "Error:" line to stderr plus a dim hint.
77
+ *
78
+ * Promoted from schema.ts so every command shares one contract. Pass an explicit
79
+ * `exitCode` only to override the table (rare — e.g. forcing 1 on an unknown).
80
+ */
81
+ export function emitError(code, message, hint = "", exitCode) {
82
+ if (isJsonMode()) {
83
+ json({ error: message, code, hint });
84
+ }
85
+ else {
86
+ error(message);
87
+ if (hint)
88
+ log(` ${colors.dim(hint)}`);
89
+ }
90
+ process.exit(exitCode ?? exitCodeFor(code));
91
+ }
@@ -124,6 +124,6 @@ export function kvList(items) {
124
124
  // Banner
125
125
  export function banner() {
126
126
  log();
127
- log(` ${colors.cyan(colors.bold("BataDB"))} ${colors.dim("v0.1.0")} ${colors.dim("— serverless Postgres platform")}`);
127
+ log(` ${colors.cyan(colors.bold("BataDB"))} ${colors.dim("v0.1.3")} ${colors.dim("— serverless Postgres platform")}`);
128
128
  log();
129
129
  }
@@ -1,6 +1,16 @@
1
1
  export declare function prompt(message: string, defaultValue?: string): Promise<string>;
2
2
  export declare function promptSecret(message: string): Promise<string>;
3
3
  export declare function confirm(message: string, defaultYes?: boolean): Promise<boolean>;
4
+ /**
5
+ * Confirmation for a destructive action that MUST proceed unattended.
6
+ *
7
+ * Returns true (proceed) without prompting whenever the caller can't answer:
8
+ * --yes / -y · --json mode · no TTY (piped / agent / CI).
9
+ * This is the one place the headless-skip rule lives so it can't drift between
10
+ * `db branch delete`, `projects delete`, and `api-keys revoke`. In an
11
+ * interactive TTY it asks `message` and defaults to NO.
12
+ */
13
+ export declare function confirmDestructive(message: string): Promise<boolean>;
4
14
  export declare function select(message: string, options: {
5
15
  label: string;
6
16
  value: string;
@@ -1,5 +1,6 @@
1
1
  import * as readline from "node:readline";
2
2
  import { colors, log } from "./logger.js";
3
+ import { isJsonMode, isYes } from "../config.js";
3
4
  function createInterface() {
4
5
  return readline.createInterface({
5
6
  input: process.stdin,
@@ -74,6 +75,20 @@ export async function confirm(message, defaultYes = true) {
74
75
  return defaultYes;
75
76
  return answer.toLowerCase().startsWith("y");
76
77
  }
78
+ /**
79
+ * Confirmation for a destructive action that MUST proceed unattended.
80
+ *
81
+ * Returns true (proceed) without prompting whenever the caller can't answer:
82
+ * --yes / -y · --json mode · no TTY (piped / agent / CI).
83
+ * This is the one place the headless-skip rule lives so it can't drift between
84
+ * `db branch delete`, `projects delete`, and `api-keys revoke`. In an
85
+ * interactive TTY it asks `message` and defaults to NO.
86
+ */
87
+ export async function confirmDestructive(message) {
88
+ if (isYes() || isJsonMode() || !process.stdin.isTTY)
89
+ return true;
90
+ return confirm(message, false);
91
+ }
77
92
  export async function select(message, options) {
78
93
  log(` ${colors.cyan("?")} ${message}`);
79
94
  log();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@batadata/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "CLI for BataDB — serverless Postgres platform",
5
5
  "bin": {
6
6
  "bata": "./dist/index.js"
@@ -8,15 +8,16 @@
8
8
  "type": "module",
9
9
  "scripts": {
10
10
  "build": "tsc",
11
- "dev": "tsc --watch"
11
+ "dev": "tsc --watch",
12
+ "test": "vitest run"
12
13
  },
13
14
  "engines": {
14
15
  "node": ">=20.0.0"
15
16
  },
16
- "dependencies": {},
17
17
  "devDependencies": {
18
+ "@types/node": "^22.10.0",
18
19
  "typescript": "^5.7.0",
19
- "@types/node": "^22.10.0"
20
+ "vitest": "^3.2.6"
20
21
  },
21
22
  "files": [
22
23
  "dist"