postgresai 0.14.0 → 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";
@@ -24,6 +27,18 @@ import { getCheckupEntry } from "../lib/checkup-dictionary";
24
27
  import { createCheckupReport, uploadCheckupReportJson, convertCheckupReportJsonToMarkdown, RpcError, formatRpcErrorForDisplay, withRetry } from "../lib/checkup-api";
25
28
  import { generateCheckSummary } from "../lib/checkup-summary";
26
29
 
30
+ // Node.js version check - require Node 18+
31
+ // Node 14 reached EOL in April 2023, Node 16 in September 2023.
32
+ // Node 18+ is required for native ESM, modern crypto APIs, and security updates.
33
+ const nodeVersion = parseInt(process.versions.node.split('.')[0], 10);
34
+ if (nodeVersion < 18) {
35
+ console.error(`\x1b[31mError: postgresai requires Node 18 or higher.\x1b[0m`);
36
+ console.error(`You are running Node.js ${process.versions.node}.`);
37
+ console.error(`Please upgrade to Node.js 20 LTS or Node.js 22 for security updates.`);
38
+ console.error(`\nDownload: https://nodejs.org/`);
39
+ process.exit(1);
40
+ }
41
+
27
42
  // Singleton readline interface for stdin prompts
28
43
  let rl: ReturnType<typeof createInterface> | null = null;
29
44
  function getReadline() {
@@ -39,21 +54,16 @@ function closeReadline() {
39
54
  }
40
55
  }
41
56
 
42
- // Helper functions for spawning processes - use Node.js child_process for compatibility
43
- async function execPromise(command: string): Promise<{ stdout: string; stderr: string }> {
44
- return new Promise((resolve, reject) => {
45
- childProcess.exec(command, (error, stdout, stderr) => {
46
- if (error) {
47
- const err = error as Error & { code: number };
48
- err.code = typeof error.code === "number" ? error.code : 1;
49
- reject(err);
50
- } else {
51
- resolve({ stdout, stderr });
52
- }
53
- });
54
- });
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;
55
64
  }
56
65
 
66
+ // Helper functions for spawning processes - use Node.js child_process for compatibility
57
67
  async function execFilePromise(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
58
68
  return new Promise((resolve, reject) => {
59
69
  childProcess.execFile(file, args, (error, stdout, stderr) => {
@@ -330,7 +340,8 @@ function writeReportFiles(reports: Record<string, any>, outputPath: string): voi
330
340
  for (const [checkId, report] of Object.entries(reports)) {
331
341
  const filePath = path.join(outputPath, `${checkId}.json`);
332
342
  fs.writeFileSync(filePath, JSON.stringify(report, null, 2), "utf8");
333
- console.log(`✓ ${checkId}: ${filePath}`);
343
+ const title = report.checkTitle || checkId;
344
+ console.log(`✓ ${checkId} ${title}: ${filePath}`);
334
345
  }
335
346
  }
336
347
 
@@ -398,6 +409,7 @@ interface CliOptions {
398
409
  apiKey?: string;
399
410
  apiBaseUrl?: string;
400
411
  uiBaseUrl?: string;
412
+ storageBaseUrl?: string;
401
413
  }
402
414
 
403
415
  /**
@@ -487,7 +499,11 @@ async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
487
499
  }
488
500
  }
489
501
 
490
- // 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
+ }
491
507
  if (!fs.existsSync(instancesFile)) {
492
508
  const header =
493
509
  "# PostgreSQL instances to monitor\n" +
@@ -566,6 +582,10 @@ program
566
582
  .option(
567
583
  "--ui-base-url <url>",
568
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)"
569
589
  );
570
590
 
571
591
  program
@@ -582,6 +602,27 @@ program
582
602
  console.log(`Default project saved: ${value}`);
583
603
  });
584
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
+
585
626
  program
586
627
  .command("prepare-db [conn]")
587
628
  .description("prepare database for monitoring: create monitoring user, required view(s), and grant permissions (idempotent)")
@@ -827,8 +868,8 @@ program
827
868
  } else {
828
869
  console.log("✓ prepare-db verify: OK");
829
870
  if (v.missingOptional.length > 0) {
830
- console.log("⚠ Optional items missing:");
831
- 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}`);
832
873
  }
833
874
  }
834
875
  return;
@@ -959,8 +1000,8 @@ program
959
1000
  } else {
960
1001
  console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
961
1002
  if (skippedOptional.length > 0) {
962
- console.log("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
963
- 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}`);
964
1005
  }
965
1006
  if (process.stdout.isTTY) {
966
1007
  console.log(`Applied ${applied.length} steps`);
@@ -1141,8 +1182,8 @@ program
1141
1182
  } else {
1142
1183
  console.log(`✓ prepare-db verify: OK${opts.provider ? ` (provider: ${opts.provider})` : ""}`);
1143
1184
  if (v.missingOptional.length > 0) {
1144
- console.log("⚠ Optional items missing:");
1145
- 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}`);
1146
1187
  }
1147
1188
  }
1148
1189
  return;
@@ -1269,8 +1310,8 @@ program
1269
1310
  } else {
1270
1311
  console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
1271
1312
  if (skippedOptional.length > 0) {
1272
- console.log("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
1273
- 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}`);
1274
1315
  }
1275
1316
  // Keep output compact but still useful
1276
1317
  if (process.stdout.isTTY) {
@@ -1586,11 +1627,11 @@ program
1586
1627
  console.log("✓ unprepare-db completed");
1587
1628
  console.log(`Applied ${applied.length} steps`);
1588
1629
  } else {
1589
- console.log("⚠ unprepare-db completed with errors");
1630
+ console.error("⚠ unprepare-db completed with errors");
1590
1631
  console.log(`Applied ${applied.length} steps`);
1591
- console.log("Errors:");
1632
+ console.error("Errors:");
1592
1633
  for (const err of errors) {
1593
- console.log(` - ${err}`);
1634
+ console.error(` - ${err}`);
1594
1635
  }
1595
1636
  process.exitCode = 1;
1596
1637
  }
@@ -1785,6 +1826,24 @@ program
1785
1826
  const connResult = await connectWithSslFallback(Client, adminConn);
1786
1827
  client = connResult.client as Client;
1787
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
+
1788
1847
  // Generate reports
1789
1848
  let reports: Record<string, any>;
1790
1849
  if (checkId === "ALL") {
@@ -1912,8 +1971,8 @@ program
1912
1971
  }
1913
1972
  }
1914
1973
 
1915
- // Output JSON to stdout
1916
- if (shouldPrintJson) {
1974
+ // Output JSON to stdout (unless --output is specified, in which case files are written instead)
1975
+ if (shouldPrintJson && !outputPath) {
1917
1976
  console.log(JSON.stringify(reports, null, 2));
1918
1977
  }
1919
1978
 
@@ -2030,13 +2089,16 @@ function isDockerRunning(): boolean {
2030
2089
  }
2031
2090
 
2032
2091
  /**
2033
- * 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).
2034
2096
  */
2035
2097
  function getComposeCmd(): string[] | null {
2036
2098
  const tryCmd = (cmd: string, args: string[]): boolean =>
2037
2099
  spawnSync(cmd, args, { stdio: "ignore", timeout: 5000 } as Parameters<typeof spawnSync>[2]).status === 0;
2038
- if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
2039
2100
  if (tryCmd("docker", ["compose", "version"])) return ["docker", "compose"];
2101
+ if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
2040
2102
  return null;
2041
2103
  }
2042
2104
 
@@ -2061,6 +2123,85 @@ function checkRunningContainers(): { running: boolean; containers: string[] } {
2061
2123
  }
2062
2124
  }
2063
2125
 
2126
+ /**
2127
+ * Register monitoring instance with the API (non-blocking).
2128
+ * Returns immediately, logs result in background.
2129
+ */
2130
+ function registerMonitoringInstance(
2131
+ apiKey: string,
2132
+ projectName: string,
2133
+ opts?: { apiBaseUrl?: string; debug?: boolean }
2134
+ ): void {
2135
+ const { apiBaseUrl } = resolveBaseUrls(opts);
2136
+ const url = `${apiBaseUrl}/rpc/monitoring_instance_register`;
2137
+ const debug = opts?.debug;
2138
+
2139
+ if (debug) {
2140
+ console.error(`\nDebug: Registering monitoring instance...`);
2141
+ console.error(`Debug: POST ${url}`);
2142
+ console.error(`Debug: project_name=${projectName}`);
2143
+ }
2144
+
2145
+ // Fire and forget - don't block the main flow
2146
+ fetch(url, {
2147
+ method: "POST",
2148
+ headers: {
2149
+ "Content-Type": "application/json",
2150
+ },
2151
+ body: JSON.stringify({
2152
+ api_token: apiKey,
2153
+ project_name: projectName,
2154
+ }),
2155
+ })
2156
+ .then(async (res) => {
2157
+ const body = await res.text().catch(() => "");
2158
+ if (!res.ok) {
2159
+ if (debug) {
2160
+ console.error(`Debug: Monitoring registration failed: HTTP ${res.status}`);
2161
+ console.error(`Debug: Response: ${body}`);
2162
+ }
2163
+ return;
2164
+ }
2165
+ if (debug) {
2166
+ console.error(`Debug: Monitoring registration response: ${body}`);
2167
+ }
2168
+ })
2169
+ .catch((err) => {
2170
+ if (debug) {
2171
+ console.error(`Debug: Monitoring registration error: ${err.message}`);
2172
+ }
2173
+ });
2174
+ }
2175
+
2176
+ /**
2177
+ * Update .pgwatch-config file with key=value pairs.
2178
+ * Preserves existing values not being updated.
2179
+ */
2180
+ function updatePgwatchConfig(configPath: string, updates: Record<string, string>): void {
2181
+ let lines: string[] = [];
2182
+
2183
+ // Read existing config if it exists
2184
+ if (fs.existsSync(configPath)) {
2185
+ const stats = fs.statSync(configPath);
2186
+ if (!stats.isDirectory()) {
2187
+ const content = fs.readFileSync(configPath, "utf8");
2188
+ lines = content.split(/\r?\n/).filter(l => l.trim() !== "");
2189
+ }
2190
+ }
2191
+
2192
+ // Update or add each key
2193
+ for (const [key, value] of Object.entries(updates)) {
2194
+ const existingIndex = lines.findIndex(l => l.startsWith(key + "="));
2195
+ if (existingIndex >= 0) {
2196
+ lines[existingIndex] = `${key}=${value}`;
2197
+ } else {
2198
+ lines.push(`${key}=${value}`);
2199
+ }
2200
+ }
2201
+
2202
+ fs.writeFileSync(configPath, lines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
2203
+ }
2204
+
2064
2205
  /**
2065
2206
  * Run docker compose command
2066
2207
  */
@@ -2115,6 +2256,26 @@ async function runCompose(args: string[], grafanaPassword?: string): Promise<num
2115
2256
  }
2116
2257
  }
2117
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
+
2118
2279
  // On macOS, self-node-exporter can't mount host root filesystem - skip it
2119
2280
  const finalArgs = [...args];
2120
2281
  if (process.platform === "darwin" && args.includes("up")) {
@@ -2145,12 +2306,13 @@ mon
2145
2306
  .option("--api-key <key>", "Postgres AI API key for automated report uploads")
2146
2307
  .option("--db-url <url>", "PostgreSQL connection URL to monitor")
2147
2308
  .option("--tag <tag>", "Docker image tag to use (e.g., 0.14.0, 0.14.0-dev.33)")
2309
+ .option("--project <name>", "Docker Compose project name (default: postgres_ai)")
2148
2310
  .option("-y, --yes", "accept all defaults and skip interactive prompts", false)
2149
- .action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; tag?: string; yes: boolean }) => {
2311
+ .action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; tag?: string; project?: string; yes: boolean }) => {
2150
2312
  // Get apiKey from global program options (--api-key is defined globally)
2151
2313
  // This is needed because Commander.js routes --api-key to the global option, not the subcommand's option
2152
2314
  const globalOpts = program.opts<CliOptions>();
2153
- const apiKey = opts.apiKey || globalOpts.apiKey;
2315
+ let apiKey = opts.apiKey || globalOpts.apiKey;
2154
2316
 
2155
2317
  console.log("\n=================================");
2156
2318
  console.log(" PostgresAI monitoring local install");
@@ -2158,16 +2320,26 @@ mon
2158
2320
  console.log("This will install, configure, and start the monitoring system\n");
2159
2321
 
2160
2322
  // Ensure we have a project directory with docker-compose.yml even if running from elsewhere
2161
- const { projectDir } = await resolveOrInitPaths();
2323
+ const { projectDir, instancesFile: instancesPath } = await resolveOrInitPaths();
2162
2324
  console.log(`Project directory: ${projectDir}\n`);
2163
2325
 
2326
+ // Save project name to .pgwatch-config if provided (used by reporter container)
2327
+ if (opts.project) {
2328
+ const cfgPath = path.resolve(projectDir, ".pgwatch-config");
2329
+ updatePgwatchConfig(cfgPath, { project_name: opts.project });
2330
+ console.log(`Using project name: ${opts.project}\n`);
2331
+ }
2332
+
2164
2333
  // Update .env with custom tag if provided
2165
2334
  const envFile = path.resolve(projectDir, ".env");
2166
2335
 
2167
- // Build .env content, preserving important existing values (registry, password)
2168
- // 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.
2169
2338
  let existingRegistry: string | null = null;
2170
2339
  let existingPassword: string | null = null;
2340
+ let existingReplicatorPassword: string | null = null;
2341
+ let existingVmAuthUsername: string | null = null;
2342
+ let existingVmAuthPassword: string | null = null;
2171
2343
 
2172
2344
  if (fs.existsSync(envFile)) {
2173
2345
  const existingEnv = fs.readFileSync(envFile, "utf8");
@@ -2176,6 +2348,12 @@ mon
2176
2348
  if (registryMatch) existingRegistry = registryMatch[1].trim();
2177
2349
  const pwdMatch = existingEnv.match(/^GF_SECURITY_ADMIN_PASSWORD=(.+)$/m);
2178
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]);
2179
2357
  }
2180
2358
 
2181
2359
  // Priority: CLI --tag flag > package version
@@ -2191,6 +2369,11 @@ mon
2191
2369
  if (existingPassword) {
2192
2370
  envLines.push(`GF_SECURITY_ADMIN_PASSWORD=${existingPassword}`);
2193
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")}`);
2194
2377
  fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
2195
2378
 
2196
2379
  if (opts.tag) {
@@ -2199,8 +2382,8 @@ mon
2199
2382
 
2200
2383
  // Validate conflicting options
2201
2384
  if (opts.demo && opts.dbUrl) {
2202
- console.log("⚠ Both --demo and --db-url provided. Demo mode includes its own database.");
2203
- 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");
2204
2387
  opts.dbUrl = undefined;
2205
2388
  }
2206
2389
 
@@ -2216,7 +2399,7 @@ mon
2216
2399
  // Check if containers are already running
2217
2400
  const { running, containers } = checkRunningContainers();
2218
2401
  if (running) {
2219
- console.log(`⚠ Monitoring services are already running: ${containers.join(", ")}`);
2402
+ console.error(`⚠ Monitoring services are already running: ${containers.join(", ")}`);
2220
2403
  console.log("Use 'postgres-ai mon restart' to restart them\n");
2221
2404
  return;
2222
2405
  }
@@ -2230,15 +2413,12 @@ mon
2230
2413
  console.log("Using API key provided via --api-key parameter");
2231
2414
  config.writeConfig({ apiKey });
2232
2415
  // Keep reporter compatibility (docker-compose mounts .pgwatch-config)
2233
- fs.writeFileSync(path.resolve(projectDir, ".pgwatch-config"), `api_key=${apiKey}\n`, {
2234
- encoding: "utf8",
2235
- mode: 0o600
2236
- });
2416
+ updatePgwatchConfig(path.resolve(projectDir, ".pgwatch-config"), { api_key: apiKey });
2237
2417
  console.log("✓ API key saved\n");
2238
2418
  } else if (opts.yes) {
2239
2419
  // Auto-yes mode without API key - skip API key setup
2240
2420
  console.log("Auto-yes mode: no API key provided, skipping API key setup");
2241
- console.log("⚠ Reports will be generated locally only");
2421
+ console.error("⚠ Reports will be generated locally only");
2242
2422
  console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
2243
2423
  } else {
2244
2424
  const answer = await question("Do you have a Postgres AI API key? (Y/n): ");
@@ -2252,24 +2432,22 @@ mon
2252
2432
  if (trimmedKey) {
2253
2433
  config.writeConfig({ apiKey: trimmedKey });
2254
2434
  // Keep reporter compatibility (docker-compose mounts .pgwatch-config)
2255
- fs.writeFileSync(path.resolve(projectDir, ".pgwatch-config"), `api_key=${trimmedKey}\n`, {
2256
- encoding: "utf8",
2257
- mode: 0o600
2258
- });
2435
+ updatePgwatchConfig(path.resolve(projectDir, ".pgwatch-config"), { api_key: trimmedKey });
2436
+ apiKey = trimmedKey; // Update for later use in registerMonitoringInstance
2259
2437
  console.log("✓ API key saved\n");
2260
2438
  break;
2261
2439
  }
2262
2440
 
2263
- console.log("⚠ API key cannot be empty");
2441
+ console.error("⚠ API key cannot be empty");
2264
2442
  const retry = await question("Try again or skip API key setup, retry? (Y/n): ");
2265
2443
  if (retry.toLowerCase() === "n") {
2266
- 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");
2267
2445
  console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
2268
2446
  break;
2269
2447
  }
2270
2448
  }
2271
2449
  } else {
2272
- 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");
2273
2451
  console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
2274
2452
  }
2275
2453
  }
@@ -2315,21 +2493,25 @@ mon
2315
2493
 
2316
2494
  // Test connection
2317
2495
  console.log("Testing connection to the added instance...");
2318
- try {
2319
- const client = new Client({ connectionString: connStr });
2320
- await client.connect();
2321
- const result = await client.query("select version();");
2322
- console.log("✓ Connection successful");
2323
- console.log(`${result.rows[0].version}\n`);
2324
- await client.end();
2325
- } catch (error) {
2326
- const message = error instanceof Error ? error.message : String(error);
2327
- 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
+ }
2328
2510
  }
2329
2511
  } else if (opts.yes) {
2330
2512
  // Auto-yes mode without database URL - skip database setup
2331
2513
  console.log("Auto-yes mode: no database URL provided, skipping database setup");
2332
- console.log("⚠ No PostgreSQL instance added");
2514
+ console.error("⚠ No PostgreSQL instance added");
2333
2515
  console.log("You can add one later with: postgres-ai mon targets add\n");
2334
2516
  } else {
2335
2517
  console.log("You need to add at least one PostgreSQL instance to monitor");
@@ -2347,7 +2529,7 @@ mon
2347
2529
  const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
2348
2530
  if (!m) {
2349
2531
  console.error("✗ Invalid connection string format");
2350
- console.log("⚠ Continuing without adding instance\n");
2532
+ console.error("⚠ Continuing without adding instance\n");
2351
2533
  } else {
2352
2534
  const host = m[3];
2353
2535
  const db = m[5];
@@ -2359,27 +2541,62 @@ mon
2359
2541
 
2360
2542
  // Test connection
2361
2543
  console.log("Testing connection to the added instance...");
2362
- try {
2363
- const client = new Client({ connectionString: connStr });
2364
- await client.connect();
2365
- const result = await client.query("select version();");
2366
- console.log("✓ Connection successful");
2367
- console.log(`${result.rows[0].version}\n`);
2368
- await client.end();
2369
- } catch (error) {
2370
- const message = error instanceof Error ? error.message : String(error);
2371
- 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
+ }
2372
2558
  }
2373
2559
  }
2374
2560
  } else {
2375
- 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");
2376
2562
  }
2377
2563
  } else {
2378
- 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");
2379
2565
  }
2380
2566
  }
2381
2567
  } else {
2382
- 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
+ }
2383
2600
  }
2384
2601
 
2385
2602
  // Step 3: Update configuration
@@ -2395,6 +2612,8 @@ mon
2395
2612
  console.log(opts.demo ? "Step 4: Configuring Grafana security..." : "Step 4: Configuring Grafana security...");
2396
2613
  const cfgPath = path.resolve(projectDir, ".pgwatch-config");
2397
2614
  let grafanaPassword = "";
2615
+ let vmAuthUsername = "";
2616
+ let vmAuthPassword = "";
2398
2617
 
2399
2618
  try {
2400
2619
  if (fs.existsSync(cfgPath)) {
@@ -2410,8 +2629,8 @@ mon
2410
2629
 
2411
2630
  if (!grafanaPassword) {
2412
2631
  console.log("Generating secure Grafana password...");
2413
- const { stdout: password } = await execPromise("openssl rand -base64 12 | tr -d '\n'");
2414
- grafanaPassword = password.trim();
2632
+ const { stdout: password } = await execFilePromise("openssl", ["rand", "-base64", "12"]);
2633
+ grafanaPassword = password.trim().replace(/\n/g, "");
2415
2634
 
2416
2635
  let configContent = "";
2417
2636
  if (fs.existsSync(cfgPath)) {
@@ -2428,12 +2647,58 @@ mon
2428
2647
 
2429
2648
  console.log("✓ Grafana password configured\n");
2430
2649
  } catch (error) {
2431
- console.log("⚠ Could not generate Grafana password automatically");
2650
+ console.error("⚠ Could not generate Grafana password automatically");
2432
2651
  console.log("Using default password: demo\n");
2433
2652
  grafanaPassword = "demo";
2434
2653
  }
2435
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
+
2436
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"]);
2437
2702
  console.log("Step 5: Starting monitoring services...");
2438
2703
  const code2 = await runCompose(["up", "-d", "--force-recreate"], grafanaPassword);
2439
2704
  if (code2 !== 0) {
@@ -2442,6 +2707,15 @@ mon
2442
2707
  }
2443
2708
  console.log("✓ Services started\n");
2444
2709
 
2710
+ // Register monitoring instance with API (non-blocking, only if API key is configured)
2711
+ if (apiKey && !opts.demo) {
2712
+ const projectName = opts.project || "postgres-ai-monitoring";
2713
+ registerMonitoringInstance(apiKey, projectName, {
2714
+ apiBaseUrl: globalOpts.apiBaseUrl,
2715
+ debug: !!process.env.DEBUG,
2716
+ });
2717
+ }
2718
+
2445
2719
  // Final summary
2446
2720
  console.log("=================================");
2447
2721
  console.log(" Local install completed!");
@@ -2474,6 +2748,9 @@ mon
2474
2748
  console.log("🚀 MAIN ACCESS POINT - Start here:");
2475
2749
  console.log(" Grafana Dashboard: http://localhost:3000");
2476
2750
  console.log(` Login: monitor / ${grafanaPassword}`);
2751
+ if (vmAuthUsername && vmAuthPassword) {
2752
+ console.log(` VictoriaMetrics Auth: ${vmAuthUsername} / ${vmAuthPassword}`);
2753
+ }
2477
2754
  console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
2478
2755
  });
2479
2756
 
@@ -2674,7 +2951,7 @@ mon
2674
2951
  console.log(`Project Directory: ${projectDir}`);
2675
2952
  console.log(`Docker Compose File: ${composeFile}`);
2676
2953
  console.log(`Instances File: ${instancesFile}`);
2677
- if (fs.existsSync(instancesFile)) {
2954
+ if (fs.existsSync(instancesFile) && !fs.lstatSync(instancesFile).isDirectory()) {
2678
2955
  console.log("\nInstances configuration:\n");
2679
2956
  const text = fs.readFileSync(instancesFile, "utf8");
2680
2957
  process.stdout.write(text);
@@ -2705,16 +2982,16 @@ mon
2705
2982
 
2706
2983
  // Fetch latest changes
2707
2984
  console.log("Fetching latest changes...");
2708
- await execPromise("git fetch origin");
2985
+ await execFilePromise("git", ["fetch", "origin"]);
2709
2986
 
2710
2987
  // Check current branch
2711
- const { stdout: branch } = await execPromise("git rev-parse --abbrev-ref HEAD");
2988
+ const { stdout: branch } = await execFilePromise("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
2712
2989
  const currentBranch = branch.trim();
2713
2990
  console.log(`Current branch: ${currentBranch}`);
2714
2991
 
2715
2992
  // Pull latest changes
2716
2993
  console.log("Pulling latest changes...");
2717
- const { stdout: pullOut } = await execPromise("git pull origin " + currentBranch);
2994
+ const { stdout: pullOut } = await execFilePromise("git", ["pull", "origin", currentBranch]);
2718
2995
  console.log(pullOut);
2719
2996
 
2720
2997
  // Update Docker images
@@ -2811,7 +3088,7 @@ mon
2811
3088
  if (downCode === 0) {
2812
3089
  console.log("✓ Monitoring services stopped and removed");
2813
3090
  } else {
2814
- console.log("⚠ Could not stop services (may not be running)");
3091
+ console.error("⚠ Could not stop services (may not be running)");
2815
3092
  }
2816
3093
 
2817
3094
  // Remove any orphaned containers that docker compose down missed
@@ -2890,7 +3167,7 @@ targets
2890
3167
  .description("list monitoring target databases")
2891
3168
  .action(async () => {
2892
3169
  const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
2893
- if (!fs.existsSync(instancesPath)) {
3170
+ if (!fs.existsSync(instancesPath) || fs.lstatSync(instancesPath).isDirectory()) {
2894
3171
  console.error(`instances.yml not found in ${projectDir}`);
2895
3172
  process.exitCode = 1;
2896
3173
  return;
@@ -2956,7 +3233,7 @@ targets
2956
3233
 
2957
3234
  // Check if instance already exists
2958
3235
  try {
2959
- if (fs.existsSync(file)) {
3236
+ if (fs.existsSync(file) && !fs.lstatSync(file).isDirectory()) {
2960
3237
  const content = fs.readFileSync(file, "utf8");
2961
3238
  const instances = yaml.load(content) as Instance[] | null || [];
2962
3239
  if (Array.isArray(instances)) {
@@ -2970,15 +3247,20 @@ targets
2970
3247
  }
2971
3248
  } catch (err) {
2972
3249
  // If YAML parsing fails, fall back to simple check
2973
- const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
2974
- 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)) {
2975
3254
  console.error(`Monitoring target '${instanceName}' already exists`);
2976
3255
  process.exitCode = 1;
2977
3256
  return;
2978
3257
  }
2979
3258
  }
2980
3259
 
2981
- // 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
+ }
2982
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`;
2983
3265
  const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
2984
3266
  fs.appendFileSync(file, (content && !/\n$/.test(content) ? "\n" : "") + body, "utf8");
@@ -2989,7 +3271,7 @@ targets
2989
3271
  .description("remove monitoring target database")
2990
3272
  .action(async (name: string) => {
2991
3273
  const { instancesFile: file } = await resolveOrInitPaths();
2992
- if (!fs.existsSync(file)) {
3274
+ if (!fs.existsSync(file) || fs.lstatSync(file).isDirectory()) {
2993
3275
  console.error("instances.yml not found");
2994
3276
  process.exitCode = 1;
2995
3277
  return;
@@ -3026,7 +3308,7 @@ targets
3026
3308
  .description("test monitoring target database connectivity")
3027
3309
  .action(async (name: string) => {
3028
3310
  const { instancesFile: instancesPath } = await resolveOrInitPaths();
3029
- if (!fs.existsSync(instancesPath)) {
3311
+ if (!fs.existsSync(instancesPath) || fs.lstatSync(instancesPath).isDirectory()) {
3030
3312
  console.error("instances.yml not found");
3031
3313
  process.exitCode = 1;
3032
3314
  return;
@@ -3059,7 +3341,7 @@ targets
3059
3341
  console.log(`Testing connection to monitoring target '${name}'...`);
3060
3342
 
3061
3343
  // Use native pg client instead of requiring psql to be installed
3062
- const client = new Client({ connectionString: instance.conn_str });
3344
+ const client = new Client({ connectionString: instance.conn_str, connectionTimeoutMillis: 10000 });
3063
3345
 
3064
3346
  try {
3065
3347
  await client.connect();
@@ -3124,8 +3406,8 @@ auth
3124
3406
  const { apiBaseUrl, uiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3125
3407
 
3126
3408
  if (opts.debug) {
3127
- console.log(`Debug: Resolved API base URL: ${apiBaseUrl}`);
3128
- 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}`);
3129
3411
  }
3130
3412
 
3131
3413
  try {
@@ -3155,8 +3437,8 @@ auth
3155
3437
  const initUrl = new URL(`${apiBaseUrl}/rpc/oauth_init`);
3156
3438
 
3157
3439
  if (opts.debug) {
3158
- console.log(`Debug: Trying to POST to: ${initUrl.toString()}`);
3159
- console.log(`Debug: Request data: ${initData}`);
3440
+ console.error(`Debug: Trying to POST to: ${initUrl.toString()}`);
3441
+ console.error(`Debug: Request data: ${initData}`);
3160
3442
  }
3161
3443
 
3162
3444
  // Step 2: Initialize OAuth session on backend using fetch
@@ -3202,7 +3484,7 @@ auth
3202
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)}`;
3203
3485
 
3204
3486
  if (opts.debug) {
3205
- console.log(`Debug: Auth URL: ${authUrl}`);
3487
+ console.error(`Debug: Auth URL: ${authUrl}`);
3206
3488
  }
3207
3489
 
3208
3490
  console.log(`\nOpening browser for authentication...`);
@@ -3412,10 +3694,10 @@ mon
3412
3694
 
3413
3695
  try {
3414
3696
  // Generate secure password using openssl
3415
- const { stdout: password } = await execPromise(
3416
- "openssl rand -base64 12 | tr -d '\n'"
3697
+ const { stdout: password } = await execFilePromise(
3698
+ "openssl", ["rand", "-base64", "12"]
3417
3699
  );
3418
- const newPassword = password.trim();
3700
+ const newPassword = password.trim().replace(/\n/g, "");
3419
3701
 
3420
3702
  if (!newPassword) {
3421
3703
  console.error("Failed to generate password");
@@ -3493,6 +3775,19 @@ mon
3493
3775
  console.log(" URL: http://localhost:3000");
3494
3776
  console.log(" Username: monitor");
3495
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
+ }
3496
3791
  console.log("");
3497
3792
  });
3498
3793
 
@@ -3626,12 +3921,12 @@ issues
3626
3921
  // Interpret escape sequences in content (e.g., \n -> newline)
3627
3922
  if (opts.debug) {
3628
3923
  // eslint-disable-next-line no-console
3629
- console.log(`Debug: Original content: ${JSON.stringify(content)}`);
3924
+ console.error(`Debug: Original content: ${JSON.stringify(content)}`);
3630
3925
  }
3631
3926
  content = interpretEscapes(content);
3632
3927
  if (opts.debug) {
3633
3928
  // eslint-disable-next-line no-console
3634
- console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
3929
+ console.error(`Debug: Interpreted content: ${JSON.stringify(content)}`);
3635
3930
  }
3636
3931
 
3637
3932
  const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Posting comment...");
@@ -3825,12 +4120,12 @@ issues
3825
4120
  .action(async (commentId: string, content: string, opts: { debug?: boolean; json?: boolean }) => {
3826
4121
  if (opts.debug) {
3827
4122
  // eslint-disable-next-line no-console
3828
- console.log(`Debug: Original content: ${JSON.stringify(content)}`);
4123
+ console.error(`Debug: Original content: ${JSON.stringify(content)}`);
3829
4124
  }
3830
4125
  content = interpretEscapes(content);
3831
4126
  if (opts.debug) {
3832
4127
  // eslint-disable-next-line no-console
3833
- console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
4128
+ console.error(`Debug: Interpreted content: ${JSON.stringify(content)}`);
3834
4129
  }
3835
4130
 
3836
4131
  const rootOpts = program.opts<CliOptions>();
@@ -3863,6 +4158,93 @@ issues
3863
4158
  }
3864
4159
  });
3865
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
+
3866
4248
  // Action Items management (subcommands of issues)
3867
4249
  issues
3868
4250
  .command("action-items <issueId>")
@@ -4087,6 +4469,228 @@ issues
4087
4469
  }
4088
4470
  });
4089
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
+
4090
4694
  // MCP server
4091
4695
  const mcp = program.command("mcp").description("MCP server integration");
4092
4696
 
@@ -4144,7 +4748,7 @@ mcp
4144
4748
  // Get the path to the current pgai executable
4145
4749
  let pgaiPath: string;
4146
4750
  try {
4147
- const execPath = await execPromise("which pgai");
4751
+ const execPath = await execFilePromise("which", ["pgai"]);
4148
4752
  pgaiPath = execPath.stdout.trim();
4149
4753
  } catch {
4150
4754
  // Fallback to just "pgai" if which fails
@@ -4156,8 +4760,8 @@ mcp
4156
4760
  console.log("Installing PostgresAI MCP server for Claude Code...");
4157
4761
 
4158
4762
  try {
4159
- const { stdout, stderr } = await execPromise(
4160
- `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"]
4161
4765
  );
4162
4766
 
4163
4767
  if (stdout) console.log(stdout);