postgresai 0.16.0-dev.10 → 0.16.0-dev.11

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.
@@ -16,29 +16,13 @@ import { fetchReports, fetchAllReports, fetchReportFiles, fetchReportFileData, r
16
16
  import {
17
17
  executeJoeCommand,
18
18
  listProjects,
19
- getCommandResult,
20
- getCommandStatus,
21
- formatJoeResult,
19
+ getCommandOutput,
20
+ formatJoeOutput,
22
21
  formatProjectsTable,
23
22
  DEFAULT_BUDGET_MS,
24
23
  type JoeCommand,
25
24
  type ExecuteJoeOutcome,
26
25
  } from "../lib/joe";
27
- import {
28
- resolveDblabInstanceId,
29
- createClone,
30
- listClones,
31
- getClone,
32
- resetClone,
33
- destroyClone,
34
- listBranches,
35
- createBranch,
36
- deleteBranch,
37
- branchLog,
38
- listSnapshots,
39
- createSnapshot,
40
- destroySnapshot,
41
- } from "../lib/dblab";
42
26
  import { resolveBaseUrls } from "../lib/util";
43
27
  import { registerAasCollection, parseVcpus } from "../lib/aas-onboard";
44
28
  import { uploadFile, downloadFile, buildMarkdownLink, uploadAttachments, appendAttachmentsToContent } from "../lib/storage";
@@ -361,12 +345,18 @@ function prepareUploadConfig(
361
345
  console.error("Tip: run 'postgresai auth' or pass --api-key / set PGAI_API_KEY");
362
346
  return null; // Signal to exit
363
347
  }
364
- // No credentials and upload not explicitly requested: fall back to
365
- // local-only mode, but say so prominently skipping the upload silently
366
- // hides the fact that results never reach the Console.
367
- console.error("Notice: no API key configured results will NOT be uploaded to PostgresAI.");
368
- console.error(" To upload: run 'postgresai auth login' or pass --api-key / set PGAI_API_KEY.");
369
- console.error(" To run locally without this notice, pass --no-upload.");
348
+ if (opts.markdown) {
349
+ console.error("Notice: no API key configuredregular report upload is disabled.");
350
+ console.error(" The full report JSON will still be sent to the PostgresAI API for markdown conversion.");
351
+ console.error(" To avoid sending report data, replace --markdown with --no-upload and --json or --output.");
352
+ } else {
353
+ // No credentials and upload not explicitly requested: fall back to
354
+ // local-only mode, but say so prominently — skipping the upload silently
355
+ // hides the fact that results never reach the Console.
356
+ console.error("Notice: no API key configured — results will NOT be uploaded to PostgresAI.");
357
+ console.error(" To upload: run 'postgresai auth login' or pass --api-key / set PGAI_API_KEY.");
358
+ console.error(" To run locally without this notice, pass --no-upload.");
359
+ }
370
360
  return undefined; // Skip upload, run checks locally
371
361
  }
372
362
 
@@ -2022,7 +2012,7 @@ program
2022
2012
  "project name or ID for remote upload (used with --upload; defaults to config defaultProject; auto-generated on first run)"
2023
2013
  )
2024
2014
  .option("--json", "output JSON to stdout")
2025
- .option("--markdown", "output markdown to stdout")
2015
+ .option("--markdown", "output markdown via PostgresAI API (transmits the full report JSON)")
2026
2016
  .addHelpText(
2027
2017
  "after",
2028
2018
  [
@@ -2036,7 +2026,7 @@ program
2036
2026
  " postgresai checkup postgresql://user:pass@host:5432/db --check-id H002",
2037
2027
  " postgresai checkup postgresql://user:pass@host:5432/db --output ./reports",
2038
2028
  " postgresai checkup postgresql://user:pass@host:5432/db --no-upload --json",
2039
- " postgresai checkup postgresql://user:pass@host:5432/db --no-upload --markdown",
2029
+ " postgresai checkup postgresql://user:pass@host:5432/db --markdown",
2040
2030
  ].join("\n")
2041
2031
  )
2042
2032
  .action(async (checkIdOrConn: string | undefined, connArg: string | undefined, opts: CheckupOptions, cmd: Command) => {
@@ -2086,9 +2076,14 @@ program
2086
2076
  process.exitCode = 1;
2087
2077
  return;
2088
2078
  }
2089
- // Note: --json, --markdown and --upload/--no-upload are independent flags.
2090
- // Use --no-upload to explicitly disable upload when using --json or --markdown.
2091
2079
  const uploadExplicitlyDisabled = opts.upload === false;
2080
+ if (uploadExplicitlyDisabled && shouldConvertMarkdown) {
2081
+ console.error("Error: --no-upload and --markdown are mutually exclusive");
2082
+ console.error("Markdown conversion is performed by the PostgresAI API and transmits the full report JSON.");
2083
+ console.error("Drop --no-upload to allow transmission, or use --json or --output for local-only output.");
2084
+ process.exitCode = 1;
2085
+ return;
2086
+ }
2092
2087
  let shouldUpload = !uploadExplicitlyDisabled;
2093
2088
 
2094
2089
  // Preflight: validate/create output directory BEFORE connecting / running checks.
@@ -2334,7 +2329,9 @@ program
2334
2329
 
2335
2330
  console.log('\nFor details:');
2336
2331
  console.log(' --json Output JSON');
2337
- console.log(' --markdown Output markdown');
2332
+ if (!uploadExplicitlyDisabled) {
2333
+ console.log(' --markdown Output markdown via PostgresAI API');
2334
+ }
2338
2335
  console.log(' --output <dir> Save to directory');
2339
2336
  }
2340
2337
  } catch (error) {
@@ -3957,13 +3954,9 @@ targets
3957
3954
  // Authentication and API key management
3958
3955
  const auth = program.command("auth").description("authentication and API key management");
3959
3956
 
3960
- auth
3961
- .command("login", { isDefault: true })
3962
- .description("authenticate via browser (OAuth) or store API key directly")
3963
- .option("--set-key <key>", "store API key directly without OAuth flow")
3964
- .option("--port <port>", "local callback server port (default: random)", parseInt)
3965
- .option("--debug", "enable debug output")
3966
- .action(async (opts: { setKey?: string; port?: number; debug?: boolean }) => {
3957
+ type AuthLoginOptions = { setKey?: string; port?: number; debug?: boolean };
3958
+
3959
+ async function runAuthLogin(opts: AuthLoginOptions) {
3967
3960
  // If --set-key is provided, store it directly without OAuth
3968
3961
  if (opts.setKey) {
3969
3962
  const trimmedKey = opts.setKey.trim();
@@ -4217,7 +4210,21 @@ auth
4217
4210
  console.error(`Authentication error: ${message}`);
4218
4211
  process.exit(1);
4219
4212
  }
4220
- });
4213
+ }
4214
+
4215
+ function configureLoginCommand(command: Command): Command {
4216
+ return command
4217
+ .description("authenticate via browser (OAuth) or store API key directly")
4218
+ .option("--set-key <key>", "store API key directly without OAuth flow")
4219
+ .option("--port <port>", "local callback server port (default: random)", parseInt)
4220
+ .option("--debug", "enable debug output");
4221
+ }
4222
+
4223
+ configureLoginCommand(auth.command("login", { isDefault: true }))
4224
+ .action(runAuthLogin);
4225
+
4226
+ configureLoginCommand(program.command("login"))
4227
+ .action(runAuthLogin);
4221
4228
 
4222
4229
  auth
4223
4230
  .command("show-key")
@@ -5403,58 +5410,24 @@ function tryParseJson(s: string): unknown {
5403
5410
  }
5404
5411
 
5405
5412
  // ---------------------------------------------------------------------------
5406
- // Joe API v2 command surface (SPEC §6). Every verb is a client-side
5407
- // submit-then-poll one-shot over `v1.joe_command_submit`; `--project` accepts a
5408
- // numeric id OR an alias/name (id-or-alias resolution). `plan` is plan-only
5409
- // (EXPLAIN, no execution); `explain` (EXPLAIN ANALYZE) and `exec` execute on the
5410
- // hardened, ephemeral DBLab clone. The backend rpcs are mocked in tests.
5413
+ // Joe command surface (Joe API v2 CLI v1, issue #438). Every verb builds the
5414
+ // RAW command text Joe's /webui/command dispatches (`plan <sql>`, `\d users`),
5415
+ // runs it via the synchronous `v1.joe_command_run`, and polls
5416
+ // `v1.joe_command_output` until `ok`/`error` within the one-shot budget.
5417
+ // `--project` accepts a numeric id OR an alias/name/label. The backend rpcs
5418
+ // are mocked in tests.
5411
5419
  // ---------------------------------------------------------------------------
5412
5420
 
5413
5421
  interface JoeCliOpts {
5414
5422
  project?: string;
5415
- session?: string;
5416
- newSession?: boolean;
5423
+ instanceId?: string;
5417
5424
  budget?: number;
5418
- query?: string;
5419
5425
  variant?: string;
5420
5426
  debug?: boolean;
5421
5427
  json?: boolean;
5422
5428
  }
5423
5429
 
5424
- // ============================================================================
5425
- // DBLab companion command surface (Joe API v2 · SPEC §8)
5426
- //
5427
- // `pgai dblab clone|branch|snapshot …` proxy the SAME Platform DBLab API the Console
5428
- // already drives (v1.dblab_api_call). Every verb is `--project <id|alias>`
5429
- // scoped: the project's single DBLab instance is resolved server-side listing,
5430
- // then the verb is proxied. The DESTRUCTIVE verbs — the HTTP DELETEs: clone
5431
- // destroy, branch delete, snapshot destroy — require the `joe:admin` token
5432
- // scope (user-approved tightening, !612); clone reset is a POST and is NOT
5433
- // gated. Read/list/create verbs stay at org-token + project-allowlist level.
5434
- // All enforced backend-side — a PT403 surfaces here as an HTTP 403.
5435
- //
5436
- // NOTE: kept in its own region and its own lib (cli/lib/dblab.ts) so it does not
5437
- // collide with the parallel Joe-commands work.
5438
- // ============================================================================
5439
-
5440
- interface DblabCmdOpts {
5441
- project?: string;
5442
- debug?: boolean;
5443
- json?: boolean;
5444
- }
5445
-
5446
- function inferCommandFromResult(r: { command?: JoeCommand; plan_json?: unknown; plan_text?: string | null; row_count?: number | null; result_rows?: unknown[] | null; hypo_plan?: unknown; hypo_used?: boolean | null; terminated?: boolean | null; pid?: number | null; reset?: boolean | null; snapshot?: unknown }): JoeCommand | null {
5447
- if (r.command) return r.command;
5448
- if (r.plan_text !== undefined || r.plan_json !== undefined) return "plan";
5449
- if (r.row_count !== undefined || r.result_rows !== undefined) return "exec";
5450
- if (r.hypo_used !== undefined) return "hypo";
5451
- if (r.terminated !== undefined) return "terminate";
5452
- if (r.reset !== undefined) return "reset";
5453
- if (r.snapshot !== undefined) return "activity";
5454
- return null;
5455
- }
5456
-
5457
- function printJoeOutcome(command: JoeCommand, outcome: ExecuteJoeOutcome, json: boolean, budgetMs?: number): void {
5430
+ function printJoeOutcome(outcome: ExecuteJoeOutcome, json: boolean, budgetMs?: number): void {
5458
5431
  // The expiry hint reports the ACTUAL effective budget (--budget when given,
5459
5432
  // the default otherwise) — not a hardcoded DEFAULT_BUDGET_MS.
5460
5433
  const effectiveBudgetMs =
@@ -5469,7 +5442,6 @@ function printJoeOutcome(command: JoeCommand, outcome: ExecuteJoeOutcome, json:
5469
5442
  {
5470
5443
  command_id: outcome.commandId,
5471
5444
  status: outcome.status,
5472
- session_id: outcome.sessionId,
5473
5445
  budget_expired: true,
5474
5446
  resume: `pgai joe result ${outcome.commandId}`,
5475
5447
  },
@@ -5479,43 +5451,37 @@ function printJoeOutcome(command: JoeCommand, outcome: ExecuteJoeOutcome, json:
5479
5451
  );
5480
5452
  } else {
5481
5453
  console.log(
5482
- `submitted ${outcome.commandId} · ${outcome.status} · budget ${budgetSeconds}s reached — resume: pgai joe result ${outcome.commandId}`
5454
+ `started ${outcome.commandId} · ${outcome.status} · budget ${budgetSeconds}s reached — resume: pgai joe result ${outcome.commandId}`
5483
5455
  );
5484
5456
  }
5485
5457
  return;
5486
5458
  }
5487
5459
 
5488
- const result = outcome.result;
5489
- if (outcome.status === "done" && result) {
5490
- if (json) {
5491
- console.log(JSON.stringify(result, null, 2));
5492
- } else {
5493
- console.log(`submitted ${outcome.commandId} · done`);
5494
- const body = formatJoeResult(command, result);
5460
+ const output = outcome.output;
5461
+ if (outcome.status === "ok") {
5462
+ if (!output) {
5463
+ console.error(`command ${outcome.commandId} ok but output is empty`);
5464
+ process.exitCode = 1;
5465
+ return;
5466
+ }
5467
+ if (json) console.log(JSON.stringify(output, null, 2));
5468
+ else {
5469
+ console.log(`command ${outcome.commandId} · ok`);
5470
+ const body = formatJoeOutput(output);
5495
5471
  if (body) console.log(body);
5496
5472
  }
5497
5473
  return;
5498
5474
  }
5499
5475
 
5500
- if (outcome.status === "error") {
5501
- const msg = result?.error ?? "command failed";
5502
- console.error(`command ${outcome.commandId} error: ${msg}`);
5503
- process.exitCode = 1;
5504
- return;
5505
- }
5506
-
5507
- // Server-side timed_out (the dispatcher gave up). The genuine reply may still
5508
- // land; suggest a resume, but signal failure with a non-zero exit.
5509
- console.error(
5510
- `command ${outcome.commandId} timed out on the server — resume: pgai joe result ${outcome.commandId}`
5511
- );
5476
+ // Terminal error.
5477
+ if (json && output) console.log(JSON.stringify(output, null, 2));
5478
+ console.error(`command ${outcome.commandId} error: ${output?.error ?? "command failed"}`);
5512
5479
  process.exitCode = 1;
5513
5480
  }
5514
5481
 
5515
5482
  async function runJoeCli(
5516
5483
  command: JoeCommand,
5517
- sql: string | null,
5518
- args: Record<string, unknown> | null,
5484
+ arg: string | null,
5519
5485
  opts: JoeCliOpts
5520
5486
  ): Promise<void> {
5521
5487
  try {
@@ -5528,30 +5494,33 @@ async function runJoeCli(
5528
5494
  return;
5529
5495
  }
5530
5496
  const projectRef = (opts.project ?? cfg.defaultProject ?? "").toString().trim();
5531
- if (!projectRef) {
5532
- console.error("Project is required. Pass --project <id|alias>.");
5497
+ const instanceRef = (opts.instanceId ?? "").toString().trim();
5498
+ if (!projectRef && !instanceRef) {
5499
+ console.error(
5500
+ "Specify --instance-id <id> (or --project <id|alias> once projects_list is available)."
5501
+ );
5533
5502
  process.exitCode = 1;
5534
5503
  return;
5535
5504
  }
5536
5505
  const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
5537
- const budgetMs =
5538
- typeof opts.budget === "number" && !Number.isNaN(opts.budget) ? opts.budget * 1000 : undefined;
5506
+ if (typeof opts.budget === "number" && (!Number.isFinite(opts.budget) || opts.budget < 0)) {
5507
+ throw new Error("--budget must be a non-negative number of seconds");
5508
+ }
5509
+ const budgetMs = typeof opts.budget === "number" ? opts.budget * 1000 : undefined;
5539
5510
 
5540
5511
  const outcome = await executeJoeCommand({
5541
5512
  apiKey,
5542
5513
  apiBaseUrl,
5543
5514
  command,
5544
- project: projectRef,
5545
- sql,
5546
- args,
5547
- session: opts.session ?? null,
5548
- newSession: !!opts.newSession,
5515
+ project: projectRef || undefined,
5516
+ instanceId: instanceRef || undefined,
5517
+ input: { arg, variant: opts.variant ?? null },
5549
5518
  orgId: cfg.orgId ?? undefined,
5550
5519
  budgetMs,
5551
5520
  debug: !!opts.debug,
5552
5521
  });
5553
5522
 
5554
- printJoeOutcome(command, outcome, !!opts.json, budgetMs);
5523
+ printJoeOutcome(outcome, !!opts.json, budgetMs);
5555
5524
  } catch (err) {
5556
5525
  const message = err instanceof Error ? err.message : String(err);
5557
5526
  console.error(message);
@@ -5559,40 +5528,37 @@ async function runJoeCli(
5559
5528
  }
5560
5529
  }
5561
5530
 
5562
- /** Attach the shared Joe options (--project / --session / --new-session / --budget). */
5531
+ /** Attach the shared Joe options (--instance-id / --project / --budget / --debug / --json). */
5563
5532
  function withJoeOptions(cmd: import("commander").Command): import("commander").Command {
5564
5533
  return cmd
5565
- .option("--project <id|alias>", "target project by numeric id OR alias/name")
5566
- .option("--session <id>", "run on a specific session's clone")
5567
- .option("--new-session", "start a fresh session/clone (null session)")
5568
- .option("--budget <seconds>", "one-shot poll budget in seconds (default 25)", (v) => parseInt(v, 10))
5534
+ .option(
5535
+ "--instance-id <id>",
5536
+ "target the Joe instance id directly (skips --project resolution; the v1 path while projects_list is unavailable)"
5537
+ )
5538
+ .option("--project <id|alias>", "target project by numeric id OR alias/name (requires projects_list)")
5539
+ .option("--budget <seconds>", "one-shot poll budget in seconds (default 25)", (v) => parseFloat(v))
5569
5540
  .option("--debug", "enable debug output")
5570
5541
  .option("--json", "output raw JSON");
5571
5542
  }
5572
5543
 
5573
- // ---------------------------------------------------------------------------
5574
- // Joe command group — every Joe verb lives under `pgai joe <verb>` (grouped CLI,
5575
- // USER-AUTHORIZED restructure). The MCP tool names stay flat/unchanged; only the
5576
- // CLI invocation path is grouped. `projects` stays top-level (org-level, SPEC §6).
5577
- // ---------------------------------------------------------------------------
5578
5544
  const joe = program
5579
5545
  .command("joe")
5580
- .description("Joe API v2 — plan/EXPLAIN/exec queries on ephemeral DBLab clones");
5546
+ .description("Joe — plan/EXPLAIN/exec queries on ephemeral DBLab clones");
5581
5547
 
5582
5548
  withJoeOptions(
5583
5549
  joe
5584
5550
  .command("plan <sql>")
5585
5551
  .description("plan a query (EXPLAIN, plan-only — no execution; the fast/safe default)")
5586
5552
  ).action(async (sql: string, opts: JoeCliOpts) => {
5587
- await runJoeCli("plan", sql, null, opts);
5553
+ await runJoeCli("plan", sql, opts);
5588
5554
  });
5589
5555
 
5590
5556
  withJoeOptions(
5591
5557
  joe
5592
5558
  .command("explain <sql>")
5593
- .description("EXPLAIN ANALYZE a query (EXECUTES on the hardened, ephemeral clone)")
5559
+ .description("EXPLAIN + EXPLAIN ANALYZE a query (EXECUTES on the ephemeral clone)")
5594
5560
  ).action(async (sql: string, opts: JoeCliOpts) => {
5595
- await runJoeCli("explain", sql, null, opts);
5561
+ await runJoeCli("explain", sql, opts);
5596
5562
  });
5597
5563
 
5598
5564
  withJoeOptions(
@@ -5600,24 +5566,23 @@ withJoeOptions(
5600
5566
  .command("exec <sql>")
5601
5567
  .description("run arbitrary DDL/DML on the clone (e.g. create index, analyze)")
5602
5568
  ).action(async (sql: string, opts: JoeCliOpts) => {
5603
- await runJoeCli("exec", sql, null, opts);
5569
+ await runJoeCli("exec", sql, opts);
5604
5570
  });
5605
5571
 
5606
5572
  withJoeOptions(
5607
5573
  joe
5608
- .command("hypo <indexSql>")
5609
- .description("HypoPG hypothetical index + re-plan (no real index, no data change)")
5610
- .requiredOption("--query <sql>", "target query the hypothetical index is evaluated against")
5611
- ).action(async (indexSql: string, opts: JoeCliOpts) => {
5612
- await runJoeCli("hypo", indexSql, { query: opts.query }, opts);
5574
+ .command("hypo <args>")
5575
+ .description("HypoPG hypothetical indexes (e.g. `hypo create index on users (email)`, `hypo desc`, `hypo reset`)")
5576
+ ).action(async (args: string, opts: JoeCliOpts) => {
5577
+ await runJoeCli("hypo", args, opts);
5613
5578
  });
5614
5579
 
5615
5580
  withJoeOptions(
5616
5581
  joe
5617
5582
  .command("activity")
5618
- .description("running-activity snapshot (pg_stat_activity; query text redacted)")
5583
+ .description("running-activity snapshot (pg_stat_activity) on the clone")
5619
5584
  ).action(async (opts: JoeCliOpts) => {
5620
- await runJoeCli("activity", null, null, opts);
5585
+ await runJoeCli("activity", null, opts);
5621
5586
  });
5622
5587
 
5623
5588
  withJoeOptions(
@@ -5625,18 +5590,9 @@ withJoeOptions(
5625
5590
  .command("terminate <pid>")
5626
5591
  .description("pg_terminate_backend(pid) on the clone")
5627
5592
  ).action(async (pid: string, opts: JoeCliOpts) => {
5628
- // A pid must be a bare non-negative integer. parseInt() silently accepts
5629
- // trailing garbage ("12x"→12), a sign ("-5"→-5), decimals ("1.5"→1) and hex
5630
- // ("0x10"→0) any of which would submit a terminate against the WRONG
5631
- // backend pid. Require a fully-numeric token so a mistyped pid is a clean,
5632
- // typed rejection that never reaches the submit rpc.
5633
- const pidStr = (pid ?? "").trim();
5634
- if (!/^[0-9]+$/.test(pidStr)) {
5635
- console.error("pid must be a number");
5636
- process.exitCode = 1;
5637
- return;
5638
- }
5639
- await runJoeCli("terminate", null, { pid: parseInt(pidStr, 10) }, opts);
5593
+ // The bare-positive-integer pid guard lives in buildJoeCommandText, which
5594
+ // runs before any network call — a mistyped pid never reaches an rpc.
5595
+ await runJoeCli("terminate", pid, opts);
5640
5596
  });
5641
5597
 
5642
5598
  withJoeOptions(
@@ -5644,7 +5600,7 @@ withJoeOptions(
5644
5600
  .command("reset")
5645
5601
  .description("reset/recreate the session's thin clone")
5646
5602
  ).action(async (opts: JoeCliOpts) => {
5647
- await runJoeCli("reset", null, null, opts);
5603
+ await runJoeCli("reset", null, opts);
5648
5604
  });
5649
5605
 
5650
5606
  withJoeOptions(
@@ -5653,138 +5609,14 @@ withJoeOptions(
5653
5609
  .description("\\d-family schema/relation/index metadata")
5654
5610
  .option("--variant <variant>", "\\d-family variant (e.g. \\d+, \\di, \\dt)")
5655
5611
  ).action(async (object: string, opts: JoeCliOpts) => {
5656
- const args: Record<string, unknown> = { object };
5657
- if (opts.variant) args.variant = opts.variant;
5658
- await runJoeCli("describe", null, args, opts);
5612
+ await runJoeCli("describe", object, opts);
5659
5613
  });
5660
5614
 
5661
- // history search is roadmapped for M1b; the search backend is not deployed yet.
5662
- // Ship a recognized subcommand so the documented `pgai joe history …` example
5663
- // degrades cleanly (an informative "not yet available" line + exit 1) instead of
5664
- // Commander's generic "unknown command 'history'". Wire it to the rpc when M1b lands.
5665
- withJoeOptions(
5666
- joe
5667
- .command("history <terms>")
5668
- .description("search prior Joe analyses, metadata-only (M1b — not yet available)")
5669
- ).action(async (_terms: string, _opts: JoeCliOpts) => {
5670
- console.error(
5671
- "Joe history search is not available yet — it is planned for M1b. " +
5672
- "No history-search backend is deployed; this command is a placeholder until then.",
5673
- );
5674
- process.exitCode = 1;
5675
- });
5676
-
5677
- // Org-level discovery — a general postgresai command, NOT a Joe endpoint (SPEC §6).
5678
- program
5679
- .command("projects")
5680
- .description("list the org's projects (shows which have Joe ready) — org-level, not a Joe endpoint")
5681
- .option("--debug", "enable debug output")
5682
- .option("--json", "output raw JSON")
5683
- .action(async (opts: { debug?: boolean; json?: boolean }) => {
5684
- try {
5685
- const rootOpts = program.opts<CliOptions>();
5686
- const cfg = config.readConfig();
5687
- const { apiKey } = getConfig(rootOpts);
5688
- if (!apiKey) {
5689
- console.error("API key is required. Run 'pgai auth' first or set --api-key.");
5690
- process.exitCode = 1;
5691
- return;
5692
- }
5693
- const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
5694
- const projects = await listProjects({
5695
- apiKey,
5696
- apiBaseUrl,
5697
- orgId: cfg.orgId ?? undefined,
5698
- debug: !!opts.debug,
5699
- });
5700
- if (opts.json) {
5701
- console.log(JSON.stringify(projects, null, 2));
5702
- } else {
5703
- console.log(formatProjectsTable(projects));
5704
- }
5705
- } catch (err) {
5706
- const message = err instanceof Error ? err.message : String(err);
5707
- console.error(message);
5708
- process.exitCode = 1;
5709
- }
5710
- });
5711
-
5712
- /**
5713
- * Resolve the shared inputs every DBLab verb needs: the api key, the api base
5714
- * url, and the project's single DBLab `instance_id`. Throws (caught by each
5715
- * action → exit 1) when the api key or `--project` is missing, or when no DBLab
5716
- * instance can be resolved for the project.
5717
- */
5718
- async function resolveDblabTarget(
5719
- project: string | undefined,
5720
- debug: boolean
5721
- ): Promise<{ apiKey: string; apiBaseUrl: string; orgId?: number; instanceId: string }> {
5722
- const rootOpts = program.opts<CliOptions>();
5723
- const cfg = config.readConfig();
5724
- const { apiKey } = getConfig(rootOpts);
5725
- if (!apiKey) {
5726
- throw new Error("API key is required. Run 'pgai auth' first or set --api-key.");
5727
- }
5728
- const ref = (project ?? "").trim();
5729
- if (!ref) {
5730
- throw new Error("--project <id|alias> is required");
5731
- }
5732
- const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
5733
- const orgId = cfg.orgId ?? undefined;
5734
- const instanceId = await resolveDblabInstanceId({ apiKey, apiBaseUrl, project: ref, orgId, debug });
5735
- return { apiKey, apiBaseUrl, orgId, instanceId };
5736
- }
5737
-
5738
- // ---------------------------------------------------------------------------
5739
- // DBLab command group — every DBLab verb lives under
5740
- // `pgai dblab <clone|branch|snapshot> …` (grouped CLI, USER-AUTHORIZED restructure).
5741
- // MCP tool names (clone_create/…) stay flat/unchanged; only the CLI path is grouped.
5742
- // ---------------------------------------------------------------------------
5743
- const dblab = program
5744
- .command("dblab")
5745
- .description("DBLab thin-clone / branch / snapshot management (proxies the Platform DBLab API)");
5746
-
5747
- // ---- clone ----------------------------------------------------------------
5748
-
5749
- const clone = dblab.command("clone").description("DBLab thin-clone management (proxies the Platform DBLab API)");
5750
-
5751
- clone
5752
- .command("create")
5753
- .description("create a thin clone of the project's database")
5754
- .requiredOption("--project <id|alias>", "project id or alias")
5755
- .option("--branch <branch>", "branch to clone from")
5756
- .option("--snapshot <id>", "snapshot id to clone from")
5757
- .option("--id <id>", "clone id (DBLab generates one when omitted)")
5758
- .option("--db-user <user>", "clone DB user")
5759
- .option("--db-password <password>", "clone DB password")
5760
- .option("--protected", "protect the clone from auto-deletion")
5761
- .option("--debug", "enable debug output")
5762
- .option("--json", "output raw JSON")
5763
- .action(async (opts: DblabCmdOpts & { branch?: string; snapshot?: string; id?: string; dbUser?: string; dbPassword?: string; protected?: boolean }) => {
5764
- try {
5765
- const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
5766
- const result = await createClone({
5767
- apiKey, apiBaseUrl, instanceId,
5768
- cloneId: opts.id,
5769
- branch: opts.branch,
5770
- snapshotId: opts.snapshot,
5771
- dbUser: opts.dbUser,
5772
- dbPassword: opts.dbPassword,
5773
- isProtected: !!opts.protected,
5774
- debug: !!opts.debug,
5775
- });
5776
- printResult(result, opts.json);
5777
- } catch (err) {
5778
- console.error(err instanceof Error ? err.message : String(err));
5779
- process.exitCode = 1;
5780
- }
5781
- });
5782
-
5783
- // Resume / inspect a previously submitted command by id. Part of the `joe` group
5784
- // (`pgai joe status <commandId>`) — distinct from `pgai dblab clone status <cloneId>`.
5615
+ // Resume / inspect a previously started command by id (the budget-expiry
5616
+ // resume handle printed by the one-shot verbs).
5785
5617
  joe
5786
- .command("status <commandId>")
5787
- .description("show a Joe command's status (metadata only)")
5618
+ .command("result <commandId>")
5619
+ .description("fetch a Joe command's output by id (resume a budget-expired one-shot)")
5788
5620
  .option("--debug", "enable debug output")
5789
5621
  .option("--json", "output raw JSON")
5790
5622
  .action(async (commandId: string, opts: { debug?: boolean; json?: boolean }) => {
@@ -5798,11 +5630,27 @@ joe
5798
5630
  return;
5799
5631
  }
5800
5632
  const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
5801
- const status = await getCommandStatus({ apiKey, apiBaseUrl, commandId, debug: !!opts.debug });
5633
+ const output = await getCommandOutput({ apiKey, apiBaseUrl, commandId, debug: !!opts.debug });
5802
5634
  if (opts.json) {
5803
- console.log(JSON.stringify(status, null, 2));
5635
+ console.log(JSON.stringify(output, null, 2));
5636
+ // Same exit contract as the human output and the one-shot path
5637
+ // (printJoeOutcome): only `ok` is a result — scripts consuming --json
5638
+ // must not proceed on `pending`/`error`.
5639
+ if (output.status !== "ok") {
5640
+ process.exitCode = 1;
5641
+ }
5642
+ return;
5643
+ }
5644
+ if (output.status === "ok") {
5645
+ console.log(`command ${output.command_id} · ok`);
5646
+ const body = formatJoeOutput(output);
5647
+ if (body) console.log(body);
5648
+ } else if (output.status === "error") {
5649
+ console.error(`command ${output.command_id} error: ${output.error ?? "command failed"}`);
5650
+ process.exitCode = 1;
5804
5651
  } else {
5805
- console.log(`command ${status.command_id} · ${status.status}${status.error ? ` · ${status.error}` : ""}`);
5652
+ console.error(`command ${output.command_id} · ${output.status} result is not ready`);
5653
+ process.exitCode = 1;
5806
5654
  }
5807
5655
  } catch (err) {
5808
5656
  const message = err instanceof Error ? err.message : String(err);
@@ -5811,29 +5659,13 @@ joe
5811
5659
  }
5812
5660
  });
5813
5661
 
5814
- clone
5815
- .command("list")
5816
- .description("list the project's thin clones")
5817
- .requiredOption("--project <id|alias>", "project id or alias")
5818
- .option("--debug", "enable debug output")
5819
- .option("--json", "output raw JSON")
5820
- .action(async (opts: DblabCmdOpts) => {
5821
- try {
5822
- const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
5823
- const result = await listClones({ apiKey, apiBaseUrl, instanceId, debug: !!opts.debug });
5824
- printResult(result, opts.json);
5825
- } catch (err) {
5826
- console.error(err instanceof Error ? err.message : String(err));
5827
- process.exitCode = 1;
5828
- }
5829
- });
5830
-
5831
- joe
5832
- .command("result <commandId>")
5833
- .description("fetch a Joe command's result by id (resume a timed-out one-shot)")
5662
+ // Org-level discovery — a general postgresai command, NOT a Joe endpoint.
5663
+ program
5664
+ .command("projects")
5665
+ .description("list the org's projects (shows which have Joe ready) — org-level, not a Joe endpoint")
5834
5666
  .option("--debug", "enable debug output")
5835
5667
  .option("--json", "output raw JSON")
5836
- .action(async (commandId: string, opts: { debug?: boolean; json?: boolean }) => {
5668
+ .action(async (opts: { debug?: boolean; json?: boolean }) => {
5837
5669
  try {
5838
5670
  const rootOpts = program.opts<CliOptions>();
5839
5671
  const cfg = config.readConfig();
@@ -5844,33 +5676,16 @@ joe
5844
5676
  return;
5845
5677
  }
5846
5678
  const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
5847
- const result = await getCommandResult({ apiKey, apiBaseUrl, commandId, debug: !!opts.debug });
5679
+ const projects = await listProjects({
5680
+ apiKey,
5681
+ apiBaseUrl,
5682
+ orgId: cfg.orgId ?? undefined,
5683
+ debug: !!opts.debug,
5684
+ });
5848
5685
  if (opts.json) {
5849
- console.log(JSON.stringify(result, null, 2));
5850
- // Same exit contract as the human output and the one-shot path
5851
- // (printJoeOutcome): a terminal failure is a non-result — scripts
5852
- // consuming --json must not proceed on it.
5853
- if (result.status === "error" || result.status === "timed_out") {
5854
- process.exitCode = 1;
5855
- }
5856
- return;
5857
- }
5858
- const inferred = inferCommandFromResult(result);
5859
- if (result.status === "done" && inferred) {
5860
- console.log(`command ${result.command_id} · done`);
5861
- const body = formatJoeResult(inferred, result);
5862
- if (body) console.log(body);
5863
- } else if (result.status === "error") {
5864
- console.error(`command ${result.command_id} error: ${result.error ?? "command failed"}`);
5865
- process.exitCode = 1;
5866
- } else if (result.status === "timed_out") {
5867
- // Terminal server-side timeout — a non-result. Match the one-shot path
5868
- // (printJoeOutcome): report and exit non-zero so `pgai joe result $id
5869
- // && next-step` scripts do not proceed.
5870
- console.error(`command ${result.command_id} timed out on the server`);
5871
- process.exitCode = 1;
5686
+ console.log(JSON.stringify(projects, null, 2));
5872
5687
  } else {
5873
- console.log(`command ${result.command_id} · ${result.status}`);
5688
+ console.log(formatProjectsTable(projects));
5874
5689
  }
5875
5690
  } catch (err) {
5876
5691
  const message = err instanceof Error ? err.message : String(err);
@@ -5879,218 +5694,6 @@ joe
5879
5694
  }
5880
5695
  });
5881
5696
 
5882
- clone
5883
- .command("status <cloneId>")
5884
- .description("show a clone's status")
5885
- .requiredOption("--project <id|alias>", "project id or alias")
5886
- .option("--debug", "enable debug output")
5887
- .option("--json", "output raw JSON")
5888
- .action(async (cloneId: string, opts: DblabCmdOpts) => {
5889
- try {
5890
- const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
5891
- const result = await getClone({ apiKey, apiBaseUrl, instanceId, cloneId, debug: !!opts.debug });
5892
- printResult(result, opts.json);
5893
- } catch (err) {
5894
- console.error(err instanceof Error ? err.message : String(err));
5895
- process.exitCode = 1;
5896
- }
5897
- });
5898
-
5899
- clone
5900
- .command("reset <cloneId>")
5901
- .description("reset a clone to a pristine snapshot")
5902
- .requiredOption("--project <id|alias>", "project id or alias")
5903
- .option("--snapshot <id>", "snapshot id to reset to (default: latest)")
5904
- .option("--latest", "reset to the latest snapshot")
5905
- .option("--debug", "enable debug output")
5906
- .option("--json", "output raw JSON")
5907
- .action(async (cloneId: string, opts: DblabCmdOpts & { snapshot?: string; latest?: boolean }) => {
5908
- try {
5909
- const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
5910
- const result = await resetClone({
5911
- apiKey, apiBaseUrl, instanceId, cloneId,
5912
- snapshotId: opts.snapshot,
5913
- latest: opts.latest,
5914
- debug: !!opts.debug,
5915
- });
5916
- printResult(result ?? { reset: true, cloneId }, opts.json);
5917
- } catch (err) {
5918
- console.error(err instanceof Error ? err.message : String(err));
5919
- process.exitCode = 1;
5920
- }
5921
- });
5922
-
5923
- clone
5924
- .command("destroy <cloneId>")
5925
- .description("destroy a clone (requires joe:admin)")
5926
- .requiredOption("--project <id|alias>", "project id or alias")
5927
- .option("--debug", "enable debug output")
5928
- .option("--json", "output raw JSON")
5929
- .action(async (cloneId: string, opts: DblabCmdOpts) => {
5930
- try {
5931
- const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
5932
- const result = await destroyClone({ apiKey, apiBaseUrl, instanceId, cloneId, debug: !!opts.debug });
5933
- printResult(result ?? { destroyed: true, cloneId }, opts.json);
5934
- } catch (err) {
5935
- console.error(err instanceof Error ? err.message : String(err));
5936
- process.exitCode = 1;
5937
- }
5938
- });
5939
-
5940
- // ---- branch ---------------------------------------------------------------
5941
-
5942
- const branch = dblab.command("branch").description("DBLab branch management (proxies the Platform DBLab API)");
5943
-
5944
- branch
5945
- .command("list")
5946
- .description("list the project's branches")
5947
- .requiredOption("--project <id|alias>", "project id or alias")
5948
- .option("--debug", "enable debug output")
5949
- .option("--json", "output raw JSON")
5950
- .action(async (opts: DblabCmdOpts) => {
5951
- try {
5952
- const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
5953
- const result = await listBranches({ apiKey, apiBaseUrl, instanceId, debug: !!opts.debug });
5954
- printResult(result, opts.json);
5955
- } catch (err) {
5956
- console.error(err instanceof Error ? err.message : String(err));
5957
- process.exitCode = 1;
5958
- }
5959
- });
5960
-
5961
- branch
5962
- .command("create <name>")
5963
- .description("create a branch")
5964
- .requiredOption("--project <id|alias>", "project id or alias")
5965
- .option("--snapshot <id>", "snapshot id to base the branch on")
5966
- .option("--base-branch <branch>", "parent branch to fork from")
5967
- .option("--debug", "enable debug output")
5968
- .option("--json", "output raw JSON")
5969
- .action(async (name: string, opts: DblabCmdOpts & { snapshot?: string; baseBranch?: string }) => {
5970
- try {
5971
- const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
5972
- const result = await createBranch({
5973
- apiKey, apiBaseUrl, instanceId,
5974
- branchName: name,
5975
- baseBranch: opts.baseBranch,
5976
- snapshotId: opts.snapshot,
5977
- debug: !!opts.debug,
5978
- });
5979
- printResult(result, opts.json);
5980
- } catch (err) {
5981
- console.error(err instanceof Error ? err.message : String(err));
5982
- process.exitCode = 1;
5983
- }
5984
- });
5985
-
5986
- branch
5987
- .command("delete <name>")
5988
- .description("delete a branch (requires joe:admin)")
5989
- .requiredOption("--project <id|alias>", "project id or alias")
5990
- .option("--debug", "enable debug output")
5991
- .option("--json", "output raw JSON")
5992
- .action(async (name: string, opts: DblabCmdOpts) => {
5993
- try {
5994
- const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
5995
- const result = await deleteBranch({ apiKey, apiBaseUrl, instanceId, branchName: name, debug: !!opts.debug });
5996
- printResult(result ?? { deleted: true, branch: name }, opts.json);
5997
- } catch (err) {
5998
- console.error(err instanceof Error ? err.message : String(err));
5999
- process.exitCode = 1;
6000
- }
6001
- });
6002
-
6003
- branch
6004
- .command("log <name>")
6005
- .description("show a branch's snapshot log")
6006
- .requiredOption("--project <id|alias>", "project id or alias")
6007
- .option("--debug", "enable debug output")
6008
- .option("--json", "output raw JSON")
6009
- .action(async (name: string, opts: DblabCmdOpts) => {
6010
- try {
6011
- const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
6012
- const result = await branchLog({ apiKey, apiBaseUrl, instanceId, branchName: name, debug: !!opts.debug });
6013
- printResult(result, opts.json);
6014
- } catch (err) {
6015
- console.error(err instanceof Error ? err.message : String(err));
6016
- process.exitCode = 1;
6017
- }
6018
- });
6019
-
6020
- // ---- snapshot -------------------------------------------------------------
6021
-
6022
- const snapshot = dblab.command("snapshot").description("DBLab snapshot management (proxies the Platform DBLab API)");
6023
-
6024
- snapshot
6025
- .command("list")
6026
- .description("list the project's snapshots")
6027
- .requiredOption("--project <id|alias>", "project id or alias")
6028
- .option("--branch <branch>", "filter by branch")
6029
- .option("--dataset <dataset>", "filter by dataset")
6030
- .option("--debug", "enable debug output")
6031
- .option("--json", "output raw JSON")
6032
- .action(async (opts: DblabCmdOpts & { branch?: string; dataset?: string }) => {
6033
- try {
6034
- const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
6035
- const result = await listSnapshots({
6036
- apiKey, apiBaseUrl, instanceId,
6037
- branchName: opts.branch,
6038
- dataset: opts.dataset,
6039
- debug: !!opts.debug,
6040
- });
6041
- printResult(result, opts.json);
6042
- } catch (err) {
6043
- console.error(err instanceof Error ? err.message : String(err));
6044
- process.exitCode = 1;
6045
- }
6046
- });
6047
-
6048
- snapshot
6049
- .command("create")
6050
- .description("create a snapshot from a clone")
6051
- .requiredOption("--project <id|alias>", "project id or alias")
6052
- .requiredOption("--clone <id>", "clone id to snapshot")
6053
- .option("--message <message>", "snapshot message")
6054
- .option("--debug", "enable debug output")
6055
- .option("--json", "output raw JSON")
6056
- .action(async (opts: DblabCmdOpts & { clone?: string; message?: string }) => {
6057
- try {
6058
- const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
6059
- const result = await createSnapshot({
6060
- apiKey, apiBaseUrl, instanceId,
6061
- cloneId: opts.clone as string,
6062
- message: opts.message,
6063
- debug: !!opts.debug,
6064
- });
6065
- printResult(result, opts.json);
6066
- } catch (err) {
6067
- console.error(err instanceof Error ? err.message : String(err));
6068
- process.exitCode = 1;
6069
- }
6070
- });
6071
-
6072
- snapshot
6073
- .command("destroy <snapshotId>")
6074
- .description("destroy a snapshot (requires joe:admin)")
6075
- .requiredOption("--project <id|alias>", "project id or alias")
6076
- .option("--force", "force-delete even when dependent clones exist")
6077
- .option("--debug", "enable debug output")
6078
- .option("--json", "output raw JSON")
6079
- .action(async (snapshotId: string, opts: DblabCmdOpts & { force?: boolean }) => {
6080
- try {
6081
- const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
6082
- const result = await destroySnapshot({
6083
- apiKey, apiBaseUrl, instanceId, snapshotId,
6084
- force: !!opts.force,
6085
- debug: !!opts.debug,
6086
- });
6087
- printResult(result ?? { destroyed: true, snapshot: snapshotId }, opts.json);
6088
- } catch (err) {
6089
- console.error(err instanceof Error ? err.message : String(err));
6090
- process.exitCode = 1;
6091
- }
6092
- });
6093
-
6094
5697
  // MCP server
6095
5698
  const mcp = program.command("mcp").description("MCP server integration");
6096
5699
 
@@ -6268,6 +5871,3 @@ if (import.meta.main) {
6268
5871
  export { refreshBundledComposeIfStale, readDeployedTag, isValidComposeYaml };
6269
5872
  export { registerMonitoringInstance, resolveAdoptedProject, type MonitoringRegistration };
6270
5873
  export { planMonitoringRegistration, type MonRegistrationPlan };
6271
- // Joe: the `pgai joe result <id>` result-body -> command inference (unit-tested
6272
- // table-driven; the shapes are the per-command contract matrix in CONTRACT_DECISIONS).
6273
- export { inferCommandFromResult };