@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
@@ -1,38 +1,202 @@
1
- import { colors, log, heading } from "../utils/logger.js";
2
- function comingSoon(cmd) {
3
- heading(`Schema: ${cmd}`);
4
- log(` ${colors.yellow("!")} ${colors.dim("Coming soon.")} Schema ${cmd} is not yet implemented.`);
1
+ import { readFileSync } from "node:fs";
2
+ import { api, apiError } from "../api.js";
3
+ import { requireToken, loadConfig, isJsonMode } from "../config.js";
4
+ import { colors, log, json, spinner, table, heading } from "../utils/logger.js";
5
+ import { emitError } from "../utils/errors.js";
6
+ const NOT_IMPLEMENTED_HINT = "Use `bata schema check <file.sql> --fail-on breaking` to gate migrations today.";
7
+ /**
8
+ * Unimplemented schema subcommand. Never exits 0 for a no-op: emits the
9
+ * NOT_IMPLEMENTED envelope (exit 3) pointing at the capability that exists.
10
+ */
11
+ function notImplemented(cmd) {
12
+ emitError("NOT_IMPLEMENTED", `Schema ${cmd} is not yet implemented.`, NOT_IMPLEMENTED_HINT);
13
+ }
14
+ async function readStdin() {
15
+ const chunks = [];
16
+ for await (const chunk of process.stdin)
17
+ chunks.push(chunk);
18
+ return Buffer.concat(chunks).toString("utf8");
19
+ }
20
+ /**
21
+ * Parse `schema check` args, consuming flag VALUES so they can't be mistaken
22
+ * for the positional file path (e.g. `--branch main migration.sql` must not
23
+ * read a file called "main").
24
+ */
25
+ function parseCheckArgs(args) {
26
+ const out = { stdin: false, window: "24h" };
27
+ for (let i = 0; i < args.length; i++) {
28
+ const a = args[i];
29
+ if (a === "--branch") {
30
+ out.branch = args[++i];
31
+ continue;
32
+ }
33
+ if (a === "--window") {
34
+ out.window = args[++i] ?? out.window;
35
+ continue;
36
+ }
37
+ if (a === "--fail-on") {
38
+ out.failOn = args[++i];
39
+ continue;
40
+ }
41
+ if (a === "-") {
42
+ out.stdin = true;
43
+ continue;
44
+ }
45
+ if (a.startsWith("-"))
46
+ continue; // unknown/global flag already handled upstream
47
+ if (out.file === undefined)
48
+ out.file = a;
49
+ }
50
+ return out;
51
+ }
52
+ const riskColor = (r) => r === "breaking" ? colors.red(r.toUpperCase())
53
+ : r === "risky" || r === "unknown" ? colors.yellow(r.toUpperCase())
54
+ : colors.green(r.toUpperCase());
55
+ function affectedLabel(a) {
56
+ if (a.index)
57
+ return `index ${a.index}`;
58
+ if (a.constraint)
59
+ return `constraint ${a.constraint}`;
60
+ if (a.table && a.columns.length)
61
+ return `${a.table}.${a.columns.join(", ")}`;
62
+ if (a.table)
63
+ return a.table;
64
+ return "—";
65
+ }
66
+ async function schemaCheck(args) {
67
+ const jsonMode = isJsonMode();
68
+ const { file, stdin, branch: branchId, window: timeRange, failOn } = parseCheckArgs(args);
69
+ // Reject a typo'd --fail-on value rather than silently disabling the gate.
70
+ if (failOn !== undefined && failOn !== "breaking" && failOn !== "risky") {
71
+ emitError("INVALID_FLAG", `Invalid --fail-on value "${failOn}".`, "Use --fail-on breaking or --fail-on risky.");
72
+ }
73
+ // ── read DDL (file path, explicit "-", or piped stdin) ──
74
+ let ddl;
75
+ try {
76
+ if (file) {
77
+ ddl = readFileSync(file, "utf8");
78
+ }
79
+ else if (stdin || !process.stdin.isTTY) {
80
+ ddl = await readStdin();
81
+ }
82
+ else {
83
+ emitError("EMPTY_INPUT", "No DDL provided.", 'Usage: bata schema check <file.sql> (or pipe SQL: echo "ALTER TABLE ..." | bata schema check -)');
84
+ }
85
+ }
86
+ catch {
87
+ emitError("FILE_NOT_FOUND", `Could not read DDL from "${file}".`, "Pass a readable .sql file path or pipe SQL on stdin.");
88
+ }
89
+ if (!ddl.trim()) {
90
+ emitError("EMPTY_INPUT", "DDL input was empty.", "Provide a schema change to check, e.g. ALTER TABLE orders DROP COLUMN status;");
91
+ }
92
+ const token = requireToken();
93
+ const config = loadConfig();
94
+ const projectId = config.defaultProject;
95
+ if (!projectId) {
96
+ emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
97
+ }
98
+ const body = { sql: ddl, timeRange };
99
+ if (branchId)
100
+ body.branch_id = branchId;
101
+ const s = jsonMode ? null : spinner("Checking schema change against live query corpus");
102
+ const res = await api.post(`/v1/insights/${projectId}/schema-check`, body, token);
103
+ s?.stop();
104
+ if (!res.ok) {
105
+ const code = res.status === 401 || res.status === 403 ? "INVALID_KEY"
106
+ : res.status === 404 ? "NO_PROJECT"
107
+ : res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE"
108
+ : "CLI_ERROR";
109
+ emitError(code, apiError(res, "Schema check failed"), "");
110
+ }
111
+ const v = res.data;
112
+ if (jsonMode) {
113
+ json(v); // server is the single source of truth for the verdict shape
114
+ }
115
+ else {
116
+ renderReport(v);
117
+ }
118
+ // Exit 0 on a successful analysis (risk lives in the payload). --fail-on gates
119
+ // for CI and is FAIL-CLOSED: `unknown` (the checker couldn't assess the change,
120
+ // e.g. TRUNCATE / DROP SCHEMA) trips BOTH thresholds, so a gate never goes
121
+ // green on an unassessable-but-dangerous migration.
122
+ if (failOn === "breaking" && (v.overallRisk === "breaking" || v.overallRisk === "unknown"))
123
+ process.exit(2);
124
+ if (failOn === "risky" && v.overallRisk !== "safe")
125
+ process.exit(2);
126
+ }
127
+ function renderReport(v) {
128
+ heading("Schema check");
129
+ if (v.corpus.empty) {
130
+ log(` ${colors.yellow("!")} No user query traffic in the last ${v.corpus.windowHours}h — cannot assert safety.`);
131
+ }
132
+ else {
133
+ const br = v.corpus.branchId ? ` on branch ${colors.cyan(v.corpus.branchId)}` : "";
134
+ log(` ${colors.dim(`Checked against ${v.corpus.queryShapes} query shapes (${v.corpus.totalCalls.toLocaleString()} calls) over ${v.corpus.windowHours}h${br}`)}`);
135
+ }
5
136
  log();
6
- log(` ${colors.dim("Track progress at")} ${colors.cyan("https://github.com/zvndev")}`);
137
+ log(` Verdict: ${riskColor(v.overallRisk)} ${colors.dim(`${v.summary.breakingCount} breaking · ${v.summary.riskyCount} risky · ${v.summary.safeCount} safe · ${v.summary.unknownCount} unknown`)}`);
138
+ if (v.summary.totalCallsAtRisk > 0) {
139
+ log(` ${colors.red(`${v.summary.totalCallsAtRisk.toLocaleString()} calls/window hit a breaking change`)}`);
140
+ }
141
+ log();
142
+ for (const f of v.findings) {
143
+ log(` ${riskColor(f.risk)} ${colors.bold(f.operation)} ${colors.dim(affectedLabel(f.affected))}`);
144
+ log(` ${colors.dim(f.statement)}`);
145
+ if (f.impactedQueries.length) {
146
+ const shown = f.impactedQueries.slice(0, 8);
147
+ table(["QUERY", "CALLS", "MEAN", "RISK"], shown.map((q) => [
148
+ q.query.length > 60 ? q.query.slice(0, 57) + "…" : q.query,
149
+ q.calls.toLocaleString(),
150
+ `${q.meanTimeMs}ms`,
151
+ q.risk,
152
+ ]));
153
+ if (f.impactedQueries.length > shown.length) {
154
+ log(` ${colors.dim(`+ ${f.impactedQueries.length - shown.length} more`)}`);
155
+ }
156
+ }
157
+ if (f.note)
158
+ log(` ${colors.yellow("note:")} ${colors.dim(f.note)}`);
159
+ if (f.suggestion)
160
+ log(` ${colors.cyan("fix:")} ${colors.dim(f.suggestion)}`);
161
+ log();
162
+ }
163
+ for (const n of v.notes)
164
+ log(` ${colors.dim(`· ${n}`)}`);
7
165
  log();
8
166
  }
9
167
  export async function handleSchema(args) {
10
168
  const sub = args[0];
11
169
  switch (sub) {
170
+ case "check":
171
+ return schemaCheck(args.slice(1));
12
172
  case "init":
13
- comingSoon("init");
14
- break;
173
+ notImplemented("init");
15
174
  case "push":
16
- comingSoon("push");
17
- break;
175
+ notImplemented("push");
18
176
  case "pull":
19
- comingSoon("pull");
20
- break;
177
+ notImplemented("pull");
21
178
  case "diff":
22
- comingSoon("diff");
23
- break;
179
+ notImplemented("diff");
24
180
  default:
25
181
  log();
26
- log(` ${colors.bold("bata schema")} ${colors.dim("— Turbine ORM schema management")}`);
182
+ log(` ${colors.bold("bata schema")} ${colors.dim("— schema safety & management")}`);
27
183
  log();
28
184
  log(` ${colors.dim("Commands:")}`);
29
- log(` ${colors.cyan("init")} Initialize schema from existing database`);
30
- log(` ${colors.cyan("push")} Push schema changes to database`);
31
- log(` ${colors.cyan("pull")} Pull schema from database`);
32
- log(` ${colors.cyan("diff")} Show pending schema changes`);
185
+ log(` ${colors.cyan("check <file|->")} Check a proposed DDL change against live query traffic`);
186
+ log(` ${colors.cyan("init")} Initialize schema from existing database ${colors.dim("(coming soon)")}`);
187
+ log(` ${colors.cyan("push")} Push schema changes to database ${colors.dim("(coming soon)")}`);
188
+ log(` ${colors.cyan("pull")} Pull schema from database ${colors.dim("(coming soon)")}`);
189
+ log(` ${colors.cyan("diff")} Show pending schema changes ${colors.dim("(coming soon)")}`);
190
+ log();
191
+ log(` ${colors.dim("Options:")}`);
192
+ log(` ${colors.dim("--branch <id> check against a branch's traffic")}`);
193
+ log(` ${colors.dim("--window <1h|24h|7d|30d> corpus window (default 24h)")}`);
194
+ log(` ${colors.dim("--fail-on <breaking|risky> exit 2 to gate CI (fail-closed: 'unknown' also trips)")}`);
33
195
  log();
34
- log(` ${colors.yellow("!")} ${colors.dim("All schema commands are coming soon.")}`);
196
+ log(` ${colors.dim("Examples:")}`);
197
+ log(` ${colors.dim("bata schema check migration.sql")}`);
198
+ log(` ${colors.dim('echo "ALTER TABLE orders DROP COLUMN status;" | bata schema check - --json')}`);
199
+ log(` ${colors.dim("bata schema check migration.sql --fail-on breaking # CI gate")}`);
35
200
  log();
36
201
  }
37
202
  }
38
- //# sourceMappingURL=schema.js.map
@@ -4,9 +4,10 @@
4
4
  * Lists projects with branch count, compute status, and storage size.
5
5
  * Colorized output: green for running, yellow for suspended, red for error.
6
6
  */
7
- import { api, apiError } from "../api.js";
7
+ import { api, apiError, resolveTeamId } from "../api.js";
8
8
  import { requireToken, loadConfig, isJsonMode } from "../config.js";
9
- import { colors, log, json, error, spinner, table, heading } from "../utils/logger.js";
9
+ import { colors, log, json, spinner, table, heading } from "../utils/logger.js";
10
+ import { emitError } from "../utils/errors.js";
10
11
  function statusBadge(status) {
11
12
  switch (status?.toLowerCase()) {
12
13
  case "active":
@@ -36,19 +37,21 @@ export async function status() {
36
37
  const config = loadConfig();
37
38
  const jsonMode = isJsonMode();
38
39
  const s = jsonMode ? null : spinner("Fetching projects");
39
- const query = {};
40
- if (config.defaultTeam)
41
- query.team_id = config.defaultTeam;
42
- const res = await api.get("/v1/projects", token, query);
40
+ // GET /v1/projects requires a team_id. On the API-key path config.defaultTeam
41
+ // is empty, so resolve it the way `create` does (falls back to the user's
42
+ // first team) — otherwise this 400s headlessly.
43
+ const teamId = await resolveTeamId(token);
44
+ if (!teamId) {
45
+ s?.stop();
46
+ 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`.");
47
+ }
48
+ const res = await api.get("/v1/projects", token, { team_id: teamId });
43
49
  if (!res.ok) {
44
50
  s?.stop();
45
- if (jsonMode) {
46
- json({ error: apiError(res, "Failed to fetch projects") });
47
- }
48
- else {
49
- error(apiError(res, "Failed to fetch projects"));
50
- }
51
- process.exit(1);
51
+ // Surface the server's validation message, not a bare "HTTP 400".
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"), "");
52
55
  }
53
56
  s?.stop();
54
57
  const projects = Array.isArray(res.data) ? res.data : [];
@@ -103,4 +106,3 @@ export async function status() {
103
106
  log(` ${colors.dim(`${projects.length} project(s). ${colors.cyan("*")} = default`)}`);
104
107
  log();
105
108
  }
106
- //# sourceMappingURL=status.js.map
@@ -0,0 +1,13 @@
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
+ export declare function usage(args: string[]): Promise<void>;
@@ -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
  }
@@ -93,4 +100,3 @@ export function getDefaultProject() {
93
100
  export function getDefaultTeam() {
94
101
  return loadConfig().defaultTeam;
95
102
  }
96
- //# sourceMappingURL=config.js.map
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.0";
17
+ import { exitCodeFor } from "./utils/errors.js";
18
+ const VERSION = "0.1.2";
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`);
@@ -63,11 +66,27 @@ 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(".")}`);
69
72
  log();
70
- log(` ${colors.dim("Documentation:")} ${colors.cyan("https://batadata.com/docs")}`);
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 — retryable)")}`);
88
+ log();
89
+ log(` ${colors.dim("Documentation:")} ${colors.cyan("https://www.npmjs.com/package/@batadata/cli")}`);
71
90
  log();
72
91
  }
73
92
  async function main() {
@@ -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,26 +166,39 @@ 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 transient = /timed out|ECONNREFUSED|ENOTFOUND|ECONNRESET|EAI_AGAIN|socket hang up/i.test(message);
192
+ const code = transient ? (/timed out/i.test(message) ? "TIMEOUT" : "API_UNAVAILABLE") : "CLI_ERROR";
157
193
  if (isJsonMode()) {
158
- log(JSON.stringify({ error: message, code: "CLI_ERROR" }, null, 2));
194
+ log(JSON.stringify({ error: message, code, hint: "" }, null, 2));
159
195
  }
160
196
  else {
161
197
  log();
162
198
  log(` ${colors.red("Error:")} ${message}`);
163
199
  log();
164
200
  }
165
- process.exit(1);
201
+ process.exit(exitCodeFor(code));
166
202
  }
167
203
  }
168
204
  main();
169
- //# sourceMappingURL=index.js.map
@@ -0,0 +1,29 @@
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 (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" | "GATE_TRIPPED";
19
+ /** Map an error code to its documented process exit code. */
20
+ export declare function exitCodeFor(code: string): number;
21
+ /**
22
+ * Emit the single error envelope and exit with the code that matches `code`.
23
+ * In --json mode: `{ error, code, hint }` to stdout-as-JSON (errors stay clean).
24
+ * In human mode: a red "Error:" line to stderr plus a dim hint.
25
+ *
26
+ * Promoted from schema.ts so every command shares one contract. Pass an explicit
27
+ * `exitCode` only to override the table (rare — e.g. forcing 1 on an unknown).
28
+ */
29
+ export declare function emitError(code: ErrorCode | string, message: string, hint?: string, exitCode?: number): never;