@batadata/cli 0.1.4 → 0.1.6
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/commands/dev.js +9 -3
- package/dist/commands/generate.js +10 -9
- package/dist/commands/projects.d.ts +6 -0
- package/dist/commands/projects.js +16 -2
- package/dist/config.js +15 -4
- package/package.json +6 -4
- package/dist/commands/db.test.d.ts +0 -1
- package/dist/commands/db.test.js +0 -193
- package/dist/commands/projects.test.d.ts +0 -1
- package/dist/commands/projects.test.js +0 -104
package/dist/commands/dev.js
CHANGED
|
@@ -4,7 +4,7 @@ export async function dev() {
|
|
|
4
4
|
log(` Get started with BataDB in your project:`);
|
|
5
5
|
log();
|
|
6
6
|
log(` ${colors.cyan("1.")} ${colors.bold("Install the ORM")}`);
|
|
7
|
-
log(` ${colors.dim("npm install
|
|
7
|
+
log(` ${colors.dim("npm install turbine-orm")}`);
|
|
8
8
|
log();
|
|
9
9
|
log(` ${colors.cyan("2.")} ${colors.bold("Set your database URL")}`);
|
|
10
10
|
log(` ${colors.dim("Add DATABASE_URL to your .env file:")}`);
|
|
@@ -14,10 +14,16 @@ export async function dev() {
|
|
|
14
14
|
log(` ${colors.dim("bata generate")}`);
|
|
15
15
|
log();
|
|
16
16
|
log(` ${colors.cyan("4.")} ${colors.bold("Import and query")}`);
|
|
17
|
-
log(` ${colors.dim("import { turbine } from '
|
|
18
|
-
log(` ${colors.dim("const db = turbine(
|
|
17
|
+
log(` ${colors.dim("import { turbine } from './generated/turbine'")}`);
|
|
18
|
+
log(` ${colors.dim("const db = turbine({ connectionString: process.env.DATABASE_URL })")}`);
|
|
19
19
|
log(` ${colors.dim("const users = await db.users.findMany()")}`);
|
|
20
20
|
log();
|
|
21
|
+
log(` ${colors.dim("On the edge? Bind BataDB's HTTP driver to Turbine with zero glue:")}`);
|
|
22
|
+
log(` ${colors.dim("import { turbineHttp } from 'turbine-orm/serverless'")}`);
|
|
23
|
+
log(` ${colors.dim("import { Pool } from '@batadata/serverless'")}`);
|
|
24
|
+
log(` ${colors.dim("import { SCHEMA } from './generated/turbine/metadata.js'")}`);
|
|
25
|
+
log(` ${colors.dim("const db = turbineHttp(new Pool({ connectionString: process.env.DATABASE_URL }), SCHEMA)")}`);
|
|
26
|
+
log();
|
|
21
27
|
log(` ${colors.bold("Useful commands:")}`);
|
|
22
28
|
log(` ${colors.cyan("bata db connect")} Open psql shell`);
|
|
23
29
|
log(` ${colors.cyan("bata db studio")} Open visual table browser`);
|
|
@@ -4,21 +4,21 @@ function printHelp() {
|
|
|
4
4
|
log();
|
|
5
5
|
log(` ${colors.bold("bata generate")} ${colors.dim("— generate types from your database schema")}`);
|
|
6
6
|
log();
|
|
7
|
-
log(` Runs the Turbine type generator (${colors.dim("
|
|
7
|
+
log(` Runs the Turbine type generator (${colors.dim("turbine-orm")}) against your`);
|
|
8
8
|
log(` database, producing typed query bindings.`);
|
|
9
9
|
log();
|
|
10
10
|
log(` ${colors.bold("Options")}`);
|
|
11
11
|
log(` ${colors.dim("--watch, -w")} Regenerate on schema changes`);
|
|
12
12
|
log(` ${colors.dim("--help, -h")} Show this help`);
|
|
13
13
|
log();
|
|
14
|
-
log(` ${colors.dim("Requires
|
|
14
|
+
log(` ${colors.dim("Requires turbine-orm. Install with:")} ${colors.cyan("npm install turbine-orm")}`);
|
|
15
15
|
log();
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
18
|
* Resolve a runnable Turbine binary without invoking the network.
|
|
19
|
-
* Prefers a locally installed binary
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* Prefers a locally installed binary (the `turbine` bin shipped by the
|
|
20
|
+
* `turbine-orm` package); falls back to a global install on PATH. We do NOT
|
|
21
|
+
* silently shell out to `npx`, which would trigger a download/prompt.
|
|
22
22
|
*/
|
|
23
23
|
function resolveTurbine() {
|
|
24
24
|
// 1. Local node_modules binary
|
|
@@ -46,10 +46,11 @@ export async function generate(args) {
|
|
|
46
46
|
if (!turbine) {
|
|
47
47
|
error("Turbine is not installed.");
|
|
48
48
|
log();
|
|
49
|
-
log(` ${colors.dim("`bata generate` uses the Turbine type generator
|
|
50
|
-
log(` ${colors.dim("bundled with the CLI. Install it
|
|
49
|
+
log(` ${colors.dim("`bata generate` uses the Turbine type generator (the `turbine` bin")}`);
|
|
50
|
+
log(` ${colors.dim("from turbine-orm), which is not bundled with the CLI. Install it")}`);
|
|
51
|
+
log(` ${colors.dim("in your project first:")}`);
|
|
51
52
|
log();
|
|
52
|
-
log(` ${colors.cyan("npm install
|
|
53
|
+
log(` ${colors.cyan("npm install turbine-orm")}`);
|
|
53
54
|
log();
|
|
54
55
|
log(` ${colors.dim("Then run")} ${colors.cyan("bata generate")} ${colors.dim("again.")}`);
|
|
55
56
|
log();
|
|
@@ -71,7 +72,7 @@ export async function generate(args) {
|
|
|
71
72
|
});
|
|
72
73
|
child.on("error", (err) => {
|
|
73
74
|
warn(`Failed to run turbine generate: ${err.message}`);
|
|
74
|
-
error("Make sure
|
|
75
|
+
error("Make sure turbine-orm is installed and its `turbine` bin is on your PATH.");
|
|
75
76
|
process.exit(1);
|
|
76
77
|
});
|
|
77
78
|
child.on("exit", (code) => {
|
|
@@ -2,4 +2,10 @@ export declare function list(): Promise<void>;
|
|
|
2
2
|
export declare function create(): Promise<void>;
|
|
3
3
|
export declare function info(projectId?: string): Promise<void>;
|
|
4
4
|
export declare function deleteProject(projectId?: string): Promise<void>;
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the target project from subcommand args: `--project <id>` /
|
|
7
|
+
* `--project=<id>` wins, otherwise the first non-flag positional. Flags like
|
|
8
|
+
* `--yes` / `--json` are never mistaken for a project ID.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveProjectArg(args: string[]): string | undefined;
|
|
5
11
|
export declare function handleProjects(args: string[]): Promise<void>;
|
|
@@ -242,15 +242,29 @@ export async function deleteProject(projectId) {
|
|
|
242
242
|
success(`Project ${id} deleted.`);
|
|
243
243
|
log();
|
|
244
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* Resolve the target project from subcommand args: `--project <id>` /
|
|
247
|
+
* `--project=<id>` wins, otherwise the first non-flag positional. Flags like
|
|
248
|
+
* `--yes` / `--json` are never mistaken for a project ID.
|
|
249
|
+
*/
|
|
250
|
+
export function resolveProjectArg(args) {
|
|
251
|
+
for (let i = 0; i < args.length; i++) {
|
|
252
|
+
if (args[i] === "--project" && args[i + 1])
|
|
253
|
+
return args[i + 1];
|
|
254
|
+
if (args[i].startsWith("--project="))
|
|
255
|
+
return args[i].slice("--project=".length);
|
|
256
|
+
}
|
|
257
|
+
return args.find((a) => !a.startsWith("-"));
|
|
258
|
+
}
|
|
245
259
|
export async function handleProjects(args) {
|
|
246
260
|
const sub = args[0];
|
|
247
261
|
switch (sub) {
|
|
248
262
|
case "create":
|
|
249
263
|
return create();
|
|
250
264
|
case "info":
|
|
251
|
-
return info(args
|
|
265
|
+
return info(resolveProjectArg(args.slice(1)));
|
|
252
266
|
case "delete":
|
|
253
|
-
return deleteProject(args
|
|
267
|
+
return deleteProject(resolveProjectArg(args.slice(1)));
|
|
254
268
|
case "list":
|
|
255
269
|
case undefined:
|
|
256
270
|
return list();
|
package/dist/config.js
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import * as os from "node:os";
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the `~/.batarc` path at CALL time, not import time. Reading
|
|
6
|
+
* `os.homedir()` once into a top-level const freezes the home directory before
|
|
7
|
+
* anything can override HOME — which silently breaks (a) users who set a custom
|
|
8
|
+
* HOME and (b) tests that redirect HOME to a temp dir (the redirect would be
|
|
9
|
+
* ignored and the suite would read the developer's REAL ~/.batarc). Preferring
|
|
10
|
+
* the env vars makes the override deterministic across platforms.
|
|
11
|
+
*/
|
|
12
|
+
function configPath() {
|
|
13
|
+
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
14
|
+
return path.join(home, ".batarc");
|
|
15
|
+
}
|
|
5
16
|
const DEFAULT_API_URL = "https://api.batadata.com";
|
|
6
17
|
const runtime = { json: false, yes: false };
|
|
7
18
|
export function setRuntime(ctx) {
|
|
@@ -23,7 +34,7 @@ export function isYes() {
|
|
|
23
34
|
}
|
|
24
35
|
export function loadConfig() {
|
|
25
36
|
try {
|
|
26
|
-
const raw = fs.readFileSync(
|
|
37
|
+
const raw = fs.readFileSync(configPath(), "utf-8");
|
|
27
38
|
return JSON.parse(raw);
|
|
28
39
|
}
|
|
29
40
|
catch {
|
|
@@ -33,11 +44,11 @@ export function loadConfig() {
|
|
|
33
44
|
export function saveConfig(config) {
|
|
34
45
|
const existing = loadConfig();
|
|
35
46
|
const merged = { ...existing, ...config };
|
|
36
|
-
fs.writeFileSync(
|
|
47
|
+
fs.writeFileSync(configPath(), JSON.stringify(merged, null, 2) + "\n", "utf-8");
|
|
37
48
|
}
|
|
38
49
|
export function clearConfig() {
|
|
39
50
|
try {
|
|
40
|
-
fs.unlinkSync(
|
|
51
|
+
fs.unlinkSync(configPath());
|
|
41
52
|
}
|
|
42
53
|
catch {
|
|
43
54
|
// File doesn't exist, that's fine
|
package/package.json
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@batadata/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "CLI for BataDB — serverless Postgres platform",
|
|
5
5
|
"bin": {
|
|
6
6
|
"bata": "./dist/index.js"
|
|
7
7
|
},
|
|
8
8
|
"type": "module",
|
|
9
9
|
"scripts": {
|
|
10
|
-
"build": "tsc",
|
|
11
|
-
"dev": "tsc --watch",
|
|
12
|
-
"
|
|
10
|
+
"build": "tsc -p tsconfig.build.json",
|
|
11
|
+
"dev": "tsc -p tsconfig.build.json --watch",
|
|
12
|
+
"typecheck": "tsc --noEmit",
|
|
13
|
+
"test": "node --test \"test/*.test.mjs\"",
|
|
14
|
+
"pretest": "npm run build"
|
|
13
15
|
},
|
|
14
16
|
"engines": {
|
|
15
17
|
"node": ">=20.0.0"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/commands/db.test.js
DELETED
|
@@ -1,193 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,104 +0,0 @@
|
|
|
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
|
-
});
|