postgresai 0.16.0-dev.1 → 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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ### Fixed
6
+
7
+ - `checkup --markdown` previously performed server-side conversion and sent the
8
+ full report JSON to the PostgresAI API even when `--no-upload` was set. The
9
+ flags are now mutually exclusive, and `--no-upload` prevents report data from
10
+ being sent to the PostgresAI API. Use `--json` or `--output` for local-only
11
+ output.
@@ -13,6 +13,16 @@ import { Client } from "pg";
13
13
  import { startMcpServer } from "../lib/mcp-server";
14
14
  import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment, fetchActionItem, fetchActionItems, createActionItem, updateActionItem, type ConfigChange } from "../lib/issues";
15
15
  import { fetchReports, fetchAllReports, fetchReportFiles, fetchReportFileData, renderMarkdownForTerminal, parseFlexibleDate } from "../lib/reports";
16
+ import {
17
+ executeJoeCommand,
18
+ listProjects,
19
+ getCommandOutput,
20
+ formatJoeOutput,
21
+ formatProjectsTable,
22
+ DEFAULT_BUDGET_MS,
23
+ type JoeCommand,
24
+ type ExecuteJoeOutcome,
25
+ } from "../lib/joe";
16
26
  import { resolveBaseUrls } from "../lib/util";
17
27
  import { registerAasCollection, parseVcpus } from "../lib/aas-onboard";
18
28
  import { uploadFile, downloadFile, buildMarkdownLink, uploadAttachments, appendAttachmentsToContent } from "../lib/storage";
@@ -335,12 +345,18 @@ function prepareUploadConfig(
335
345
  console.error("Tip: run 'postgresai auth' or pass --api-key / set PGAI_API_KEY");
336
346
  return null; // Signal to exit
337
347
  }
338
- // No credentials and upload not explicitly requested: fall back to
339
- // local-only mode, but say so prominently skipping the upload silently
340
- // hides the fact that results never reach the Console.
341
- console.error("Notice: no API key configured results will NOT be uploaded to PostgresAI.");
342
- console.error(" To upload: run 'postgresai auth login' or pass --api-key / set PGAI_API_KEY.");
343
- 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
+ }
344
360
  return undefined; // Skip upload, run checks locally
345
361
  }
346
362
 
@@ -1996,7 +2012,7 @@ program
1996
2012
  "project name or ID for remote upload (used with --upload; defaults to config defaultProject; auto-generated on first run)"
1997
2013
  )
1998
2014
  .option("--json", "output JSON to stdout")
1999
- .option("--markdown", "output markdown to stdout")
2015
+ .option("--markdown", "output markdown via PostgresAI API (transmits the full report JSON)")
2000
2016
  .addHelpText(
2001
2017
  "after",
2002
2018
  [
@@ -2010,7 +2026,7 @@ program
2010
2026
  " postgresai checkup postgresql://user:pass@host:5432/db --check-id H002",
2011
2027
  " postgresai checkup postgresql://user:pass@host:5432/db --output ./reports",
2012
2028
  " postgresai checkup postgresql://user:pass@host:5432/db --no-upload --json",
2013
- " postgresai checkup postgresql://user:pass@host:5432/db --no-upload --markdown",
2029
+ " postgresai checkup postgresql://user:pass@host:5432/db --markdown",
2014
2030
  ].join("\n")
2015
2031
  )
2016
2032
  .action(async (checkIdOrConn: string | undefined, connArg: string | undefined, opts: CheckupOptions, cmd: Command) => {
@@ -2060,9 +2076,14 @@ program
2060
2076
  process.exitCode = 1;
2061
2077
  return;
2062
2078
  }
2063
- // Note: --json, --markdown and --upload/--no-upload are independent flags.
2064
- // Use --no-upload to explicitly disable upload when using --json or --markdown.
2065
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
+ }
2066
2087
  let shouldUpload = !uploadExplicitlyDisabled;
2067
2088
 
2068
2089
  // Preflight: validate/create output directory BEFORE connecting / running checks.
@@ -2308,7 +2329,9 @@ program
2308
2329
 
2309
2330
  console.log('\nFor details:');
2310
2331
  console.log(' --json Output JSON');
2311
- console.log(' --markdown Output markdown');
2332
+ if (!uploadExplicitlyDisabled) {
2333
+ console.log(' --markdown Output markdown via PostgresAI API');
2334
+ }
2312
2335
  console.log(' --output <dir> Save to directory');
2313
2336
  }
2314
2337
  } catch (error) {
@@ -2443,9 +2466,44 @@ interface MonitoringRegistration {
2443
2466
  *
2444
2467
  * Never throws — registration is best-effort; returns null on failure.
2445
2468
  */
2469
+
2470
+ /**
2471
+ * Classify how `mon local-install` should register the monitoring instance,
2472
+ * given the raw `--project` value and the resolved instance id.
2473
+ *
2474
+ * - With an instance id: ADOPT the provisioned instance. No project name is
2475
+ * required (the platform returns the real project); a provided name is
2476
+ * normalized and carried through for messaging/fallback.
2477
+ * - No instance id and no project name: ERROR. The hardcoded
2478
+ * "postgres-ai-monitoring" default was removed, and a nameless legacy
2479
+ * self-registration is rejected by v1.monitoring_instance_register (PT400).
2480
+ * - No instance id but a project name: legacy SELF-REGISTER.
2481
+ *
2482
+ * Pure (no I/O) so the decision is unit-testable independently of the large
2483
+ * install command. `projectName` is the trimmed value, or undefined when empty.
2484
+ */
2485
+ type MonRegistrationPlan =
2486
+ | { kind: "adopt"; projectName: string | undefined }
2487
+ | { kind: "self-register"; projectName: string }
2488
+ | { kind: "error-missing-project"; projectName: undefined };
2489
+
2490
+ function planMonitoringRegistration(args: {
2491
+ project?: string;
2492
+ instanceId?: string;
2493
+ }): MonRegistrationPlan {
2494
+ const projectName = args.project?.trim() || undefined;
2495
+ if (args.instanceId) {
2496
+ return { kind: "adopt", projectName };
2497
+ }
2498
+ if (!projectName) {
2499
+ return { kind: "error-missing-project", projectName: undefined };
2500
+ }
2501
+ return { kind: "self-register", projectName };
2502
+ }
2503
+
2446
2504
  async function registerMonitoringInstance(
2447
2505
  apiKey: string,
2448
- projectName: string,
2506
+ projectName: string | undefined,
2449
2507
  opts?: { apiBaseUrl?: string; debug?: boolean; instanceId?: string; retries?: number; retryDelayMs?: number }
2450
2508
  ): Promise<MonitoringRegistration | null> {
2451
2509
  const { apiBaseUrl } = resolveBaseUrls(opts);
@@ -2457,16 +2515,25 @@ async function registerMonitoringInstance(
2457
2515
  // moment to recover; skipped before the first attempt. Tests pass 0.
2458
2516
  const retryDelayMs = opts?.retryDelayMs ?? 400;
2459
2517
 
2518
+ // Omit project_name entirely when empty: the adopt path (instance_id present)
2519
+ // relies on the platform returning the real project, and a nameless legacy
2520
+ // self-registration is rejected by v1.monitoring_instance_register (PT400).
2521
+ const hasProjectName = !!(projectName && projectName.trim());
2522
+
2460
2523
  if (debug) {
2461
2524
  console.error(`\nDebug: Registering monitoring instance...`);
2462
2525
  console.error(`Debug: POST ${url}`);
2463
- console.error(`Debug: project_name=${projectName}${instanceId ? ` instance_id=${instanceId}` : ""}`);
2526
+ console.error(
2527
+ `Debug: ${hasProjectName ? `project_name=${projectName}` : "project_name=(omitted)"}${instanceId ? ` instance_id=${instanceId}` : ""}`
2528
+ );
2464
2529
  }
2465
2530
 
2466
2531
  const requestBody: Record<string, string> = {
2467
2532
  api_token: apiKey,
2468
- project_name: projectName,
2469
2533
  };
2534
+ if (hasProjectName) {
2535
+ requestBody.project_name = projectName as string;
2536
+ }
2470
2537
  if (instanceId) {
2471
2538
  requestBody.instance_id = instanceId;
2472
2539
  }
@@ -3137,8 +3204,11 @@ mon
3137
3204
  // and persisted; the legacy self-registration stays fire-and-forget
3138
3205
  // (issue platform-all#311).
3139
3206
  if (apiKey && !opts.demo) {
3140
- const projectName = opts.project || "postgres-ai-monitoring";
3141
3207
  const instanceId = opts.instanceId || process.env.PGAI_INSTANCE_ID;
3208
+ const plan = planMonitoringRegistration({ project: opts.project, instanceId });
3209
+ const projectName = plan.projectName;
3210
+ // `instanceId` truthy ⟺ plan.kind === "adopt"; branch on it directly so
3211
+ // TypeScript narrows instanceId to a defined string in the adopt path.
3142
3212
  if (instanceId) {
3143
3213
  const reg = await registerMonitoringInstance(apiKey, projectName, {
3144
3214
  apiBaseUrl: globalOpts.apiBaseUrl,
@@ -3160,11 +3230,17 @@ mon
3160
3230
  // Request succeeded but carried no usable project field — don't claim
3161
3231
  // adoption, but don't report a hard failure either (no re-run needed).
3162
3232
  console.error(
3163
- `⚠ Adopted provisioned instance ${instanceId} but the platform returned no project — reports will use project '${projectName}'`
3233
+ `⚠ Adopted provisioned instance ${instanceId} but the platform returned no project` +
3234
+ (projectName
3235
+ ? ` — reports will use project '${projectName}'`
3236
+ : ` — reports will have no project until 'postgresai mon local-install' is re-run with --project <name>`)
3164
3237
  );
3165
3238
  } else {
3166
3239
  console.error(
3167
- `⚠ Could not adopt provisioned instance ${instanceId} — reports will use project '${projectName}' until 'postgresai mon local-install' is re-run`
3240
+ `⚠ Could not adopt provisioned instance ${instanceId}` +
3241
+ (projectName
3242
+ ? ` — reports will use project '${projectName}' until 'postgresai mon local-install' is re-run`
3243
+ : ` — reports will have no project until 'postgresai mon local-install' is re-run with --project <name>`)
3168
3244
  );
3169
3245
  }
3170
3246
 
@@ -3188,6 +3264,17 @@ mon
3188
3264
  `⚠ AAS auto-collection not registered (${aas.reason}); it can be enabled later by re-running 'postgresai mon local-install'\n`
3189
3265
  );
3190
3266
  }
3267
+ } else if (plan.kind === "error-missing-project") {
3268
+ // Legacy self-registration (no --instance-id) now requires a project
3269
+ // name: the hardcoded "postgres-ai-monitoring" default was removed, and
3270
+ // v1.monitoring_instance_register raises PT400 for a nameless legacy
3271
+ // registration. Console-provisioned installs should adopt with
3272
+ // --instance-id instead.
3273
+ console.error(
3274
+ "✗ A project name is required for self-registration (the 'postgres-ai-monitoring' default was removed). " +
3275
+ "Re-run with --project <name>, or adopt a console-provisioned instance with --instance-id <uuid>."
3276
+ );
3277
+ process.exitCode = 1;
3191
3278
  } else {
3192
3279
  void registerMonitoringInstance(apiKey, projectName, {
3193
3280
  apiBaseUrl: globalOpts.apiBaseUrl,
@@ -3867,13 +3954,9 @@ targets
3867
3954
  // Authentication and API key management
3868
3955
  const auth = program.command("auth").description("authentication and API key management");
3869
3956
 
3870
- auth
3871
- .command("login", { isDefault: true })
3872
- .description("authenticate via browser (OAuth) or store API key directly")
3873
- .option("--set-key <key>", "store API key directly without OAuth flow")
3874
- .option("--port <port>", "local callback server port (default: random)", parseInt)
3875
- .option("--debug", "enable debug output")
3876
- .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) {
3877
3960
  // If --set-key is provided, store it directly without OAuth
3878
3961
  if (opts.setKey) {
3879
3962
  const trimmedKey = opts.setKey.trim();
@@ -4127,7 +4210,21 @@ auth
4127
4210
  console.error(`Authentication error: ${message}`);
4128
4211
  process.exit(1);
4129
4212
  }
4130
- });
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);
4131
4228
 
4132
4229
  auth
4133
4230
  .command("show-key")
@@ -5312,6 +5409,291 @@ function tryParseJson(s: string): unknown {
5312
5409
  try { return JSON.parse(s); } catch { return s; }
5313
5410
  }
5314
5411
 
5412
+ // ---------------------------------------------------------------------------
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.
5419
+ // ---------------------------------------------------------------------------
5420
+
5421
+ interface JoeCliOpts {
5422
+ project?: string;
5423
+ instanceId?: string;
5424
+ budget?: number;
5425
+ variant?: string;
5426
+ debug?: boolean;
5427
+ json?: boolean;
5428
+ }
5429
+
5430
+ function printJoeOutcome(outcome: ExecuteJoeOutcome, json: boolean, budgetMs?: number): void {
5431
+ // The expiry hint reports the ACTUAL effective budget (--budget when given,
5432
+ // the default otherwise) — not a hardcoded DEFAULT_BUDGET_MS.
5433
+ const effectiveBudgetMs =
5434
+ typeof budgetMs === "number" && Number.isFinite(budgetMs) ? budgetMs : DEFAULT_BUDGET_MS;
5435
+ const budgetSeconds = Math.round(effectiveBudgetMs / 1000);
5436
+ // One-shot budget reached before a terminal state — hand back a resume handle.
5437
+ // This is expected (a cold clone), NOT a failure: exit 0.
5438
+ if (outcome.budgetExpired) {
5439
+ if (json) {
5440
+ console.log(
5441
+ JSON.stringify(
5442
+ {
5443
+ command_id: outcome.commandId,
5444
+ status: outcome.status,
5445
+ budget_expired: true,
5446
+ resume: `pgai joe result ${outcome.commandId}`,
5447
+ },
5448
+ null,
5449
+ 2
5450
+ )
5451
+ );
5452
+ } else {
5453
+ console.log(
5454
+ `started ${outcome.commandId} · ${outcome.status} · budget ${budgetSeconds}s reached — resume: pgai joe result ${outcome.commandId}`
5455
+ );
5456
+ }
5457
+ return;
5458
+ }
5459
+
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);
5471
+ if (body) console.log(body);
5472
+ }
5473
+ return;
5474
+ }
5475
+
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"}`);
5479
+ process.exitCode = 1;
5480
+ }
5481
+
5482
+ async function runJoeCli(
5483
+ command: JoeCommand,
5484
+ arg: string | null,
5485
+ opts: JoeCliOpts
5486
+ ): Promise<void> {
5487
+ try {
5488
+ const rootOpts = program.opts<CliOptions>();
5489
+ const cfg = config.readConfig();
5490
+ const { apiKey } = getConfig(rootOpts);
5491
+ if (!apiKey) {
5492
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
5493
+ process.exitCode = 1;
5494
+ return;
5495
+ }
5496
+ const projectRef = (opts.project ?? cfg.defaultProject ?? "").toString().trim();
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
+ );
5502
+ process.exitCode = 1;
5503
+ return;
5504
+ }
5505
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
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;
5510
+
5511
+ const outcome = await executeJoeCommand({
5512
+ apiKey,
5513
+ apiBaseUrl,
5514
+ command,
5515
+ project: projectRef || undefined,
5516
+ instanceId: instanceRef || undefined,
5517
+ input: { arg, variant: opts.variant ?? null },
5518
+ orgId: cfg.orgId ?? undefined,
5519
+ budgetMs,
5520
+ debug: !!opts.debug,
5521
+ });
5522
+
5523
+ printJoeOutcome(outcome, !!opts.json, budgetMs);
5524
+ } catch (err) {
5525
+ const message = err instanceof Error ? err.message : String(err);
5526
+ console.error(message);
5527
+ process.exitCode = 1;
5528
+ }
5529
+ }
5530
+
5531
+ /** Attach the shared Joe options (--instance-id / --project / --budget / --debug / --json). */
5532
+ function withJoeOptions(cmd: import("commander").Command): import("commander").Command {
5533
+ return cmd
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))
5540
+ .option("--debug", "enable debug output")
5541
+ .option("--json", "output raw JSON");
5542
+ }
5543
+
5544
+ const joe = program
5545
+ .command("joe")
5546
+ .description("Joe — plan/EXPLAIN/exec queries on ephemeral DBLab clones");
5547
+
5548
+ withJoeOptions(
5549
+ joe
5550
+ .command("plan <sql>")
5551
+ .description("plan a query (EXPLAIN, plan-only — no execution; the fast/safe default)")
5552
+ ).action(async (sql: string, opts: JoeCliOpts) => {
5553
+ await runJoeCli("plan", sql, opts);
5554
+ });
5555
+
5556
+ withJoeOptions(
5557
+ joe
5558
+ .command("explain <sql>")
5559
+ .description("EXPLAIN + EXPLAIN ANALYZE a query (EXECUTES on the ephemeral clone)")
5560
+ ).action(async (sql: string, opts: JoeCliOpts) => {
5561
+ await runJoeCli("explain", sql, opts);
5562
+ });
5563
+
5564
+ withJoeOptions(
5565
+ joe
5566
+ .command("exec <sql>")
5567
+ .description("run arbitrary DDL/DML on the clone (e.g. create index, analyze)")
5568
+ ).action(async (sql: string, opts: JoeCliOpts) => {
5569
+ await runJoeCli("exec", sql, opts);
5570
+ });
5571
+
5572
+ withJoeOptions(
5573
+ joe
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);
5578
+ });
5579
+
5580
+ withJoeOptions(
5581
+ joe
5582
+ .command("activity")
5583
+ .description("running-activity snapshot (pg_stat_activity) on the clone")
5584
+ ).action(async (opts: JoeCliOpts) => {
5585
+ await runJoeCli("activity", null, opts);
5586
+ });
5587
+
5588
+ withJoeOptions(
5589
+ joe
5590
+ .command("terminate <pid>")
5591
+ .description("pg_terminate_backend(pid) on the clone")
5592
+ ).action(async (pid: string, opts: JoeCliOpts) => {
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);
5596
+ });
5597
+
5598
+ withJoeOptions(
5599
+ joe
5600
+ .command("reset")
5601
+ .description("reset/recreate the session's thin clone")
5602
+ ).action(async (opts: JoeCliOpts) => {
5603
+ await runJoeCli("reset", null, opts);
5604
+ });
5605
+
5606
+ withJoeOptions(
5607
+ joe
5608
+ .command("describe <object>")
5609
+ .description("\\d-family schema/relation/index metadata")
5610
+ .option("--variant <variant>", "\\d-family variant (e.g. \\d+, \\di, \\dt)")
5611
+ ).action(async (object: string, opts: JoeCliOpts) => {
5612
+ await runJoeCli("describe", object, opts);
5613
+ });
5614
+
5615
+ // Resume / inspect a previously started command by id (the budget-expiry
5616
+ // resume handle printed by the one-shot verbs).
5617
+ joe
5618
+ .command("result <commandId>")
5619
+ .description("fetch a Joe command's output by id (resume a budget-expired one-shot)")
5620
+ .option("--debug", "enable debug output")
5621
+ .option("--json", "output raw JSON")
5622
+ .action(async (commandId: string, opts: { debug?: boolean; json?: boolean }) => {
5623
+ try {
5624
+ const rootOpts = program.opts<CliOptions>();
5625
+ const cfg = config.readConfig();
5626
+ const { apiKey } = getConfig(rootOpts);
5627
+ if (!apiKey) {
5628
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
5629
+ process.exitCode = 1;
5630
+ return;
5631
+ }
5632
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
5633
+ const output = await getCommandOutput({ apiKey, apiBaseUrl, commandId, debug: !!opts.debug });
5634
+ if (opts.json) {
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;
5651
+ } else {
5652
+ console.error(`command ${output.command_id} · ${output.status} — result is not ready`);
5653
+ process.exitCode = 1;
5654
+ }
5655
+ } catch (err) {
5656
+ const message = err instanceof Error ? err.message : String(err);
5657
+ console.error(message);
5658
+ process.exitCode = 1;
5659
+ }
5660
+ });
5661
+
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")
5666
+ .option("--debug", "enable debug output")
5667
+ .option("--json", "output raw JSON")
5668
+ .action(async (opts: { debug?: boolean; json?: boolean }) => {
5669
+ try {
5670
+ const rootOpts = program.opts<CliOptions>();
5671
+ const cfg = config.readConfig();
5672
+ const { apiKey } = getConfig(rootOpts);
5673
+ if (!apiKey) {
5674
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
5675
+ process.exitCode = 1;
5676
+ return;
5677
+ }
5678
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
5679
+ const projects = await listProjects({
5680
+ apiKey,
5681
+ apiBaseUrl,
5682
+ orgId: cfg.orgId ?? undefined,
5683
+ debug: !!opts.debug,
5684
+ });
5685
+ if (opts.json) {
5686
+ console.log(JSON.stringify(projects, null, 2));
5687
+ } else {
5688
+ console.log(formatProjectsTable(projects));
5689
+ }
5690
+ } catch (err) {
5691
+ const message = err instanceof Error ? err.message : String(err);
5692
+ console.error(message);
5693
+ process.exitCode = 1;
5694
+ }
5695
+ });
5696
+
5315
5697
  // MCP server
5316
5698
  const mcp = program.command("mcp").description("MCP server integration");
5317
5699
 
@@ -5488,3 +5870,4 @@ if (import.meta.main) {
5488
5870
  // same functions used by the `mon` commands).
5489
5871
  export { refreshBundledComposeIfStale, readDeployedTag, isValidComposeYaml };
5490
5872
  export { registerMonitoringInstance, resolveAdoptedProject, type MonitoringRegistration };
5873
+ export { planMonitoringRegistration, type MonRegistrationPlan };