postgresai 0.12.0-beta.6 → 0.12.0-beta.7

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.
@@ -71,6 +71,25 @@ function getConfig(opts) {
71
71
  }
72
72
  return { apiKey };
73
73
  }
74
+ // Human-friendly output helper: YAML for TTY by default, JSON when --json or non-TTY
75
+ function printResult(result, json) {
76
+ if (typeof result === "string") {
77
+ process.stdout.write(result);
78
+ if (!/\n$/.test(result))
79
+ console.log();
80
+ return;
81
+ }
82
+ if (json || !process.stdout.isTTY) {
83
+ console.log(JSON.stringify(result, null, 2));
84
+ }
85
+ else {
86
+ let text = yaml.dump(result);
87
+ if (Array.isArray(result)) {
88
+ text = text.replace(/\n- /g, "\n\n- ");
89
+ }
90
+ console.log(text);
91
+ }
92
+ }
74
93
  const program = new commander_1.Command();
75
94
  program
76
95
  .name("postgres-ai")
@@ -206,23 +225,284 @@ const mon = program.command("mon").description("monitoring services management")
206
225
  mon
207
226
  .command("quickstart")
208
227
  .description("complete setup (generate config, start monitoring services)")
209
- .option("--demo", "demo mode", false)
210
- .action(async () => {
228
+ .option("--demo", "demo mode with sample database", false)
229
+ .option("--api-key <key>", "Postgres AI API key for automated report uploads")
230
+ .option("--db-url <url>", "PostgreSQL connection URL to monitor")
231
+ .option("-y, --yes", "accept all defaults and skip interactive prompts", false)
232
+ .action(async (opts) => {
233
+ console.log("\n=================================");
234
+ console.log(" PostgresAI Monitoring Quickstart");
235
+ console.log("=================================\n");
236
+ console.log("This will install, configure, and start the monitoring system\n");
237
+ // Validate conflicting options
238
+ if (opts.demo && opts.dbUrl) {
239
+ console.log("⚠ Both --demo and --db-url provided. Demo mode includes its own database.");
240
+ console.log("⚠ The --db-url will be ignored in demo mode.\n");
241
+ opts.dbUrl = undefined;
242
+ }
243
+ if (opts.demo && opts.apiKey) {
244
+ console.error("✗ Cannot use --api-key with --demo mode");
245
+ console.error("✗ Demo mode is for testing only and does not support API key integration");
246
+ console.error("\nUse demo mode without API key: postgres-ai mon quickstart --demo");
247
+ console.error("Or use production mode with API key: postgres-ai mon quickstart --api-key=your_key");
248
+ process.exitCode = 1;
249
+ return;
250
+ }
211
251
  // Check if containers are already running
212
252
  const { running, containers } = checkRunningContainers();
213
253
  if (running) {
214
- console.log(`Monitoring services are already running: ${containers.join(", ")}`);
215
- console.log("Use 'postgres-ai mon restart' to restart them");
254
+ console.log(`⚠ Monitoring services are already running: ${containers.join(", ")}`);
255
+ console.log("Use 'postgres-ai mon restart' to restart them\n");
216
256
  return;
217
257
  }
258
+ // Step 1: API key configuration (only in production mode)
259
+ if (!opts.demo) {
260
+ console.log("Step 1: Postgres AI API Configuration (Optional)");
261
+ console.log("An API key enables automatic upload of PostgreSQL reports to Postgres AI\n");
262
+ if (opts.apiKey) {
263
+ console.log("Using API key provided via --api-key parameter");
264
+ config.writeConfig({ apiKey: opts.apiKey });
265
+ console.log("✓ API key saved\n");
266
+ }
267
+ else if (opts.yes) {
268
+ // Auto-yes mode without API key - skip API key setup
269
+ console.log("Auto-yes mode: no API key provided, skipping API key setup");
270
+ console.log("⚠ Reports will be generated locally only");
271
+ console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
272
+ }
273
+ else {
274
+ const rl = readline.createInterface({
275
+ input: process.stdin,
276
+ output: process.stdout
277
+ });
278
+ const question = (prompt) => new Promise((resolve) => rl.question(prompt, resolve));
279
+ try {
280
+ const answer = await question("Do you have a Postgres AI API key? (Y/n): ");
281
+ const proceedWithApiKey = !answer || answer.toLowerCase() === "y";
282
+ if (proceedWithApiKey) {
283
+ while (true) {
284
+ const inputApiKey = await question("Enter your Postgres AI API key: ");
285
+ const trimmedKey = inputApiKey.trim();
286
+ if (trimmedKey) {
287
+ config.writeConfig({ apiKey: trimmedKey });
288
+ console.log("✓ API key saved\n");
289
+ break;
290
+ }
291
+ console.log("⚠ API key cannot be empty");
292
+ const retry = await question("Try again or skip API key setup, retry? (Y/n): ");
293
+ if (retry.toLowerCase() === "n") {
294
+ console.log("⚠ Skipping API key setup - reports will be generated locally only");
295
+ console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
296
+ break;
297
+ }
298
+ }
299
+ }
300
+ else {
301
+ console.log("⚠ Skipping API key setup - reports will be generated locally only");
302
+ console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
303
+ }
304
+ }
305
+ finally {
306
+ rl.close();
307
+ }
308
+ }
309
+ }
310
+ else {
311
+ console.log("Step 1: Demo mode - API key configuration skipped");
312
+ console.log("Demo mode is for testing only and does not support API key integration\n");
313
+ }
314
+ // Step 2: Add PostgreSQL instance (if not demo mode)
315
+ if (!opts.demo) {
316
+ console.log("Step 2: Add PostgreSQL Instance to Monitor\n");
317
+ // Clear instances.yml in production mode (start fresh)
318
+ const instancesPath = path.resolve(process.cwd(), "instances.yml");
319
+ const emptyInstancesContent = "# PostgreSQL instances to monitor\n# Add your instances using: postgres-ai mon targets add\n\n";
320
+ fs.writeFileSync(instancesPath, emptyInstancesContent, "utf8");
321
+ if (opts.dbUrl) {
322
+ console.log("Using database URL provided via --db-url parameter");
323
+ console.log(`Adding PostgreSQL instance from: ${opts.dbUrl}\n`);
324
+ const match = opts.dbUrl.match(/^postgresql:\/\/[^@]+@([^:/]+)/);
325
+ const autoInstanceName = match ? match[1] : "db-instance";
326
+ const connStr = opts.dbUrl;
327
+ const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
328
+ if (!m) {
329
+ console.error("✗ Invalid connection string format");
330
+ process.exitCode = 1;
331
+ return;
332
+ }
333
+ const host = m[3];
334
+ const db = m[5];
335
+ const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
336
+ const body = `- name: ${instanceName}\n conn_str: ${connStr}\n preset_metrics: full\n custom_metrics:\n is_enabled: true\n group: default\n custom_tags:\n env: production\n cluster: default\n node_name: ${instanceName}\n sink_type: ~sink_type~\n`;
337
+ fs.appendFileSync(instancesPath, body, "utf8");
338
+ console.log(`✓ Monitoring target '${instanceName}' added\n`);
339
+ // Test connection
340
+ console.log("Testing connection to the added instance...");
341
+ try {
342
+ const { Client } = require("pg");
343
+ const client = new Client({ connectionString: connStr });
344
+ await client.connect();
345
+ const result = await client.query("select version();");
346
+ console.log("✓ Connection successful");
347
+ console.log(`${result.rows[0].version}\n`);
348
+ await client.end();
349
+ }
350
+ catch (error) {
351
+ const message = error instanceof Error ? error.message : String(error);
352
+ console.error(`✗ Connection failed: ${message}\n`);
353
+ }
354
+ }
355
+ else if (opts.yes) {
356
+ // Auto-yes mode without database URL - skip database setup
357
+ console.log("Auto-yes mode: no database URL provided, skipping database setup");
358
+ console.log("⚠ No PostgreSQL instance added");
359
+ console.log("You can add one later with: postgres-ai mon targets add\n");
360
+ }
361
+ else {
362
+ const rl = readline.createInterface({
363
+ input: process.stdin,
364
+ output: process.stdout
365
+ });
366
+ const question = (prompt) => new Promise((resolve) => rl.question(prompt, resolve));
367
+ try {
368
+ console.log("You need to add at least one PostgreSQL instance to monitor");
369
+ const answer = await question("Do you want to add a PostgreSQL instance now? (Y/n): ");
370
+ const proceedWithInstance = !answer || answer.toLowerCase() === "y";
371
+ if (proceedWithInstance) {
372
+ console.log("\nYou can provide either:");
373
+ console.log(" 1. A full connection string: postgresql://user:pass@host:port/database");
374
+ console.log(" 2. Press Enter to skip for now\n");
375
+ const connStr = await question("Enter connection string (or press Enter to skip): ");
376
+ if (connStr.trim()) {
377
+ const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
378
+ if (!m) {
379
+ console.error("✗ Invalid connection string format");
380
+ console.log("⚠ Continuing without adding instance\n");
381
+ }
382
+ else {
383
+ const host = m[3];
384
+ const db = m[5];
385
+ const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
386
+ const body = `- name: ${instanceName}\n conn_str: ${connStr}\n preset_metrics: full\n custom_metrics:\n is_enabled: true\n group: default\n custom_tags:\n env: production\n cluster: default\n node_name: ${instanceName}\n sink_type: ~sink_type~\n`;
387
+ fs.appendFileSync(instancesPath, body, "utf8");
388
+ console.log(`✓ Monitoring target '${instanceName}' added\n`);
389
+ // Test connection
390
+ console.log("Testing connection to the added instance...");
391
+ try {
392
+ const { Client } = require("pg");
393
+ const client = new Client({ connectionString: connStr });
394
+ await client.connect();
395
+ const result = await client.query("select version();");
396
+ console.log("✓ Connection successful");
397
+ console.log(`${result.rows[0].version}\n`);
398
+ await client.end();
399
+ }
400
+ catch (error) {
401
+ const message = error instanceof Error ? error.message : String(error);
402
+ console.error(`✗ Connection failed: ${message}\n`);
403
+ }
404
+ }
405
+ }
406
+ else {
407
+ console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
408
+ }
409
+ }
410
+ else {
411
+ console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
412
+ }
413
+ }
414
+ finally {
415
+ rl.close();
416
+ }
417
+ }
418
+ }
419
+ else {
420
+ console.log("Step 2: Demo mode enabled - using included demo PostgreSQL database\n");
421
+ }
422
+ // Step 3: Update configuration
423
+ console.log(opts.demo ? "Step 3: Updating configuration..." : "Step 3: Updating configuration...");
218
424
  const code1 = await runCompose(["run", "--rm", "sources-generator"]);
219
425
  if (code1 !== 0) {
220
426
  process.exitCode = code1;
221
427
  return;
222
428
  }
223
- const code2 = await runCompose(["up", "-d"]);
224
- if (code2 !== 0)
429
+ console.log(" Configuration updated\n");
430
+ // Step 4: Ensure Grafana password is configured
431
+ console.log(opts.demo ? "Step 4: Configuring Grafana security..." : "Step 4: Configuring Grafana security...");
432
+ const cfgPath = path.resolve(process.cwd(), ".pgwatch-config");
433
+ let grafanaPassword = "";
434
+ try {
435
+ if (fs.existsSync(cfgPath)) {
436
+ const stats = fs.statSync(cfgPath);
437
+ if (!stats.isDirectory()) {
438
+ const content = fs.readFileSync(cfgPath, "utf8");
439
+ const match = content.match(/^grafana_password=([^\r\n]+)/m);
440
+ if (match) {
441
+ grafanaPassword = match[1].trim();
442
+ }
443
+ }
444
+ }
445
+ if (!grafanaPassword) {
446
+ console.log("Generating secure Grafana password...");
447
+ const { stdout: password } = await execPromise("openssl rand -base64 12 | tr -d '\n'");
448
+ grafanaPassword = password.trim();
449
+ let configContent = "";
450
+ if (fs.existsSync(cfgPath)) {
451
+ const stats = fs.statSync(cfgPath);
452
+ if (!stats.isDirectory()) {
453
+ configContent = fs.readFileSync(cfgPath, "utf8");
454
+ }
455
+ }
456
+ const lines = configContent.split(/\r?\n/).filter((l) => !/^grafana_password=/.test(l));
457
+ lines.push(`grafana_password=${grafanaPassword}`);
458
+ fs.writeFileSync(cfgPath, lines.filter(Boolean).join("\n") + "\n", "utf8");
459
+ }
460
+ console.log("✓ Grafana password configured\n");
461
+ }
462
+ catch (error) {
463
+ console.log("⚠ Could not generate Grafana password automatically");
464
+ console.log("Using default password: demo\n");
465
+ grafanaPassword = "demo";
466
+ }
467
+ // Step 5: Start services
468
+ console.log(opts.demo ? "Step 5: Starting monitoring services..." : "Step 5: Starting monitoring services...");
469
+ const code2 = await runCompose(["up", "-d", "--force-recreate"]);
470
+ if (code2 !== 0) {
225
471
  process.exitCode = code2;
472
+ return;
473
+ }
474
+ console.log("✓ Services started\n");
475
+ // Final summary
476
+ console.log("=================================");
477
+ console.log(" 🎉 Quickstart setup completed!");
478
+ console.log("=================================\n");
479
+ console.log("What's running:");
480
+ if (opts.demo) {
481
+ console.log(" ✅ Demo PostgreSQL database (monitoring target)");
482
+ }
483
+ console.log(" ✅ PostgreSQL monitoring infrastructure");
484
+ console.log(" ✅ Grafana dashboards (with secure password)");
485
+ console.log(" ✅ Prometheus metrics storage");
486
+ console.log(" ✅ Flask API backend");
487
+ console.log(" ✅ Automated report generation (every 24h)");
488
+ console.log(" ✅ Host stats monitoring (CPU, memory, disk, I/O)\n");
489
+ if (!opts.demo) {
490
+ console.log("Next steps:");
491
+ console.log(" • Add more PostgreSQL instances: postgres-ai mon targets add");
492
+ console.log(" • View configured instances: postgres-ai mon targets list");
493
+ console.log(" • Check service health: postgres-ai mon health\n");
494
+ }
495
+ else {
496
+ console.log("Demo mode next steps:");
497
+ console.log(" • Explore Grafana dashboards at http://localhost:3000");
498
+ console.log(" • Connect to demo database: postgresql://postgres:postgres@localhost:55432/target_database");
499
+ console.log(" • Generate some load on the demo database to see metrics\n");
500
+ }
501
+ console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
502
+ console.log("🚀 MAIN ACCESS POINT - Start here:");
503
+ console.log(" Grafana Dashboard: http://localhost:3000");
504
+ console.log(` Login: monitor / ${grafanaPassword}`);
505
+ console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
226
506
  });
227
507
  mon
228
508
  .command("start")
@@ -289,10 +569,12 @@ mon
289
569
  .option("--wait <seconds>", "wait time in seconds for services to become healthy", parseInt, 0)
290
570
  .action(async (opts) => {
291
571
  const services = [
292
- { name: "Grafana", url: "http://localhost:3000/api/health" },
293
- { name: "Prometheus", url: "http://localhost:59090/-/healthy" },
294
- { name: "PGWatch (Postgres)", url: "http://localhost:58080/health" },
295
- { name: "PGWatch (Prometheus)", url: "http://localhost:58089/health" },
572
+ { name: "Grafana", container: "grafana-with-datasources" },
573
+ { name: "Prometheus", container: "sink-prometheus" },
574
+ { name: "PGWatch (Postgres)", container: "pgwatch-postgres" },
575
+ { name: "PGWatch (Prometheus)", container: "pgwatch-prometheus" },
576
+ { name: "Target DB", container: "target-db" },
577
+ { name: "Sink Postgres", container: "sink-postgres" },
296
578
  ];
297
579
  const waitTime = opts.wait || 0;
298
580
  const maxAttempts = waitTime > 0 ? Math.ceil(waitTime / 5) : 1;
@@ -306,19 +588,16 @@ mon
306
588
  allHealthy = true;
307
589
  for (const service of services) {
308
590
  try {
309
- // Use native fetch instead of requiring curl to be installed
310
- const controller = new AbortController();
311
- const timeoutId = setTimeout(() => controller.abort(), 5000);
312
- const response = await fetch(service.url, {
313
- signal: controller.signal,
314
- method: 'GET',
315
- });
316
- clearTimeout(timeoutId);
317
- if (response.status === 200) {
591
+ const { execSync } = require("child_process");
592
+ const status = execSync(`docker inspect -f '{{.State.Status}}' ${service.container} 2>/dev/null`, {
593
+ encoding: 'utf8',
594
+ stdio: ['pipe', 'pipe', 'pipe']
595
+ }).trim();
596
+ if (status === 'running') {
318
597
  console.log(`✓ ${service.name}: healthy`);
319
598
  }
320
599
  else {
321
- console.log(`✗ ${service.name}: unhealthy (HTTP ${response.status})`);
600
+ console.log(`✗ ${service.name}: unhealthy (status: ${status})`);
322
601
  allHealthy = false;
323
602
  }
324
603
  }
@@ -1041,12 +1320,30 @@ mon
1041
1320
  console.log(` Password: ${password}`);
1042
1321
  console.log("");
1043
1322
  });
1323
+ /**
1324
+ * Interpret escape sequences in a string (e.g., \n -> newline)
1325
+ * Note: In regex, to match literal backslash-n, we need \\n in the pattern
1326
+ * which requires \\\\n in the JavaScript string literal
1327
+ */
1328
+ function interpretEscapes(str) {
1329
+ // First handle double backslashes by temporarily replacing them
1330
+ // Then handle other escapes, then restore double backslashes as single
1331
+ return str
1332
+ .replace(/\\\\/g, '\x00') // Temporarily mark double backslashes
1333
+ .replace(/\\n/g, '\n') // Match literal backslash-n (\\\\n in JS string -> \\n in regex -> matches \n)
1334
+ .replace(/\\t/g, '\t')
1335
+ .replace(/\\r/g, '\r')
1336
+ .replace(/\\"/g, '"')
1337
+ .replace(/\\'/g, "'")
1338
+ .replace(/\x00/g, '\\'); // Restore double backslashes as single
1339
+ }
1044
1340
  // Issues management
1045
1341
  const issues = program.command("issues").description("issues management");
1046
1342
  issues
1047
1343
  .command("list")
1048
1344
  .description("list issues")
1049
1345
  .option("--debug", "enable debug output")
1346
+ .option("--json", "output raw JSON")
1050
1347
  .action(async (opts) => {
1051
1348
  try {
1052
1349
  const rootOpts = program.opts();
@@ -1059,14 +1356,90 @@ issues
1059
1356
  }
1060
1357
  const { apiBaseUrl } = (0, util_2.resolveBaseUrls)(rootOpts, cfg);
1061
1358
  const result = await (0, issues_1.fetchIssues)({ apiKey, apiBaseUrl, debug: !!opts.debug });
1062
- if (typeof result === "string") {
1063
- process.stdout.write(result);
1064
- if (!/\n$/.test(result))
1065
- console.log();
1359
+ const trimmed = Array.isArray(result)
1360
+ ? result.map((r) => ({
1361
+ id: r.id,
1362
+ title: r.title,
1363
+ status: r.status,
1364
+ created_at: r.created_at,
1365
+ }))
1366
+ : result;
1367
+ printResult(trimmed, opts.json);
1368
+ }
1369
+ catch (err) {
1370
+ const message = err instanceof Error ? err.message : String(err);
1371
+ console.error(message);
1372
+ process.exitCode = 1;
1373
+ }
1374
+ });
1375
+ issues
1376
+ .command("view <issueId>")
1377
+ .description("view issue details and comments")
1378
+ .option("--debug", "enable debug output")
1379
+ .option("--json", "output raw JSON")
1380
+ .action(async (issueId, opts) => {
1381
+ try {
1382
+ const rootOpts = program.opts();
1383
+ const cfg = config.readConfig();
1384
+ const { apiKey } = getConfig(rootOpts);
1385
+ if (!apiKey) {
1386
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
1387
+ process.exitCode = 1;
1388
+ return;
1066
1389
  }
1067
- else {
1068
- console.log(JSON.stringify(result, null, 2));
1390
+ const { apiBaseUrl } = (0, util_2.resolveBaseUrls)(rootOpts, cfg);
1391
+ const issue = await (0, issues_1.fetchIssue)({ apiKey, apiBaseUrl, issueId, debug: !!opts.debug });
1392
+ if (!issue) {
1393
+ console.error("Issue not found");
1394
+ process.exitCode = 1;
1395
+ return;
1396
+ }
1397
+ const comments = await (0, issues_1.fetchIssueComments)({ apiKey, apiBaseUrl, issueId, debug: !!opts.debug });
1398
+ const combined = { issue, comments };
1399
+ printResult(combined, opts.json);
1400
+ }
1401
+ catch (err) {
1402
+ const message = err instanceof Error ? err.message : String(err);
1403
+ console.error(message);
1404
+ process.exitCode = 1;
1405
+ }
1406
+ });
1407
+ issues
1408
+ .command("post_comment <issueId> <content>")
1409
+ .description("post a new comment to an issue")
1410
+ .option("--parent <uuid>", "parent comment id")
1411
+ .option("--debug", "enable debug output")
1412
+ .option("--json", "output raw JSON")
1413
+ .action(async (issueId, content, opts) => {
1414
+ try {
1415
+ // Interpret escape sequences in content (e.g., \n -> newline)
1416
+ if (opts.debug) {
1417
+ // eslint-disable-next-line no-console
1418
+ console.log(`Debug: Original content: ${JSON.stringify(content)}`);
1419
+ }
1420
+ content = interpretEscapes(content);
1421
+ if (opts.debug) {
1422
+ // eslint-disable-next-line no-console
1423
+ console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
1424
+ }
1425
+ const rootOpts = program.opts();
1426
+ const cfg = config.readConfig();
1427
+ const { apiKey } = getConfig(rootOpts);
1428
+ if (!apiKey) {
1429
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
1430
+ process.exitCode = 1;
1431
+ return;
1069
1432
  }
1433
+ const { apiBaseUrl } = (0, util_2.resolveBaseUrls)(rootOpts, cfg);
1434
+ const result = await (0, issues_1.createIssueComment)({
1435
+ apiKey,
1436
+ apiBaseUrl,
1437
+ issueId,
1438
+ content,
1439
+ parentCommentId: opts.parent,
1440
+ debug: !!opts.debug,
1441
+ });
1442
+ printResult(result, opts.json);
1070
1443
  }
1071
1444
  catch (err) {
1072
1445
  const message = err instanceof Error ? err.message : String(err);