postgresai 0.14.0-beta.4 → 0.14.0-beta.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.
package/README.md CHANGED
@@ -303,17 +303,24 @@ Normalization:
303
303
 
304
304
  ### Examples
305
305
 
306
- Linux/macOS (bash/zsh):
306
+ For production (uses default URLs):
307
307
 
308
308
  ```bash
309
+ # Production auth - uses console.postgres.ai by default
310
+ postgresai auth --debug
311
+ ```
312
+
313
+ For staging/development environments:
314
+
315
+ ```bash
316
+ # Linux/macOS (bash/zsh)
309
317
  export PGAI_API_BASE_URL=https://v2.postgres.ai/api/general/
310
318
  export PGAI_UI_BASE_URL=https://console-dev.postgres.ai
311
319
  postgresai auth --debug
312
320
  ```
313
321
 
314
- Windows PowerShell:
315
-
316
322
  ```powershell
323
+ # Windows PowerShell
317
324
  $env:PGAI_API_BASE_URL = "https://v2.postgres.ai/api/general/"
318
325
  $env:PGAI_UI_BASE_URL = "https://console-dev.postgres.ai"
319
326
  postgresai auth --debug
@@ -330,6 +337,27 @@ postgresai auth --debug \
330
337
  Notes:
331
338
  - If `PGAI_UI_BASE_URL` is not set, the default is `https://console.postgres.ai`.
332
339
 
340
+ ## Development
341
+
342
+ ### Testing
343
+
344
+ The CLI uses [Bun](https://bun.sh/) as the test runner with built-in coverage reporting.
345
+
346
+ ```bash
347
+ # Run tests with coverage (default)
348
+ bun run test
349
+
350
+ # Run tests without coverage (faster iteration during development)
351
+ bun run test:fast
352
+
353
+ # Run tests with coverage and show report location
354
+ bun run test:coverage
355
+ ```
356
+
357
+ Coverage configuration is in `bunfig.toml`. Reports are generated in `coverage/` directory:
358
+ - `coverage/lcov-report/index.html` - HTML coverage report
359
+ - `coverage/lcov.info` - LCOV format for CI integration
360
+
333
361
  ## Requirements
334
362
 
335
363
  - Node.js 18 or higher
@@ -10,7 +10,7 @@ import * as os from "os";
10
10
  import * as crypto from "node:crypto";
11
11
  import { Client } from "pg";
12
12
  import { startMcpServer } from "../lib/mcp-server";
13
- import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue } from "../lib/issues";
13
+ import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment } from "../lib/issues";
14
14
  import { resolveBaseUrls } from "../lib/util";
15
15
  import { applyInitPlan, buildInitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, verifyInitSetup } from "../lib/init";
16
16
  import * as pkce from "../lib/pkce";
@@ -42,7 +42,7 @@ async function execPromise(command: string): Promise<{ stdout: string; stderr: s
42
42
  childProcess.exec(command, (error, stdout, stderr) => {
43
43
  if (error) {
44
44
  const err = error as Error & { code: number };
45
- err.code = error.code ?? 1;
45
+ err.code = typeof error.code === "number" ? error.code : 1;
46
46
  reject(err);
47
47
  } else {
48
48
  resolve({ stdout, stderr });
@@ -56,7 +56,7 @@ async function execFilePromise(file: string, args: string[]): Promise<{ stdout:
56
56
  childProcess.execFile(file, args, (error, stdout, stderr) => {
57
57
  if (error) {
58
58
  const err = error as Error & { code: number };
59
- err.code = error.code ?? 1;
59
+ err.code = typeof error.code === "number" ? error.code : 1;
60
60
  reject(err);
61
61
  } else {
62
62
  resolve({ stdout, stderr });
@@ -1174,6 +1174,11 @@ mon
1174
1174
  .option("--tag <tag>", "Docker image tag to use (e.g., 0.14.0, 0.14.0-dev.33)")
1175
1175
  .option("-y, --yes", "accept all defaults and skip interactive prompts", false)
1176
1176
  .action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; tag?: string; yes: boolean }) => {
1177
+ // Get apiKey from global program options (--api-key is defined globally)
1178
+ // This is needed because Commander.js routes --api-key to the global option, not the subcommand's option
1179
+ const globalOpts = program.opts<CliOptions>();
1180
+ const apiKey = opts.apiKey || globalOpts.apiKey;
1181
+
1177
1182
  console.log("\n=================================");
1178
1183
  console.log(" PostgresAI monitoring local install");
1179
1184
  console.log("=================================\n");
@@ -1185,17 +1190,33 @@ mon
1185
1190
 
1186
1191
  // Update .env with custom tag if provided
1187
1192
  const envFile = path.resolve(projectDir, ".env");
1188
- const imageTag = opts.tag || pkg.version;
1189
1193
 
1190
- // Build .env content
1191
- const envLines: string[] = [`PGAI_TAG=${imageTag}`];
1192
- // Preserve GF_SECURITY_ADMIN_PASSWORD if it exists
1194
+ // Build .env content, preserving important existing values
1195
+ // Read existing .env first to preserve CI/custom settings
1196
+ let existingTag: string | null = null;
1197
+ let existingRegistry: string | null = null;
1198
+ let existingPassword: string | null = null;
1199
+
1193
1200
  if (fs.existsSync(envFile)) {
1194
1201
  const existingEnv = fs.readFileSync(envFile, "utf8");
1202
+ // Extract existing values
1203
+ const tagMatch = existingEnv.match(/^PGAI_TAG=(.+)$/m);
1204
+ if (tagMatch) existingTag = tagMatch[1].trim();
1205
+ const registryMatch = existingEnv.match(/^PGAI_REGISTRY=(.+)$/m);
1206
+ if (registryMatch) existingRegistry = registryMatch[1].trim();
1195
1207
  const pwdMatch = existingEnv.match(/^GF_SECURITY_ADMIN_PASSWORD=(.+)$/m);
1196
- if (pwdMatch) {
1197
- envLines.push(`GF_SECURITY_ADMIN_PASSWORD=${pwdMatch[1]}`);
1198
- }
1208
+ if (pwdMatch) existingPassword = pwdMatch[1].trim();
1209
+ }
1210
+
1211
+ // Priority: CLI --tag flag > existing .env > package version
1212
+ const imageTag = opts.tag || existingTag || pkg.version;
1213
+
1214
+ const envLines: string[] = [`PGAI_TAG=${imageTag}`];
1215
+ if (existingRegistry) {
1216
+ envLines.push(`PGAI_REGISTRY=${existingRegistry}`);
1217
+ }
1218
+ if (existingPassword) {
1219
+ envLines.push(`GF_SECURITY_ADMIN_PASSWORD=${existingPassword}`);
1199
1220
  }
1200
1221
  fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
1201
1222
 
@@ -1210,7 +1231,7 @@ mon
1210
1231
  opts.dbUrl = undefined;
1211
1232
  }
1212
1233
 
1213
- if (opts.demo && opts.apiKey) {
1234
+ if (opts.demo && apiKey) {
1214
1235
  console.error("✗ Cannot use --api-key with --demo mode");
1215
1236
  console.error("✗ Demo mode is for testing only and does not support API key integration");
1216
1237
  console.error("\nUse demo mode without API key: postgres-ai mon local-install --demo");
@@ -1232,11 +1253,11 @@ mon
1232
1253
  console.log("Step 1: Postgres AI API Configuration (Optional)");
1233
1254
  console.log("An API key enables automatic upload of PostgreSQL reports to Postgres AI\n");
1234
1255
 
1235
- if (opts.apiKey) {
1256
+ if (apiKey) {
1236
1257
  console.log("Using API key provided via --api-key parameter");
1237
- config.writeConfig({ apiKey: opts.apiKey });
1258
+ config.writeConfig({ apiKey });
1238
1259
  // Keep reporter compatibility (docker-compose mounts .pgwatch-config)
1239
- fs.writeFileSync(path.resolve(projectDir, ".pgwatch-config"), `api_key=${opts.apiKey}\n`, {
1260
+ fs.writeFileSync(path.resolve(projectDir, ".pgwatch-config"), `api_key=${apiKey}\n`, {
1240
1261
  encoding: "utf8",
1241
1262
  mode: 0o600
1242
1263
  });
@@ -2102,7 +2123,8 @@ auth
2102
2123
  }
2103
2124
 
2104
2125
  // Step 3: Open browser
2105
- const authUrl = `${uiBaseUrl}/cli/auth?state=${encodeURIComponent(params.state)}&code_challenge=${encodeURIComponent(params.codeChallenge)}&code_challenge_method=S256&redirect_uri=${encodeURIComponent(redirectUri)}`;
2126
+ // Pass api_url so UI calls oauth_approve on the same backend where oauth_init created the session
2127
+ const authUrl = `${uiBaseUrl}/cli/auth?state=${encodeURIComponent(params.state)}&code_challenge=${encodeURIComponent(params.codeChallenge)}&code_challenge_method=S256&redirect_uri=${encodeURIComponent(redirectUri)}&api_url=${encodeURIComponent(apiBaseUrl)}`;
2106
2128
 
2107
2129
  if (opts.debug) {
2108
2130
  console.log(`Debug: Auth URL: ${authUrl}`);
@@ -2491,7 +2513,7 @@ issues
2491
2513
  });
2492
2514
 
2493
2515
  issues
2494
- .command("post_comment <issueId> <content>")
2516
+ .command("post-comment <issueId> <content>")
2495
2517
  .description("post a new comment to an issue")
2496
2518
  .option("--parent <uuid>", "parent comment id")
2497
2519
  .option("--debug", "enable debug output")
@@ -2536,6 +2558,194 @@ issues
2536
2558
  }
2537
2559
  });
2538
2560
 
2561
+ issues
2562
+ .command("create <title>")
2563
+ .description("create a new issue")
2564
+ .option("--org-id <id>", "organization id (defaults to config orgId)", (v) => parseInt(v, 10))
2565
+ .option("--project-id <id>", "project id", (v) => parseInt(v, 10))
2566
+ .option("--description <text>", "issue description (supports \\\\n)")
2567
+ .option(
2568
+ "--label <label>",
2569
+ "issue label (repeatable)",
2570
+ (value: string, previous: string[]) => {
2571
+ previous.push(value);
2572
+ return previous;
2573
+ },
2574
+ [] as string[]
2575
+ )
2576
+ .option("--debug", "enable debug output")
2577
+ .option("--json", "output raw JSON")
2578
+ .action(async (rawTitle: string, opts: { orgId?: number; projectId?: number; description?: string; label?: string[]; debug?: boolean; json?: boolean }) => {
2579
+ try {
2580
+ const rootOpts = program.opts<CliOptions>();
2581
+ const cfg = config.readConfig();
2582
+ const { apiKey } = getConfig(rootOpts);
2583
+ if (!apiKey) {
2584
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
2585
+ process.exitCode = 1;
2586
+ return;
2587
+ }
2588
+
2589
+ const title = interpretEscapes(String(rawTitle || "").trim());
2590
+ if (!title) {
2591
+ console.error("title is required");
2592
+ process.exitCode = 1;
2593
+ return;
2594
+ }
2595
+
2596
+ const orgId = typeof opts.orgId === "number" && !Number.isNaN(opts.orgId) ? opts.orgId : cfg.orgId;
2597
+ if (typeof orgId !== "number") {
2598
+ console.error("org_id is required. Either pass --org-id or run 'pgai auth' to store it in config.");
2599
+ process.exitCode = 1;
2600
+ return;
2601
+ }
2602
+
2603
+ const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
2604
+ const labels = Array.isArray(opts.label) && opts.label.length > 0 ? opts.label.map(String) : undefined;
2605
+ const projectId = typeof opts.projectId === "number" && !Number.isNaN(opts.projectId) ? opts.projectId : undefined;
2606
+
2607
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
2608
+ const result = await createIssue({
2609
+ apiKey,
2610
+ apiBaseUrl,
2611
+ title,
2612
+ orgId,
2613
+ description,
2614
+ projectId,
2615
+ labels,
2616
+ debug: !!opts.debug,
2617
+ });
2618
+ printResult(result, opts.json);
2619
+ } catch (err) {
2620
+ const message = err instanceof Error ? err.message : String(err);
2621
+ console.error(message);
2622
+ process.exitCode = 1;
2623
+ }
2624
+ });
2625
+
2626
+ issues
2627
+ .command("update <issueId>")
2628
+ .description("update an existing issue (title/description/status/labels)")
2629
+ .option("--title <text>", "new title (supports \\\\n)")
2630
+ .option("--description <text>", "new description (supports \\\\n)")
2631
+ .option("--status <value>", "status: open|closed|0|1")
2632
+ .option(
2633
+ "--label <label>",
2634
+ "set labels (repeatable). If provided, replaces existing labels.",
2635
+ (value: string, previous: string[]) => {
2636
+ previous.push(value);
2637
+ return previous;
2638
+ },
2639
+ [] as string[]
2640
+ )
2641
+ .option("--clear-labels", "set labels to an empty list")
2642
+ .option("--debug", "enable debug output")
2643
+ .option("--json", "output raw JSON")
2644
+ .action(async (issueId: string, opts: { title?: string; description?: string; status?: string; label?: string[]; clearLabels?: boolean; debug?: boolean; json?: boolean }) => {
2645
+ try {
2646
+ const rootOpts = program.opts<CliOptions>();
2647
+ const cfg = config.readConfig();
2648
+ const { apiKey } = getConfig(rootOpts);
2649
+ if (!apiKey) {
2650
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
2651
+ process.exitCode = 1;
2652
+ return;
2653
+ }
2654
+
2655
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
2656
+
2657
+ const title = opts.title !== undefined ? interpretEscapes(String(opts.title)) : undefined;
2658
+ const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
2659
+
2660
+ let status: number | undefined = undefined;
2661
+ if (opts.status !== undefined) {
2662
+ const raw = String(opts.status).trim().toLowerCase();
2663
+ if (raw === "open") status = 0;
2664
+ else if (raw === "closed") status = 1;
2665
+ else {
2666
+ const n = Number(raw);
2667
+ if (!Number.isFinite(n)) {
2668
+ console.error("status must be open|closed|0|1");
2669
+ process.exitCode = 1;
2670
+ return;
2671
+ }
2672
+ status = n;
2673
+ }
2674
+ if (status !== 0 && status !== 1) {
2675
+ console.error("status must be 0 (open) or 1 (closed)");
2676
+ process.exitCode = 1;
2677
+ return;
2678
+ }
2679
+ }
2680
+
2681
+ let labels: string[] | undefined = undefined;
2682
+ if (opts.clearLabels) {
2683
+ labels = [];
2684
+ } else if (Array.isArray(opts.label) && opts.label.length > 0) {
2685
+ labels = opts.label.map(String);
2686
+ }
2687
+
2688
+ const result = await updateIssue({
2689
+ apiKey,
2690
+ apiBaseUrl,
2691
+ issueId,
2692
+ title,
2693
+ description,
2694
+ status,
2695
+ labels,
2696
+ debug: !!opts.debug,
2697
+ });
2698
+ printResult(result, opts.json);
2699
+ } catch (err) {
2700
+ const message = err instanceof Error ? err.message : String(err);
2701
+ console.error(message);
2702
+ process.exitCode = 1;
2703
+ }
2704
+ });
2705
+
2706
+ issues
2707
+ .command("update-comment <commentId> <content>")
2708
+ .description("update an existing issue comment")
2709
+ .option("--debug", "enable debug output")
2710
+ .option("--json", "output raw JSON")
2711
+ .action(async (commentId: string, content: string, opts: { debug?: boolean; json?: boolean }) => {
2712
+ try {
2713
+ if (opts.debug) {
2714
+ // eslint-disable-next-line no-console
2715
+ console.log(`Debug: Original content: ${JSON.stringify(content)}`);
2716
+ }
2717
+ content = interpretEscapes(content);
2718
+ if (opts.debug) {
2719
+ // eslint-disable-next-line no-console
2720
+ console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
2721
+ }
2722
+
2723
+ const rootOpts = program.opts<CliOptions>();
2724
+ const cfg = config.readConfig();
2725
+ const { apiKey } = getConfig(rootOpts);
2726
+ if (!apiKey) {
2727
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
2728
+ process.exitCode = 1;
2729
+ return;
2730
+ }
2731
+
2732
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
2733
+
2734
+ const result = await updateIssueComment({
2735
+ apiKey,
2736
+ apiBaseUrl,
2737
+ commentId,
2738
+ content,
2739
+ debug: !!opts.debug,
2740
+ });
2741
+ printResult(result, opts.json);
2742
+ } catch (err) {
2743
+ const message = err instanceof Error ? err.message : String(err);
2744
+ console.error(message);
2745
+ process.exitCode = 1;
2746
+ }
2747
+ });
2748
+
2539
2749
  // MCP server
2540
2750
  const mcp = program.command("mcp").description("MCP server integration");
2541
2751
 
package/bunfig.toml CHANGED
@@ -6,6 +6,14 @@
6
6
  # Integration tests that connect to databases need longer timeouts
7
7
  timeout = 30000
8
8
 
9
- # Coverage settings (if needed in future)
10
- # coverage = true
11
- # coverageDir = "coverage"
9
+ # Coverage settings - enabled by default for test runs
10
+ coverage = true
11
+ coverageDir = "coverage"
12
+
13
+ # Skip coverage for test files and node_modules
14
+ coverageSkipTestFiles = true
15
+
16
+ # Reporter format for CI integration
17
+ # - text: console output with summary table
18
+ # - lcov: standard format for coverage tools
19
+ coverageReporter = ["text", "lcov"]