@batadata/cli 0.2.12 → 0.2.14

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 CHANGED
@@ -103,6 +103,7 @@ flag always wins**, so linking never silently overrides an intentional request:
103
103
  | `db branch create` / `db branch delete` | Manage branches |
104
104
  | `db branch checkout <name-or-id>` | Pin a branch into the directory's link |
105
105
  | `db studio` | Open the table browser in your browser |
106
+ | `studio` | Run Turbine Studio locally against a branch: a local web UI (`npx turbine-orm studio`) over a direct TCP connection, nothing hosted. Wakes the compute first. Read-only by default; PII redaction is turbine's UI feature, not a BataDB server control (`--project`, `--branch`, `--port`) |
106
107
  | `restore points` | List recovery points and the PITR window (see [Point-in-time restore](#point-in-time-restore-pitr)) |
107
108
  | `restore create` | Restore a branch to a timestamp/LSN as a **new** branch |
108
109
  | `schema check <file>` | Check a proposed schema change against live query traffic |
@@ -0,0 +1,26 @@
1
+ /**
2
+ * bata doctor: run turbine's index doctor against a branch and (optionally)
3
+ * upload the report into the project's index-advice ledger.
4
+ *
5
+ * `turbine doctor --json` (turbine-orm >= 0.46) emits a versioned report
6
+ * (schemaVersion 1) of missing-index findings scored against live statistics.
7
+ * This command wires that to BataDB so a CI job can keep the advice ledger
8
+ * current with one line, instead of hand-rolling a curl of a piped JSON blob.
9
+ *
10
+ * Nothing here interprets the report: the control plane owns the contract, and
11
+ * an unknown schemaVersion is its 400 to give, not ours to guess at.
12
+ */
13
+ export interface DoctorArgs {
14
+ project?: string;
15
+ branch?: string;
16
+ /** Skip the upload and just print the report. */
17
+ dryRun: boolean;
18
+ /** Pass `--unused`/`--audit` through to turbine doctor. */
19
+ unused: boolean;
20
+ audit: boolean;
21
+ help: boolean;
22
+ }
23
+ export declare function parseDoctorArgs(args: string[]): DoctorArgs | {
24
+ error: string;
25
+ };
26
+ export declare function doctorCommand(args: string[]): Promise<void>;
@@ -0,0 +1,201 @@
1
+ /**
2
+ * bata doctor: run turbine's index doctor against a branch and (optionally)
3
+ * upload the report into the project's index-advice ledger.
4
+ *
5
+ * `turbine doctor --json` (turbine-orm >= 0.46) emits a versioned report
6
+ * (schemaVersion 1) of missing-index findings scored against live statistics.
7
+ * This command wires that to BataDB so a CI job can keep the advice ledger
8
+ * current with one line, instead of hand-rolling a curl of a piped JSON blob.
9
+ *
10
+ * Nothing here interprets the report: the control plane owns the contract, and
11
+ * an unknown schemaVersion is its 400 to give, not ours to guess at.
12
+ */
13
+ import { spawn } from "node:child_process";
14
+ import { api, apiError } from "../api.js";
15
+ import { requireToken, isJsonMode } from "../config.js";
16
+ import { colors, log, spinner, info as logInfo } from "../utils/logger.js";
17
+ import { emitError } from "../utils/errors.js";
18
+ import { resolveProjectId, readLinkFile } from "../link.js";
19
+ export function parseDoctorArgs(args) {
20
+ const out = { dryRun: false, unused: false, audit: false, help: false };
21
+ for (let i = 0; i < args.length; i++) {
22
+ const a = args[i];
23
+ const takeVal = (flag) => {
24
+ const next = args[i + 1];
25
+ if (next === undefined || next.startsWith("-"))
26
+ return undefined;
27
+ i++;
28
+ return next;
29
+ };
30
+ if (a === "--help" || a === "-h") {
31
+ out.help = true;
32
+ }
33
+ else if (a === "--project" || a === "-p") {
34
+ const v = takeVal("--project");
35
+ if (v === undefined)
36
+ return { error: "--project requires a value" };
37
+ out.project = v;
38
+ }
39
+ else if (a.startsWith("--project=")) {
40
+ out.project = a.slice("--project=".length);
41
+ }
42
+ else if (a === "--branch" || a === "-b") {
43
+ const v = takeVal("--branch");
44
+ if (v === undefined)
45
+ return { error: "--branch requires a value" };
46
+ out.branch = v;
47
+ }
48
+ else if (a.startsWith("--branch=")) {
49
+ out.branch = a.slice("--branch=".length);
50
+ }
51
+ else if (a === "--dry-run") {
52
+ out.dryRun = true;
53
+ }
54
+ else if (a === "--unused") {
55
+ out.unused = true;
56
+ }
57
+ else if (a === "--audit") {
58
+ out.audit = true;
59
+ }
60
+ else {
61
+ return { error: `Unknown argument: ${a}` };
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+ function doctorHelp() {
67
+ log();
68
+ log(` ${colors.bold("bata doctor")}: index advice from turbine doctor, into your ledger`);
69
+ log();
70
+ log(` ${colors.dim("Usage:")}`);
71
+ log(` bata doctor upload [--project <id>] [--branch <name|id>] [--dry-run]`);
72
+ log();
73
+ log(` ${colors.dim("Runs `turbine doctor --json` against the branch's direct connection and")}`);
74
+ log(` ${colors.dim("imports the findings into the project's index-advice ledger. Re-imports")}`);
75
+ log(` ${colors.dim("refresh the numbers; advice you dismissed stays dismissed.")}`);
76
+ log();
77
+ log(` ${colors.dim("--project <id> Target project (default: linked/default project)")}`);
78
+ log(` ${colors.dim("--branch <name|id> Target branch (default: checked-out, else primary)")}`);
79
+ log(` ${colors.dim("--dry-run Print the report; do not upload")}`);
80
+ log(` ${colors.dim("--unused Also collect never-scanned/redundant index advice")}`);
81
+ log(` ${colors.dim("--audit Also audit previously-suggested indexes")}`);
82
+ log();
83
+ log(` ${colors.dim("Requires turbine-orm >= 0.46 (fetched via npx if not installed).")}`);
84
+ log();
85
+ }
86
+ /** Run `turbine doctor --json` and capture stdout. */
87
+ function runTurbineDoctor(databaseUrl, flags) {
88
+ return new Promise((resolve) => {
89
+ const child = spawn("npx", ["--yes", "turbine-orm@latest", "doctor", "--json", ...flags], {
90
+ env: { ...process.env, DATABASE_URL: databaseUrl },
91
+ stdio: ["ignore", "pipe", "pipe"],
92
+ });
93
+ let stdout = "";
94
+ let stderr = "";
95
+ child.stdout.on("data", (d) => (stdout += d.toString()));
96
+ child.stderr.on("data", (d) => (stderr += d.toString()));
97
+ child.on("error", (err) => {
98
+ resolve({ ok: false, error: `Could not run turbine doctor: ${err.message}` });
99
+ });
100
+ child.on("close", (code) => {
101
+ // doctor exits non-zero when it HAS findings, which is not a failure for
102
+ // us: parse first, and only treat unparseable output as an error.
103
+ const start = stdout.indexOf("{");
104
+ if (start === -1) {
105
+ resolve({
106
+ ok: false,
107
+ error: `turbine doctor produced no JSON report (exit ${code}). ${stderr.trim().slice(0, 300)}`,
108
+ });
109
+ return;
110
+ }
111
+ try {
112
+ resolve({ ok: true, report: JSON.parse(stdout.slice(start)) });
113
+ }
114
+ catch (err) {
115
+ resolve({ ok: false, error: `Could not parse the doctor report: ${String(err)}` });
116
+ }
117
+ });
118
+ });
119
+ }
120
+ export async function doctorCommand(args) {
121
+ const sub = args[0] === "upload" ? args.slice(1) : args;
122
+ const parsed = parseDoctorArgs(sub);
123
+ if ("error" in parsed) {
124
+ emitError("INVALID_FLAG", parsed.error, "Usage: bata doctor upload [--project <id>] [--branch <name|id>]");
125
+ }
126
+ if (parsed.help) {
127
+ doctorHelp();
128
+ return;
129
+ }
130
+ const token = requireToken();
131
+ const projectId = parsed.project ?? resolveProjectId().projectId;
132
+ if (!projectId) {
133
+ emitError("NO_PROJECT", "No project specified.", "Pass --project <id>, or run `bata link <project>` to set a default.");
134
+ }
135
+ const s = spinner("Fetching connection info");
136
+ const connRes = await api.get(`/v1/connection-info/${projectId}`, token, { reveal: "true" });
137
+ if (!connRes.ok || !connRes.data?.connections?.length) {
138
+ s.stop();
139
+ emitError(connRes.status >= 500 || connRes.status === 0 ? "API_UNAVAILABLE" : "NOT_FOUND", apiError(connRes, "Could not fetch connection info for this project."), "");
140
+ }
141
+ // Branch precedence matches `db query`: --branch, else the checked-out branch
142
+ // when it belongs to this project, else primary.
143
+ const link = readLinkFile()?.link;
144
+ const pinned = link && link.projectId === projectId ? link.branchId ?? undefined : undefined;
145
+ const branchRef = parsed.branch ?? pinned;
146
+ const conns = connRes.data.connections;
147
+ const conn = branchRef
148
+ ? conns.find((c) => c.branch_id === branchRef || c.branch_name === branchRef)
149
+ : conns.find((c) => c.is_primary) ?? conns[0];
150
+ if (!conn?.direct) {
151
+ s.stop();
152
+ emitError("BRANCH_NOT_FOUND", branchRef
153
+ ? `Branch "${branchRef}" not found, or it has no direct connection.`
154
+ : "No direct connection string available for this project.", "List branches with: bata db branches --json");
155
+ }
156
+ s.update("Running turbine doctor");
157
+ const flags = [];
158
+ if (parsed.unused)
159
+ flags.push("--unused");
160
+ if (parsed.audit)
161
+ flags.push("--audit");
162
+ const result = await runTurbineDoctor(conn.direct, flags);
163
+ s.stop();
164
+ if (!result.ok) {
165
+ emitError("CLI_ERROR", result.error, "Requires turbine-orm >= 0.46 for `doctor --json`.");
166
+ }
167
+ const report = result.report;
168
+ const findingCount = Array.isArray(report.findings) ? report.findings.length : 0;
169
+ if (parsed.dryRun) {
170
+ if (isJsonMode()) {
171
+ log(JSON.stringify(report, null, 2));
172
+ }
173
+ else {
174
+ logInfo(`${findingCount} finding(s); not uploaded (--dry-run)`);
175
+ log(JSON.stringify(report, null, 2));
176
+ }
177
+ return;
178
+ }
179
+ const s2 = spinner("Uploading to the index-advice ledger");
180
+ const importRes = await api.post(`/v1/projects/${projectId}/index-advice/import`, report, token);
181
+ s2.stop();
182
+ if (!importRes.ok) {
183
+ emitError(importRes.status === 401 || importRes.status === 403
184
+ ? "INVALID_KEY"
185
+ : importRes.status >= 500 || importRes.status === 0
186
+ ? "API_UNAVAILABLE"
187
+ : "CLI_ERROR", apiError(importRes, "Could not import the doctor report."), "");
188
+ }
189
+ const data = importRes.data;
190
+ if (isJsonMode()) {
191
+ log(JSON.stringify({ ...data, branch: conn.branch_name ?? conn.branch_id }, null, 2));
192
+ return;
193
+ }
194
+ logInfo(`Imported ${colors.bold(String(data.imported))} new and refreshed ${colors.bold(String(data.updated))} existing recommendation(s)` +
195
+ (conn.branch_name ? ` from ${colors.cyan(conn.branch_name)}` : ""));
196
+ if (data.invalidIndexes > 0) {
197
+ log(` ${colors.dim(`${data.invalidIndexes} invalid index(es) reported; see the console for cleanup SQL.`)}`);
198
+ }
199
+ log(` ${colors.dim("Review them in the console under Insights, or with:")}`);
200
+ log(` ${colors.dim(`bata db query "..." --json`)} ${colors.dim("/ GET /v1/projects/" + projectId + "/index-advice")}`);
201
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * bata studio: launch turbine-orm's Studio locally against a BataDB branch.
3
+ *
4
+ * Zero hosting infra: resolves the branch's DIRECT connection URI from the
5
+ * control plane, wakes the compute if it scaled to zero, then spawns
6
+ * `npx --yes turbine-orm@latest studio` with DATABASE_URL set. Studio itself
7
+ * is turbine's local web UI (loopback-only, read-only by default); everything
8
+ * it renders travels over a direct TCP connection from this machine.
9
+ *
10
+ * Compat-honest: PII redaction in Studio is turbine's feature (PII-tagged
11
+ * columns are redacted by default, `--show-pii` unredacts), not something
12
+ * BataDB enforces server-side.
13
+ */
14
+ export interface StudioArgs {
15
+ project?: string;
16
+ branch?: string;
17
+ port?: number;
18
+ write: boolean;
19
+ showPii: boolean;
20
+ noOpen: boolean;
21
+ help: boolean;
22
+ }
23
+ /**
24
+ * Parse `bata studio` args (both `--flag value` and `--flag=value` forms).
25
+ * Returns `{ error }` on a malformed flag so the caller can fail fast with
26
+ * INVALID_FLAG before touching the network. Exported for unit testing.
27
+ */
28
+ export declare function parseStudioArgs(args: string[]): StudioArgs | {
29
+ error: string;
30
+ };
31
+ export declare function studio(args?: string[]): Promise<void>;
@@ -0,0 +1,262 @@
1
+ /**
2
+ * bata studio: launch turbine-orm's Studio locally against a BataDB branch.
3
+ *
4
+ * Zero hosting infra: resolves the branch's DIRECT connection URI from the
5
+ * control plane, wakes the compute if it scaled to zero, then spawns
6
+ * `npx --yes turbine-orm@latest studio` with DATABASE_URL set. Studio itself
7
+ * is turbine's local web UI (loopback-only, read-only by default); everything
8
+ * it renders travels over a direct TCP connection from this machine.
9
+ *
10
+ * Compat-honest: PII redaction in Studio is turbine's feature (PII-tagged
11
+ * columns are redacted by default, `--show-pii` unredacts), not something
12
+ * BataDB enforces server-side.
13
+ */
14
+ import { spawn } from "node:child_process";
15
+ import { mkdtempSync, rmSync } from "node:fs";
16
+ import * as os from "node:os";
17
+ import * as path from "node:path";
18
+ import { api, asList, apiError } from "../api.js";
19
+ import { requireToken, loadConfig, isJsonMode } from "../config.js";
20
+ import { colors, log, spinner, info as logInfo } from "../utils/logger.js";
21
+ import { emitError, isRetryable } from "../utils/errors.js";
22
+ import { resolveProjectId, readLinkFile } from "../link.js";
23
+ /**
24
+ * Parse `bata studio` args (both `--flag value` and `--flag=value` forms).
25
+ * Returns `{ error }` on a malformed flag so the caller can fail fast with
26
+ * INVALID_FLAG before touching the network. Exported for unit testing.
27
+ */
28
+ export function parseStudioArgs(args) {
29
+ const out = { write: false, showPii: false, noOpen: false, help: false };
30
+ for (let i = 0; i < args.length; i++) {
31
+ const a = args[i];
32
+ const takeVal = (flag) => {
33
+ const next = args[++i];
34
+ if (next === undefined || next.startsWith("-")) {
35
+ return { error: `${flag} requires a value.` };
36
+ }
37
+ return next;
38
+ };
39
+ let v;
40
+ if (a === "--project") {
41
+ v = takeVal("--project");
42
+ if (typeof v !== "string")
43
+ return v;
44
+ out.project = v;
45
+ }
46
+ else if (a.startsWith("--project=")) {
47
+ out.project = a.slice("--project=".length);
48
+ }
49
+ else if (a === "--branch") {
50
+ v = takeVal("--branch");
51
+ if (typeof v !== "string")
52
+ return v;
53
+ out.branch = v;
54
+ }
55
+ else if (a.startsWith("--branch=")) {
56
+ out.branch = a.slice("--branch=".length);
57
+ }
58
+ else if (a === "--port" || a.startsWith("--port=")) {
59
+ const raw = a === "--port" ? takeVal("--port") : a.slice("--port=".length);
60
+ if (typeof raw !== "string")
61
+ return raw;
62
+ const n = Number(raw);
63
+ if (!Number.isInteger(n) || n <= 0 || n > 65535) {
64
+ return { error: `Invalid port "${raw}". Use an integer between 1 and 65535.` };
65
+ }
66
+ out.port = n;
67
+ }
68
+ else if (a === "--write") {
69
+ out.write = true;
70
+ }
71
+ else if (a === "--show-pii") {
72
+ out.showPii = true;
73
+ }
74
+ else if (a === "--no-open") {
75
+ out.noOpen = true;
76
+ }
77
+ else if (a === "--help" || a === "-h") {
78
+ out.help = true;
79
+ }
80
+ else {
81
+ return { error: `Unknown flag "${a}".` };
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+ function studioHelp() {
87
+ const usage = (line) => log(` ${colors.cyan(line)}`);
88
+ const note = (line) => log(` ${colors.dim(line)}`);
89
+ log();
90
+ log(` ${colors.bold("bata studio")}: launch Turbine Studio locally against a BataDB branch`);
91
+ log();
92
+ usage("bata studio [--project <id|name>] [--branch <name|id>] [--port <n>]");
93
+ usage("bata studio [--write] [--show-pii] [--no-open]");
94
+ log();
95
+ note("Runs turbine-orm's Studio on YOUR machine (npx turbine-orm@latest studio)");
96
+ note("over a direct TCP connection to the branch. Nothing is hosted by BataDB;");
97
+ note("close the terminal and it's gone.");
98
+ log();
99
+ note("--project <id|name> Target project (default: linked/default project)");
100
+ note("--branch <name|id> Target branch (default: the checked-out branch from");
101
+ note(" `bata db branch checkout`, else the primary branch)");
102
+ note("--port <n> Local port for Studio (default: turbine's 4983)");
103
+ note("--write Forwarded to turbine: enable single-row writes (default read-only)");
104
+ note("--show-pii Forwarded to turbine: show PII-tagged columns unredacted");
105
+ note("--no-open Forwarded to turbine: don't auto-open the browser");
106
+ log();
107
+ note("Honesty notes: Studio binds to loopback only and is read-only by default.");
108
+ note("PII redaction is turbine's client-side feature (PII-tagged columns are");
109
+ note("redacted in the UI), NOT a BataDB server-side control: the direct");
110
+ note("connection itself sees real data.");
111
+ note("A scaled-to-zero compute is woken first; that can take a few seconds.");
112
+ log();
113
+ }
114
+ function sleep(ms) {
115
+ return new Promise((resolve) => setTimeout(resolve, ms));
116
+ }
117
+ /**
118
+ * Wake a branch's compute and wait until it answers a query. Kicks the start
119
+ * endpoint when the compute reports suspended, then probes with `SELECT 1`
120
+ * via /v1/sql/execute: retryable failures (503 COMPUTE_STARTING, connection
121
+ * refused) keep polling; a hard failure stops early (Studio's own connection
122
+ * will surface the real error). Budget exhausted → COMPUTE_STARTING (exit 6),
123
+ * the retryable contract agents already know.
124
+ */
125
+ async function wakeBranch(branchId, computeStatus, token, onTick) {
126
+ if (computeStatus === "suspended") {
127
+ // Non-fatal if it fails: the probe below (or the proxy) can still wake it.
128
+ await api.post(`/v1/computes/${branchId}/start`, {}, token).catch(() => undefined);
129
+ }
130
+ const budget = 120_000;
131
+ const interval = 3_000;
132
+ const start = Date.now();
133
+ while (true) {
134
+ const res = await api.post("/v1/sql/execute", { branch_id: branchId, query: "SELECT 1" }, token);
135
+ if (res.ok && !res.data?.error)
136
+ return;
137
+ const body = res.data;
138
+ const retryable = isRetryable({ status: res.status, code: body?.code, message: body?.error });
139
+ if (!retryable)
140
+ return; // hard failure: let Studio's own connection report it
141
+ if (Date.now() - start >= budget) {
142
+ emitError("COMPUTE_STARTING", apiError(res, "Compute is still starting"), "compute is starting; retry `bata studio` in a few seconds");
143
+ }
144
+ onTick(Math.round((Date.now() - start) / 1000));
145
+ await sleep(interval);
146
+ }
147
+ }
148
+ export async function studio(args = []) {
149
+ const parsed = parseStudioArgs(args);
150
+ if ("error" in parsed) {
151
+ emitError("INVALID_FLAG", parsed.error, "Usage: bata studio [--project <id|name>] [--branch <name|id>] [--port <n>]");
152
+ }
153
+ if (parsed.help) {
154
+ studioHelp();
155
+ return;
156
+ }
157
+ // Studio is a long-running local web server with browser interaction; there
158
+ // is no headless JSON equivalent. Point agents at the headless surfaces.
159
+ if (isJsonMode()) {
160
+ emitError("INTERACTIVE_ONLY", "bata studio launches a local web UI and can't run in --json mode.", "Use `bata db query <sql> --json` or `bata schema dump` for headless access.");
161
+ }
162
+ const token = requireToken();
163
+ const config = loadConfig();
164
+ // Project precedence: --project (id OR name, resolved against /v1/projects)
165
+ // > .batadata link > config default.
166
+ let projectId = resolveProjectId().projectId;
167
+ if (parsed.project) {
168
+ const s = spinner("Looking up project");
169
+ const query = {};
170
+ if (config.defaultTeam)
171
+ query.team_id = config.defaultTeam;
172
+ const projRes = await api.get("/v1/projects", token, query);
173
+ s.stop();
174
+ if (!projRes.ok) {
175
+ emitError(projRes.status === 401 || projRes.status === 403 ? "INVALID_KEY"
176
+ : projRes.status >= 500 || projRes.status === 0 ? "API_UNAVAILABLE"
177
+ : "CLI_ERROR", apiError(projRes, "Failed to fetch projects."), "");
178
+ }
179
+ const found = asList(projRes.data).find((p) => p.id === parsed.project || p.name === parsed.project);
180
+ if (!found) {
181
+ emitError("NOT_FOUND", `Project "${parsed.project}" not found.`, "List projects with: bata status");
182
+ }
183
+ projectId = found.id;
184
+ }
185
+ if (!projectId) {
186
+ emitError("NO_PROJECT", "No project specified.", "Pass --project <id|name>, or run `bata link <project>` to set a default.");
187
+ }
188
+ const s = spinner("Fetching connection info");
189
+ const connRes = await api.get(`/v1/connection-info/${projectId}`, token, { reveal: "true" });
190
+ if (!connRes.ok || !connRes.data?.connections?.length) {
191
+ s.stop();
192
+ emitError(connRes.status >= 500 || connRes.status === 0 ? "API_UNAVAILABLE" : "NOT_FOUND", apiError(connRes, "Could not fetch connection info for this project."), "");
193
+ }
194
+ const conns = connRes.data.connections;
195
+ // Branch precedence: an explicit --branch wins, else the branch pinned by
196
+ // `bata db branch checkout` in .batadata/project.json, but ONLY when studio
197
+ // targets the linked project (same guard as `db query`: a pinned branch from
198
+ // project A must never resolve against an explicit --project B).
199
+ const link = readLinkFile()?.link;
200
+ const pinnedBranch = link && link.projectId === projectId ? link.branchId ?? undefined : undefined;
201
+ const branchRef = parsed.branch ?? pinnedBranch;
202
+ const conn = branchRef
203
+ ? conns.find((c) => c.branch_id === branchRef || c.branch_name === branchRef)
204
+ : conns.find((c) => c.is_primary) ?? conns[0];
205
+ if (!conn) {
206
+ s.stop();
207
+ emitError("BRANCH_NOT_FOUND", `Branch "${branchRef}" not found in this project.`, "List branches with: bata db branches --json");
208
+ }
209
+ if (!conn.direct || !conn.branch_id) {
210
+ s.stop();
211
+ emitError("NOT_FOUND", "No direct connection string available for this branch.", "");
212
+ }
213
+ // BataDB computes scale to zero: wake the branch before handing the URI to
214
+ // Studio, so its first introspection doesn't hit a cold compute and die.
215
+ s.update("Waking compute");
216
+ await wakeBranch(conn.branch_id, conn.compute_status, token, (secs) => {
217
+ s.update(`Waking compute (${secs}s)`);
218
+ });
219
+ s.stop();
220
+ log();
221
+ logInfo(`Launching Turbine Studio for ${colors.cyan(connRes.data.project_name ?? projectId)}`
222
+ + (conn.branch_name ? ` ${colors.dim(`branch ${conn.branch_name}`)}` : ""));
223
+ log(` ${colors.dim("Local UI over a direct TCP connection. Read-only by default;")}`);
224
+ log(` ${colors.dim("PII redaction is turbine's UI feature, not a BataDB server control.")}`);
225
+ log(` ${colors.dim("Press Ctrl+C to stop.")}`);
226
+ log();
227
+ // Run npx from a scratch dir so a turbine.config.* in the user's cwd can
228
+ // never override the DATABASE_URL we resolved (turbine's env wins over its
229
+ // config file, but a scratch cwd removes the question entirely). Studio
230
+ // introspects the live DB, so no config/client generation is needed.
231
+ const scratchDir = mkdtempSync(path.join(os.tmpdir(), "bata-studio-"));
232
+ const cleanup = () => {
233
+ try {
234
+ rmSync(scratchDir, { recursive: true, force: true });
235
+ }
236
+ catch {
237
+ /* best effort */
238
+ }
239
+ };
240
+ const turbineArgs = ["--yes", "turbine-orm@latest", "studio"];
241
+ if (parsed.port !== undefined)
242
+ turbineArgs.push("--port", String(parsed.port));
243
+ if (parsed.write)
244
+ turbineArgs.push("--write");
245
+ if (parsed.showPii)
246
+ turbineArgs.push("--show-pii");
247
+ if (parsed.noOpen)
248
+ turbineArgs.push("--no-open");
249
+ const child = spawn("npx", turbineArgs, {
250
+ cwd: scratchDir,
251
+ stdio: "inherit",
252
+ env: { ...process.env, DATABASE_URL: conn.direct },
253
+ });
254
+ child.on("error", (err) => {
255
+ cleanup();
256
+ emitError("CLI_ERROR", `Failed to launch turbine-orm studio via npx: ${err.message}`, "Is npx (Node.js) on your PATH?");
257
+ });
258
+ child.on("exit", (code) => {
259
+ cleanup();
260
+ process.exit(code ?? 0);
261
+ });
262
+ }
package/dist/index.js CHANGED
@@ -17,6 +17,8 @@ import { handleCompute } from "./commands/compute.js";
17
17
  import { handleRestore } from "./commands/restore.js";
18
18
  import { handlePowdb } from "./commands/powdb.js";
19
19
  import { importDb } from "./commands/import.js";
20
+ import { studio } from "./commands/studio.js";
21
+ import { doctorCommand } from "./commands/doctor.js";
20
22
  import { link, unlink } from "./commands/link.js";
21
23
  import { parseGlobalFlags } from "./args.js";
22
24
  import { isJsonMode } from "./config.js";
@@ -63,6 +65,8 @@ function help() {
63
65
  log(` ${colors.cyan("db branch delete")} Delete a branch`);
64
66
  log(` ${colors.cyan("db branch checkout")} Pin a branch into ${colors.dim(".batadata/project.json")}`);
65
67
  log(` ${colors.cyan("db studio")} Open table browser in browser`);
68
+ log(` ${colors.cyan("studio")} Run Turbine Studio locally against a branch ${colors.dim("(local UI, direct TCP)")}`);
69
+ log(` ${colors.cyan("doctor upload")} Import turbine index advice into the project ledger`);
66
70
  log(` ${colors.cyan("db query")} Run a SQL query ${colors.dim("(--branch <id> to target a branch)")}`);
67
71
  log();
68
72
  log(` ${colors.bold("Compute")}`);
@@ -220,6 +224,13 @@ async function main() {
220
224
  case "import":
221
225
  await importDb(rest);
222
226
  break;
227
+ // Local Turbine Studio against a branch
228
+ case "doctor":
229
+ await doctorCommand(rest);
230
+ break;
231
+ case "studio":
232
+ await studio(rest);
233
+ break;
223
234
  // Dev
224
235
  case "dev":
225
236
  await dev();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@batadata/cli",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "description": "CLI for BataDB — serverless Postgres platform",
5
5
  "bin": {
6
6
  "bata": "./dist/index.js"