@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.
@@ -1,37 +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
  }
@@ -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,8 +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
  }
@@ -1,37 +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
  }
@@ -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 : [];
@@ -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
+ }