@batadata/cli 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -10
- package/dist/api.js +1 -1
- package/dist/args.js +1 -1
- package/dist/commands/api-keys.js +20 -37
- package/dist/commands/connect.js +12 -8
- package/dist/commands/db.js +123 -81
- package/dist/commands/migrate.js +20 -20
- package/dist/commands/projects.js +39 -37
- package/dist/commands/schema.js +184 -19
- package/dist/commands/status.js +16 -13
- package/dist/commands/usage.d.ts +13 -0
- package/dist/commands/usage.js +164 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +9 -2
- package/dist/index.js +47 -11
- package/dist/utils/errors.d.ts +29 -0
- package/dist/utils/errors.js +65 -0
- package/dist/utils/logger.js +1 -1
- package/dist/utils/prompts.d.ts +10 -0
- package/dist/utils/prompts.js +15 -0
- package/package.json +1 -1
package/dist/commands/migrate.js
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
import { colors, log
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
14
|
-
break;
|
|
16
|
+
notImplemented("create");
|
|
15
17
|
case "deploy":
|
|
16
|
-
|
|
17
|
-
break;
|
|
18
|
+
notImplemented("deploy");
|
|
18
19
|
case "status":
|
|
19
|
-
|
|
20
|
-
break;
|
|
20
|
+
notImplemented("status");
|
|
21
21
|
case "reset":
|
|
22
|
-
|
|
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 (
|
|
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("
|
|
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,
|
|
4
|
-
import { prompt,
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
212
|
-
process.exit(1);
|
|
208
|
+
emitError("NO_PROJECT", "No project specified.", "Pass a project ID: bata projects delete <id>");
|
|
213
209
|
}
|
|
214
|
-
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/schema.js
CHANGED
|
@@ -1,37 +1,202 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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(
|
|
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
|
-
|
|
14
|
-
break;
|
|
173
|
+
notImplemented("init");
|
|
15
174
|
case "push":
|
|
16
|
-
|
|
17
|
-
break;
|
|
175
|
+
notImplemented("push");
|
|
18
176
|
case "pull":
|
|
19
|
-
|
|
20
|
-
break;
|
|
177
|
+
notImplemented("pull");
|
|
21
178
|
case "diff":
|
|
22
|
-
|
|
23
|
-
break;
|
|
179
|
+
notImplemented("diff");
|
|
24
180
|
default:
|
|
25
181
|
log();
|
|
26
|
-
log(` ${colors.bold("bata schema")} ${colors.dim("—
|
|
182
|
+
log(` ${colors.bold("bata schema")} ${colors.dim("— schema safety & management")}`);
|
|
27
183
|
log();
|
|
28
184
|
log(` ${colors.dim("Commands:")}`);
|
|
29
|
-
log(` ${colors.cyan("
|
|
30
|
-
log(` ${colors.cyan("
|
|
31
|
-
log(` ${colors.cyan("
|
|
32
|
-
log(` ${colors.cyan("
|
|
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.
|
|
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
|
}
|
package/dist/commands/status.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
+
}
|