@batadata/cli 0.1.2 → 0.1.4
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/dist/api.js +1 -1
- package/dist/commands/db.d.ts +1 -1
- package/dist/commands/db.js +193 -38
- package/dist/commands/db.test.d.ts +1 -0
- package/dist/commands/db.test.js +193 -0
- package/dist/commands/projects.test.d.ts +1 -0
- package/dist/commands/projects.test.js +104 -0
- package/dist/index.js +10 -7
- package/dist/utils/errors.d.ts +20 -2
- package/dist/utils/errors.js +27 -1
- package/dist/utils/logger.js +1 -1
- package/package.json +5 -4
package/dist/api.js
CHANGED
|
@@ -14,7 +14,7 @@ export async function request(method, path, options = {}) {
|
|
|
14
14
|
}
|
|
15
15
|
const headers = {
|
|
16
16
|
"Content-Type": "application/json",
|
|
17
|
-
"User-Agent": "@batadata/cli 0.1.
|
|
17
|
+
"User-Agent": "@batadata/cli 0.1.4",
|
|
18
18
|
};
|
|
19
19
|
if (options.token) {
|
|
20
20
|
headers["Authorization"] = `Bearer ${options.token}`;
|
package/dist/commands/db.d.ts
CHANGED
|
@@ -4,5 +4,5 @@ export declare function branches(): Promise<void>;
|
|
|
4
4
|
export declare function branchCreate(name?: string): Promise<void>;
|
|
5
5
|
export declare function branchDelete(name?: string): Promise<void>;
|
|
6
6
|
export declare function studio(): Promise<void>;
|
|
7
|
-
export declare function query(
|
|
7
|
+
export declare function query(args?: string[]): Promise<void>;
|
|
8
8
|
export declare function handleDb(args: string[]): Promise<void>;
|
package/dist/commands/db.js
CHANGED
|
@@ -4,7 +4,7 @@ import { requireToken, loadConfig, isJsonMode } from "../config.js";
|
|
|
4
4
|
import { colors, log, json, error, spinner, table, heading, info as logInfo } from "../utils/logger.js";
|
|
5
5
|
import { prompt, confirmDestructive } from "../utils/prompts.js";
|
|
6
6
|
import { openBrowser } from "../utils/open.js";
|
|
7
|
-
import { emitError } from "../utils/errors.js";
|
|
7
|
+
import { emitError, isRetryable } from "../utils/errors.js";
|
|
8
8
|
async function getConnectionInfo(projectId, token) {
|
|
9
9
|
// reveal=true so the returned string is actually usable (the owner is asking).
|
|
10
10
|
const res = await api.get(`/v1/connection-info/${projectId}`, token, { reveal: "true" });
|
|
@@ -31,20 +31,51 @@ function formatDate(iso) {
|
|
|
31
31
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
32
32
|
}
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* the
|
|
37
|
-
* has no branches or the lookup failed.
|
|
34
|
+
* Human-readable STATUS cell for a branch. Prefers the live compute lifecycle
|
|
35
|
+
* (computeStatus) over the static branch row status, and appends a green check
|
|
36
|
+
* when the compute is ready to serve a query without a cold start.
|
|
38
37
|
*/
|
|
39
|
-
|
|
38
|
+
function branchStatusLabel(b) {
|
|
39
|
+
const base = b.computeStatus || "-";
|
|
40
|
+
if (b.ready === true)
|
|
41
|
+
return `${base} ${colors.green("✓")}`;
|
|
42
|
+
return base;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* List a project's branches (GET /v1/projects/:id), each enriched by the server
|
|
46
|
+
* with computeStatus + ready. Returns null if the lookup failed.
|
|
47
|
+
*/
|
|
48
|
+
async function listBranches(projectId, token, teamId) {
|
|
40
49
|
const query = {};
|
|
41
50
|
if (teamId)
|
|
42
51
|
query.team_id = teamId;
|
|
43
52
|
const res = await api.get(`/v1/projects/${projectId}`, token, query);
|
|
44
53
|
if (!res.ok)
|
|
45
54
|
return null;
|
|
46
|
-
|
|
47
|
-
|
|
55
|
+
return { branches: res.data.branches ?? [], projectName: res.data.name };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Resolve a project's primary branch (the one to run a query against), falling
|
|
59
|
+
* back to the first branch. Returns null if the project has no branches or the
|
|
60
|
+
* lookup failed.
|
|
61
|
+
*/
|
|
62
|
+
async function getPrimaryBranch(projectId, token, teamId) {
|
|
63
|
+
const result = await listBranches(projectId, token, teamId);
|
|
64
|
+
if (!result)
|
|
65
|
+
return null;
|
|
66
|
+
return result.branches.find((b) => b.isPrimary) ?? result.branches[0] ?? null;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Resolve a `--branch` reference — which may be a branch ID **or** a branch
|
|
70
|
+
* name — to a concrete branch. Agents naturally pass the name (the same string
|
|
71
|
+
* they used with `db branch create`), so accepting only an id was a footgun
|
|
72
|
+
* ("Branch not found"). Returns null if no branch matches.
|
|
73
|
+
*/
|
|
74
|
+
async function resolveBranchRef(projectId, token, teamId, ref) {
|
|
75
|
+
const result = await listBranches(projectId, token, teamId);
|
|
76
|
+
if (!result)
|
|
77
|
+
return null;
|
|
78
|
+
return result.branches.find((b) => b.id === ref || b.name === ref) ?? null;
|
|
48
79
|
}
|
|
49
80
|
export async function connect() {
|
|
50
81
|
// psql is an interactive session — there's no headless equivalent. Don't spawn
|
|
@@ -112,34 +143,31 @@ export async function branches() {
|
|
|
112
143
|
emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
|
|
113
144
|
}
|
|
114
145
|
const s = jsonMode ? null : spinner("Fetching branches");
|
|
115
|
-
const
|
|
116
|
-
if (config.defaultTeam)
|
|
117
|
-
query.team_id = config.defaultTeam;
|
|
118
|
-
const res = await api.get(`/v1/projects/${projectId}`, token, query);
|
|
146
|
+
const result = await listBranches(projectId, token, config.defaultTeam);
|
|
119
147
|
s?.stop();
|
|
120
|
-
if (!
|
|
121
|
-
emitError(
|
|
122
|
-
: res.status === 404 ? "NO_PROJECT"
|
|
123
|
-
: res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE"
|
|
124
|
-
: "CLI_ERROR", apiError(res, "Failed to fetch branches"), "");
|
|
148
|
+
if (!result) {
|
|
149
|
+
emitError("API_UNAVAILABLE", "Failed to fetch branches.", "");
|
|
125
150
|
}
|
|
126
|
-
const branchList =
|
|
151
|
+
const branchList = result.branches;
|
|
127
152
|
if (jsonMode) {
|
|
128
153
|
json({
|
|
129
154
|
project_id: projectId,
|
|
130
|
-
project_name:
|
|
155
|
+
project_name: result.projectName,
|
|
131
156
|
branches: branchList.map((b) => ({
|
|
132
157
|
id: b.id,
|
|
133
158
|
name: b.name,
|
|
134
|
-
is_primary: b.
|
|
135
|
-
|
|
136
|
-
|
|
159
|
+
is_primary: b.isPrimary ?? false,
|
|
160
|
+
// Compute readiness — what agents poll on between a create/cold start
|
|
161
|
+
// and a successful query.
|
|
162
|
+
computeStatus: b.computeStatus ?? null,
|
|
163
|
+
ready: b.ready ?? null,
|
|
164
|
+
created_at: b.createdAt ?? null,
|
|
137
165
|
})),
|
|
138
166
|
count: branchList.length,
|
|
139
167
|
});
|
|
140
168
|
return;
|
|
141
169
|
}
|
|
142
|
-
heading(`Branches — ${
|
|
170
|
+
heading(`Branches — ${result.projectName}`);
|
|
143
171
|
if (branchList.length === 0) {
|
|
144
172
|
log(` ${colors.dim("No branches found.")}`);
|
|
145
173
|
log();
|
|
@@ -147,9 +175,11 @@ export async function branches() {
|
|
|
147
175
|
}
|
|
148
176
|
table(["NAME", "PRIMARY", "STATUS", "CREATED"], branchList.map((b) => [
|
|
149
177
|
b.name,
|
|
150
|
-
b.
|
|
151
|
-
|
|
152
|
-
|
|
178
|
+
b.isPrimary ? colors.green("yes") : "-",
|
|
179
|
+
// Live compute lifecycle (computeStatus), with a check once ready so the
|
|
180
|
+
// column is scannable.
|
|
181
|
+
branchStatusLabel(b),
|
|
182
|
+
formatDate(b.createdAt ?? ""),
|
|
153
183
|
]));
|
|
154
184
|
log();
|
|
155
185
|
}
|
|
@@ -257,8 +287,34 @@ export async function studio() {
|
|
|
257
287
|
log();
|
|
258
288
|
openBrowser(studioUrl);
|
|
259
289
|
}
|
|
260
|
-
|
|
290
|
+
/**
|
|
291
|
+
* Pull a `--branch <ref>` / `--branch=<ref>` flag out of the query args and
|
|
292
|
+
* return the explicit branch ref — an id OR a name, resolved later — plus the
|
|
293
|
+
* remaining args, which join into the SQL string. Keeps `db query`
|
|
294
|
+
* order-independent (the flag can sit before or after the SQL) and consistent
|
|
295
|
+
* with how global flags are parsed.
|
|
296
|
+
*/
|
|
297
|
+
function parseBranchFlag(args) {
|
|
298
|
+
let branchId;
|
|
299
|
+
const rest = [];
|
|
300
|
+
for (let i = 0; i < args.length; i++) {
|
|
301
|
+
const arg = args[i];
|
|
302
|
+
if (arg === "--branch") {
|
|
303
|
+
branchId = args[++i];
|
|
304
|
+
}
|
|
305
|
+
else if (arg.startsWith("--branch=")) {
|
|
306
|
+
branchId = arg.slice("--branch=".length);
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
rest.push(arg);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return { branchId, rest };
|
|
313
|
+
}
|
|
314
|
+
export async function query(args = []) {
|
|
261
315
|
const jsonMode = isJsonMode();
|
|
316
|
+
const { branchId, rest } = parseBranchFlag(args);
|
|
317
|
+
const sql = rest.join(" ").trim();
|
|
262
318
|
if (!sql) {
|
|
263
319
|
emitError("MISSING_ARG", "SQL query is required.", 'Usage: bata db query "SELECT 1"');
|
|
264
320
|
}
|
|
@@ -269,19 +325,41 @@ export async function query(sql) {
|
|
|
269
325
|
emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
|
|
270
326
|
}
|
|
271
327
|
const s = jsonMode ? null : spinner("Running query");
|
|
272
|
-
//
|
|
273
|
-
// project.
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
328
|
+
// Target branch: an explicit --branch <id-or-name> wins; otherwise resolve
|
|
329
|
+
// the project's primary branch. /v1/sql/execute is keyed on branch_id, not
|
|
330
|
+
// project. (The old code POSTed to /v1/query/:projectId, which 404s.) We
|
|
331
|
+
// resolve the --branch value against the project's branch list so a NAME
|
|
332
|
+
// works too — sending a name straight through as branch_id 404'd ("Branch
|
|
333
|
+
// not found").
|
|
334
|
+
let branchId_;
|
|
335
|
+
if (branchId) {
|
|
336
|
+
const branch = await resolveBranchRef(projectId, token, config.defaultTeam, branchId);
|
|
337
|
+
if (!branch) {
|
|
338
|
+
s?.stop();
|
|
339
|
+
emitError("BRANCH_NOT_FOUND", `Branch "${branchId}" not found in this project.`, "List branches with: bata db branches --json");
|
|
340
|
+
}
|
|
341
|
+
branchId_ = branch.id;
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
const branch = await getPrimaryBranch(projectId, token, config.defaultTeam);
|
|
345
|
+
if (!branch) {
|
|
346
|
+
s?.stop();
|
|
347
|
+
emitError("BRANCH_NOT_FOUND", "No branch found for this project to run the query against.", "Check the project with: bata db branches --json");
|
|
348
|
+
}
|
|
349
|
+
branchId_ = branch.id;
|
|
278
350
|
}
|
|
279
|
-
const res = await api.post("/v1/sql/execute", { branch_id:
|
|
351
|
+
const res = await api.post("/v1/sql/execute", { branch_id: branchId_, query: sql }, token);
|
|
280
352
|
s?.stop();
|
|
281
353
|
if (!res.ok) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
354
|
+
// Cold-start / upstream blips are retryable (exit 6) so an agent backs off
|
|
355
|
+
// and retries instead of treating it as a hard CLI bug. The backend returns
|
|
356
|
+
// 503 + { code: "COMPUTE_STARTING" } while a just-created/idle branch's
|
|
357
|
+
// compute is still waking; connection-refused transients land here too.
|
|
358
|
+
const body = res.data;
|
|
359
|
+
if (isRetryable({ status: res.status, code: body?.code, message: body?.error })) {
|
|
360
|
+
emitError("COMPUTE_STARTING", apiError(res, "Compute is starting"), "compute is starting; retry in a few seconds");
|
|
361
|
+
}
|
|
362
|
+
const code = res.status === 401 || res.status === 403 ? "INVALID_KEY" : "CLI_ERROR";
|
|
285
363
|
emitError(code, apiError(res, "Query failed"), "");
|
|
286
364
|
}
|
|
287
365
|
// The endpoint returns 200 even when the SQL itself errored (it executed and
|
|
@@ -330,8 +408,85 @@ function fmtCell(v) {
|
|
|
330
408
|
return JSON.stringify(v);
|
|
331
409
|
return String(v);
|
|
332
410
|
}
|
|
411
|
+
/** Was a `--help` / `-h` flag passed anywhere in the args? */
|
|
412
|
+
function hasHelpFlag(args) {
|
|
413
|
+
return args.some((a) => a === "--help" || a === "-h");
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Print usage for a `db` subcommand (or general `db` help if none/unknown) and
|
|
417
|
+
* return. Pure stdout — NEVER touches the network. `db query --help` must print
|
|
418
|
+
* this, not run the SQL "--help".
|
|
419
|
+
*/
|
|
420
|
+
function dbHelp(sub) {
|
|
421
|
+
const usage = (line) => log(` ${colors.cyan(line)}`);
|
|
422
|
+
const note = (line) => log(` ${colors.dim(line)}`);
|
|
423
|
+
log();
|
|
424
|
+
switch (sub) {
|
|
425
|
+
case "query":
|
|
426
|
+
log(` ${colors.bold("bata db query")} — run a SQL query against a branch`);
|
|
427
|
+
log();
|
|
428
|
+
usage('bata db query "SELECT 1"');
|
|
429
|
+
usage("bata db query <sql> [--branch <id-or-name>] [--json]");
|
|
430
|
+
log();
|
|
431
|
+
note("--branch <id-or-name> Target a specific branch by id OR name (default: the project's primary)");
|
|
432
|
+
note("--json Emit rows as JSON objects + row_count");
|
|
433
|
+
note("Cold-start note: a just-created/idle branch may answer with exit 6");
|
|
434
|
+
note("(retryable) while its compute wakes — retry in a few seconds.");
|
|
435
|
+
break;
|
|
436
|
+
case "branches":
|
|
437
|
+
log(` ${colors.bold("bata db branches")} — list branches and their compute status`);
|
|
438
|
+
log();
|
|
439
|
+
usage("bata db branches [--json]");
|
|
440
|
+
log();
|
|
441
|
+
note("STATUS shows the live compute state; a ✓ means ready to query.");
|
|
442
|
+
note("--json includes computeStatus + ready — poll these after create/cold start.");
|
|
443
|
+
break;
|
|
444
|
+
case "branch":
|
|
445
|
+
log(` ${colors.bold("bata db branch")} — create or delete a branch`);
|
|
446
|
+
log();
|
|
447
|
+
usage("bata db branch create <name>");
|
|
448
|
+
usage("bata db branch delete <name> [--yes]");
|
|
449
|
+
break;
|
|
450
|
+
case "url":
|
|
451
|
+
log(` ${colors.bold("bata db url")} — print the connection string`);
|
|
452
|
+
log();
|
|
453
|
+
usage("bata db url [--json]");
|
|
454
|
+
break;
|
|
455
|
+
case "connect":
|
|
456
|
+
log(` ${colors.bold("bata db connect")} — open an interactive psql session`);
|
|
457
|
+
log();
|
|
458
|
+
usage("bata db connect");
|
|
459
|
+
note("Interactive only — use `bata db query` / `bata db url` headlessly.");
|
|
460
|
+
break;
|
|
461
|
+
case "studio":
|
|
462
|
+
log(` ${colors.bold("bata db studio")} — open the table browser in your browser`);
|
|
463
|
+
log();
|
|
464
|
+
usage("bata db studio");
|
|
465
|
+
break;
|
|
466
|
+
default:
|
|
467
|
+
log(` ${colors.bold("bata db")} — database commands`);
|
|
468
|
+
log();
|
|
469
|
+
usage("bata db connect Open psql to your database");
|
|
470
|
+
usage("bata db url Print connection string");
|
|
471
|
+
usage("bata db branches List branches + compute status");
|
|
472
|
+
usage("bata db branch create Create a new branch");
|
|
473
|
+
usage("bata db branch delete Delete a branch");
|
|
474
|
+
usage("bata db studio Open the table browser");
|
|
475
|
+
usage("bata db query <sql> Run a SQL query (--branch <id-or-name> to target a branch)");
|
|
476
|
+
log();
|
|
477
|
+
note("Add --help to any subcommand for details (e.g. bata db query --help).");
|
|
478
|
+
}
|
|
479
|
+
log();
|
|
480
|
+
}
|
|
333
481
|
export async function handleDb(args) {
|
|
334
482
|
const sub = args[0];
|
|
483
|
+
// A --help / -h anywhere prints usage for the subcommand and exits 0 WITHOUT
|
|
484
|
+
// any network call. This must run before dispatch so `db query --help` never
|
|
485
|
+
// treats "--help" as SQL and hits /v1/sql/execute.
|
|
486
|
+
if (hasHelpFlag(args)) {
|
|
487
|
+
dbHelp(sub);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
335
490
|
switch (sub) {
|
|
336
491
|
case "connect":
|
|
337
492
|
return connect();
|
|
@@ -350,7 +505,7 @@ export async function handleDb(args) {
|
|
|
350
505
|
case "studio":
|
|
351
506
|
return studio();
|
|
352
507
|
case "query":
|
|
353
|
-
return query(args.slice(1)
|
|
508
|
+
return query(args.slice(1));
|
|
354
509
|
default:
|
|
355
510
|
emitError("INVALID_FLAG", `Unknown subcommand: db ${sub || ""}`, "Available: connect, url, branches, branch, studio, query");
|
|
356
511
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
const { get, post } = vi.hoisted(() => ({
|
|
6
|
+
get: vi.fn(async () => ({
|
|
7
|
+
ok: true,
|
|
8
|
+
status: 200,
|
|
9
|
+
// Shape matches GET /v1/projects/:id: camelCase branch fields enriched
|
|
10
|
+
// with computeStatus + ready. `feature` is a second, non-primary branch
|
|
11
|
+
// so name/id resolution has something to match.
|
|
12
|
+
data: {
|
|
13
|
+
id: "proj_1",
|
|
14
|
+
name: "demo",
|
|
15
|
+
branches: [
|
|
16
|
+
{ id: "br_primary", name: "main", isPrimary: true, computeStatus: "active", ready: true },
|
|
17
|
+
{ id: "br_feature", name: "feature", isPrimary: false, computeStatus: "idle", ready: false },
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
})),
|
|
21
|
+
post: vi.fn(async () => ({ ok: true, status: 200, data: { columns: ["n"], rows: [{ n: "1" }], rowCount: 1 } })),
|
|
22
|
+
}));
|
|
23
|
+
vi.mock("../api.js", () => ({
|
|
24
|
+
api: { get, post, del: vi.fn() },
|
|
25
|
+
resolveTeamId: vi.fn(async () => "team_test"),
|
|
26
|
+
// Mirror the real apiError so retryable mapping (which reads the server code)
|
|
27
|
+
// gets a realistic message; we don't need its exact format here.
|
|
28
|
+
apiError: (res, fallback) => {
|
|
29
|
+
const msg = res?.data?.error ?? fallback;
|
|
30
|
+
const code = res?.data?.code ? ` (${res.data.code})` : "";
|
|
31
|
+
return `${msg}${code}`;
|
|
32
|
+
},
|
|
33
|
+
asList: (data) => (Array.isArray(data) ? data : []),
|
|
34
|
+
}));
|
|
35
|
+
import { query, handleDb } from "./db.js";
|
|
36
|
+
import { setRuntime } from "../config.js";
|
|
37
|
+
let tmpHome;
|
|
38
|
+
let logSpy;
|
|
39
|
+
// process.exit must NOT actually exit the test runner. Throw a tagged error so
|
|
40
|
+
// the handler unwinds at the exit point; tests catch it and read the code.
|
|
41
|
+
class ExitError extends Error {
|
|
42
|
+
code;
|
|
43
|
+
constructor(code) {
|
|
44
|
+
super(`exit ${code}`);
|
|
45
|
+
this.code = code;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "bata-cli-db-test-"));
|
|
50
|
+
vi.stubEnv("HOME", tmpHome);
|
|
51
|
+
vi.stubEnv("USERPROFILE", tmpHome);
|
|
52
|
+
// A real credential + a default project so query() reaches the network seam.
|
|
53
|
+
fs.writeFileSync(path.join(tmpHome, ".batarc"), JSON.stringify({ defaultProject: "proj_1" }));
|
|
54
|
+
setRuntime({ apiKey: "test-token", apiUrl: "https://api.test.local", json: true, yes: false });
|
|
55
|
+
get.mockClear();
|
|
56
|
+
post.mockClear();
|
|
57
|
+
vi.spyOn(process, "exit").mockImplementation(((code) => {
|
|
58
|
+
throw new ExitError(code ?? 0);
|
|
59
|
+
}));
|
|
60
|
+
// Silence the JSON/decorative output so test logs stay clean.
|
|
61
|
+
logSpy = vi.spyOn(console, "log").mockImplementation(() => { });
|
|
62
|
+
vi.spyOn(console, "error").mockImplementation(() => { });
|
|
63
|
+
});
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
vi.unstubAllEnvs();
|
|
66
|
+
vi.restoreAllMocks();
|
|
67
|
+
setRuntime({ json: false, yes: false });
|
|
68
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
69
|
+
});
|
|
70
|
+
/** Run a handler that's expected to call process.exit; return the exit code. */
|
|
71
|
+
async function exitCodeOf(run) {
|
|
72
|
+
try {
|
|
73
|
+
await run();
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
if (e instanceof ExitError)
|
|
77
|
+
return e.code;
|
|
78
|
+
throw e;
|
|
79
|
+
}
|
|
80
|
+
return 0; // handler returned without exiting (success path)
|
|
81
|
+
}
|
|
82
|
+
describe("db query — --help short-circuits before any network call", () => {
|
|
83
|
+
it("`db query --help` prints help and makes NO api call (exit 0)", async () => {
|
|
84
|
+
const code = await exitCodeOf(() => handleDb(["query", "--help"]));
|
|
85
|
+
expect(post).not.toHaveBeenCalled();
|
|
86
|
+
expect(get).not.toHaveBeenCalled();
|
|
87
|
+
expect(code).toBe(0);
|
|
88
|
+
// It actually printed usage, not a row result.
|
|
89
|
+
const printed = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
90
|
+
expect(printed).toContain("bata db query");
|
|
91
|
+
expect(printed).toContain("--branch");
|
|
92
|
+
});
|
|
93
|
+
it("`db query -h` (short flag) also short-circuits with no api call", async () => {
|
|
94
|
+
const code = await exitCodeOf(() => handleDb(["query", "-h"]));
|
|
95
|
+
expect(post).not.toHaveBeenCalled();
|
|
96
|
+
expect(get).not.toHaveBeenCalled();
|
|
97
|
+
expect(code).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
it("`db --help` prints general db help with no api call", async () => {
|
|
100
|
+
const code = await exitCodeOf(() => handleDb(["--help"]));
|
|
101
|
+
expect(post).not.toHaveBeenCalled();
|
|
102
|
+
expect(get).not.toHaveBeenCalled();
|
|
103
|
+
expect(code).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
describe("db query — branch targeting", () => {
|
|
107
|
+
it("--branch <id> resolves to that branch and sets branch_id", async () => {
|
|
108
|
+
await exitCodeOf(() => query(["SELECT 1", "--branch", "br_feature"]));
|
|
109
|
+
expect(post).toHaveBeenCalledTimes(1);
|
|
110
|
+
expect(post).toHaveBeenCalledWith("/v1/sql/execute", expect.objectContaining({ branch_id: "br_feature", query: "SELECT 1" }), "test-token");
|
|
111
|
+
});
|
|
112
|
+
it("--branch <name> resolves the NAME to the branch id (was 'Branch not found')", async () => {
|
|
113
|
+
await exitCodeOf(() => query(["SELECT 1", "--branch", "feature"]));
|
|
114
|
+
expect(post).toHaveBeenCalledTimes(1);
|
|
115
|
+
expect(post).toHaveBeenCalledWith("/v1/sql/execute",
|
|
116
|
+
// Resolved the NAME "feature" → its id "br_feature".
|
|
117
|
+
expect.objectContaining({ branch_id: "br_feature", query: "SELECT 1" }), "test-token");
|
|
118
|
+
});
|
|
119
|
+
it("--branch=<name> (equals form) is also resolved", async () => {
|
|
120
|
+
await exitCodeOf(() => query(["--branch=feature", "SELECT 2"]));
|
|
121
|
+
expect(post).toHaveBeenCalledWith("/v1/sql/execute", expect.objectContaining({ branch_id: "br_feature", query: "SELECT 2" }), "test-token");
|
|
122
|
+
});
|
|
123
|
+
it("--branch <unknown> exits 5 (not-found) and never runs a query", async () => {
|
|
124
|
+
const code = await exitCodeOf(() => query(["SELECT 1", "--branch", "does-not-exist"]));
|
|
125
|
+
expect(code).toBe(5);
|
|
126
|
+
expect(post).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
it("without --branch, resolves the project's primary branch (default behavior)", async () => {
|
|
129
|
+
await exitCodeOf(() => query(["SELECT 1"]));
|
|
130
|
+
expect(get).toHaveBeenCalledTimes(1); // resolved primary
|
|
131
|
+
expect(post).toHaveBeenCalledWith("/v1/sql/execute", expect.objectContaining({ branch_id: "br_primary", query: "SELECT 1" }), "test-token");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
describe("db query — retryable cold-start mapping (exit 6)", () => {
|
|
135
|
+
it("503 + { code: COMPUTE_STARTING } maps to exit 6 (retryable), not 1", async () => {
|
|
136
|
+
post.mockResolvedValueOnce({
|
|
137
|
+
ok: false,
|
|
138
|
+
status: 503,
|
|
139
|
+
data: { error: "compute is starting", code: "COMPUTE_STARTING" },
|
|
140
|
+
});
|
|
141
|
+
const code = await exitCodeOf(() => query(["SELECT 1", "--branch", "feature"]));
|
|
142
|
+
expect(code).toBe(6);
|
|
143
|
+
});
|
|
144
|
+
it("REGRESSION GUARD: a non-5xx status carrying { code: COMPUTE_STARTING } still maps to exit 6", async () => {
|
|
145
|
+
// The pre-fix code only looked at the HTTP status (>=500 || 0), so a
|
|
146
|
+
// COMPUTE_STARTING surfaced on, say, a 425 would have been CLI_ERROR (exit
|
|
147
|
+
// 1). This is the exact guard for the body-code inspection.
|
|
148
|
+
post.mockResolvedValueOnce({
|
|
149
|
+
ok: false,
|
|
150
|
+
status: 425,
|
|
151
|
+
data: { error: "compute is starting", code: "COMPUTE_STARTING" },
|
|
152
|
+
});
|
|
153
|
+
const code = await exitCodeOf(() => query(["SELECT 1", "--branch", "feature"]));
|
|
154
|
+
expect(code).toBe(6);
|
|
155
|
+
});
|
|
156
|
+
it("a connection-refused error body maps to exit 6", async () => {
|
|
157
|
+
post.mockResolvedValueOnce({
|
|
158
|
+
ok: false,
|
|
159
|
+
status: 0,
|
|
160
|
+
data: { error: "connect ECONNREFUSED 127.0.0.1:5432" },
|
|
161
|
+
});
|
|
162
|
+
const code = await exitCodeOf(() => query(["SELECT 1", "--branch", "feature"]));
|
|
163
|
+
expect(code).toBe(6);
|
|
164
|
+
});
|
|
165
|
+
it("a generic 5xx maps to exit 6", async () => {
|
|
166
|
+
post.mockResolvedValueOnce({ ok: false, status: 502, data: { error: "bad gateway" } });
|
|
167
|
+
const code = await exitCodeOf(() => query(["SELECT 1", "--branch", "feature"]));
|
|
168
|
+
expect(code).toBe(6);
|
|
169
|
+
});
|
|
170
|
+
it("a genuine SQL syntax error (200 with error field) stays exit 1, NOT retryable", async () => {
|
|
171
|
+
post.mockResolvedValueOnce({
|
|
172
|
+
ok: true,
|
|
173
|
+
status: 200,
|
|
174
|
+
data: { error: 'syntax error at or near "SELCT"' },
|
|
175
|
+
});
|
|
176
|
+
const code = await exitCodeOf(() => query(["SELCT 1", "--branch", "feature"]));
|
|
177
|
+
expect(code).toBe(1);
|
|
178
|
+
});
|
|
179
|
+
it("a 400 bad-input SQL error stays exit 1, NOT retryable", async () => {
|
|
180
|
+
post.mockResolvedValueOnce({
|
|
181
|
+
ok: false,
|
|
182
|
+
status: 400,
|
|
183
|
+
data: { error: "relation does not exist" },
|
|
184
|
+
});
|
|
185
|
+
const code = await exitCodeOf(() => query(["SELECT * FROM nope", "--branch", "feature"]));
|
|
186
|
+
expect(code).toBe(1);
|
|
187
|
+
});
|
|
188
|
+
it("a 401 stays auth (exit 4), not retryable", async () => {
|
|
189
|
+
post.mockResolvedValueOnce({ ok: false, status: 401, data: { error: "invalid key" } });
|
|
190
|
+
const code = await exitCodeOf(() => query(["SELECT 1", "--branch", "feature"]));
|
|
191
|
+
expect(code).toBe(4);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
// --- Mock the network layer so no real HTTP happens and we can assert that the
|
|
6
|
+
// --- DELETE call is (or isn't) invoked. This is the seam: deleteProject() calls
|
|
7
|
+
// --- `api.del(...)`, so a spy here proves whether the delete actually proceeds.
|
|
8
|
+
// `vi.hoisted` so the spy exists before the hoisted vi.mock factory runs.
|
|
9
|
+
const { del, readlineQuestion } = vi.hoisted(() => ({
|
|
10
|
+
del: vi.fn(async () => ({ ok: true, status: 200, data: {} })),
|
|
11
|
+
// The interactive prompt seam: confirm() -> prompt() -> readline.question().
|
|
12
|
+
// Default answer is empty; a TTY test sets it to "n" to decline.
|
|
13
|
+
readlineQuestion: vi.fn((_q, cb) => cb("")),
|
|
14
|
+
}));
|
|
15
|
+
vi.mock("../api.js", () => ({
|
|
16
|
+
api: {
|
|
17
|
+
get: vi.fn(async () => ({ ok: true, status: 200, data: {} })),
|
|
18
|
+
post: vi.fn(async () => ({ ok: true, status: 200, data: {} })),
|
|
19
|
+
del,
|
|
20
|
+
},
|
|
21
|
+
// No real /v1/teams round-trip; deleteProject only needs an optional team id.
|
|
22
|
+
resolveTeamId: vi.fn(async () => "team_test"),
|
|
23
|
+
apiError: (_res, fallback) => fallback,
|
|
24
|
+
asList: (data) => (Array.isArray(data) ? data : []),
|
|
25
|
+
}));
|
|
26
|
+
// Mock readline so the interactive confirm() branch never blocks on real stdin.
|
|
27
|
+
vi.mock("node:readline", () => ({
|
|
28
|
+
default: { createInterface: () => ({ question: readlineQuestion, close: () => { } }) },
|
|
29
|
+
createInterface: () => ({ question: readlineQuestion, close: () => { } }),
|
|
30
|
+
}));
|
|
31
|
+
// We deliberately do NOT mock ../config.js or ../utils/prompts.js — the whole
|
|
32
|
+
// point of this test is to exercise the REAL confirmDestructive() + the REAL
|
|
33
|
+
// runtime flag plumbing (setRuntime/isYes/isJsonMode), since that branch is the
|
|
34
|
+
// destructive-headless contract under test.
|
|
35
|
+
import { deleteProject } from "./projects.js";
|
|
36
|
+
import { setRuntime } from "../config.js";
|
|
37
|
+
let tmpHome;
|
|
38
|
+
let realIsTTY;
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
// Point ~/.batarc at a throwaway HOME so loadConfig/saveConfig never touch the
|
|
41
|
+
// developer's real config. os.homedir() reads HOME at call time (verified).
|
|
42
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "bata-cli-test-"));
|
|
43
|
+
vi.stubEnv("HOME", tmpHome);
|
|
44
|
+
vi.stubEnv("USERPROFILE", tmpHome); // Windows parity
|
|
45
|
+
// requireToken() resolves the --api-key runtime value first, so a real
|
|
46
|
+
// credential exists without writing a config file.
|
|
47
|
+
setRuntime({ apiKey: "test-token", apiUrl: "https://api.test.local", json: false, yes: false });
|
|
48
|
+
del.mockClear();
|
|
49
|
+
readlineQuestion.mockClear();
|
|
50
|
+
readlineQuestion.mockImplementation((_q, cb) => cb(""));
|
|
51
|
+
// Snapshot isTTY so individual tests can override it and we restore after.
|
|
52
|
+
realIsTTY = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
|
|
53
|
+
});
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
vi.unstubAllEnvs();
|
|
56
|
+
vi.restoreAllMocks();
|
|
57
|
+
setRuntime({ json: false, yes: false });
|
|
58
|
+
if (realIsTTY) {
|
|
59
|
+
Object.defineProperty(process.stdin, "isTTY", realIsTTY);
|
|
60
|
+
}
|
|
61
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
62
|
+
});
|
|
63
|
+
function setTTY(value) {
|
|
64
|
+
Object.defineProperty(process.stdin, "isTTY", { value, configurable: true });
|
|
65
|
+
}
|
|
66
|
+
describe("projects delete — destructive headless contract", () => {
|
|
67
|
+
it("REGRESSION GUARD: with --yes, does NOT prompt and the DELETE actually proceeds", async () => {
|
|
68
|
+
// The original bug: headless `--yes` exited 0 having deleted NOTHING.
|
|
69
|
+
// This is the exact guard — if deleteProject silently skipped the delete,
|
|
70
|
+
// `del` would never be called and this expectation would fail.
|
|
71
|
+
setTTY(true); // even on a TTY, --yes must skip the prompt and proceed
|
|
72
|
+
setRuntime({ yes: true });
|
|
73
|
+
await deleteProject("proj_123");
|
|
74
|
+
expect(del).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(del).toHaveBeenCalledWith("/v1/projects/proj_123", "test-token", expect.objectContaining({ team_id: "team_test" }));
|
|
76
|
+
});
|
|
77
|
+
it("in --json mode, proceeds without prompting and deletes", async () => {
|
|
78
|
+
setTTY(true);
|
|
79
|
+
setRuntime({ json: true });
|
|
80
|
+
await deleteProject("proj_json");
|
|
81
|
+
expect(del).toHaveBeenCalledTimes(1);
|
|
82
|
+
expect(del).toHaveBeenCalledWith("/v1/projects/proj_json", "test-token", expect.anything());
|
|
83
|
+
});
|
|
84
|
+
it("when stdin is not a TTY (agent / piped / CI), proceeds and deletes without --yes", async () => {
|
|
85
|
+
// This is the CLI's whole purpose: unattended deletes must go through.
|
|
86
|
+
setTTY(false);
|
|
87
|
+
setRuntime({ yes: false, json: false });
|
|
88
|
+
await deleteProject("proj_pipe");
|
|
89
|
+
expect(del).toHaveBeenCalledTimes(1);
|
|
90
|
+
expect(del).toHaveBeenCalledWith("/v1/projects/proj_pipe", "test-token", expect.anything());
|
|
91
|
+
});
|
|
92
|
+
it("interactive TTY without --yes WOULD prompt; answering no aborts (no DELETE)", async () => {
|
|
93
|
+
// Covers the interactive branch of confirmDestructive(): it must ask, and a
|
|
94
|
+
// "no" answer must NOT call the delete API. The mocked readline (above) is
|
|
95
|
+
// the seam confirm() -> prompt() uses; we answer "n" to decline.
|
|
96
|
+
setTTY(true);
|
|
97
|
+
setRuntime({ yes: false, json: false });
|
|
98
|
+
readlineQuestion.mockImplementation((_q, cb) => cb("n"));
|
|
99
|
+
await deleteProject("proj_keep");
|
|
100
|
+
expect(readlineQuestion).toHaveBeenCalledTimes(1); // it DID prompt
|
|
101
|
+
expect(readlineQuestion.mock.calls[0][0]).toContain("Delete project");
|
|
102
|
+
expect(del).not.toHaveBeenCalled(); // declined → no destructive call
|
|
103
|
+
});
|
|
104
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -14,8 +14,8 @@ import { usage } from "./commands/usage.js";
|
|
|
14
14
|
import { parseGlobalFlags } from "./args.js";
|
|
15
15
|
import { isJsonMode } from "./config.js";
|
|
16
16
|
import { colors, log, banner } from "./utils/logger.js";
|
|
17
|
-
import { exitCodeFor } from "./utils/errors.js";
|
|
18
|
-
const VERSION = "0.1.
|
|
17
|
+
import { exitCodeFor, isRetryable } from "./utils/errors.js";
|
|
18
|
+
const VERSION = "0.1.4";
|
|
19
19
|
function help() {
|
|
20
20
|
banner();
|
|
21
21
|
log(` ${colors.bold("Usage")}`);
|
|
@@ -43,11 +43,11 @@ function help() {
|
|
|
43
43
|
log(` ${colors.bold("Database")}`);
|
|
44
44
|
log(` ${colors.cyan("db connect")} Open psql to your database`);
|
|
45
45
|
log(` ${colors.cyan("db url")} Print connection string`);
|
|
46
|
-
log(` ${colors.cyan("db branches")} List database branches`);
|
|
46
|
+
log(` ${colors.cyan("db branches")} List database branches ${colors.dim("(STATUS shows compute readiness)")}`);
|
|
47
47
|
log(` ${colors.cyan("db branch create")} Create a new branch`);
|
|
48
48
|
log(` ${colors.cyan("db branch delete")} Delete a branch`);
|
|
49
49
|
log(` ${colors.cyan("db studio")} Open table browser in browser`);
|
|
50
|
-
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)")}`);
|
|
51
51
|
log();
|
|
52
52
|
log(` ${colors.bold("Schema & Types")}`);
|
|
53
53
|
log(` ${colors.cyan("generate")} Generate types from database schema`);
|
|
@@ -84,7 +84,7 @@ function help() {
|
|
|
84
84
|
log(` ${colors.dim("3")} not implemented ${colors.dim("(NOT_IMPLEMENTED)")}`);
|
|
85
85
|
log(` ${colors.dim("4")} auth / credentials ${colors.dim("(NO_CREDENTIALS, INVALID_KEY)")}`);
|
|
86
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)")}`);
|
|
87
|
+
log(` ${colors.dim("6")} upstream / transient ${colors.dim("(API_UNAVAILABLE, TIMEOUT, COMPUTE_STARTING — retryable)")}`);
|
|
88
88
|
log();
|
|
89
89
|
log(` ${colors.dim("Documentation:")} ${colors.cyan("https://www.npmjs.com/package/@batadata/cli")}`);
|
|
90
90
|
log();
|
|
@@ -188,8 +188,11 @@ async function main() {
|
|
|
188
188
|
// Network/transport throws (e.g. the request timed out, DNS/connection
|
|
189
189
|
// refused) are upstream/transient — surface them with a retryable code so
|
|
190
190
|
// agents know to back off rather than treating it as a hard CLI bug.
|
|
191
|
-
const
|
|
192
|
-
const
|
|
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";
|
|
193
196
|
if (isJsonMode()) {
|
|
194
197
|
log(JSON.stringify({ error: message, code, hint: "" }, null, 2));
|
|
195
198
|
}
|
package/dist/utils/errors.d.ts
CHANGED
|
@@ -13,9 +13,27 @@
|
|
|
13
13
|
* 3 NOT_IMPLEMENTED (coming-soon command — never a silent no-op exit 0)
|
|
14
14
|
* 4 auth / creds — NO_CREDENTIALS, INVALID_KEY
|
|
15
15
|
* 5 not-found/bad-input — NO_PROJECT, BRANCH_NOT_FOUND, INVALID_FLAG, INTERACTIVE_ONLY, ...
|
|
16
|
-
* 6 upstream/transient — API_UNAVAILABLE, TIMEOUT (retryable)
|
|
16
|
+
* 6 upstream/transient — API_UNAVAILABLE, TIMEOUT, COMPUTE_STARTING (retryable)
|
|
17
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";
|
|
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;
|
|
19
37
|
/** Map an error code to its documented process exit code. */
|
|
20
38
|
export declare function exitCodeFor(code: string): number;
|
|
21
39
|
/**
|
package/dist/utils/errors.js
CHANGED
|
@@ -13,10 +13,35 @@
|
|
|
13
13
|
* 3 NOT_IMPLEMENTED (coming-soon command — never a silent no-op exit 0)
|
|
14
14
|
* 4 auth / creds — NO_CREDENTIALS, INVALID_KEY
|
|
15
15
|
* 5 not-found/bad-input — NO_PROJECT, BRANCH_NOT_FOUND, INVALID_FLAG, INTERACTIVE_ONLY, ...
|
|
16
|
-
* 6 upstream/transient — API_UNAVAILABLE, TIMEOUT (retryable)
|
|
16
|
+
* 6 upstream/transient — API_UNAVAILABLE, TIMEOUT, COMPUTE_STARTING (retryable)
|
|
17
17
|
*/
|
|
18
18
|
import { isJsonMode } from "../config.js";
|
|
19
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
|
+
}
|
|
20
45
|
/** Map an error code to its documented process exit code. */
|
|
21
46
|
export function exitCodeFor(code) {
|
|
22
47
|
switch (code) {
|
|
@@ -37,6 +62,7 @@ export function exitCodeFor(code) {
|
|
|
37
62
|
return 5;
|
|
38
63
|
case "API_UNAVAILABLE":
|
|
39
64
|
case "TIMEOUT":
|
|
65
|
+
case "COMPUTE_STARTING":
|
|
40
66
|
return 6;
|
|
41
67
|
case "GATE_TRIPPED":
|
|
42
68
|
return 2;
|
package/dist/utils/logger.js
CHANGED
|
@@ -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.
|
|
127
|
+
log(` ${colors.cyan(colors.bold("BataDB"))} ${colors.dim("v0.1.4")} ${colors.dim("— serverless Postgres platform")}`);
|
|
128
128
|
log();
|
|
129
129
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@batadata/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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
|
-
"
|
|
20
|
+
"vitest": "^3.2.6"
|
|
20
21
|
},
|
|
21
22
|
"files": [
|
|
22
23
|
"dist"
|