postgresai 0.15.0-dev.1 → 0.15.0-dev.10

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.
@@ -1,18 +1,21 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { Command } from "commander";
3
+ import { Command, Option } from "commander";
4
4
  import pkg from "../package.json";
5
5
  import * as config from "../lib/config";
6
6
  import * as yaml from "js-yaml";
7
7
  import * as fs from "fs";
8
8
  import * as path from "path";
9
9
  import * as os from "os";
10
+ import { fileURLToPath } from "url";
10
11
  import * as crypto from "node:crypto";
11
12
  import { Client } from "pg";
12
13
  import { startMcpServer } from "../lib/mcp-server";
13
14
  import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment, fetchActionItem, fetchActionItems, createActionItem, updateActionItem, type ConfigChange } from "../lib/issues";
15
+ import { fetchReports, fetchAllReports, fetchReportFiles, fetchReportFileData, renderMarkdownForTerminal, parseFlexibleDate } from "../lib/reports";
14
16
  import { resolveBaseUrls } from "../lib/util";
15
- import { applyInitPlan, applyUninitPlan, buildInitPlan, buildUninitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, KNOWN_PROVIDERS, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, validateProvider, verifyInitSetup } from "../lib/init";
17
+ import { uploadFile, downloadFile, buildMarkdownLink } from "../lib/storage";
18
+ import { applyInitPlan, applyUninitPlan, buildInitPlan, buildUninitPlan, checkCurrentUserPermissions, connectWithSslFallback, DEFAULT_MONITORING_USER, formatPermissionCheckMessages, KNOWN_PROVIDERS, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, validateProvider, verifyInitSetup } from "../lib/init";
16
19
  import { SupabaseClient, resolveSupabaseConfig, extractProjectRefFromUrl, applyInitPlanViaSupabase, verifyInitSetupViaSupabase, fetchPoolerDatabaseUrl, type PgCompatibleError } from "../lib/supabase";
17
20
  import * as pkce from "../lib/pkce";
18
21
  import * as authServer from "../lib/auth-server";
@@ -51,21 +54,16 @@ function closeReadline() {
51
54
  }
52
55
  }
53
56
 
54
- // Helper functions for spawning processes - use Node.js child_process for compatibility
55
- async function execPromise(command: string): Promise<{ stdout: string; stderr: string }> {
56
- return new Promise((resolve, reject) => {
57
- childProcess.exec(command, (error, stdout, stderr) => {
58
- if (error) {
59
- const err = error as Error & { code: number };
60
- err.code = typeof error.code === "number" ? error.code : 1;
61
- reject(err);
62
- } else {
63
- resolve({ stdout, stderr });
64
- }
65
- });
66
- });
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;
67
64
  }
68
65
 
66
+ // Helper functions for spawning processes - use Node.js child_process for compatibility
69
67
  async function execFilePromise(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
70
68
  return new Promise((resolve, reject) => {
71
69
  childProcess.execFile(file, args, (error, stdout, stderr) => {
@@ -342,7 +340,8 @@ function writeReportFiles(reports: Record<string, any>, outputPath: string): voi
342
340
  for (const [checkId, report] of Object.entries(reports)) {
343
341
  const filePath = path.join(outputPath, `${checkId}.json`);
344
342
  fs.writeFileSync(filePath, JSON.stringify(report, null, 2), "utf8");
345
- console.log(`✓ ${checkId}: ${filePath}`);
343
+ const title = report.checkTitle || checkId;
344
+ console.log(`✓ ${checkId} ${title}: ${filePath}`);
346
345
  }
347
346
  }
348
347
 
@@ -410,6 +409,7 @@ interface CliOptions {
410
409
  apiKey?: string;
411
410
  apiBaseUrl?: string;
412
411
  uiBaseUrl?: string;
412
+ storageBaseUrl?: string;
413
413
  }
414
414
 
415
415
  /**
@@ -499,7 +499,11 @@ async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
499
499
  }
500
500
  }
501
501
 
502
- // Ensure instances.yml exists as a FILE (avoid Docker creating a directory)
502
+ // Ensure instances.yml exists as a FILE (avoid Docker creating a directory).
503
+ // Docker bind-mounts create missing paths as directories; replace if so.
504
+ if (fs.existsSync(instancesFile) && fs.lstatSync(instancesFile).isDirectory()) {
505
+ fs.rmSync(instancesFile, { recursive: true, force: true });
506
+ }
503
507
  if (!fs.existsSync(instancesFile)) {
504
508
  const header =
505
509
  "# PostgreSQL instances to monitor\n" +
@@ -578,6 +582,10 @@ program
578
582
  .option(
579
583
  "--ui-base-url <url>",
580
584
  "UI base URL for browser routes (overrides PGAI_UI_BASE_URL)"
585
+ )
586
+ .option(
587
+ "--storage-base-url <url>",
588
+ "Storage base URL for file uploads (overrides PGAI_STORAGE_BASE_URL)"
581
589
  );
582
590
 
583
591
  program
@@ -594,6 +602,27 @@ program
594
602
  console.log(`Default project saved: ${value}`);
595
603
  });
596
604
 
605
+ program
606
+ .command("set-storage-url <url>")
607
+ .description("store storage base URL for file uploads")
608
+ .action(async (url: string) => {
609
+ const value = (url || "").trim();
610
+ if (!value) {
611
+ console.error("Error: url is required");
612
+ process.exitCode = 1;
613
+ return;
614
+ }
615
+ try {
616
+ const { normalizeBaseUrl } = await import("../lib/util");
617
+ const normalized = normalizeBaseUrl(value);
618
+ config.writeConfig({ storageBaseUrl: normalized });
619
+ console.log(`Storage URL saved: ${normalized}`);
620
+ } catch {
621
+ console.error(`Error: invalid URL: ${value}`);
622
+ process.exitCode = 1;
623
+ }
624
+ });
625
+
597
626
  program
598
627
  .command("prepare-db [conn]")
599
628
  .description("prepare database for monitoring: create monitoring user, required view(s), and grant permissions (idempotent)")
@@ -839,8 +868,8 @@ program
839
868
  } else {
840
869
  console.log("✓ prepare-db verify: OK");
841
870
  if (v.missingOptional.length > 0) {
842
- console.log("⚠ Optional items missing:");
843
- for (const m of v.missingOptional) console.log(`- ${m}`);
871
+ console.error("⚠ Optional items missing:");
872
+ for (const m of v.missingOptional) console.error(`- ${m}`);
844
873
  }
845
874
  }
846
875
  return;
@@ -971,8 +1000,8 @@ program
971
1000
  } else {
972
1001
  console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
973
1002
  if (skippedOptional.length > 0) {
974
- console.log("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
975
- for (const s of skippedOptional) console.log(`- ${s}`);
1003
+ console.error("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
1004
+ for (const s of skippedOptional) console.error(`- ${s}`);
976
1005
  }
977
1006
  if (process.stdout.isTTY) {
978
1007
  console.log(`Applied ${applied.length} steps`);
@@ -1153,8 +1182,8 @@ program
1153
1182
  } else {
1154
1183
  console.log(`✓ prepare-db verify: OK${opts.provider ? ` (provider: ${opts.provider})` : ""}`);
1155
1184
  if (v.missingOptional.length > 0) {
1156
- console.log("⚠ Optional items missing:");
1157
- for (const m of v.missingOptional) console.log(`- ${m}`);
1185
+ console.error("⚠ Optional items missing:");
1186
+ for (const m of v.missingOptional) console.error(`- ${m}`);
1158
1187
  }
1159
1188
  }
1160
1189
  return;
@@ -1281,8 +1310,8 @@ program
1281
1310
  } else {
1282
1311
  console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
1283
1312
  if (skippedOptional.length > 0) {
1284
- console.log("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
1285
- for (const s of skippedOptional) console.log(`- ${s}`);
1313
+ console.error("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
1314
+ for (const s of skippedOptional) console.error(`- ${s}`);
1286
1315
  }
1287
1316
  // Keep output compact but still useful
1288
1317
  if (process.stdout.isTTY) {
@@ -1598,11 +1627,11 @@ program
1598
1627
  console.log("✓ unprepare-db completed");
1599
1628
  console.log(`Applied ${applied.length} steps`);
1600
1629
  } else {
1601
- console.log("⚠ unprepare-db completed with errors");
1630
+ console.error("⚠ unprepare-db completed with errors");
1602
1631
  console.log(`Applied ${applied.length} steps`);
1603
- console.log("Errors:");
1632
+ console.error("Errors:");
1604
1633
  for (const err of errors) {
1605
- console.log(` - ${err}`);
1634
+ console.error(` - ${err}`);
1606
1635
  }
1607
1636
  process.exitCode = 1;
1608
1637
  }
@@ -1797,6 +1826,24 @@ program
1797
1826
  const connResult = await connectWithSslFallback(Client, adminConn);
1798
1827
  client = connResult.client as Client;
1799
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
+
1800
1847
  // Generate reports
1801
1848
  let reports: Record<string, any>;
1802
1849
  if (checkId === "ALL") {
@@ -1924,8 +1971,8 @@ program
1924
1971
  }
1925
1972
  }
1926
1973
 
1927
- // Output JSON to stdout
1928
- if (shouldPrintJson) {
1974
+ // Output JSON to stdout (unless --output is specified, in which case files are written instead)
1975
+ if (shouldPrintJson && !outputPath) {
1929
1976
  console.log(JSON.stringify(reports, null, 2));
1930
1977
  }
1931
1978
 
@@ -2042,13 +2089,16 @@ function isDockerRunning(): boolean {
2042
2089
  }
2043
2090
 
2044
2091
  /**
2045
- * Get docker compose command
2092
+ * Get docker compose command.
2093
+ * Prefer "docker compose" (V2 plugin) over legacy "docker-compose" (V1 standalone)
2094
+ * because docker-compose V1 (<=1.29) is incompatible with modern Docker engines
2095
+ * (KeyError: 'ContainerConfig' on container recreation).
2046
2096
  */
2047
2097
  function getComposeCmd(): string[] | null {
2048
2098
  const tryCmd = (cmd: string, args: string[]): boolean =>
2049
2099
  spawnSync(cmd, args, { stdio: "ignore", timeout: 5000 } as Parameters<typeof spawnSync>[2]).status === 0;
2050
- if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
2051
2100
  if (tryCmd("docker", ["compose", "version"])) return ["docker", "compose"];
2101
+ if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
2052
2102
  return null;
2053
2103
  }
2054
2104
 
@@ -2087,9 +2137,9 @@ function registerMonitoringInstance(
2087
2137
  const debug = opts?.debug;
2088
2138
 
2089
2139
  if (debug) {
2090
- console.log(`\nDebug: Registering monitoring instance...`);
2091
- console.log(`Debug: POST ${url}`);
2092
- console.log(`Debug: project_name=${projectName}`);
2140
+ console.error(`\nDebug: Registering monitoring instance...`);
2141
+ console.error(`Debug: POST ${url}`);
2142
+ console.error(`Debug: project_name=${projectName}`);
2093
2143
  }
2094
2144
 
2095
2145
  // Fire and forget - don't block the main flow
@@ -2107,18 +2157,18 @@ function registerMonitoringInstance(
2107
2157
  const body = await res.text().catch(() => "");
2108
2158
  if (!res.ok) {
2109
2159
  if (debug) {
2110
- console.log(`Debug: Monitoring registration failed: HTTP ${res.status}`);
2111
- console.log(`Debug: Response: ${body}`);
2160
+ console.error(`Debug: Monitoring registration failed: HTTP ${res.status}`);
2161
+ console.error(`Debug: Response: ${body}`);
2112
2162
  }
2113
2163
  return;
2114
2164
  }
2115
2165
  if (debug) {
2116
- console.log(`Debug: Monitoring registration response: ${body}`);
2166
+ console.error(`Debug: Monitoring registration response: ${body}`);
2117
2167
  }
2118
2168
  })
2119
2169
  .catch((err) => {
2120
2170
  if (debug) {
2121
- console.log(`Debug: Monitoring registration error: ${err.message}`);
2171
+ console.error(`Debug: Monitoring registration error: ${err.message}`);
2122
2172
  }
2123
2173
  });
2124
2174
  }
@@ -2206,6 +2256,26 @@ async function runCompose(args: string[], grafanaPassword?: string): Promise<num
2206
2256
  }
2207
2257
  }
2208
2258
 
2259
+ // Load VM auth credentials from .env if not already set
2260
+ const envFilePath = path.resolve(projectDir, ".env");
2261
+ if (fs.existsSync(envFilePath)) {
2262
+ try {
2263
+ const envContent = fs.readFileSync(envFilePath, "utf8");
2264
+ if (!env.VM_AUTH_USERNAME) {
2265
+ const m = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
2266
+ if (m) env.VM_AUTH_USERNAME = stripMatchingQuotes(m[1]);
2267
+ }
2268
+ if (!env.VM_AUTH_PASSWORD) {
2269
+ const m = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
2270
+ if (m) env.VM_AUTH_PASSWORD = stripMatchingQuotes(m[1]);
2271
+ }
2272
+ } catch (err) {
2273
+ if (process.env.DEBUG) {
2274
+ console.warn(`Warning: Could not read VM auth from .env: ${err instanceof Error ? err.message : String(err)}`);
2275
+ }
2276
+ }
2277
+ }
2278
+
2209
2279
  // On macOS, self-node-exporter can't mount host root filesystem - skip it
2210
2280
  const finalArgs = [...args];
2211
2281
  if (process.platform === "darwin" && args.includes("up")) {
@@ -2250,7 +2320,7 @@ mon
2250
2320
  console.log("This will install, configure, and start the monitoring system\n");
2251
2321
 
2252
2322
  // Ensure we have a project directory with docker-compose.yml even if running from elsewhere
2253
- const { projectDir } = await resolveOrInitPaths();
2323
+ const { projectDir, instancesFile: instancesPath } = await resolveOrInitPaths();
2254
2324
  console.log(`Project directory: ${projectDir}\n`);
2255
2325
 
2256
2326
  // Save project name to .pgwatch-config if provided (used by reporter container)
@@ -2263,10 +2333,13 @@ mon
2263
2333
  // Update .env with custom tag if provided
2264
2334
  const envFile = path.resolve(projectDir, ".env");
2265
2335
 
2266
- // Build .env content, preserving important existing values (registry, password)
2267
- // 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.
2268
2338
  let existingRegistry: string | null = null;
2269
2339
  let existingPassword: string | null = null;
2340
+ let existingReplicatorPassword: string | null = null;
2341
+ let existingVmAuthUsername: string | null = null;
2342
+ let existingVmAuthPassword: string | null = null;
2270
2343
 
2271
2344
  if (fs.existsSync(envFile)) {
2272
2345
  const existingEnv = fs.readFileSync(envFile, "utf8");
@@ -2275,6 +2348,12 @@ mon
2275
2348
  if (registryMatch) existingRegistry = registryMatch[1].trim();
2276
2349
  const pwdMatch = existingEnv.match(/^GF_SECURITY_ADMIN_PASSWORD=(.+)$/m);
2277
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]);
2278
2357
  }
2279
2358
 
2280
2359
  // Priority: CLI --tag flag > package version
@@ -2290,6 +2369,11 @@ mon
2290
2369
  if (existingPassword) {
2291
2370
  envLines.push(`GF_SECURITY_ADMIN_PASSWORD=${existingPassword}`);
2292
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")}`);
2293
2377
  fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
2294
2378
 
2295
2379
  if (opts.tag) {
@@ -2298,8 +2382,8 @@ mon
2298
2382
 
2299
2383
  // Validate conflicting options
2300
2384
  if (opts.demo && opts.dbUrl) {
2301
- console.log("⚠ Both --demo and --db-url provided. Demo mode includes its own database.");
2302
- console.log("⚠ The --db-url will be ignored in demo mode.\n");
2385
+ console.error("⚠ Both --demo and --db-url provided. Demo mode includes its own database.");
2386
+ console.error("⚠ The --db-url will be ignored in demo mode.\n");
2303
2387
  opts.dbUrl = undefined;
2304
2388
  }
2305
2389
 
@@ -2315,7 +2399,7 @@ mon
2315
2399
  // Check if containers are already running
2316
2400
  const { running, containers } = checkRunningContainers();
2317
2401
  if (running) {
2318
- console.log(`⚠ Monitoring services are already running: ${containers.join(", ")}`);
2402
+ console.error(`⚠ Monitoring services are already running: ${containers.join(", ")}`);
2319
2403
  console.log("Use 'postgres-ai mon restart' to restart them\n");
2320
2404
  return;
2321
2405
  }
@@ -2334,7 +2418,7 @@ mon
2334
2418
  } else if (opts.yes) {
2335
2419
  // Auto-yes mode without API key - skip API key setup
2336
2420
  console.log("Auto-yes mode: no API key provided, skipping API key setup");
2337
- console.log("⚠ Reports will be generated locally only");
2421
+ console.error("⚠ Reports will be generated locally only");
2338
2422
  console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
2339
2423
  } else {
2340
2424
  const answer = await question("Do you have a Postgres AI API key? (Y/n): ");
@@ -2354,16 +2438,16 @@ mon
2354
2438
  break;
2355
2439
  }
2356
2440
 
2357
- console.log("⚠ API key cannot be empty");
2441
+ console.error("⚠ API key cannot be empty");
2358
2442
  const retry = await question("Try again or skip API key setup, retry? (Y/n): ");
2359
2443
  if (retry.toLowerCase() === "n") {
2360
- console.log("⚠ Skipping API key setup - reports will be generated locally only");
2444
+ console.error("⚠ Skipping API key setup - reports will be generated locally only");
2361
2445
  console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
2362
2446
  break;
2363
2447
  }
2364
2448
  }
2365
2449
  } else {
2366
- console.log("⚠ Skipping API key setup - reports will be generated locally only");
2450
+ console.error("⚠ Skipping API key setup - reports will be generated locally only");
2367
2451
  console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
2368
2452
  }
2369
2453
  }
@@ -2409,21 +2493,25 @@ mon
2409
2493
 
2410
2494
  // Test connection
2411
2495
  console.log("Testing connection to the added instance...");
2412
- try {
2413
- const client = new Client({ connectionString: connStr });
2414
- await client.connect();
2415
- const result = await client.query("select version();");
2416
- console.log("✓ Connection successful");
2417
- console.log(`${result.rows[0].version}\n`);
2418
- await client.end();
2419
- } catch (error) {
2420
- const message = error instanceof Error ? error.message : String(error);
2421
- 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
+ }
2422
2510
  }
2423
2511
  } else if (opts.yes) {
2424
2512
  // Auto-yes mode without database URL - skip database setup
2425
2513
  console.log("Auto-yes mode: no database URL provided, skipping database setup");
2426
- console.log("⚠ No PostgreSQL instance added");
2514
+ console.error("⚠ No PostgreSQL instance added");
2427
2515
  console.log("You can add one later with: postgres-ai mon targets add\n");
2428
2516
  } else {
2429
2517
  console.log("You need to add at least one PostgreSQL instance to monitor");
@@ -2441,7 +2529,7 @@ mon
2441
2529
  const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
2442
2530
  if (!m) {
2443
2531
  console.error("✗ Invalid connection string format");
2444
- console.log("⚠ Continuing without adding instance\n");
2532
+ console.error("⚠ Continuing without adding instance\n");
2445
2533
  } else {
2446
2534
  const host = m[3];
2447
2535
  const db = m[5];
@@ -2453,27 +2541,62 @@ mon
2453
2541
 
2454
2542
  // Test connection
2455
2543
  console.log("Testing connection to the added instance...");
2456
- try {
2457
- const client = new Client({ connectionString: connStr });
2458
- await client.connect();
2459
- const result = await client.query("select version();");
2460
- console.log("✓ Connection successful");
2461
- console.log(`${result.rows[0].version}\n`);
2462
- await client.end();
2463
- } catch (error) {
2464
- const message = error instanceof Error ? error.message : String(error);
2465
- 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
+ }
2466
2558
  }
2467
2559
  }
2468
2560
  } else {
2469
- console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
2561
+ console.error("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
2470
2562
  }
2471
2563
  } else {
2472
- console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
2564
+ console.error("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
2473
2565
  }
2474
2566
  }
2475
2567
  } else {
2476
- console.log("Step 2: Demo mode enabled - using included demo PostgreSQL database\n");
2568
+ // Demo mode: configure instances.yml from the bundled demo template.
2569
+ //
2570
+ // Side effects:
2571
+ // - Writes instancesPath (instances.yml next to docker-compose.yml)
2572
+ // - If Docker previously bind-mounted instances.yml as a directory, removes it first.
2573
+ //
2574
+ // Failure modes:
2575
+ // - Exits with code 1 if instances.demo.yml is not found in any candidate path.
2576
+ // This is fatal because starting without a target produces empty dashboards that
2577
+ // look like a bug rather than a misconfiguration.
2578
+ //
2579
+ // Template search order (import.meta.url is resolved at runtime, not baked in at build):
2580
+ // 1. npm layout: dist/bin/../../instances.demo.yml → package-root/instances.demo.yml
2581
+ // 2. dev layout: cli/bin/../../../instances.demo.yml → repo-root/instances.demo.yml
2582
+ console.log("Step 2: Demo mode enabled - using included demo PostgreSQL database");
2583
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
2584
+ const demoCandidates = [
2585
+ path.resolve(currentDir, "..", "..", "instances.demo.yml"), // npm: dist/bin -> package root
2586
+ path.resolve(currentDir, "..", "..", "..", "instances.demo.yml"), // dev: cli/bin -> repo root
2587
+ ];
2588
+ const demoSrc = demoCandidates.find(p => fs.existsSync(p));
2589
+ if (demoSrc) {
2590
+ // Remove directory artifact left by Docker bind-mounts before copying
2591
+ if (fs.existsSync(instancesPath) && fs.lstatSync(instancesPath).isDirectory()) {
2592
+ fs.rmSync(instancesPath, { recursive: true, force: true });
2593
+ }
2594
+ fs.copyFileSync(demoSrc, instancesPath);
2595
+ console.log("✓ Demo monitoring target configured\n");
2596
+ } else {
2597
+ console.error(`Error: instances.demo.yml not found — cannot configure demo target.\nSearched: ${demoCandidates.join(", ")}\n`);
2598
+ process.exit(1);
2599
+ }
2477
2600
  }
2478
2601
 
2479
2602
  // Step 3: Update configuration
@@ -2489,6 +2612,8 @@ mon
2489
2612
  console.log(opts.demo ? "Step 4: Configuring Grafana security..." : "Step 4: Configuring Grafana security...");
2490
2613
  const cfgPath = path.resolve(projectDir, ".pgwatch-config");
2491
2614
  let grafanaPassword = "";
2615
+ let vmAuthUsername = "";
2616
+ let vmAuthPassword = "";
2492
2617
 
2493
2618
  try {
2494
2619
  if (fs.existsSync(cfgPath)) {
@@ -2504,8 +2629,8 @@ mon
2504
2629
 
2505
2630
  if (!grafanaPassword) {
2506
2631
  console.log("Generating secure Grafana password...");
2507
- const { stdout: password } = await execPromise("openssl rand -base64 12 | tr -d '\n'");
2508
- grafanaPassword = password.trim();
2632
+ const { stdout: password } = await execFilePromise("openssl", ["rand", "-base64", "12"]);
2633
+ grafanaPassword = password.trim().replace(/\n/g, "");
2509
2634
 
2510
2635
  let configContent = "";
2511
2636
  if (fs.existsSync(cfgPath)) {
@@ -2522,12 +2647,58 @@ mon
2522
2647
 
2523
2648
  console.log("✓ Grafana password configured\n");
2524
2649
  } catch (error) {
2525
- console.log("⚠ Could not generate Grafana password automatically");
2650
+ console.error("⚠ Could not generate Grafana password automatically");
2526
2651
  console.log("Using default password: demo\n");
2527
2652
  grafanaPassword = "demo";
2528
2653
  }
2529
2654
 
2655
+ // Generate VictoriaMetrics auth credentials
2656
+ try {
2657
+ const envFile = path.resolve(projectDir, ".env");
2658
+
2659
+ // Read existing VM auth from .env if present
2660
+ if (fs.existsSync(envFile)) {
2661
+ const envContent = fs.readFileSync(envFile, "utf8");
2662
+ const userMatch = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
2663
+ const passMatch = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
2664
+ if (userMatch) vmAuthUsername = stripMatchingQuotes(userMatch[1]);
2665
+ if (passMatch) vmAuthPassword = stripMatchingQuotes(passMatch[1]);
2666
+ }
2667
+
2668
+ if (!vmAuthUsername || !vmAuthPassword) {
2669
+ console.log("Generating VictoriaMetrics auth credentials...");
2670
+ vmAuthUsername = vmAuthUsername || "vmauth";
2671
+ if (!vmAuthPassword) {
2672
+ const { stdout: vmPass } = await execFilePromise("openssl", ["rand", "-base64", "12"]);
2673
+ vmAuthPassword = vmPass.trim().replace(/\n/g, "");
2674
+ }
2675
+
2676
+ // Update .env file with VM auth credentials
2677
+ let envContent = "";
2678
+ if (fs.existsSync(envFile)) {
2679
+ envContent = fs.readFileSync(envFile, "utf8");
2680
+ }
2681
+ const envLines = envContent.split(/\r?\n/)
2682
+ .filter((l) => !/^VM_AUTH_USERNAME=/.test(l) && !/^VM_AUTH_PASSWORD=/.test(l))
2683
+ .filter((l, i, arr) => !(i === arr.length - 1 && l === ''));
2684
+ envLines.push(`VM_AUTH_USERNAME=${vmAuthUsername}`);
2685
+ envLines.push(`VM_AUTH_PASSWORD=${vmAuthPassword}`);
2686
+ fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
2687
+ }
2688
+
2689
+ console.log("✓ VictoriaMetrics auth configured\n");
2690
+ } catch (error) {
2691
+ console.error("⚠ Could not generate VictoriaMetrics auth credentials automatically");
2692
+ if (process.env.DEBUG) {
2693
+ console.warn(` ${error instanceof Error ? error.message : String(error)}`);
2694
+ }
2695
+ }
2696
+
2530
2697
  // Step 5: Start services
2698
+ // Remove stopped containers left by "run --rm" dependencies (e.g. config-init)
2699
+ // to avoid docker-compose v1 'ContainerConfig' error on recreation.
2700
+ // Best-effort: ignore exit code — container may not exist, failure here is non-fatal.
2701
+ await runCompose(["rm", "-f", "-s", "config-init"]);
2531
2702
  console.log("Step 5: Starting monitoring services...");
2532
2703
  const code2 = await runCompose(["up", "-d", "--force-recreate"], grafanaPassword);
2533
2704
  if (code2 !== 0) {
@@ -2577,6 +2748,9 @@ mon
2577
2748
  console.log("🚀 MAIN ACCESS POINT - Start here:");
2578
2749
  console.log(" Grafana Dashboard: http://localhost:3000");
2579
2750
  console.log(` Login: monitor / ${grafanaPassword}`);
2751
+ if (vmAuthUsername && vmAuthPassword) {
2752
+ console.log(` VictoriaMetrics Auth: ${vmAuthUsername} / ${vmAuthPassword}`);
2753
+ }
2580
2754
  console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
2581
2755
  });
2582
2756
 
@@ -2777,7 +2951,7 @@ mon
2777
2951
  console.log(`Project Directory: ${projectDir}`);
2778
2952
  console.log(`Docker Compose File: ${composeFile}`);
2779
2953
  console.log(`Instances File: ${instancesFile}`);
2780
- if (fs.existsSync(instancesFile)) {
2954
+ if (fs.existsSync(instancesFile) && !fs.lstatSync(instancesFile).isDirectory()) {
2781
2955
  console.log("\nInstances configuration:\n");
2782
2956
  const text = fs.readFileSync(instancesFile, "utf8");
2783
2957
  process.stdout.write(text);
@@ -2808,16 +2982,16 @@ mon
2808
2982
 
2809
2983
  // Fetch latest changes
2810
2984
  console.log("Fetching latest changes...");
2811
- await execPromise("git fetch origin");
2985
+ await execFilePromise("git", ["fetch", "origin"]);
2812
2986
 
2813
2987
  // Check current branch
2814
- const { stdout: branch } = await execPromise("git rev-parse --abbrev-ref HEAD");
2988
+ const { stdout: branch } = await execFilePromise("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
2815
2989
  const currentBranch = branch.trim();
2816
2990
  console.log(`Current branch: ${currentBranch}`);
2817
2991
 
2818
2992
  // Pull latest changes
2819
2993
  console.log("Pulling latest changes...");
2820
- const { stdout: pullOut } = await execPromise("git pull origin " + currentBranch);
2994
+ const { stdout: pullOut } = await execFilePromise("git", ["pull", "origin", currentBranch]);
2821
2995
  console.log(pullOut);
2822
2996
 
2823
2997
  // Update Docker images
@@ -2914,7 +3088,7 @@ mon
2914
3088
  if (downCode === 0) {
2915
3089
  console.log("✓ Monitoring services stopped and removed");
2916
3090
  } else {
2917
- console.log("⚠ Could not stop services (may not be running)");
3091
+ console.error("⚠ Could not stop services (may not be running)");
2918
3092
  }
2919
3093
 
2920
3094
  // Remove any orphaned containers that docker compose down missed
@@ -2993,7 +3167,7 @@ targets
2993
3167
  .description("list monitoring target databases")
2994
3168
  .action(async () => {
2995
3169
  const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
2996
- if (!fs.existsSync(instancesPath)) {
3170
+ if (!fs.existsSync(instancesPath) || fs.lstatSync(instancesPath).isDirectory()) {
2997
3171
  console.error(`instances.yml not found in ${projectDir}`);
2998
3172
  process.exitCode = 1;
2999
3173
  return;
@@ -3059,7 +3233,7 @@ targets
3059
3233
 
3060
3234
  // Check if instance already exists
3061
3235
  try {
3062
- if (fs.existsSync(file)) {
3236
+ if (fs.existsSync(file) && !fs.lstatSync(file).isDirectory()) {
3063
3237
  const content = fs.readFileSync(file, "utf8");
3064
3238
  const instances = yaml.load(content) as Instance[] | null || [];
3065
3239
  if (Array.isArray(instances)) {
@@ -3073,15 +3247,20 @@ targets
3073
3247
  }
3074
3248
  } catch (err) {
3075
3249
  // If YAML parsing fails, fall back to simple check
3076
- const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
3077
- if (new RegExp(`^- name: ${instanceName}$`, "m").test(content)) {
3250
+ const isFile = fs.existsSync(file) && !fs.lstatSync(file).isDirectory();
3251
+ const content = isFile ? fs.readFileSync(file, "utf8") : "";
3252
+ const escapedName = instanceName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3253
+ if (new RegExp(`^- name: ${escapedName}$`, "m").test(content)) {
3078
3254
  console.error(`Monitoring target '${instanceName}' already exists`);
3079
3255
  process.exitCode = 1;
3080
3256
  return;
3081
3257
  }
3082
3258
  }
3083
3259
 
3084
- // Add new instance
3260
+ // Add new instance — if instances.yml is a directory (Docker artifact), replace it with a file
3261
+ if (fs.existsSync(file) && fs.lstatSync(file).isDirectory()) {
3262
+ fs.rmSync(file, { recursive: true, force: true });
3263
+ }
3085
3264
  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`;
3086
3265
  const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
3087
3266
  fs.appendFileSync(file, (content && !/\n$/.test(content) ? "\n" : "") + body, "utf8");
@@ -3092,7 +3271,7 @@ targets
3092
3271
  .description("remove monitoring target database")
3093
3272
  .action(async (name: string) => {
3094
3273
  const { instancesFile: file } = await resolveOrInitPaths();
3095
- if (!fs.existsSync(file)) {
3274
+ if (!fs.existsSync(file) || fs.lstatSync(file).isDirectory()) {
3096
3275
  console.error("instances.yml not found");
3097
3276
  process.exitCode = 1;
3098
3277
  return;
@@ -3129,7 +3308,7 @@ targets
3129
3308
  .description("test monitoring target database connectivity")
3130
3309
  .action(async (name: string) => {
3131
3310
  const { instancesFile: instancesPath } = await resolveOrInitPaths();
3132
- if (!fs.existsSync(instancesPath)) {
3311
+ if (!fs.existsSync(instancesPath) || fs.lstatSync(instancesPath).isDirectory()) {
3133
3312
  console.error("instances.yml not found");
3134
3313
  process.exitCode = 1;
3135
3314
  return;
@@ -3162,7 +3341,7 @@ targets
3162
3341
  console.log(`Testing connection to monitoring target '${name}'...`);
3163
3342
 
3164
3343
  // Use native pg client instead of requiring psql to be installed
3165
- const client = new Client({ connectionString: instance.conn_str });
3344
+ const client = new Client({ connectionString: instance.conn_str, connectionTimeoutMillis: 10000 });
3166
3345
 
3167
3346
  try {
3168
3347
  await client.connect();
@@ -3227,8 +3406,8 @@ auth
3227
3406
  const { apiBaseUrl, uiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3228
3407
 
3229
3408
  if (opts.debug) {
3230
- console.log(`Debug: Resolved API base URL: ${apiBaseUrl}`);
3231
- console.log(`Debug: Resolved UI base URL: ${uiBaseUrl}`);
3409
+ console.error(`Debug: Resolved API base URL: ${apiBaseUrl}`);
3410
+ console.error(`Debug: Resolved UI base URL: ${uiBaseUrl}`);
3232
3411
  }
3233
3412
 
3234
3413
  try {
@@ -3258,8 +3437,8 @@ auth
3258
3437
  const initUrl = new URL(`${apiBaseUrl}/rpc/oauth_init`);
3259
3438
 
3260
3439
  if (opts.debug) {
3261
- console.log(`Debug: Trying to POST to: ${initUrl.toString()}`);
3262
- console.log(`Debug: Request data: ${initData}`);
3440
+ console.error(`Debug: Trying to POST to: ${initUrl.toString()}`);
3441
+ console.error(`Debug: Request data: ${initData}`);
3263
3442
  }
3264
3443
 
3265
3444
  // Step 2: Initialize OAuth session on backend using fetch
@@ -3305,7 +3484,7 @@ auth
3305
3484
  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)}`;
3306
3485
 
3307
3486
  if (opts.debug) {
3308
- console.log(`Debug: Auth URL: ${authUrl}`);
3487
+ console.error(`Debug: Auth URL: ${authUrl}`);
3309
3488
  }
3310
3489
 
3311
3490
  console.log(`\nOpening browser for authentication...`);
@@ -3515,10 +3694,10 @@ mon
3515
3694
 
3516
3695
  try {
3517
3696
  // Generate secure password using openssl
3518
- const { stdout: password } = await execPromise(
3519
- "openssl rand -base64 12 | tr -d '\n'"
3697
+ const { stdout: password } = await execFilePromise(
3698
+ "openssl", ["rand", "-base64", "12"]
3520
3699
  );
3521
- const newPassword = password.trim();
3700
+ const newPassword = password.trim().replace(/\n/g, "");
3522
3701
 
3523
3702
  if (!newPassword) {
3524
3703
  console.error("Failed to generate password");
@@ -3596,6 +3775,19 @@ mon
3596
3775
  console.log(" URL: http://localhost:3000");
3597
3776
  console.log(" Username: monitor");
3598
3777
  console.log(` Password: ${password}`);
3778
+
3779
+ // Show VM auth credentials from .env
3780
+ const envFile = path.resolve(projectDir, ".env");
3781
+ if (fs.existsSync(envFile)) {
3782
+ const envContent = fs.readFileSync(envFile, "utf8");
3783
+ const vmUser = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
3784
+ const vmPass = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
3785
+ if (vmUser && vmPass) {
3786
+ console.log("\nVictoriaMetrics credentials:");
3787
+ console.log(` Username: ${stripMatchingQuotes(vmUser[1])}`);
3788
+ console.log(` Password: ${stripMatchingQuotes(vmPass[1])}`);
3789
+ }
3790
+ }
3599
3791
  console.log("");
3600
3792
  });
3601
3793
 
@@ -3729,12 +3921,12 @@ issues
3729
3921
  // Interpret escape sequences in content (e.g., \n -> newline)
3730
3922
  if (opts.debug) {
3731
3923
  // eslint-disable-next-line no-console
3732
- console.log(`Debug: Original content: ${JSON.stringify(content)}`);
3924
+ console.error(`Debug: Original content: ${JSON.stringify(content)}`);
3733
3925
  }
3734
3926
  content = interpretEscapes(content);
3735
3927
  if (opts.debug) {
3736
3928
  // eslint-disable-next-line no-console
3737
- console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
3929
+ console.error(`Debug: Interpreted content: ${JSON.stringify(content)}`);
3738
3930
  }
3739
3931
 
3740
3932
  const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Posting comment...");
@@ -3928,12 +4120,12 @@ issues
3928
4120
  .action(async (commentId: string, content: string, opts: { debug?: boolean; json?: boolean }) => {
3929
4121
  if (opts.debug) {
3930
4122
  // eslint-disable-next-line no-console
3931
- console.log(`Debug: Original content: ${JSON.stringify(content)}`);
4123
+ console.error(`Debug: Original content: ${JSON.stringify(content)}`);
3932
4124
  }
3933
4125
  content = interpretEscapes(content);
3934
4126
  if (opts.debug) {
3935
4127
  // eslint-disable-next-line no-console
3936
- console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
4128
+ console.error(`Debug: Interpreted content: ${JSON.stringify(content)}`);
3937
4129
  }
3938
4130
 
3939
4131
  const rootOpts = program.opts<CliOptions>();
@@ -3966,6 +4158,93 @@ issues
3966
4158
  }
3967
4159
  });
3968
4160
 
4161
+ // File upload/download (subcommands of issues)
4162
+ const issueFiles = issues.command("files").description("upload and download files for issues");
4163
+
4164
+ issueFiles
4165
+ .command("upload <path>")
4166
+ .description("upload a file to storage and get a markdown link")
4167
+ .option("--debug", "enable debug output")
4168
+ .option("--json", "output raw JSON")
4169
+ .action(async (filePath: string, opts: { debug?: boolean; json?: boolean }) => {
4170
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Uploading file...");
4171
+ try {
4172
+ const rootOpts = program.opts<CliOptions>();
4173
+ const cfg = config.readConfig();
4174
+ const { apiKey } = getConfig(rootOpts);
4175
+ if (!apiKey) {
4176
+ spinner.stop();
4177
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
4178
+ process.exitCode = 1;
4179
+ return;
4180
+ }
4181
+
4182
+ const { storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4183
+
4184
+ const result = await uploadFile({
4185
+ apiKey,
4186
+ storageBaseUrl,
4187
+ filePath,
4188
+ debug: !!opts.debug,
4189
+ });
4190
+ spinner.stop();
4191
+
4192
+ if (opts.json) {
4193
+ printResult(result, true);
4194
+ } else {
4195
+ const md = buildMarkdownLink(result.url, storageBaseUrl, result.metadata.originalName);
4196
+ const displayUrl = result.url.startsWith("/") ? `${storageBaseUrl}${result.url}` : `${storageBaseUrl}/${result.url}`;
4197
+ console.log(`URL: ${displayUrl}`);
4198
+ console.log(`File: ${result.metadata.originalName}`);
4199
+ console.log(`Size: ${result.metadata.size} bytes`);
4200
+ console.log(`Type: ${result.metadata.mimeType}`);
4201
+ console.log(`Markdown: ${md}`);
4202
+ }
4203
+ } catch (err) {
4204
+ spinner.stop();
4205
+ const message = err instanceof Error ? err.message : String(err);
4206
+ console.error(message);
4207
+ process.exitCode = 1;
4208
+ }
4209
+ });
4210
+
4211
+ issueFiles
4212
+ .command("download <url>")
4213
+ .description("download a file from storage")
4214
+ .option("-o, --output <path>", "output file path (default: derive from URL)")
4215
+ .option("--debug", "enable debug output")
4216
+ .action(async (fileUrl: string, opts: { output?: string; debug?: boolean }) => {
4217
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Downloading file...");
4218
+ try {
4219
+ const rootOpts = program.opts<CliOptions>();
4220
+ const cfg = config.readConfig();
4221
+ const { apiKey } = getConfig(rootOpts);
4222
+ if (!apiKey) {
4223
+ spinner.stop();
4224
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
4225
+ process.exitCode = 1;
4226
+ return;
4227
+ }
4228
+
4229
+ const { storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4230
+
4231
+ const result = await downloadFile({
4232
+ apiKey,
4233
+ storageBaseUrl,
4234
+ fileUrl,
4235
+ outputPath: opts.output,
4236
+ debug: !!opts.debug,
4237
+ });
4238
+ spinner.stop();
4239
+ console.log(`Saved: ${result.savedTo}`);
4240
+ } catch (err) {
4241
+ spinner.stop();
4242
+ const message = err instanceof Error ? err.message : String(err);
4243
+ console.error(message);
4244
+ process.exitCode = 1;
4245
+ }
4246
+ });
4247
+
3969
4248
  // Action Items management (subcommands of issues)
3970
4249
  issues
3971
4250
  .command("action-items <issueId>")
@@ -4190,6 +4469,228 @@ issues
4190
4469
  }
4191
4470
  });
4192
4471
 
4472
+ // Reports management
4473
+ const reports = program.command("reports").description("checkup reports management");
4474
+
4475
+ reports
4476
+ .command("list")
4477
+ .description("list checkup reports")
4478
+ .option("--project-id <id>", "filter by project id", (v: string) => parseInt(v, 10))
4479
+ .addOption(new Option("--status <status>", "filter by status (e.g., completed)").hideHelp())
4480
+ .option("--limit <n>", "max number of reports to return (default: 20, max: 100)", (v: string) => { const n = parseInt(v, 10); return Number.isNaN(n) ? 20 : Math.max(1, Math.min(n, 100)); })
4481
+ .option("--before <date>", "show reports created before this date (YYYY-MM-DD, DD.MM.YYYY, etc.)")
4482
+ .option("--all", "fetch all reports (paginated automatically)")
4483
+ .addOption(new Option("--debug", "enable debug output").hideHelp())
4484
+ .option("--json", "output raw JSON")
4485
+ .action(async (opts: { projectId?: number; status?: string; limit?: number; before?: string; all?: boolean; debug?: boolean; json?: boolean }) => {
4486
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching reports...");
4487
+ try {
4488
+ const rootOpts = program.opts<CliOptions>();
4489
+ const cfg = config.readConfig();
4490
+ const { apiKey } = getConfig(rootOpts);
4491
+ if (!apiKey) {
4492
+ spinner.stop();
4493
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
4494
+ process.exitCode = 1;
4495
+ return;
4496
+ }
4497
+ if (opts.all && opts.before) {
4498
+ spinner.stop();
4499
+ console.error("--all and --before cannot be used together");
4500
+ process.exitCode = 1;
4501
+ return;
4502
+ }
4503
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4504
+
4505
+ let result;
4506
+ if (opts.all) {
4507
+ result = await fetchAllReports({
4508
+ apiKey,
4509
+ apiBaseUrl,
4510
+ projectId: opts.projectId,
4511
+ status: opts.status,
4512
+ limit: opts.limit,
4513
+ debug: !!opts.debug,
4514
+ });
4515
+ } else {
4516
+ result = await fetchReports({
4517
+ apiKey,
4518
+ apiBaseUrl,
4519
+ projectId: opts.projectId,
4520
+ status: opts.status,
4521
+ limit: opts.limit,
4522
+ beforeDate: opts.before ? parseFlexibleDate(opts.before) : undefined,
4523
+ debug: !!opts.debug,
4524
+ });
4525
+ }
4526
+ spinner.stop();
4527
+ printResult(result, opts.json);
4528
+ } catch (err) {
4529
+ spinner.stop();
4530
+ const message = err instanceof Error ? err.message : String(err);
4531
+ console.error(message);
4532
+ process.exitCode = 1;
4533
+ }
4534
+ });
4535
+
4536
+ reports
4537
+ .command("files [reportId]")
4538
+ .description("list files of a checkup report (metadata only, no content)")
4539
+ .option("--type <type>", "filter by file type: json, md")
4540
+ .option("--check-id <id>", "filter by check ID (e.g., H002)")
4541
+ .addOption(new Option("--debug", "enable debug output").hideHelp())
4542
+ .option("--json", "output raw JSON")
4543
+ .action(async (reportId: string | undefined, opts: { type?: "json" | "md"; checkId?: string; debug?: boolean; json?: boolean }) => {
4544
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching report files...");
4545
+ try {
4546
+ const rootOpts = program.opts<CliOptions>();
4547
+ const cfg = config.readConfig();
4548
+ const { apiKey } = getConfig(rootOpts);
4549
+ if (!apiKey) {
4550
+ spinner.stop();
4551
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
4552
+ process.exitCode = 1;
4553
+ return;
4554
+ }
4555
+ let numericId: number | undefined;
4556
+ if (reportId !== undefined) {
4557
+ numericId = parseInt(reportId, 10);
4558
+ if (isNaN(numericId)) {
4559
+ spinner.stop();
4560
+ console.error("reportId must be a number");
4561
+ process.exitCode = 1;
4562
+ return;
4563
+ }
4564
+ }
4565
+ if (numericId === undefined && !opts.checkId) {
4566
+ spinner.stop();
4567
+ console.error("Either reportId or --check-id is required");
4568
+ process.exitCode = 1;
4569
+ return;
4570
+ }
4571
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4572
+
4573
+ const result = await fetchReportFiles({
4574
+ apiKey,
4575
+ apiBaseUrl,
4576
+ reportId: numericId,
4577
+ type: opts.type,
4578
+ checkId: opts.checkId,
4579
+ debug: !!opts.debug,
4580
+ });
4581
+ spinner.stop();
4582
+ printResult(result, opts.json);
4583
+ } catch (err) {
4584
+ spinner.stop();
4585
+ const message = err instanceof Error ? err.message : String(err);
4586
+ console.error(message);
4587
+ process.exitCode = 1;
4588
+ }
4589
+ });
4590
+
4591
+ reports
4592
+ .command("data [reportId]")
4593
+ .description("get checkup report file data (includes content)")
4594
+ .option("--type <type>", "filter by file type: json, md")
4595
+ .option("--check-id <id>", "filter by check ID (e.g., H002)")
4596
+ .option("--formatted", "render markdown with ANSI styling (experimental)")
4597
+ .option("-o, --output <dir>", "save files to directory (uses original filenames)")
4598
+ .addOption(new Option("--debug", "enable debug output").hideHelp())
4599
+ .option("--json", "output raw JSON")
4600
+ .action(async (reportId: string | undefined, opts: { type?: "json" | "md"; checkId?: string; formatted?: boolean; output?: string; debug?: boolean; json?: boolean }) => {
4601
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching report data...");
4602
+ try {
4603
+ const rootOpts = program.opts<CliOptions>();
4604
+ const cfg = config.readConfig();
4605
+ const { apiKey } = getConfig(rootOpts);
4606
+ if (!apiKey) {
4607
+ spinner.stop();
4608
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
4609
+ process.exitCode = 1;
4610
+ return;
4611
+ }
4612
+ let numericId: number | undefined;
4613
+ if (reportId !== undefined) {
4614
+ numericId = parseInt(reportId, 10);
4615
+ if (isNaN(numericId)) {
4616
+ spinner.stop();
4617
+ console.error("reportId must be a number");
4618
+ process.exitCode = 1;
4619
+ return;
4620
+ }
4621
+ }
4622
+ if (numericId === undefined && !opts.checkId) {
4623
+ spinner.stop();
4624
+ console.error("Either reportId or --check-id is required");
4625
+ process.exitCode = 1;
4626
+ return;
4627
+ }
4628
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4629
+
4630
+ // Default to "md" for terminal output (human-readable); --json and --output get all types
4631
+ const effectiveType = opts.type ?? (!opts.json && !opts.output ? "md" as const : undefined);
4632
+ const result = await fetchReportFileData({
4633
+ apiKey,
4634
+ apiBaseUrl,
4635
+ reportId: numericId,
4636
+ type: effectiveType,
4637
+ checkId: opts.checkId,
4638
+ debug: !!opts.debug,
4639
+ });
4640
+ spinner.stop();
4641
+
4642
+ if (opts.output) {
4643
+ const dir = path.resolve(opts.output);
4644
+ fs.mkdirSync(dir, { recursive: true });
4645
+ for (const f of result) {
4646
+ const safeName = path.basename(f.filename);
4647
+ const filePath = path.join(dir, safeName);
4648
+ const content = f.type === "json"
4649
+ ? JSON.stringify(tryParseJson(f.data), null, 2)
4650
+ : f.data;
4651
+ fs.writeFileSync(filePath, content, "utf-8");
4652
+ console.log(filePath);
4653
+ }
4654
+ } else if (opts.json) {
4655
+ const processed = result.map((f) => ({
4656
+ ...f,
4657
+ data: f.type === "json" ? tryParseJson(f.data) : f.data,
4658
+ }));
4659
+ printResult(processed, true);
4660
+ } else if (opts.formatted && process.stdout.isTTY) {
4661
+ for (const f of result) {
4662
+ if (result.length > 1) {
4663
+ console.log(`\x1b[1m--- ${f.filename} (${f.check_id}, ${f.type}) ---\x1b[0m`);
4664
+ }
4665
+ if (f.type === "md") {
4666
+ console.log(renderMarkdownForTerminal(f.data));
4667
+ } else if (f.type === "json") {
4668
+ const parsed = tryParseJson(f.data);
4669
+ console.log(typeof parsed === "string" ? parsed : JSON.stringify(parsed, null, 2));
4670
+ } else {
4671
+ console.log(f.data);
4672
+ }
4673
+ }
4674
+ } else {
4675
+ for (const f of result) {
4676
+ if (result.length > 1) {
4677
+ console.log(`--- ${f.filename} (${f.check_id}, ${f.type}) ---`);
4678
+ }
4679
+ console.log(f.data);
4680
+ }
4681
+ }
4682
+ } catch (err) {
4683
+ spinner.stop();
4684
+ const message = err instanceof Error ? err.message : String(err);
4685
+ console.error(message);
4686
+ process.exitCode = 1;
4687
+ }
4688
+ });
4689
+
4690
+ function tryParseJson(s: string): unknown {
4691
+ try { return JSON.parse(s); } catch { return s; }
4692
+ }
4693
+
4193
4694
  // MCP server
4194
4695
  const mcp = program.command("mcp").description("MCP server integration");
4195
4696
 
@@ -4247,7 +4748,7 @@ mcp
4247
4748
  // Get the path to the current pgai executable
4248
4749
  let pgaiPath: string;
4249
4750
  try {
4250
- const execPath = await execPromise("which pgai");
4751
+ const execPath = await execFilePromise("which", ["pgai"]);
4251
4752
  pgaiPath = execPath.stdout.trim();
4252
4753
  } catch {
4253
4754
  // Fallback to just "pgai" if which fails
@@ -4259,8 +4760,8 @@ mcp
4259
4760
  console.log("Installing PostgresAI MCP server for Claude Code...");
4260
4761
 
4261
4762
  try {
4262
- const { stdout, stderr } = await execPromise(
4263
- `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"]
4264
4765
  );
4265
4766
 
4266
4767
  if (stdout) console.log(stdout);