@batadata/cli 0.2.4 → 0.2.5

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.
@@ -652,6 +652,106 @@ 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 ? "RETRYABLE"
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 === "RETRYABLE" ? "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
+ async function powdbLifecycle(action, args) {
733
+ const token = requireToken();
734
+ const jsonMode = isJsonMode();
735
+ const positional = args.find((a) => !a.startsWith("-"));
736
+ const projectId = powdbProjectFlag(args) ?? positional ?? resolvePowdbProject(args);
737
+ const res = action === "status"
738
+ ? await api.get(`/v1/powdb/${encodeURIComponent(projectId)}`, token)
739
+ : await api.post(`/v1/powdb/${encodeURIComponent(projectId)}/${action}`, {}, token);
740
+ if (!res.ok) {
741
+ emitError(res.status === 404 ? "NOT_FOUND"
742
+ : res.status === 401 || res.status === 403 ? "INVALID_KEY"
743
+ : res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE"
744
+ : "CLI_ERROR", apiError(res, `PowDB ${action} failed`), "");
745
+ return;
746
+ }
747
+ if (jsonMode) {
748
+ json(res.data);
749
+ return;
750
+ }
751
+ log();
752
+ log(` ${colors.green(">")} ${colors.cyan(projectId)} ${colors.dim("engine=powdb state=")}${res.data.state}`);
753
+ log();
754
+ }
655
755
  // ─── Help + dispatch ──────────────────────────────────────────────────────────
656
756
  function powdbHelp() {
657
757
  log();
@@ -662,7 +762,13 @@ function powdbHelp() {
662
762
  log(` ${colors.dim("BataDB branching / metering / server insights do NOT apply to it.")}`);
663
763
  log();
664
764
  log(` ${colors.dim("Commands:")}`);
665
- log(` ${colors.cyan("pull")} Pull a branch's schema + data into a local PowDB-loadable PowQL script`);
765
+ log(` ${colors.cyan("pull")} Pull a Postgres branch's schema + data into a local PowDB-loadable PowQL script`);
766
+ log();
767
+ log(` ${colors.dim("Hosted PowDB projects (create one with: bata projects create --engine powdb):")}`);
768
+ log(` ${colors.cyan("query")} Run a PowQL statement: bata powdb query "Note" [--project <id>]`);
769
+ log(` ${colors.cyan("status")} Show the server state (running | parked) [--project <id>]`);
770
+ log(` ${colors.cyan("park")} Scale the server to zero (WAL is durable) [--project <id>]`);
771
+ log(` ${colors.cyan("wake")} Start a parked server (~tens of ms) [--project <id>]`);
666
772
  log();
667
773
  log(` ${colors.dim("Options (pull):")}`);
668
774
  log(` ${colors.dim("--project <id> override the linked/default project")}`);
@@ -688,10 +794,18 @@ export async function handlePowdb(args) {
688
794
  switch (sub) {
689
795
  case "pull":
690
796
  return powdbPull(args.slice(1));
797
+ case "query":
798
+ return powdbQuery(args.slice(1));
799
+ case "status":
800
+ return powdbLifecycle("status", args.slice(1));
801
+ case "park":
802
+ return powdbLifecycle("park", args.slice(1));
803
+ case "wake":
804
+ return powdbLifecycle("wake", args.slice(1));
691
805
  case undefined:
692
806
  powdbHelp();
693
807
  return;
694
808
  default:
695
- emitError("INVALID_FLAG", `Unknown subcommand: powdb ${sub}`, "Available: pull. Run `bata powdb --help`.");
809
+ emitError("INVALID_FLAG", `Unknown subcommand: powdb ${sub}`, "Available: pull, query, status, park, wake. Run `bata powdb --help`.");
696
810
  }
697
811
  }
@@ -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.5",
4
4
  "description": "CLI for BataDB — serverless Postgres platform",
5
5
  "bin": {
6
6
  "bata": "./dist/index.js"