@batadata/cli 0.2.4 → 0.2.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.
@@ -1,4 +1,4 @@
1
- import { writeFileSync } from "node:fs";
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { api, apiError } from "../api.js";
4
4
  import { requireToken, isJsonMode, loadConfig } from "../config.js";
@@ -652,6 +652,191 @@ function renderPullSummary(o) {
652
652
  log(` ${colors.dim("Load it:")} ${colors.cyan(o.loadCommand)}`);
653
653
  log();
654
654
  }
655
+ /** `--project <id>` / `--project=<id>` only (the positional arg is the query). */
656
+ function powdbProjectFlag(args) {
657
+ for (let i = 0; i < args.length; i++) {
658
+ if (args[i] === "--project" && args[i + 1])
659
+ return args[i + 1];
660
+ if (args[i].startsWith("--project="))
661
+ return args[i].slice("--project=".length);
662
+ }
663
+ return undefined;
664
+ }
665
+ function resolvePowdbProject(args) {
666
+ const explicit = powdbProjectFlag(args);
667
+ if (explicit)
668
+ return explicit;
669
+ const { projectId } = resolveProjectId(undefined);
670
+ if (!projectId) {
671
+ emitError("MISSING_ARG", "No project specified.", "Pass --project <id>, run `bata link <project>`, or set a default project.");
672
+ }
673
+ return projectId;
674
+ }
675
+ async function powdbQuery(args) {
676
+ const token = requireToken();
677
+ const jsonMode = isJsonMode();
678
+ const query = args.find((a) => !a.startsWith("-") && a !== powdbProjectFlag(args));
679
+ if (!query) {
680
+ emitError("MISSING_ARG", "Usage: bata powdb query \"<PowQL>\" [--project <id>]", "");
681
+ return;
682
+ }
683
+ const projectId = resolvePowdbProject(args);
684
+ const res = await api.post(`/v1/powdb/${encodeURIComponent(projectId)}/query`, { query }, token);
685
+ if (!res.ok) {
686
+ const code = res.status === 503 ? "COMPUTE_STARTING"
687
+ : res.status === 401 || res.status === 403 ? "INVALID_KEY"
688
+ : res.status === 404 ? "NOT_FOUND"
689
+ : res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE"
690
+ : "CLI_ERROR";
691
+ emitError(code, apiError(res, "PowQL query failed"), code === "COMPUTE_STARTING" ? "The database is starting — retry in a few seconds." : "");
692
+ return;
693
+ }
694
+ const data = res.data;
695
+ if (jsonMode) {
696
+ json(data);
697
+ if ("error" in data && data.error)
698
+ process.exitCode = 1;
699
+ return;
700
+ }
701
+ if ("error" in data && data.error) {
702
+ // Query executed, PowQL rejected it — surface verbatim, non-zero exit.
703
+ emitError("QUERY_ERROR", data.error, "");
704
+ return;
705
+ }
706
+ log();
707
+ if ("kind" in data) {
708
+ switch (data.kind) {
709
+ case "rows":
710
+ if (data.rows.length === 0)
711
+ log(` ${colors.dim("(0 rows)")}`);
712
+ else {
713
+ table(data.columns, data.rows);
714
+ log(` ${colors.dim(`(${data.rows.length} row${data.rows.length === 1 ? "" : "s"})`)}`);
715
+ }
716
+ break;
717
+ case "scalar":
718
+ log(` ${data.value}`);
719
+ break;
720
+ case "ok":
721
+ log(` ${colors.green(">")} ${data.affected} row${data.affected === 1 ? "" : "s"} affected`);
722
+ break;
723
+ case "message":
724
+ log(` ${data.message}`);
725
+ break;
726
+ }
727
+ if (data.duration_ms !== undefined)
728
+ log(` ${colors.dim(`${data.duration_ms}ms`)}`);
729
+ }
730
+ log();
731
+ }
732
+ function truncateStatement(statement) {
733
+ const flat = statement.replace(/\s+/g, " ").trim();
734
+ return flat.length > 60 ? `${flat.slice(0, 57)}...` : flat;
735
+ }
736
+ async function powdbExec(args) {
737
+ const token = requireToken();
738
+ const jsonMode = isJsonMode();
739
+ const transactional = args.includes("--transactional");
740
+ const continueOnError = args.includes("--continue-on-error");
741
+ if (transactional && continueOnError) {
742
+ emitError("INVALID_ARG", "--transactional and --continue-on-error are mutually exclusive.", "Transactional scripts stop (and roll back) on the first failure by definition.");
743
+ return;
744
+ }
745
+ let filePath;
746
+ for (let i = 0; i < args.length; i++) {
747
+ if (args[i] === "--file" && args[i + 1])
748
+ filePath = args[i + 1];
749
+ if (args[i].startsWith("--file="))
750
+ filePath = args[i].slice("--file=".length);
751
+ }
752
+ let script;
753
+ if (filePath) {
754
+ try {
755
+ // `--file -` reads stdin (fd 0) — the pipe-friendly agent path.
756
+ script = readFileSync(filePath === "-" ? 0 : filePath, "utf8");
757
+ }
758
+ catch (err) {
759
+ emitError("FILE_ERROR", `Could not read ${filePath}: ${err instanceof Error ? err.message : String(err)}`, "");
760
+ return;
761
+ }
762
+ }
763
+ else {
764
+ script = args.find((a) => !a.startsWith("-") && a !== powdbProjectFlag(args) && a !== filePath);
765
+ }
766
+ if (!script || script.trim().length === 0) {
767
+ emitError("MISSING_ARG", 'Usage: bata powdb exec "<PowQL script>" | --file <path|-> [--transactional | --continue-on-error] [--project <id>]', "Statements are ;-separated (string/#-comment aware) and run pipelined on one connection.");
768
+ return;
769
+ }
770
+ const projectId = resolvePowdbProject(args);
771
+ const res = await api.post(`/v1/powdb/${encodeURIComponent(projectId)}/exec`, {
772
+ script,
773
+ ...(transactional ? { transactional: true } : {}),
774
+ ...(continueOnError ? { continue_on_error: true } : {}),
775
+ }, token);
776
+ if (!res.ok) {
777
+ const code = res.status === 503 ? "COMPUTE_STARTING"
778
+ : res.status === 401 || res.status === 403 ? "INVALID_KEY"
779
+ : res.status === 404 ? "NOT_FOUND"
780
+ : res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE"
781
+ : "CLI_ERROR";
782
+ emitError(code, apiError(res, "PowQL script failed"), code === "COMPUTE_STARTING" ? "The database is starting — retry in a few seconds." : "");
783
+ return;
784
+ }
785
+ const data = res.data;
786
+ if (jsonMode) {
787
+ json(data);
788
+ if (("error" in data && data.error) || ("failed_count" in data && data.failed_count > 0)) {
789
+ process.exitCode = 1;
790
+ }
791
+ return;
792
+ }
793
+ if ("error" in data && data.error) {
794
+ const where = data.statement_index !== undefined
795
+ ? ` (statement ${data.statement_index + 1}${data.rolled_back ? ", rolled back — nothing persisted" : ""})`
796
+ : "";
797
+ emitError("QUERY_ERROR", `${data.error}${where}`, data.statement ?? "");
798
+ return;
799
+ }
800
+ log();
801
+ if ("outcomes" in data) {
802
+ for (const o of data.outcomes) {
803
+ if (o.ok)
804
+ log(` ${colors.green(">")} ok ${colors.dim(truncateStatement(o.statement))}`);
805
+ else
806
+ log(` ${colors.red("x")} ${o.error} ${colors.dim(truncateStatement(o.statement))}`);
807
+ }
808
+ log(` ${colors.dim(`${data.statement_count} statement${data.statement_count === 1 ? "" : "s"}, ${data.failed_count} failed, ${data.duration_ms}ms`)}`);
809
+ if (data.failed_count > 0)
810
+ process.exitCode = 1;
811
+ }
812
+ else if ("results" in data) {
813
+ log(` ${colors.green(">")} ${data.statement_count} statement${data.statement_count === 1 ? "" : "s"} executed${transactional ? " (transactional)" : ""} ${colors.dim(`${data.duration_ms}ms`)}`);
814
+ }
815
+ log();
816
+ }
817
+ async function powdbLifecycle(action, args) {
818
+ const token = requireToken();
819
+ const jsonMode = isJsonMode();
820
+ const positional = args.find((a) => !a.startsWith("-"));
821
+ const projectId = powdbProjectFlag(args) ?? positional ?? resolvePowdbProject(args);
822
+ const res = action === "status"
823
+ ? await api.get(`/v1/powdb/${encodeURIComponent(projectId)}`, token)
824
+ : await api.post(`/v1/powdb/${encodeURIComponent(projectId)}/${action}`, {}, token);
825
+ if (!res.ok) {
826
+ emitError(res.status === 404 ? "NOT_FOUND"
827
+ : res.status === 401 || res.status === 403 ? "INVALID_KEY"
828
+ : res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE"
829
+ : "CLI_ERROR", apiError(res, `PowDB ${action} failed`), "");
830
+ return;
831
+ }
832
+ if (jsonMode) {
833
+ json(res.data);
834
+ return;
835
+ }
836
+ log();
837
+ log(` ${colors.green(">")} ${colors.cyan(projectId)} ${colors.dim("engine=powdb state=")}${res.data.state}`);
838
+ log();
839
+ }
655
840
  // ─── Help + dispatch ──────────────────────────────────────────────────────────
656
841
  function powdbHelp() {
657
842
  log();
@@ -662,7 +847,16 @@ function powdbHelp() {
662
847
  log(` ${colors.dim("BataDB branching / metering / server insights do NOT apply to it.")}`);
663
848
  log();
664
849
  log(` ${colors.dim("Commands:")}`);
665
- log(` ${colors.cyan("pull")} Pull a branch's schema + data into a local PowDB-loadable PowQL script`);
850
+ log(` ${colors.cyan("pull")} Pull a Postgres branch's schema + data into a local PowDB-loadable PowQL script`);
851
+ log();
852
+ log(` ${colors.dim("Hosted PowDB projects (create one with: bata projects create --engine powdb):")}`);
853
+ log(` ${colors.cyan("query")} Run a PowQL statement: bata powdb query "Note" [--project <id>]`);
854
+ log(` ${colors.cyan("exec")} Run a multi-statement script, pipelined (bulk load / seed / migrate):`);
855
+ log(` ${colors.dim(' bata powdb exec --file seed.powql [--transactional | --continue-on-error]')}`);
856
+ log(` ${colors.dim(' bata powdb pull --out - | bata powdb exec --file - (pipe-friendly)')}`);
857
+ log(` ${colors.cyan("status")} Show the server state (running | parked) [--project <id>]`);
858
+ log(` ${colors.cyan("park")} Scale the server to zero (WAL is durable) [--project <id>]`);
859
+ log(` ${colors.cyan("wake")} Start a parked server (~tens of ms) [--project <id>]`);
666
860
  log();
667
861
  log(` ${colors.dim("Options (pull):")}`);
668
862
  log(` ${colors.dim("--project <id> override the linked/default project")}`);
@@ -688,10 +882,20 @@ export async function handlePowdb(args) {
688
882
  switch (sub) {
689
883
  case "pull":
690
884
  return powdbPull(args.slice(1));
885
+ case "query":
886
+ return powdbQuery(args.slice(1));
887
+ case "exec":
888
+ return powdbExec(args.slice(1));
889
+ case "status":
890
+ return powdbLifecycle("status", args.slice(1));
891
+ case "park":
892
+ return powdbLifecycle("park", args.slice(1));
893
+ case "wake":
894
+ return powdbLifecycle("wake", args.slice(1));
691
895
  case undefined:
692
896
  powdbHelp();
693
897
  return;
694
898
  default:
695
- emitError("INVALID_FLAG", `Unknown subcommand: powdb ${sub}`, "Available: pull. Run `bata powdb --help`.");
899
+ emitError("INVALID_FLAG", `Unknown subcommand: powdb ${sub}`, "Available: pull, query, exec, status, park, wake. Run `bata powdb --help`.");
696
900
  }
697
901
  }
@@ -23,6 +23,11 @@ export declare function parseCreateComputeArgs(args: string[]): {
23
23
  } | {
24
24
  error: string;
25
25
  };
26
+ /** Parse `--engine postgres|powdb` (default: postgres). PowDB is a different
27
+ * ENGINE, not a different Postgres — the compute tier/size flags don't apply. */
28
+ export declare function parseEngineArg(args: string[]): "postgres" | "powdb" | {
29
+ error: string;
30
+ };
26
31
  export declare function create(args?: string[]): Promise<void>;
27
32
  export declare function info(projectId?: string): Promise<void>;
28
33
  export declare function deleteProject(projectId?: string): Promise<void>;
@@ -160,29 +160,122 @@ export function parseCreateComputeArgs(args) {
160
160
  }
161
161
  return { tier, sizeCu };
162
162
  }
163
+ /** `--flag <value>` / `--flag=<value>` reader. Distinguishes "absent"
164
+ * (undefined) from "present but missing its value" ({ error }) so a headless
165
+ * caller's typo fails loudly instead of silently changing behavior. */
166
+ function readFlagValue(args, flag) {
167
+ for (let i = 0; i < args.length; i++) {
168
+ if (args[i] === flag) {
169
+ const next = args[i + 1];
170
+ if (next === undefined || next.startsWith("-")) {
171
+ return { error: `${flag} requires a value.` };
172
+ }
173
+ return next;
174
+ }
175
+ if (args[i].startsWith(`${flag}=`)) {
176
+ const v = args[i].slice(flag.length + 1);
177
+ if (!v)
178
+ return { error: `${flag} requires a value.` };
179
+ return v;
180
+ }
181
+ }
182
+ return undefined;
183
+ }
184
+ /** Parse `--engine postgres|powdb` (default: postgres). PowDB is a different
185
+ * ENGINE, not a different Postgres — the compute tier/size flags don't apply. */
186
+ export function parseEngineArg(args) {
187
+ let raw;
188
+ for (let i = 0; i < args.length; i++) {
189
+ if (args[i] === "--engine") {
190
+ const next = args[i + 1];
191
+ if (next === undefined || next.startsWith("-")) {
192
+ return { error: "--engine requires a value (postgres|powdb)." };
193
+ }
194
+ raw = next;
195
+ i++;
196
+ }
197
+ else if (args[i].startsWith("--engine="))
198
+ raw = args[i].slice("--engine=".length);
199
+ }
200
+ if (raw === undefined)
201
+ return "postgres";
202
+ const normalized = raw.toLowerCase();
203
+ if (normalized === "postgres" || normalized === "powdb")
204
+ return normalized;
205
+ return { error: `Invalid --engine "${raw}". Use "postgres" (default) or "powdb".` };
206
+ }
163
207
  export async function create(args = []) {
164
208
  const token = requireToken();
165
209
  const config = loadConfig();
210
+ const engine = parseEngineArg(args);
211
+ if (typeof engine !== "string") {
212
+ emitError("INVALID_FLAG", engine.error, "");
213
+ return;
214
+ }
215
+ if (engine === "powdb" && args.some((a) => a === "--tier" || a === "--size" || a.startsWith("--tier=") || a.startsWith("--size="))) {
216
+ emitError("INVALID_FLAG", "--tier/--size do not apply to --engine powdb (PowDB has one serverless shape in this phase).", "");
217
+ return;
218
+ }
166
219
  const compute = parseCreateComputeArgs(args);
167
220
  if ("error" in compute) {
168
221
  emitError("INVALID_FLAG", compute.error, "");
169
222
  return;
170
223
  }
171
- heading("Create a new project");
172
- const name = await prompt("Project name");
224
+ const jsonMode = isJsonMode();
225
+ if (!jsonMode)
226
+ heading(engine === "powdb" ? "Create a new PowDB project" : "Create a new project");
227
+ // Agent-native path: --name (+ optional --region) skips the prompts entirely.
228
+ // Piped stdin cannot drive TWO sequential prompts — each prompt() opens a
229
+ // fresh readline that swallows the remaining buffered input, so the second
230
+ // question never resolves and Node exits silently on the unsettled await.
231
+ // Flags are the contract for headless use; prompts are for humans on a TTY.
232
+ const nameFlag = readFlagValue(args, "--name");
233
+ if (typeof nameFlag === "object") {
234
+ emitError("INVALID_FLAG", nameFlag.error, "");
235
+ return;
236
+ }
237
+ const regionFlag = readFlagValue(args, "--region");
238
+ if (typeof regionFlag === "object") {
239
+ emitError("INVALID_FLAG", regionFlag.error, "");
240
+ return;
241
+ }
242
+ if (jsonMode && nameFlag === undefined) {
243
+ // JSON mode is the machine contract — prompts would pollute stdout and a
244
+ // headless caller can't answer them anyway.
245
+ emitError("MISSING_ARG", "--json requires --name <name>.", "");
246
+ return;
247
+ }
248
+ const name = nameFlag ?? (await prompt("Project name"));
173
249
  if (!name) {
174
- emitError("MISSING_ARG", "Project name is required.", "");
250
+ emitError("MISSING_ARG", "Project name is required.", "Pass --name <name> for headless use.");
251
+ }
252
+ let region;
253
+ if (regionFlag !== undefined) {
254
+ if (!REGIONS.some((r) => r.value === regionFlag)) {
255
+ emitError("INVALID_FLAG", `Invalid --region "${regionFlag}". Use one of: ${REGIONS.map((r) => r.value).join(", ")}.`, "");
256
+ }
257
+ region = regionFlag;
258
+ }
259
+ else if (nameFlag !== undefined) {
260
+ // --name without --region: default to the first region rather than opening
261
+ // a prompt a headless caller can't answer.
262
+ region = REGIONS[0].value;
263
+ }
264
+ else {
265
+ region = await select("Select a region", REGIONS);
175
266
  }
176
- const region = await select("Select a region", REGIONS);
177
267
  const s = spinner("Creating project");
178
268
  const teamId = await resolveTeamId(token);
179
- const body = {
180
- name,
181
- region,
182
- // One friendly step: the compute is born on the chosen tier + size (no
183
- // follow-up `bata compute set` needed). Defaults to serverless / 1 CU.
184
- compute: { tier: compute.tier, size_cu: compute.sizeCu },
185
- };
269
+ const body = engine === "powdb"
270
+ ? // PowDB: no compute picker — the server rejects pg-only options.
271
+ { name, region, engine: "powdb" }
272
+ : {
273
+ name,
274
+ region,
275
+ // One friendly step: the compute is born on the chosen tier + size (no
276
+ // follow-up `bata compute set` needed). Defaults to serverless / 1 CU.
277
+ compute: { tier: compute.tier, size_cu: compute.sizeCu },
278
+ };
186
279
  if (teamId) {
187
280
  body.team_id = teamId;
188
281
  }
@@ -197,19 +290,47 @@ export async function create(args = []) {
197
290
  const project = res.data;
198
291
  // Set as default project
199
292
  saveConfig({ defaultProject: project.id });
293
+ if (jsonMode) {
294
+ json({
295
+ id: project.id,
296
+ name: project.name,
297
+ region: project.region,
298
+ engine,
299
+ status: project.status,
300
+ ...(engine === "powdb"
301
+ ? { pricing: "not metered yet" }
302
+ : { compute: { tier: compute.tier, size_cu: compute.sizeCu } }),
303
+ default_project_saved: true,
304
+ });
305
+ return;
306
+ }
200
307
  const tierLabel = compute.tier === "always_on" ? "Always-on (dedicated)" : "Serverless";
201
308
  log();
202
309
  success(`Project ${colors.cyan(project.name)} created`);
203
310
  log();
204
- kvList([
205
- ["ID", colors.dim(project.id)],
206
- ["Region", project.region],
207
- ["Compute", `${tierLabel} · ${compute.sizeCu} CU`],
208
- ["Pricing", colors.dim(computePriceSummary(compute.tier, compute.sizeCu))],
209
- ["Status", statusBadge(project.status)],
210
- ]);
211
- log();
212
- log(` ${colors.dim("Set as default project. Run")} ${colors.cyan("bata db connect")} ${colors.dim("to connect.")}`);
311
+ if (engine === "powdb") {
312
+ kvList([
313
+ ["ID", colors.dim(project.id)],
314
+ ["Region", project.region],
315
+ ["Engine", "PowDB (serverless — parks to zero when idle)"],
316
+ // Honest: PowDB compute isn't in the usage meter yet — say so, never $0.
317
+ ["Pricing", colors.dim("not metered yet")],
318
+ ["Status", statusBadge(project.status)],
319
+ ]);
320
+ log();
321
+ log(` ${colors.dim("Set as default project. Run")} ${colors.cyan('bata powdb query "<PowQL>"')} ${colors.dim("to query it.")}`);
322
+ }
323
+ else {
324
+ kvList([
325
+ ["ID", colors.dim(project.id)],
326
+ ["Region", project.region],
327
+ ["Compute", `${tierLabel} · ${compute.sizeCu} CU`],
328
+ ["Pricing", colors.dim(computePriceSummary(compute.tier, compute.sizeCu))],
329
+ ["Status", statusBadge(project.status)],
330
+ ]);
331
+ log();
332
+ log(` ${colors.dim("Set as default project. Run")} ${colors.cyan("bata db connect")} ${colors.dim("to connect.")}`);
333
+ }
213
334
  log();
214
335
  }
215
336
  export async function info(projectId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@batadata/cli",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "CLI for BataDB — serverless Postgres platform",
5
5
  "bin": {
6
6
  "bata": "./dist/index.js"