postgresai 0.15.0-dev.7 → 0.15.0-dev.9

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
@@ -173,7 +173,7 @@ postgresai mon local-install --api-key your_key --db-url postgresql://user:pass@
173
173
  This will:
174
174
  - Configure API key for automated report uploads (if provided)
175
175
  - Add PostgreSQL instance to monitor (if provided)
176
- - Generate secure Grafana password
176
+ - Generate secure Grafana and replication passwords
177
177
  - Start all monitoring services
178
178
  - Open Grafana at http://localhost:3000
179
179
 
@@ -205,6 +205,8 @@ postgresai mon health [--wait <sec>] # Check monitoring services health
205
205
  - `--db-url <url>` - PostgreSQL connection URL to monitor (format: `postgresql://user:pass@host:port/db`)
206
206
  - `-y, --yes` - Accept all defaults and skip interactive prompts
207
207
 
208
+ `local-install` writes `.env` in the monitoring directory. It preserves existing `REPLICATOR_PASSWORD` and `VM_AUTH_*` values or generates new random ones when missing; `VM_AUTH_USERNAME` defaults to `vmauth` when absent. The replication password is used by the demo PostgreSQL standby replication user, and the VM auth credentials are required before Docker Compose can provision Grafana datasources. If you run `docker compose` directly or maintain `.env` yourself, set both VM auth values before upgrading. For rotation, run `VM_AUTH_PASSWORD="$(openssl rand -base64 18)" ./scripts/rotate-vm-auth.sh` from the monitoring directory so `.env`, `sink-prometheus`, and `grafana` update together.
209
+
208
210
  #### Monitoring target databases (`mon targets` subgroup)
209
211
  ```bash
210
212
  postgresai mon targets list # List databases to monitor
@@ -15,7 +15,7 @@ import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, create
15
15
  import { fetchReports, fetchAllReports, fetchReportFiles, fetchReportFileData, renderMarkdownForTerminal, parseFlexibleDate } from "../lib/reports";
16
16
  import { resolveBaseUrls } from "../lib/util";
17
17
  import { uploadFile, downloadFile, buildMarkdownLink } from "../lib/storage";
18
- import { applyInitPlan, applyUninitPlan, buildInitPlan, buildUninitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, KNOWN_PROVIDERS, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, validateProvider, verifyInitSetup } from "../lib/init";
18
+ import { applyInitPlan, applyUninitPlan, buildInitPlan, buildUninitPlan, checkCurrentUserPermissions, connectWithSslFallback, DEFAULT_MONITORING_USER, formatPermissionCheckMessages, KNOWN_PROVIDERS, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, validateProvider, verifyInitSetup } from "../lib/init";
19
19
  import { SupabaseClient, resolveSupabaseConfig, extractProjectRefFromUrl, applyInitPlanViaSupabase, verifyInitSetupViaSupabase, fetchPoolerDatabaseUrl, type PgCompatibleError } from "../lib/supabase";
20
20
  import * as pkce from "../lib/pkce";
21
21
  import * as authServer from "../lib/auth-server";
@@ -54,21 +54,16 @@ function closeReadline() {
54
54
  }
55
55
  }
56
56
 
57
- // Helper functions for spawning processes - use Node.js child_process for compatibility
58
- async function execPromise(command: string): Promise<{ stdout: string; stderr: string }> {
59
- return new Promise((resolve, reject) => {
60
- childProcess.exec(command, (error, stdout, stderr) => {
61
- if (error) {
62
- const err = error as Error & { code: number };
63
- err.code = typeof error.code === "number" ? error.code : 1;
64
- reject(err);
65
- } else {
66
- resolve({ stdout, stderr });
67
- }
68
- });
69
- });
57
+ function stripMatchingQuotes(value: string): string {
58
+ const trimmed = value.trim();
59
+ const quote = trimmed[0];
60
+ if (trimmed.length >= 2 && (quote === '"' || quote === "'") && trimmed.endsWith(quote)) {
61
+ return trimmed.slice(1, -1);
62
+ }
63
+ return trimmed;
70
64
  }
71
65
 
66
+ // Helper functions for spawning processes - use Node.js child_process for compatibility
72
67
  async function execFilePromise(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
73
68
  return new Promise((resolve, reject) => {
74
69
  childProcess.execFile(file, args, (error, stdout, stderr) => {
@@ -1831,6 +1826,24 @@ program
1831
1826
  const connResult = await connectWithSslFallback(Client, adminConn);
1832
1827
  client = connResult.client as Client;
1833
1828
 
1829
+ // Preflight: verify the connected user has sufficient permissions
1830
+ spinner.update("Checking database permissions");
1831
+ const permCheck = await checkCurrentUserPermissions(client);
1832
+ const permMessages = formatPermissionCheckMessages(permCheck);
1833
+
1834
+ for (const w of permMessages.warnings) {
1835
+ console.error(w);
1836
+ }
1837
+
1838
+ if (permMessages.failed) {
1839
+ spinner.stop();
1840
+ for (const e of permMessages.errors) {
1841
+ console.error(e);
1842
+ }
1843
+ process.exitCode = 1;
1844
+ return;
1845
+ }
1846
+
1834
1847
  // Generate reports
1835
1848
  let reports: Record<string, any>;
1836
1849
  if (checkId === "ALL") {
@@ -2250,11 +2263,11 @@ async function runCompose(args: string[], grafanaPassword?: string): Promise<num
2250
2263
  const envContent = fs.readFileSync(envFilePath, "utf8");
2251
2264
  if (!env.VM_AUTH_USERNAME) {
2252
2265
  const m = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
2253
- if (m) env.VM_AUTH_USERNAME = m[1].trim().replace(/^["']|["']$/g, '');
2266
+ if (m) env.VM_AUTH_USERNAME = stripMatchingQuotes(m[1]);
2254
2267
  }
2255
2268
  if (!env.VM_AUTH_PASSWORD) {
2256
2269
  const m = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
2257
- if (m) env.VM_AUTH_PASSWORD = m[1].trim().replace(/^["']|["']$/g, '');
2270
+ if (m) env.VM_AUTH_PASSWORD = stripMatchingQuotes(m[1]);
2258
2271
  }
2259
2272
  } catch (err) {
2260
2273
  if (process.env.DEBUG) {
@@ -2320,10 +2333,13 @@ mon
2320
2333
  // Update .env with custom tag if provided
2321
2334
  const envFile = path.resolve(projectDir, ".env");
2322
2335
 
2323
- // Build .env content, preserving important existing values (registry, password)
2324
- // Note: PGAI_TAG is intentionally NOT preserved - the CLI version should always match Docker images
2336
+ // Build .env content, preserving important existing values.
2337
+ // Note: PGAI_TAG is intentionally NOT preserved - the CLI version should always match Docker images.
2325
2338
  let existingRegistry: string | null = null;
2326
2339
  let existingPassword: string | null = null;
2340
+ let existingReplicatorPassword: string | null = null;
2341
+ let existingVmAuthUsername: string | null = null;
2342
+ let existingVmAuthPassword: string | null = null;
2327
2343
 
2328
2344
  if (fs.existsSync(envFile)) {
2329
2345
  const existingEnv = fs.readFileSync(envFile, "utf8");
@@ -2332,6 +2348,12 @@ mon
2332
2348
  if (registryMatch) existingRegistry = registryMatch[1].trim();
2333
2349
  const pwdMatch = existingEnv.match(/^GF_SECURITY_ADMIN_PASSWORD=(.+)$/m);
2334
2350
  if (pwdMatch) existingPassword = pwdMatch[1].trim();
2351
+ const replicatorPwdMatch = existingEnv.match(/^REPLICATOR_PASSWORD=(.+)$/m);
2352
+ if (replicatorPwdMatch) existingReplicatorPassword = replicatorPwdMatch[1].trim();
2353
+ const vmAuthUserMatch = existingEnv.match(/^VM_AUTH_USERNAME=(.+)$/m);
2354
+ if (vmAuthUserMatch) existingVmAuthUsername = stripMatchingQuotes(vmAuthUserMatch[1]);
2355
+ const vmAuthPasswordMatch = existingEnv.match(/^VM_AUTH_PASSWORD=(.+)$/m);
2356
+ if (vmAuthPasswordMatch) existingVmAuthPassword = stripMatchingQuotes(vmAuthPasswordMatch[1]);
2335
2357
  }
2336
2358
 
2337
2359
  // Priority: CLI --tag flag > package version
@@ -2347,6 +2369,11 @@ mon
2347
2369
  if (existingPassword) {
2348
2370
  envLines.push(`GF_SECURITY_ADMIN_PASSWORD=${existingPassword}`);
2349
2371
  }
2372
+ envLines.push(
2373
+ `REPLICATOR_PASSWORD=${existingReplicatorPassword || crypto.randomBytes(32).toString("hex")}`,
2374
+ );
2375
+ envLines.push(`VM_AUTH_USERNAME=${existingVmAuthUsername || "vmauth"}`);
2376
+ envLines.push(`VM_AUTH_PASSWORD=${existingVmAuthPassword || crypto.randomBytes(18).toString("base64")}`);
2350
2377
  fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
2351
2378
 
2352
2379
  if (opts.tag) {
@@ -2466,16 +2493,20 @@ mon
2466
2493
 
2467
2494
  // Test connection
2468
2495
  console.log("Testing connection to the added instance...");
2469
- try {
2470
- const client = new Client({ connectionString: connStr });
2471
- await client.connect();
2472
- const result = await client.query("select version();");
2473
- console.log("✓ Connection successful");
2474
- console.log(`${result.rows[0].version}\n`);
2475
- await client.end();
2476
- } catch (error) {
2477
- const message = error instanceof Error ? error.message : String(error);
2478
- console.error(`✗ Connection failed: ${message}\n`);
2496
+ {
2497
+ let testClient: InstanceType<typeof Client> | null = null;
2498
+ try {
2499
+ testClient = new Client({ connectionString: connStr, connectionTimeoutMillis: 10000 });
2500
+ await testClient.connect();
2501
+ const result = await testClient.query("select version();");
2502
+ console.log("✓ Connection successful");
2503
+ console.log(`${result.rows[0].version}\n`);
2504
+ } catch (error) {
2505
+ const message = error instanceof Error ? error.message : String(error);
2506
+ console.error(`✗ Connection failed: ${message}\n`);
2507
+ } finally {
2508
+ if (testClient) await testClient.end();
2509
+ }
2479
2510
  }
2480
2511
  } else if (opts.yes) {
2481
2512
  // Auto-yes mode without database URL - skip database setup
@@ -2510,16 +2541,20 @@ mon
2510
2541
 
2511
2542
  // Test connection
2512
2543
  console.log("Testing connection to the added instance...");
2513
- try {
2514
- const client = new Client({ connectionString: connStr });
2515
- await client.connect();
2516
- const result = await client.query("select version();");
2517
- console.log("✓ Connection successful");
2518
- console.log(`${result.rows[0].version}\n`);
2519
- await client.end();
2520
- } catch (error) {
2521
- const message = error instanceof Error ? error.message : String(error);
2522
- console.error(`✗ Connection failed: ${message}\n`);
2544
+ {
2545
+ let testClient: InstanceType<typeof Client> | null = null;
2546
+ try {
2547
+ testClient = new Client({ connectionString: connStr, connectionTimeoutMillis: 10000 });
2548
+ await testClient.connect();
2549
+ const result = await testClient.query("select version();");
2550
+ console.log("✓ Connection successful");
2551
+ console.log(`${result.rows[0].version}\n`);
2552
+ } catch (error) {
2553
+ const message = error instanceof Error ? error.message : String(error);
2554
+ console.error(`✗ Connection failed: ${message}\n`);
2555
+ } finally {
2556
+ if (testClient) await testClient.end();
2557
+ }
2523
2558
  }
2524
2559
  }
2525
2560
  } else {
@@ -2594,8 +2629,8 @@ mon
2594
2629
 
2595
2630
  if (!grafanaPassword) {
2596
2631
  console.log("Generating secure Grafana password...");
2597
- const { stdout: password } = await execPromise("openssl rand -base64 12 | tr -d '\n'");
2598
- grafanaPassword = password.trim();
2632
+ const { stdout: password } = await execFilePromise("openssl", ["rand", "-base64", "12"]);
2633
+ grafanaPassword = password.trim().replace(/\n/g, "");
2599
2634
 
2600
2635
  let configContent = "";
2601
2636
  if (fs.existsSync(cfgPath)) {
@@ -2626,16 +2661,16 @@ mon
2626
2661
  const envContent = fs.readFileSync(envFile, "utf8");
2627
2662
  const userMatch = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
2628
2663
  const passMatch = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
2629
- if (userMatch) vmAuthUsername = userMatch[1].trim().replace(/^["']|["']$/g, '');
2630
- if (passMatch) vmAuthPassword = passMatch[1].trim().replace(/^["']|["']$/g, '');
2664
+ if (userMatch) vmAuthUsername = stripMatchingQuotes(userMatch[1]);
2665
+ if (passMatch) vmAuthPassword = stripMatchingQuotes(passMatch[1]);
2631
2666
  }
2632
2667
 
2633
2668
  if (!vmAuthUsername || !vmAuthPassword) {
2634
2669
  console.log("Generating VictoriaMetrics auth credentials...");
2635
2670
  vmAuthUsername = vmAuthUsername || "vmauth";
2636
2671
  if (!vmAuthPassword) {
2637
- const { stdout: vmPass } = await execPromise("openssl rand -base64 12 | tr -d '\n'");
2638
- vmAuthPassword = vmPass.trim();
2672
+ const { stdout: vmPass } = await execFilePromise("openssl", ["rand", "-base64", "12"]);
2673
+ vmAuthPassword = vmPass.trim().replace(/\n/g, "");
2639
2674
  }
2640
2675
 
2641
2676
  // Update .env file with VM auth credentials
@@ -2947,16 +2982,16 @@ mon
2947
2982
 
2948
2983
  // Fetch latest changes
2949
2984
  console.log("Fetching latest changes...");
2950
- await execPromise("git fetch origin");
2985
+ await execFilePromise("git", ["fetch", "origin"]);
2951
2986
 
2952
2987
  // Check current branch
2953
- const { stdout: branch } = await execPromise("git rev-parse --abbrev-ref HEAD");
2988
+ const { stdout: branch } = await execFilePromise("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
2954
2989
  const currentBranch = branch.trim();
2955
2990
  console.log(`Current branch: ${currentBranch}`);
2956
2991
 
2957
2992
  // Pull latest changes
2958
2993
  console.log("Pulling latest changes...");
2959
- const { stdout: pullOut } = await execPromise("git pull origin " + currentBranch);
2994
+ const { stdout: pullOut } = await execFilePromise("git", ["pull", "origin", currentBranch]);
2960
2995
  console.log(pullOut);
2961
2996
 
2962
2997
  // Update Docker images
@@ -3214,7 +3249,8 @@ targets
3214
3249
  // If YAML parsing fails, fall back to simple check
3215
3250
  const isFile = fs.existsSync(file) && !fs.lstatSync(file).isDirectory();
3216
3251
  const content = isFile ? fs.readFileSync(file, "utf8") : "";
3217
- if (new RegExp(`^- name: ${instanceName}$`, "m").test(content)) {
3252
+ const escapedName = instanceName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3253
+ if (new RegExp(`^- name: ${escapedName}$`, "m").test(content)) {
3218
3254
  console.error(`Monitoring target '${instanceName}' already exists`);
3219
3255
  process.exitCode = 1;
3220
3256
  return;
@@ -3305,7 +3341,7 @@ targets
3305
3341
  console.log(`Testing connection to monitoring target '${name}'...`);
3306
3342
 
3307
3343
  // Use native pg client instead of requiring psql to be installed
3308
- const client = new Client({ connectionString: instance.conn_str });
3344
+ const client = new Client({ connectionString: instance.conn_str, connectionTimeoutMillis: 10000 });
3309
3345
 
3310
3346
  try {
3311
3347
  await client.connect();
@@ -3658,10 +3694,10 @@ mon
3658
3694
 
3659
3695
  try {
3660
3696
  // Generate secure password using openssl
3661
- const { stdout: password } = await execPromise(
3662
- "openssl rand -base64 12 | tr -d '\n'"
3697
+ const { stdout: password } = await execFilePromise(
3698
+ "openssl", ["rand", "-base64", "12"]
3663
3699
  );
3664
- const newPassword = password.trim();
3700
+ const newPassword = password.trim().replace(/\n/g, "");
3665
3701
 
3666
3702
  if (!newPassword) {
3667
3703
  console.error("Failed to generate password");
@@ -3748,8 +3784,8 @@ mon
3748
3784
  const vmPass = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
3749
3785
  if (vmUser && vmPass) {
3750
3786
  console.log("\nVictoriaMetrics credentials:");
3751
- console.log(` Username: ${vmUser[1].trim().replace(/^["']|["']$/g, '')}`);
3752
- console.log(` Password: ${vmPass[1].trim().replace(/^["']|["']$/g, '')}`);
3787
+ console.log(` Username: ${stripMatchingQuotes(vmUser[1])}`);
3788
+ console.log(` Password: ${stripMatchingQuotes(vmPass[1])}`);
3753
3789
  }
3754
3790
  }
3755
3791
  console.log("");
@@ -4712,7 +4748,7 @@ mcp
4712
4748
  // Get the path to the current pgai executable
4713
4749
  let pgaiPath: string;
4714
4750
  try {
4715
- const execPath = await execPromise("which pgai");
4751
+ const execPath = await execFilePromise("which", ["pgai"]);
4716
4752
  pgaiPath = execPath.stdout.trim();
4717
4753
  } catch {
4718
4754
  // Fallback to just "pgai" if which fails
@@ -4724,8 +4760,8 @@ mcp
4724
4760
  console.log("Installing PostgresAI MCP server for Claude Code...");
4725
4761
 
4726
4762
  try {
4727
- const { stdout, stderr } = await execPromise(
4728
- `claude mcp add -s user postgresai ${pgaiPath} mcp start`
4763
+ const { stdout, stderr } = await execFilePromise(
4764
+ "claude", ["mcp", "add", "-s", "user", "postgresai", pgaiPath, "mcp", "start"]
4729
4765
  );
4730
4766
 
4731
4767
  if (stdout) console.log(stdout);
package/bun.lock CHANGED
@@ -6,7 +6,7 @@
6
6
  "name": "postgresai",
7
7
  "dependencies": {
8
8
  "@modelcontextprotocol/sdk": "^1.20.2",
9
- "commander": "^12.1.0",
9
+ "commander": "^14.0.3",
10
10
  "js-yaml": "^4.1.0",
11
11
  "pg": "^8.16.3",
12
12
  },
@@ -16,7 +16,7 @@
16
16
  "@types/pg": "^8.15.6",
17
17
  "ajv": "^8.17.1",
18
18
  "ajv-formats": "^3.0.1",
19
- "typescript": "^5.9.3",
19
+ "typescript": "^6.0.2",
20
20
  },
21
21
  },
22
22
  },
@@ -51,7 +51,7 @@
51
51
 
52
52
  "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
53
53
 
54
- "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
54
+ "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
55
55
 
56
56
  "content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="],
57
57
 
@@ -233,7 +233,7 @@
233
233
 
234
234
  "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
235
235
 
236
- "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
236
+ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
237
237
 
238
238
  "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
239
239