postgresai 0.15.0-dev.1 → 0.15.0-dev.11

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, uploadAttachments, appendAttachmentsToContent } 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";
@@ -23,6 +26,17 @@ import { REPORT_GENERATORS, CHECK_INFO, generateAllReports } from "../lib/checku
23
26
  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";
29
+ import {
30
+ type Instance,
31
+ InstancesParseError,
32
+ loadInstances,
33
+ buildInstance,
34
+ addInstanceToFile,
35
+ removeInstanceFromFile,
36
+ buildClientConfig,
37
+ sslOptionFromConnString,
38
+ warnIfLaxSslmode,
39
+ } from "../lib/instances";
26
40
 
27
41
  // Node.js version check - require Node 18+
28
42
  // Node 14 reached EOL in April 2023, Node 16 in September 2023.
@@ -51,21 +65,16 @@ function closeReadline() {
51
65
  }
52
66
  }
53
67
 
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
- });
68
+ function stripMatchingQuotes(value: string): string {
69
+ const trimmed = value.trim();
70
+ const quote = trimmed[0];
71
+ if (trimmed.length >= 2 && (quote === '"' || quote === "'") && trimmed.endsWith(quote)) {
72
+ return trimmed.slice(1, -1);
73
+ }
74
+ return trimmed;
67
75
  }
68
76
 
77
+ // Helper functions for spawning processes - use Node.js child_process for compatibility
69
78
  async function execFilePromise(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
70
79
  return new Promise((resolve, reject) => {
71
80
  childProcess.execFile(file, args, (error, stdout, stderr) => {
@@ -342,7 +351,8 @@ function writeReportFiles(reports: Record<string, any>, outputPath: string): voi
342
351
  for (const [checkId, report] of Object.entries(reports)) {
343
352
  const filePath = path.join(outputPath, `${checkId}.json`);
344
353
  fs.writeFileSync(filePath, JSON.stringify(report, null, 2), "utf8");
345
- console.log(`✓ ${checkId}: ${filePath}`);
354
+ const title = report.checkTitle || checkId;
355
+ console.log(`✓ ${checkId} ${title}: ${filePath}`);
346
356
  }
347
357
  }
348
358
 
@@ -410,6 +420,7 @@ interface CliOptions {
410
420
  apiKey?: string;
411
421
  apiBaseUrl?: string;
412
422
  uiBaseUrl?: string;
423
+ storageBaseUrl?: string;
413
424
  }
414
425
 
415
426
  /**
@@ -419,19 +430,6 @@ interface ConfigResult {
419
430
  apiKey: string;
420
431
  }
421
432
 
422
- /**
423
- * Instance configuration
424
- */
425
- interface Instance {
426
- name: string;
427
- conn_str?: string;
428
- preset_metrics?: string;
429
- custom_metrics?: any;
430
- is_enabled?: boolean;
431
- group?: string;
432
- custom_tags?: Record<string, any>;
433
- }
434
-
435
433
  /**
436
434
  * Path resolution result
437
435
  */
@@ -499,7 +497,11 @@ async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
499
497
  }
500
498
  }
501
499
 
502
- // Ensure instances.yml exists as a FILE (avoid Docker creating a directory)
500
+ // Ensure instances.yml exists as a FILE (avoid Docker creating a directory).
501
+ // Docker bind-mounts create missing paths as directories; replace if so.
502
+ if (fs.existsSync(instancesFile) && fs.lstatSync(instancesFile).isDirectory()) {
503
+ fs.rmSync(instancesFile, { recursive: true, force: true });
504
+ }
503
505
  if (!fs.existsSync(instancesFile)) {
504
506
  const header =
505
507
  "# PostgreSQL instances to monitor\n" +
@@ -578,6 +580,10 @@ program
578
580
  .option(
579
581
  "--ui-base-url <url>",
580
582
  "UI base URL for browser routes (overrides PGAI_UI_BASE_URL)"
583
+ )
584
+ .option(
585
+ "--storage-base-url <url>",
586
+ "Storage base URL for file uploads (overrides PGAI_STORAGE_BASE_URL)"
581
587
  );
582
588
 
583
589
  program
@@ -594,6 +600,27 @@ program
594
600
  console.log(`Default project saved: ${value}`);
595
601
  });
596
602
 
603
+ program
604
+ .command("set-storage-url <url>")
605
+ .description("store storage base URL for file uploads")
606
+ .action(async (url: string) => {
607
+ const value = (url || "").trim();
608
+ if (!value) {
609
+ console.error("Error: url is required");
610
+ process.exitCode = 1;
611
+ return;
612
+ }
613
+ try {
614
+ const { normalizeBaseUrl } = await import("../lib/util");
615
+ const normalized = normalizeBaseUrl(value);
616
+ config.writeConfig({ storageBaseUrl: normalized });
617
+ console.log(`Storage URL saved: ${normalized}`);
618
+ } catch {
619
+ console.error(`Error: invalid URL: ${value}`);
620
+ process.exitCode = 1;
621
+ }
622
+ });
623
+
597
624
  program
598
625
  .command("prepare-db [conn]")
599
626
  .description("prepare database for monitoring: create monitoring user, required view(s), and grant permissions (idempotent)")
@@ -839,8 +866,8 @@ program
839
866
  } else {
840
867
  console.log("✓ prepare-db verify: OK");
841
868
  if (v.missingOptional.length > 0) {
842
- console.log("⚠ Optional items missing:");
843
- for (const m of v.missingOptional) console.log(`- ${m}`);
869
+ console.error("⚠ Optional items missing:");
870
+ for (const m of v.missingOptional) console.error(`- ${m}`);
844
871
  }
845
872
  }
846
873
  return;
@@ -971,8 +998,8 @@ program
971
998
  } else {
972
999
  console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
973
1000
  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}`);
1001
+ console.error("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
1002
+ for (const s of skippedOptional) console.error(`- ${s}`);
976
1003
  }
977
1004
  if (process.stdout.isTTY) {
978
1005
  console.log(`Applied ${applied.length} steps`);
@@ -1153,8 +1180,8 @@ program
1153
1180
  } else {
1154
1181
  console.log(`✓ prepare-db verify: OK${opts.provider ? ` (provider: ${opts.provider})` : ""}`);
1155
1182
  if (v.missingOptional.length > 0) {
1156
- console.log("⚠ Optional items missing:");
1157
- for (const m of v.missingOptional) console.log(`- ${m}`);
1183
+ console.error("⚠ Optional items missing:");
1184
+ for (const m of v.missingOptional) console.error(`- ${m}`);
1158
1185
  }
1159
1186
  }
1160
1187
  return;
@@ -1281,8 +1308,8 @@ program
1281
1308
  } else {
1282
1309
  console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
1283
1310
  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}`);
1311
+ console.error("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
1312
+ for (const s of skippedOptional) console.error(`- ${s}`);
1286
1313
  }
1287
1314
  // Keep output compact but still useful
1288
1315
  if (process.stdout.isTTY) {
@@ -1598,11 +1625,11 @@ program
1598
1625
  console.log("✓ unprepare-db completed");
1599
1626
  console.log(`Applied ${applied.length} steps`);
1600
1627
  } else {
1601
- console.log("⚠ unprepare-db completed with errors");
1628
+ console.error("⚠ unprepare-db completed with errors");
1602
1629
  console.log(`Applied ${applied.length} steps`);
1603
- console.log("Errors:");
1630
+ console.error("Errors:");
1604
1631
  for (const err of errors) {
1605
- console.log(` - ${err}`);
1632
+ console.error(` - ${err}`);
1606
1633
  }
1607
1634
  process.exitCode = 1;
1608
1635
  }
@@ -1797,6 +1824,24 @@ program
1797
1824
  const connResult = await connectWithSslFallback(Client, adminConn);
1798
1825
  client = connResult.client as Client;
1799
1826
 
1827
+ // Preflight: verify the connected user has sufficient permissions
1828
+ spinner.update("Checking database permissions");
1829
+ const permCheck = await checkCurrentUserPermissions(client);
1830
+ const permMessages = formatPermissionCheckMessages(permCheck);
1831
+
1832
+ for (const w of permMessages.warnings) {
1833
+ console.error(w);
1834
+ }
1835
+
1836
+ if (permMessages.failed) {
1837
+ spinner.stop();
1838
+ for (const e of permMessages.errors) {
1839
+ console.error(e);
1840
+ }
1841
+ process.exitCode = 1;
1842
+ return;
1843
+ }
1844
+
1800
1845
  // Generate reports
1801
1846
  let reports: Record<string, any>;
1802
1847
  if (checkId === "ALL") {
@@ -1924,8 +1969,8 @@ program
1924
1969
  }
1925
1970
  }
1926
1971
 
1927
- // Output JSON to stdout
1928
- if (shouldPrintJson) {
1972
+ // Output JSON to stdout (unless --output is specified, in which case files are written instead)
1973
+ if (shouldPrintJson && !outputPath) {
1929
1974
  console.log(JSON.stringify(reports, null, 2));
1930
1975
  }
1931
1976
 
@@ -2042,13 +2087,16 @@ function isDockerRunning(): boolean {
2042
2087
  }
2043
2088
 
2044
2089
  /**
2045
- * Get docker compose command
2090
+ * Get docker compose command.
2091
+ * Prefer "docker compose" (V2 plugin) over legacy "docker-compose" (V1 standalone)
2092
+ * because docker-compose V1 (<=1.29) is incompatible with modern Docker engines
2093
+ * (KeyError: 'ContainerConfig' on container recreation).
2046
2094
  */
2047
2095
  function getComposeCmd(): string[] | null {
2048
2096
  const tryCmd = (cmd: string, args: string[]): boolean =>
2049
2097
  spawnSync(cmd, args, { stdio: "ignore", timeout: 5000 } as Parameters<typeof spawnSync>[2]).status === 0;
2050
- if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
2051
2098
  if (tryCmd("docker", ["compose", "version"])) return ["docker", "compose"];
2099
+ if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
2052
2100
  return null;
2053
2101
  }
2054
2102
 
@@ -2087,9 +2135,9 @@ function registerMonitoringInstance(
2087
2135
  const debug = opts?.debug;
2088
2136
 
2089
2137
  if (debug) {
2090
- console.log(`\nDebug: Registering monitoring instance...`);
2091
- console.log(`Debug: POST ${url}`);
2092
- console.log(`Debug: project_name=${projectName}`);
2138
+ console.error(`\nDebug: Registering monitoring instance...`);
2139
+ console.error(`Debug: POST ${url}`);
2140
+ console.error(`Debug: project_name=${projectName}`);
2093
2141
  }
2094
2142
 
2095
2143
  // Fire and forget - don't block the main flow
@@ -2107,18 +2155,18 @@ function registerMonitoringInstance(
2107
2155
  const body = await res.text().catch(() => "");
2108
2156
  if (!res.ok) {
2109
2157
  if (debug) {
2110
- console.log(`Debug: Monitoring registration failed: HTTP ${res.status}`);
2111
- console.log(`Debug: Response: ${body}`);
2158
+ console.error(`Debug: Monitoring registration failed: HTTP ${res.status}`);
2159
+ console.error(`Debug: Response: ${body}`);
2112
2160
  }
2113
2161
  return;
2114
2162
  }
2115
2163
  if (debug) {
2116
- console.log(`Debug: Monitoring registration response: ${body}`);
2164
+ console.error(`Debug: Monitoring registration response: ${body}`);
2117
2165
  }
2118
2166
  })
2119
2167
  .catch((err) => {
2120
2168
  if (debug) {
2121
- console.log(`Debug: Monitoring registration error: ${err.message}`);
2169
+ console.error(`Debug: Monitoring registration error: ${err.message}`);
2122
2170
  }
2123
2171
  });
2124
2172
  }
@@ -2206,6 +2254,26 @@ async function runCompose(args: string[], grafanaPassword?: string): Promise<num
2206
2254
  }
2207
2255
  }
2208
2256
 
2257
+ // Load VM auth credentials from .env if not already set
2258
+ const envFilePath = path.resolve(projectDir, ".env");
2259
+ if (fs.existsSync(envFilePath)) {
2260
+ try {
2261
+ const envContent = fs.readFileSync(envFilePath, "utf8");
2262
+ if (!env.VM_AUTH_USERNAME) {
2263
+ const m = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
2264
+ if (m) env.VM_AUTH_USERNAME = stripMatchingQuotes(m[1]);
2265
+ }
2266
+ if (!env.VM_AUTH_PASSWORD) {
2267
+ const m = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
2268
+ if (m) env.VM_AUTH_PASSWORD = stripMatchingQuotes(m[1]);
2269
+ }
2270
+ } catch (err) {
2271
+ if (process.env.DEBUG) {
2272
+ console.warn(`Warning: Could not read VM auth from .env: ${err instanceof Error ? err.message : String(err)}`);
2273
+ }
2274
+ }
2275
+ }
2276
+
2209
2277
  // On macOS, self-node-exporter can't mount host root filesystem - skip it
2210
2278
  const finalArgs = [...args];
2211
2279
  if (process.platform === "darwin" && args.includes("up")) {
@@ -2232,6 +2300,18 @@ const mon = program.command("mon").description("monitoring services management")
2232
2300
  mon
2233
2301
  .command("local-install")
2234
2302
  .description("install local monitoring stack (generate config, start services)")
2303
+ .addHelpText(
2304
+ "after",
2305
+ [
2306
+ "",
2307
+ "Networking:",
2308
+ " Compose enables IPv6 on the project's default network so containers can",
2309
+ " reach IPv6-only databases (e.g. Supabase free-tier db.<ref>.supabase.co).",
2310
+ " Override on hosts whose Docker daemon cannot create an IPv6 network:",
2311
+ " PGAI_ENABLE_IPV6=false (accepted: true|false|yes|no, lowercase)",
2312
+ "",
2313
+ ].join("\n"),
2314
+ )
2235
2315
  .option("--demo", "demo mode with sample database", false)
2236
2316
  .option("--api-key <key>", "Postgres AI API key for automated report uploads")
2237
2317
  .option("--db-url <url>", "PostgreSQL connection URL to monitor")
@@ -2250,7 +2330,7 @@ mon
2250
2330
  console.log("This will install, configure, and start the monitoring system\n");
2251
2331
 
2252
2332
  // Ensure we have a project directory with docker-compose.yml even if running from elsewhere
2253
- const { projectDir } = await resolveOrInitPaths();
2333
+ const { projectDir, instancesFile: instancesPath } = await resolveOrInitPaths();
2254
2334
  console.log(`Project directory: ${projectDir}\n`);
2255
2335
 
2256
2336
  // Save project name to .pgwatch-config if provided (used by reporter container)
@@ -2263,10 +2343,13 @@ mon
2263
2343
  // Update .env with custom tag if provided
2264
2344
  const envFile = path.resolve(projectDir, ".env");
2265
2345
 
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
2346
+ // Build .env content, preserving important existing values.
2347
+ // Note: PGAI_TAG is intentionally NOT preserved - the CLI version should always match Docker images.
2268
2348
  let existingRegistry: string | null = null;
2269
2349
  let existingPassword: string | null = null;
2350
+ let existingReplicatorPassword: string | null = null;
2351
+ let existingVmAuthUsername: string | null = null;
2352
+ let existingVmAuthPassword: string | null = null;
2270
2353
 
2271
2354
  if (fs.existsSync(envFile)) {
2272
2355
  const existingEnv = fs.readFileSync(envFile, "utf8");
@@ -2275,6 +2358,12 @@ mon
2275
2358
  if (registryMatch) existingRegistry = registryMatch[1].trim();
2276
2359
  const pwdMatch = existingEnv.match(/^GF_SECURITY_ADMIN_PASSWORD=(.+)$/m);
2277
2360
  if (pwdMatch) existingPassword = pwdMatch[1].trim();
2361
+ const replicatorPwdMatch = existingEnv.match(/^REPLICATOR_PASSWORD=(.+)$/m);
2362
+ if (replicatorPwdMatch) existingReplicatorPassword = replicatorPwdMatch[1].trim();
2363
+ const vmAuthUserMatch = existingEnv.match(/^VM_AUTH_USERNAME=(.+)$/m);
2364
+ if (vmAuthUserMatch) existingVmAuthUsername = stripMatchingQuotes(vmAuthUserMatch[1]);
2365
+ const vmAuthPasswordMatch = existingEnv.match(/^VM_AUTH_PASSWORD=(.+)$/m);
2366
+ if (vmAuthPasswordMatch) existingVmAuthPassword = stripMatchingQuotes(vmAuthPasswordMatch[1]);
2278
2367
  }
2279
2368
 
2280
2369
  // Priority: CLI --tag flag > package version
@@ -2290,6 +2379,11 @@ mon
2290
2379
  if (existingPassword) {
2291
2380
  envLines.push(`GF_SECURITY_ADMIN_PASSWORD=${existingPassword}`);
2292
2381
  }
2382
+ envLines.push(
2383
+ `REPLICATOR_PASSWORD=${existingReplicatorPassword || crypto.randomBytes(32).toString("hex")}`,
2384
+ );
2385
+ envLines.push(`VM_AUTH_USERNAME=${existingVmAuthUsername || "vmauth"}`);
2386
+ envLines.push(`VM_AUTH_PASSWORD=${existingVmAuthPassword || crypto.randomBytes(18).toString("base64")}`);
2293
2387
  fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
2294
2388
 
2295
2389
  if (opts.tag) {
@@ -2298,8 +2392,8 @@ mon
2298
2392
 
2299
2393
  // Validate conflicting options
2300
2394
  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");
2395
+ console.error("⚠ Both --demo and --db-url provided. Demo mode includes its own database.");
2396
+ console.error("⚠ The --db-url will be ignored in demo mode.\n");
2303
2397
  opts.dbUrl = undefined;
2304
2398
  }
2305
2399
 
@@ -2315,7 +2409,7 @@ mon
2315
2409
  // Check if containers are already running
2316
2410
  const { running, containers } = checkRunningContainers();
2317
2411
  if (running) {
2318
- console.log(`⚠ Monitoring services are already running: ${containers.join(", ")}`);
2412
+ console.error(`⚠ Monitoring services are already running: ${containers.join(", ")}`);
2319
2413
  console.log("Use 'postgres-ai mon restart' to restart them\n");
2320
2414
  return;
2321
2415
  }
@@ -2334,7 +2428,7 @@ mon
2334
2428
  } else if (opts.yes) {
2335
2429
  // Auto-yes mode without API key - skip API key setup
2336
2430
  console.log("Auto-yes mode: no API key provided, skipping API key setup");
2337
- console.log("⚠ Reports will be generated locally only");
2431
+ console.error("⚠ Reports will be generated locally only");
2338
2432
  console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
2339
2433
  } else {
2340
2434
  const answer = await question("Do you have a Postgres AI API key? (Y/n): ");
@@ -2354,16 +2448,16 @@ mon
2354
2448
  break;
2355
2449
  }
2356
2450
 
2357
- console.log("⚠ API key cannot be empty");
2451
+ console.error("⚠ API key cannot be empty");
2358
2452
  const retry = await question("Try again or skip API key setup, retry? (Y/n): ");
2359
2453
  if (retry.toLowerCase() === "n") {
2360
- console.log("⚠ Skipping API key setup - reports will be generated locally only");
2454
+ console.error("⚠ Skipping API key setup - reports will be generated locally only");
2361
2455
  console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
2362
2456
  break;
2363
2457
  }
2364
2458
  }
2365
2459
  } else {
2366
- console.log("⚠ Skipping API key setup - reports will be generated locally only");
2460
+ console.error("⚠ Skipping API key setup - reports will be generated locally only");
2367
2461
  console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
2368
2462
  }
2369
2463
  }
@@ -2403,27 +2497,31 @@ mon
2403
2497
  const db = m[5];
2404
2498
  const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
2405
2499
 
2406
- 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`;
2407
- fs.appendFileSync(instancesPath, body, "utf8");
2500
+ addInstanceToFile(instancesPath, buildInstance(instanceName, connStr));
2408
2501
  console.log(`✓ Monitoring target '${instanceName}' added\n`);
2409
2502
 
2410
2503
  // Test connection
2411
2504
  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`);
2505
+ {
2506
+ let testClient: InstanceType<typeof Client> | null = null;
2507
+ try {
2508
+ warnIfLaxSslmode(connStr);
2509
+ testClient = new Client(buildClientConfig(connStr, { connectionTimeoutMillis: 10000 }));
2510
+ await testClient.connect();
2511
+ const result = await testClient.query("select version();");
2512
+ console.log("✓ Connection successful");
2513
+ console.log(`${result.rows[0].version}\n`);
2514
+ } catch (error) {
2515
+ const message = error instanceof Error ? error.message : String(error);
2516
+ console.error(`✗ Connection failed: ${message}\n`);
2517
+ } finally {
2518
+ if (testClient) await testClient.end();
2519
+ }
2422
2520
  }
2423
2521
  } else if (opts.yes) {
2424
2522
  // Auto-yes mode without database URL - skip database setup
2425
2523
  console.log("Auto-yes mode: no database URL provided, skipping database setup");
2426
- console.log("⚠ No PostgreSQL instance added");
2524
+ console.error("⚠ No PostgreSQL instance added");
2427
2525
  console.log("You can add one later with: postgres-ai mon targets add\n");
2428
2526
  } else {
2429
2527
  console.log("You need to add at least one PostgreSQL instance to monitor");
@@ -2441,39 +2539,74 @@ mon
2441
2539
  const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
2442
2540
  if (!m) {
2443
2541
  console.error("✗ Invalid connection string format");
2444
- console.log("⚠ Continuing without adding instance\n");
2542
+ console.error("⚠ Continuing without adding instance\n");
2445
2543
  } else {
2446
2544
  const host = m[3];
2447
2545
  const db = m[5];
2448
2546
  const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
2449
2547
 
2450
- 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`;
2451
- fs.appendFileSync(instancesPath, body, "utf8");
2548
+ addInstanceToFile(instancesPath, buildInstance(instanceName, connStr));
2452
2549
  console.log(`✓ Monitoring target '${instanceName}' added\n`);
2453
2550
 
2454
2551
  // Test connection
2455
2552
  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`);
2553
+ {
2554
+ let testClient: InstanceType<typeof Client> | null = null;
2555
+ try {
2556
+ warnIfLaxSslmode(connStr);
2557
+ testClient = new Client(buildClientConfig(connStr, { connectionTimeoutMillis: 10000 }));
2558
+ await testClient.connect();
2559
+ const result = await testClient.query("select version();");
2560
+ console.log("✓ Connection successful");
2561
+ console.log(`${result.rows[0].version}\n`);
2562
+ } catch (error) {
2563
+ const message = error instanceof Error ? error.message : String(error);
2564
+ console.error(`✗ Connection failed: ${message}\n`);
2565
+ } finally {
2566
+ if (testClient) await testClient.end();
2567
+ }
2466
2568
  }
2467
2569
  }
2468
2570
  } else {
2469
- console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
2571
+ console.error("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
2470
2572
  }
2471
2573
  } else {
2472
- console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
2574
+ console.error("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
2473
2575
  }
2474
2576
  }
2475
2577
  } else {
2476
- console.log("Step 2: Demo mode enabled - using included demo PostgreSQL database\n");
2578
+ // Demo mode: configure instances.yml from the bundled demo template.
2579
+ //
2580
+ // Side effects:
2581
+ // - Writes instancesPath (instances.yml next to docker-compose.yml)
2582
+ // - If Docker previously bind-mounted instances.yml as a directory, removes it first.
2583
+ //
2584
+ // Failure modes:
2585
+ // - Exits with code 1 if instances.demo.yml is not found in any candidate path.
2586
+ // This is fatal because starting without a target produces empty dashboards that
2587
+ // look like a bug rather than a misconfiguration.
2588
+ //
2589
+ // Template search order (import.meta.url is resolved at runtime, not baked in at build):
2590
+ // 1. npm layout: dist/bin/../../instances.demo.yml → package-root/instances.demo.yml
2591
+ // 2. dev layout: cli/bin/../../../instances.demo.yml → repo-root/instances.demo.yml
2592
+ console.log("Step 2: Demo mode enabled - using included demo PostgreSQL database");
2593
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
2594
+ const demoCandidates = [
2595
+ path.resolve(currentDir, "..", "..", "instances.demo.yml"), // npm: dist/bin -> package root
2596
+ path.resolve(currentDir, "..", "..", "..", "instances.demo.yml"), // dev: cli/bin -> repo root
2597
+ ];
2598
+ const demoSrc = demoCandidates.find(p => fs.existsSync(p));
2599
+ if (demoSrc) {
2600
+ // Remove directory artifact left by Docker bind-mounts before copying
2601
+ if (fs.existsSync(instancesPath) && fs.lstatSync(instancesPath).isDirectory()) {
2602
+ fs.rmSync(instancesPath, { recursive: true, force: true });
2603
+ }
2604
+ fs.copyFileSync(demoSrc, instancesPath);
2605
+ console.log("✓ Demo monitoring target configured\n");
2606
+ } else {
2607
+ console.error(`Error: instances.demo.yml not found — cannot configure demo target.\nSearched: ${demoCandidates.join(", ")}\n`);
2608
+ process.exit(1);
2609
+ }
2477
2610
  }
2478
2611
 
2479
2612
  // Step 3: Update configuration
@@ -2489,6 +2622,8 @@ mon
2489
2622
  console.log(opts.demo ? "Step 4: Configuring Grafana security..." : "Step 4: Configuring Grafana security...");
2490
2623
  const cfgPath = path.resolve(projectDir, ".pgwatch-config");
2491
2624
  let grafanaPassword = "";
2625
+ let vmAuthUsername = "";
2626
+ let vmAuthPassword = "";
2492
2627
 
2493
2628
  try {
2494
2629
  if (fs.existsSync(cfgPath)) {
@@ -2504,8 +2639,8 @@ mon
2504
2639
 
2505
2640
  if (!grafanaPassword) {
2506
2641
  console.log("Generating secure Grafana password...");
2507
- const { stdout: password } = await execPromise("openssl rand -base64 12 | tr -d '\n'");
2508
- grafanaPassword = password.trim();
2642
+ const { stdout: password } = await execFilePromise("openssl", ["rand", "-base64", "12"]);
2643
+ grafanaPassword = password.trim().replace(/\n/g, "");
2509
2644
 
2510
2645
  let configContent = "";
2511
2646
  if (fs.existsSync(cfgPath)) {
@@ -2522,12 +2657,58 @@ mon
2522
2657
 
2523
2658
  console.log("✓ Grafana password configured\n");
2524
2659
  } catch (error) {
2525
- console.log("⚠ Could not generate Grafana password automatically");
2660
+ console.error("⚠ Could not generate Grafana password automatically");
2526
2661
  console.log("Using default password: demo\n");
2527
2662
  grafanaPassword = "demo";
2528
2663
  }
2529
2664
 
2665
+ // Generate VictoriaMetrics auth credentials
2666
+ try {
2667
+ const envFile = path.resolve(projectDir, ".env");
2668
+
2669
+ // Read existing VM auth from .env if present
2670
+ if (fs.existsSync(envFile)) {
2671
+ const envContent = fs.readFileSync(envFile, "utf8");
2672
+ const userMatch = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
2673
+ const passMatch = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
2674
+ if (userMatch) vmAuthUsername = stripMatchingQuotes(userMatch[1]);
2675
+ if (passMatch) vmAuthPassword = stripMatchingQuotes(passMatch[1]);
2676
+ }
2677
+
2678
+ if (!vmAuthUsername || !vmAuthPassword) {
2679
+ console.log("Generating VictoriaMetrics auth credentials...");
2680
+ vmAuthUsername = vmAuthUsername || "vmauth";
2681
+ if (!vmAuthPassword) {
2682
+ const { stdout: vmPass } = await execFilePromise("openssl", ["rand", "-base64", "12"]);
2683
+ vmAuthPassword = vmPass.trim().replace(/\n/g, "");
2684
+ }
2685
+
2686
+ // Update .env file with VM auth credentials
2687
+ let envContent = "";
2688
+ if (fs.existsSync(envFile)) {
2689
+ envContent = fs.readFileSync(envFile, "utf8");
2690
+ }
2691
+ const envLines = envContent.split(/\r?\n/)
2692
+ .filter((l) => !/^VM_AUTH_USERNAME=/.test(l) && !/^VM_AUTH_PASSWORD=/.test(l))
2693
+ .filter((l, i, arr) => !(i === arr.length - 1 && l === ''));
2694
+ envLines.push(`VM_AUTH_USERNAME=${vmAuthUsername}`);
2695
+ envLines.push(`VM_AUTH_PASSWORD=${vmAuthPassword}`);
2696
+ fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
2697
+ }
2698
+
2699
+ console.log("✓ VictoriaMetrics auth configured\n");
2700
+ } catch (error) {
2701
+ console.error("⚠ Could not generate VictoriaMetrics auth credentials automatically");
2702
+ if (process.env.DEBUG) {
2703
+ console.warn(` ${error instanceof Error ? error.message : String(error)}`);
2704
+ }
2705
+ }
2706
+
2530
2707
  // Step 5: Start services
2708
+ // Remove stopped containers left by "run --rm" dependencies (e.g. config-init)
2709
+ // to avoid docker-compose v1 'ContainerConfig' error on recreation.
2710
+ // Best-effort: ignore exit code — container may not exist, failure here is non-fatal.
2711
+ await runCompose(["rm", "-f", "-s", "config-init"]);
2531
2712
  console.log("Step 5: Starting monitoring services...");
2532
2713
  const code2 = await runCompose(["up", "-d", "--force-recreate"], grafanaPassword);
2533
2714
  if (code2 !== 0) {
@@ -2577,6 +2758,9 @@ mon
2577
2758
  console.log("🚀 MAIN ACCESS POINT - Start here:");
2578
2759
  console.log(" Grafana Dashboard: http://localhost:3000");
2579
2760
  console.log(` Login: monitor / ${grafanaPassword}`);
2761
+ if (vmAuthUsername && vmAuthPassword) {
2762
+ console.log(` VictoriaMetrics Auth: ${vmAuthUsername} / ${vmAuthPassword}`);
2763
+ }
2580
2764
  console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
2581
2765
  });
2582
2766
 
@@ -2777,7 +2961,7 @@ mon
2777
2961
  console.log(`Project Directory: ${projectDir}`);
2778
2962
  console.log(`Docker Compose File: ${composeFile}`);
2779
2963
  console.log(`Instances File: ${instancesFile}`);
2780
- if (fs.existsSync(instancesFile)) {
2964
+ if (fs.existsSync(instancesFile) && !fs.lstatSync(instancesFile).isDirectory()) {
2781
2965
  console.log("\nInstances configuration:\n");
2782
2966
  const text = fs.readFileSync(instancesFile, "utf8");
2783
2967
  process.stdout.write(text);
@@ -2808,16 +2992,16 @@ mon
2808
2992
 
2809
2993
  // Fetch latest changes
2810
2994
  console.log("Fetching latest changes...");
2811
- await execPromise("git fetch origin");
2995
+ await execFilePromise("git", ["fetch", "origin"]);
2812
2996
 
2813
2997
  // Check current branch
2814
- const { stdout: branch } = await execPromise("git rev-parse --abbrev-ref HEAD");
2998
+ const { stdout: branch } = await execFilePromise("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
2815
2999
  const currentBranch = branch.trim();
2816
3000
  console.log(`Current branch: ${currentBranch}`);
2817
3001
 
2818
3002
  // Pull latest changes
2819
3003
  console.log("Pulling latest changes...");
2820
- const { stdout: pullOut } = await execPromise("git pull origin " + currentBranch);
3004
+ const { stdout: pullOut } = await execFilePromise("git", ["pull", "origin", currentBranch]);
2821
3005
  console.log(pullOut);
2822
3006
 
2823
3007
  // Update Docker images
@@ -2914,7 +3098,7 @@ mon
2914
3098
  if (downCode === 0) {
2915
3099
  console.log("✓ Monitoring services stopped and removed");
2916
3100
  } else {
2917
- console.log("⚠ Could not stop services (may not be running)");
3101
+ console.error("⚠ Could not stop services (may not be running)");
2918
3102
  }
2919
3103
 
2920
3104
  // Remove any orphaned containers that docker compose down missed
@@ -2993,48 +3177,38 @@ targets
2993
3177
  .description("list monitoring target databases")
2994
3178
  .action(async () => {
2995
3179
  const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
2996
- if (!fs.existsSync(instancesPath)) {
3180
+ if (!fs.existsSync(instancesPath) || fs.lstatSync(instancesPath).isDirectory()) {
2997
3181
  console.error(`instances.yml not found in ${projectDir}`);
2998
3182
  process.exitCode = 1;
2999
3183
  return;
3000
3184
  }
3001
3185
 
3186
+ let instances: Instance[];
3002
3187
  try {
3003
- const content = fs.readFileSync(instancesPath, "utf8");
3004
- const instances = yaml.load(content) as Instance[] | null;
3005
-
3006
- if (!instances || !Array.isArray(instances) || instances.length === 0) {
3007
- console.log("No monitoring targets configured");
3008
- console.log("");
3009
- console.log("To add a monitoring target:");
3010
- console.log(" postgres-ai mon targets add <connection-string> <name>");
3011
- console.log("");
3012
- console.log("Example:");
3013
- console.log(" postgres-ai mon targets add 'postgresql://user:pass@host:5432/db' my-db");
3014
- return;
3015
- }
3016
-
3017
- // Filter out disabled instances (e.g., demo placeholders)
3018
- const filtered = instances.filter((inst) => inst.name && inst.is_enabled !== false);
3019
-
3020
- if (filtered.length === 0) {
3021
- console.log("No monitoring targets configured");
3022
- console.log("");
3023
- console.log("To add a monitoring target:");
3024
- console.log(" postgres-ai mon targets add <connection-string> <name>");
3025
- console.log("");
3026
- console.log("Example:");
3027
- console.log(" postgres-ai mon targets add 'postgresql://user:pass@host:5432/db' my-db");
3028
- return;
3029
- }
3030
-
3031
- for (const inst of filtered) {
3032
- console.log(`Target: ${inst.name}`);
3033
- }
3188
+ instances = loadInstances(instancesPath);
3034
3189
  } catch (err) {
3035
3190
  const message = err instanceof Error ? err.message : String(err);
3036
3191
  console.error(`Error parsing instances.yml: ${message}`);
3037
3192
  process.exitCode = 1;
3193
+ return;
3194
+ }
3195
+
3196
+ // Filter out disabled instances (e.g., demo placeholders)
3197
+ const filtered = instances.filter((inst) => inst.name && inst.is_enabled !== false);
3198
+
3199
+ if (filtered.length === 0) {
3200
+ console.log("No monitoring targets configured");
3201
+ console.log("");
3202
+ console.log("To add a monitoring target:");
3203
+ console.log(" postgres-ai mon targets add <connection-string> <name>");
3204
+ console.log("");
3205
+ console.log("Example:");
3206
+ console.log(" postgres-ai mon targets add 'postgresql://user:pass@host:5432/db' my-db");
3207
+ return;
3208
+ }
3209
+
3210
+ for (const inst of filtered) {
3211
+ console.log(`Target: ${inst.name}`);
3038
3212
  }
3039
3213
  });
3040
3214
  targets
@@ -3057,66 +3231,36 @@ targets
3057
3231
  const db = m[5];
3058
3232
  const instanceName = name && name.trim() ? name.trim() : `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
3059
3233
 
3060
- // Check if instance already exists
3061
3234
  try {
3062
- if (fs.existsSync(file)) {
3063
- const content = fs.readFileSync(file, "utf8");
3064
- const instances = yaml.load(content) as Instance[] | null || [];
3065
- if (Array.isArray(instances)) {
3066
- const exists = instances.some((inst) => inst.name === instanceName);
3067
- if (exists) {
3068
- console.error(`Monitoring target '${instanceName}' already exists`);
3069
- process.exitCode = 1;
3070
- return;
3071
- }
3072
- }
3073
- }
3235
+ addInstanceToFile(file, buildInstance(instanceName, connStr));
3236
+ console.log(`Monitoring target '${instanceName}' added`);
3074
3237
  } catch (err) {
3075
- // 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)) {
3078
- console.error(`Monitoring target '${instanceName}' already exists`);
3079
- process.exitCode = 1;
3080
- return;
3081
- }
3238
+ // Surface InstancesParseError as-is so we don't silently overwrite a
3239
+ // corrupted file (which could discard several targets, including the
3240
+ // credentials in their conn_str values).
3241
+ const message = err instanceof Error ? err.message : String(err);
3242
+ console.error(message);
3243
+ process.exitCode = 1;
3082
3244
  }
3083
-
3084
- // Add new instance
3085
- 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
- const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
3087
- fs.appendFileSync(file, (content && !/\n$/.test(content) ? "\n" : "") + body, "utf8");
3088
- console.log(`Monitoring target '${instanceName}' added`);
3089
3245
  });
3090
3246
  targets
3091
3247
  .command("remove <name>")
3092
3248
  .description("remove monitoring target database")
3093
3249
  .action(async (name: string) => {
3094
3250
  const { instancesFile: file } = await resolveOrInitPaths();
3095
- if (!fs.existsSync(file)) {
3251
+ if (!fs.existsSync(file) || fs.lstatSync(file).isDirectory()) {
3096
3252
  console.error("instances.yml not found");
3097
3253
  process.exitCode = 1;
3098
3254
  return;
3099
3255
  }
3100
3256
 
3101
3257
  try {
3102
- const content = fs.readFileSync(file, "utf8");
3103
- const instances = yaml.load(content) as Instance[] | null;
3104
-
3105
- if (!instances || !Array.isArray(instances)) {
3106
- console.error("Invalid instances.yml format");
3107
- process.exitCode = 1;
3108
- return;
3109
- }
3110
-
3111
- const filtered = instances.filter((inst) => inst.name !== name);
3112
-
3113
- if (filtered.length === instances.length) {
3258
+ const removed = removeInstanceFromFile(file, name);
3259
+ if (!removed) {
3114
3260
  console.error(`Monitoring target '${name}' not found`);
3115
3261
  process.exitCode = 1;
3116
3262
  return;
3117
3263
  }
3118
-
3119
- fs.writeFileSync(file, yaml.dump(filtered), "utf8");
3120
3264
  console.log(`Monitoring target '${name}' removed`);
3121
3265
  } catch (err) {
3122
3266
  const message = err instanceof Error ? err.message : String(err);
@@ -3129,41 +3273,41 @@ targets
3129
3273
  .description("test monitoring target database connectivity")
3130
3274
  .action(async (name: string) => {
3131
3275
  const { instancesFile: instancesPath } = await resolveOrInitPaths();
3132
- if (!fs.existsSync(instancesPath)) {
3276
+ if (!fs.existsSync(instancesPath) || fs.lstatSync(instancesPath).isDirectory()) {
3133
3277
  console.error("instances.yml not found");
3134
3278
  process.exitCode = 1;
3135
3279
  return;
3136
3280
  }
3137
3281
 
3282
+ let instances: Instance[];
3138
3283
  try {
3139
- const content = fs.readFileSync(instancesPath, "utf8");
3140
- const instances = yaml.load(content) as Instance[] | null;
3141
-
3142
- if (!instances || !Array.isArray(instances)) {
3143
- console.error("Invalid instances.yml format");
3144
- process.exitCode = 1;
3145
- return;
3146
- }
3147
-
3148
- const instance = instances.find((inst) => inst.name === name);
3284
+ instances = loadInstances(instancesPath);
3285
+ } catch (err) {
3286
+ const message = err instanceof Error ? err.message : String(err);
3287
+ console.error(`Error parsing instances.yml: ${message}`);
3288
+ process.exitCode = 1;
3289
+ return;
3290
+ }
3291
+ const instance = instances.find((inst) => inst.name === name);
3149
3292
 
3150
- if (!instance) {
3151
- console.error(`Monitoring target '${name}' not found`);
3152
- process.exitCode = 1;
3153
- return;
3154
- }
3293
+ if (!instance) {
3294
+ console.error(`Monitoring target '${name}' not found`);
3295
+ process.exitCode = 1;
3296
+ return;
3297
+ }
3155
3298
 
3156
- if (!instance.conn_str) {
3157
- console.error(`Connection string not found for monitoring target '${name}'`);
3158
- process.exitCode = 1;
3159
- return;
3160
- }
3299
+ if (!instance.conn_str) {
3300
+ console.error(`Connection string not found for monitoring target '${name}'`);
3301
+ process.exitCode = 1;
3302
+ return;
3303
+ }
3161
3304
 
3162
- console.log(`Testing connection to monitoring target '${name}'...`);
3305
+ console.log(`Testing connection to monitoring target '${name}'...`);
3163
3306
 
3164
- // Use native pg client instead of requiring psql to be installed
3165
- const client = new Client({ connectionString: instance.conn_str });
3307
+ warnIfLaxSslmode(instance.conn_str);
3308
+ const client = new Client(buildClientConfig(instance.conn_str, { connectionTimeoutMillis: 10000 }));
3166
3309
 
3310
+ try {
3167
3311
  try {
3168
3312
  await client.connect();
3169
3313
  const result = await client.query('select version();');
@@ -3197,17 +3341,17 @@ auth
3197
3341
  process.exitCode = 1;
3198
3342
  return;
3199
3343
  }
3200
-
3344
+
3201
3345
  // Read existing config to check for defaultProject before updating
3202
3346
  const existingConfig = config.readConfig();
3203
3347
  const existingProject = existingConfig.defaultProject;
3204
-
3348
+
3205
3349
  config.writeConfig({ apiKey: trimmedKey });
3206
3350
  // When API key is set directly, only clear orgId (org selection may differ).
3207
3351
  // Preserve defaultProject to avoid orphaning historical reports.
3208
3352
  // If the new key lacks access to the project, upload will fail with a clear error.
3209
3353
  config.deleteConfigKeys(["orgId"]);
3210
-
3354
+
3211
3355
  console.log(`API key saved to ${config.getConfigPath()}`);
3212
3356
  if (existingProject) {
3213
3357
  console.log(`Note: Your default project "${existingProject}" has been preserved.`);
@@ -3227,8 +3371,8 @@ auth
3227
3371
  const { apiBaseUrl, uiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3228
3372
 
3229
3373
  if (opts.debug) {
3230
- console.log(`Debug: Resolved API base URL: ${apiBaseUrl}`);
3231
- console.log(`Debug: Resolved UI base URL: ${uiBaseUrl}`);
3374
+ console.error(`Debug: Resolved API base URL: ${apiBaseUrl}`);
3375
+ console.error(`Debug: Resolved UI base URL: ${uiBaseUrl}`);
3232
3376
  }
3233
3377
 
3234
3378
  try {
@@ -3258,8 +3402,8 @@ auth
3258
3402
  const initUrl = new URL(`${apiBaseUrl}/rpc/oauth_init`);
3259
3403
 
3260
3404
  if (opts.debug) {
3261
- console.log(`Debug: Trying to POST to: ${initUrl.toString()}`);
3262
- console.log(`Debug: Request data: ${initData}`);
3405
+ console.error(`Debug: Trying to POST to: ${initUrl.toString()}`);
3406
+ console.error(`Debug: Request data: ${initData}`);
3263
3407
  }
3264
3408
 
3265
3409
  // Step 2: Initialize OAuth session on backend using fetch
@@ -3305,7 +3449,7 @@ auth
3305
3449
  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
3450
 
3307
3451
  if (opts.debug) {
3308
- console.log(`Debug: Auth URL: ${authUrl}`);
3452
+ console.error(`Debug: Auth URL: ${authUrl}`);
3309
3453
  }
3310
3454
 
3311
3455
  console.log(`\nOpening browser for authentication...`);
@@ -3391,13 +3535,13 @@ auth
3391
3535
  const existingOrgId = existingConfig.orgId;
3392
3536
  const existingProject = existingConfig.defaultProject;
3393
3537
  const orgChanged = existingOrgId && existingOrgId !== orgId;
3394
-
3538
+
3395
3539
  config.writeConfig({
3396
3540
  apiKey: apiToken,
3397
3541
  baseUrl: apiBaseUrl,
3398
3542
  orgId: orgId,
3399
3543
  });
3400
-
3544
+
3401
3545
  // Only clear defaultProject if org actually changed
3402
3546
  if (orgChanged && existingProject) {
3403
3547
  config.deleteConfigKeys(["defaultProject"]);
@@ -3515,10 +3659,10 @@ mon
3515
3659
 
3516
3660
  try {
3517
3661
  // Generate secure password using openssl
3518
- const { stdout: password } = await execPromise(
3519
- "openssl rand -base64 12 | tr -d '\n'"
3662
+ const { stdout: password } = await execFilePromise(
3663
+ "openssl", ["rand", "-base64", "12"]
3520
3664
  );
3521
- const newPassword = password.trim();
3665
+ const newPassword = password.trim().replace(/\n/g, "");
3522
3666
 
3523
3667
  if (!newPassword) {
3524
3668
  console.error("Failed to generate password");
@@ -3596,6 +3740,19 @@ mon
3596
3740
  console.log(" URL: http://localhost:3000");
3597
3741
  console.log(" Username: monitor");
3598
3742
  console.log(` Password: ${password}`);
3743
+
3744
+ // Show VM auth credentials from .env
3745
+ const envFile = path.resolve(projectDir, ".env");
3746
+ if (fs.existsSync(envFile)) {
3747
+ const envContent = fs.readFileSync(envFile, "utf8");
3748
+ const vmUser = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
3749
+ const vmPass = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
3750
+ if (vmUser && vmPass) {
3751
+ console.log("\nVictoriaMetrics credentials:");
3752
+ console.log(` Username: ${stripMatchingQuotes(vmUser[1])}`);
3753
+ console.log(` Password: ${stripMatchingQuotes(vmPass[1])}`);
3754
+ }
3755
+ }
3599
3756
  console.log("");
3600
3757
  });
3601
3758
 
@@ -3723,21 +3880,34 @@ issues
3723
3880
  .command("post-comment <issueId> <content>")
3724
3881
  .description("post a new comment to an issue")
3725
3882
  .option("--parent <uuid>", "parent comment id")
3883
+ .option(
3884
+ "--attach <path>",
3885
+ "attach a file (uploads to storage and appends a markdown link; repeatable)",
3886
+ (value: string, previous: string[]) => {
3887
+ previous.push(value);
3888
+ return previous;
3889
+ },
3890
+ [] as string[]
3891
+ )
3726
3892
  .option("--debug", "enable debug output")
3727
3893
  .option("--json", "output raw JSON")
3728
- .action(async (issueId: string, content: string, opts: { parent?: string; debug?: boolean; json?: boolean }) => {
3894
+ .action(async (issueId: string, content: string, opts: { parent?: string; attach?: string[]; debug?: boolean; json?: boolean }) => {
3729
3895
  // Interpret escape sequences in content (e.g., \n -> newline)
3730
3896
  if (opts.debug) {
3731
3897
  // eslint-disable-next-line no-console
3732
- console.log(`Debug: Original content: ${JSON.stringify(content)}`);
3898
+ console.error(`Debug: Original content: ${JSON.stringify(content)}`);
3733
3899
  }
3734
3900
  content = interpretEscapes(content);
3735
3901
  if (opts.debug) {
3736
3902
  // eslint-disable-next-line no-console
3737
- console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
3903
+ console.error(`Debug: Interpreted content: ${JSON.stringify(content)}`);
3738
3904
  }
3739
3905
 
3740
- const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Posting comment...");
3906
+ const attachPaths = Array.isArray(opts.attach) ? opts.attach : [];
3907
+ const spinner = createTtySpinner(
3908
+ process.stdout.isTTY ?? false,
3909
+ attachPaths.length > 0 ? "Uploading attachments..." : "Posting comment..."
3910
+ );
3741
3911
  try {
3742
3912
  const rootOpts = program.opts<CliOptions>();
3743
3913
  const cfg = config.readConfig();
@@ -3749,13 +3919,25 @@ issues
3749
3919
  return;
3750
3920
  }
3751
3921
 
3752
- const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3922
+ const { apiBaseUrl, storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3923
+
3924
+ let augmentedContent = content;
3925
+ if (attachPaths.length > 0) {
3926
+ const uploaded = await uploadAttachments({
3927
+ apiKey,
3928
+ storageBaseUrl,
3929
+ attachmentPaths: attachPaths,
3930
+ debug: !!opts.debug,
3931
+ });
3932
+ augmentedContent = appendAttachmentsToContent(content, uploaded);
3933
+ spinner.update("Posting comment...");
3934
+ }
3753
3935
 
3754
3936
  const result = await createIssueComment({
3755
3937
  apiKey,
3756
3938
  apiBaseUrl,
3757
3939
  issueId,
3758
- content,
3940
+ content: augmentedContent,
3759
3941
  parentCommentId: opts.parent,
3760
3942
  debug: !!opts.debug,
3761
3943
  });
@@ -3784,9 +3966,18 @@ issues
3784
3966
  },
3785
3967
  [] as string[]
3786
3968
  )
3969
+ .option(
3970
+ "--attach <path>",
3971
+ "attach a file (uploads to storage and appends a markdown link to the description; repeatable)",
3972
+ (value: string, previous: string[]) => {
3973
+ previous.push(value);
3974
+ return previous;
3975
+ },
3976
+ [] as string[]
3977
+ )
3787
3978
  .option("--debug", "enable debug output")
3788
3979
  .option("--json", "output raw JSON")
3789
- .action(async (rawTitle: string, opts: { orgId?: number; projectId?: number; description?: string; label?: string[]; debug?: boolean; json?: boolean }) => {
3980
+ .action(async (rawTitle: string, opts: { orgId?: number; projectId?: number; description?: string; label?: string[]; attach?: string[]; debug?: boolean; json?: boolean }) => {
3790
3981
  const rootOpts = program.opts<CliOptions>();
3791
3982
  const cfg = config.readConfig();
3792
3983
  const { apiKey } = getConfig(rootOpts);
@@ -3813,16 +4004,33 @@ issues
3813
4004
  const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
3814
4005
  const labels = Array.isArray(opts.label) && opts.label.length > 0 ? opts.label.map(String) : undefined;
3815
4006
  const projectId = typeof opts.projectId === "number" && !Number.isNaN(opts.projectId) ? opts.projectId : undefined;
4007
+ const attachPaths = Array.isArray(opts.attach) ? opts.attach : [];
3816
4008
 
3817
- const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Creating issue...");
4009
+ const spinner = createTtySpinner(
4010
+ process.stdout.isTTY ?? false,
4011
+ attachPaths.length > 0 ? "Uploading attachments..." : "Creating issue..."
4012
+ );
3818
4013
  try {
3819
- const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4014
+ const { apiBaseUrl, storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4015
+
4016
+ let augmentedDescription = description;
4017
+ if (attachPaths.length > 0) {
4018
+ const uploaded = await uploadAttachments({
4019
+ apiKey,
4020
+ storageBaseUrl,
4021
+ attachmentPaths: attachPaths,
4022
+ debug: !!opts.debug,
4023
+ });
4024
+ augmentedDescription = appendAttachmentsToContent(description ?? "", uploaded);
4025
+ spinner.update("Creating issue...");
4026
+ }
4027
+
3820
4028
  const result = await createIssue({
3821
4029
  apiKey,
3822
4030
  apiBaseUrl,
3823
4031
  title,
3824
4032
  orgId,
3825
- description,
4033
+ description: augmentedDescription,
3826
4034
  projectId,
3827
4035
  labels,
3828
4036
  debug: !!opts.debug,
@@ -3853,9 +4061,18 @@ issues
3853
4061
  [] as string[]
3854
4062
  )
3855
4063
  .option("--clear-labels", "set labels to an empty list")
4064
+ .option(
4065
+ "--attach <path>",
4066
+ "attach a file (uploads and appends a markdown link to --description; if --description is omitted the existing description is fetched and appended to; repeatable)",
4067
+ (value: string, previous: string[]) => {
4068
+ previous.push(value);
4069
+ return previous;
4070
+ },
4071
+ [] as string[]
4072
+ )
3856
4073
  .option("--debug", "enable debug output")
3857
4074
  .option("--json", "output raw JSON")
3858
- .action(async (issueId: string, opts: { title?: string; description?: string; status?: string; label?: string[]; clearLabels?: boolean; debug?: boolean; json?: boolean }) => {
4075
+ .action(async (issueId: string, opts: { title?: string; description?: string; status?: string; label?: string[]; clearLabels?: boolean; attach?: string[]; debug?: boolean; json?: boolean }) => {
3859
4076
  const rootOpts = program.opts<CliOptions>();
3860
4077
  const cfg = config.readConfig();
3861
4078
  const { apiKey } = getConfig(rootOpts);
@@ -3865,10 +4082,10 @@ issues
3865
4082
  return;
3866
4083
  }
3867
4084
 
3868
- const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4085
+ const { apiBaseUrl, storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3869
4086
 
3870
4087
  const title = opts.title !== undefined ? interpretEscapes(String(opts.title)) : undefined;
3871
- const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
4088
+ let description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
3872
4089
 
3873
4090
  let status: number | undefined = undefined;
3874
4091
  if (opts.status !== undefined) {
@@ -3898,8 +4115,38 @@ issues
3898
4115
  labels = opts.label.map(String);
3899
4116
  }
3900
4117
 
3901
- const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Updating issue...");
4118
+ const attachPaths = Array.isArray(opts.attach) ? opts.attach : [];
4119
+ const spinner = createTtySpinner(
4120
+ process.stdout.isTTY ?? false,
4121
+ attachPaths.length > 0 ? "Uploading attachments..." : "Updating issue..."
4122
+ );
3902
4123
  try {
4124
+ if (attachPaths.length > 0) {
4125
+ // If the caller did not supply a new description, fetch the existing one
4126
+ // and append to it. This makes "add a screenshot to issue X" a one-step
4127
+ // operation rather than forcing the caller to copy-paste the existing
4128
+ // description first. Small race window if someone else updates
4129
+ // concurrently, which is acceptable for an interactive CLI / agent.
4130
+ if (description === undefined) {
4131
+ const existing = await fetchIssue({ apiKey, apiBaseUrl, issueId, debug: !!opts.debug });
4132
+ if (!existing) {
4133
+ spinner.stop();
4134
+ console.error(`Issue not found: ${issueId}`);
4135
+ process.exitCode = 1;
4136
+ return;
4137
+ }
4138
+ description = (existing as { description?: string | null }).description ?? "";
4139
+ }
4140
+ const uploaded = await uploadAttachments({
4141
+ apiKey,
4142
+ storageBaseUrl,
4143
+ attachmentPaths: attachPaths,
4144
+ debug: !!opts.debug,
4145
+ });
4146
+ description = appendAttachmentsToContent(description ?? "", uploaded);
4147
+ spinner.update("Updating issue...");
4148
+ }
4149
+
3903
4150
  const result = await updateIssue({
3904
4151
  apiKey,
3905
4152
  apiBaseUrl,
@@ -3923,17 +4170,26 @@ issues
3923
4170
  issues
3924
4171
  .command("update-comment <commentId> <content>")
3925
4172
  .description("update an existing issue comment")
4173
+ .option(
4174
+ "--attach <path>",
4175
+ "attach a file (uploads and appends a markdown link to <content>; repeatable)",
4176
+ (value: string, previous: string[]) => {
4177
+ previous.push(value);
4178
+ return previous;
4179
+ },
4180
+ [] as string[]
4181
+ )
3926
4182
  .option("--debug", "enable debug output")
3927
4183
  .option("--json", "output raw JSON")
3928
- .action(async (commentId: string, content: string, opts: { debug?: boolean; json?: boolean }) => {
4184
+ .action(async (commentId: string, content: string, opts: { attach?: string[]; debug?: boolean; json?: boolean }) => {
3929
4185
  if (opts.debug) {
3930
4186
  // eslint-disable-next-line no-console
3931
- console.log(`Debug: Original content: ${JSON.stringify(content)}`);
4187
+ console.error(`Debug: Original content: ${JSON.stringify(content)}`);
3932
4188
  }
3933
4189
  content = interpretEscapes(content);
3934
4190
  if (opts.debug) {
3935
4191
  // eslint-disable-next-line no-console
3936
- console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
4192
+ console.error(`Debug: Interpreted content: ${JSON.stringify(content)}`);
3937
4193
  }
3938
4194
 
3939
4195
  const rootOpts = program.opts<CliOptions>();
@@ -3945,15 +4201,31 @@ issues
3945
4201
  return;
3946
4202
  }
3947
4203
 
3948
- const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Updating comment...");
4204
+ const attachPaths = Array.isArray(opts.attach) ? opts.attach : [];
4205
+ const spinner = createTtySpinner(
4206
+ process.stdout.isTTY ?? false,
4207
+ attachPaths.length > 0 ? "Uploading attachments..." : "Updating comment..."
4208
+ );
3949
4209
  try {
3950
- const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4210
+ const { apiBaseUrl, storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4211
+
4212
+ let augmentedContent = content;
4213
+ if (attachPaths.length > 0) {
4214
+ const uploaded = await uploadAttachments({
4215
+ apiKey,
4216
+ storageBaseUrl,
4217
+ attachmentPaths: attachPaths,
4218
+ debug: !!opts.debug,
4219
+ });
4220
+ augmentedContent = appendAttachmentsToContent(content, uploaded);
4221
+ spinner.update("Updating comment...");
4222
+ }
3951
4223
 
3952
4224
  const result = await updateIssueComment({
3953
4225
  apiKey,
3954
4226
  apiBaseUrl,
3955
4227
  commentId,
3956
- content,
4228
+ content: augmentedContent,
3957
4229
  debug: !!opts.debug,
3958
4230
  });
3959
4231
  spinner.stop();
@@ -3966,6 +4238,93 @@ issues
3966
4238
  }
3967
4239
  });
3968
4240
 
4241
+ // File upload/download (subcommands of issues)
4242
+ const issueFiles = issues.command("files").description("upload and download files for issues");
4243
+
4244
+ issueFiles
4245
+ .command("upload <path>")
4246
+ .description("upload a file to storage and get a markdown link")
4247
+ .option("--debug", "enable debug output")
4248
+ .option("--json", "output raw JSON")
4249
+ .action(async (filePath: string, opts: { debug?: boolean; json?: boolean }) => {
4250
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Uploading file...");
4251
+ try {
4252
+ const rootOpts = program.opts<CliOptions>();
4253
+ const cfg = config.readConfig();
4254
+ const { apiKey } = getConfig(rootOpts);
4255
+ if (!apiKey) {
4256
+ spinner.stop();
4257
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
4258
+ process.exitCode = 1;
4259
+ return;
4260
+ }
4261
+
4262
+ const { storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4263
+
4264
+ const result = await uploadFile({
4265
+ apiKey,
4266
+ storageBaseUrl,
4267
+ filePath,
4268
+ debug: !!opts.debug,
4269
+ });
4270
+ spinner.stop();
4271
+
4272
+ if (opts.json) {
4273
+ printResult(result, true);
4274
+ } else {
4275
+ const md = buildMarkdownLink(result.url, storageBaseUrl, result.metadata.originalName);
4276
+ const displayUrl = result.url.startsWith("/") ? `${storageBaseUrl}${result.url}` : `${storageBaseUrl}/${result.url}`;
4277
+ console.log(`URL: ${displayUrl}`);
4278
+ console.log(`File: ${result.metadata.originalName}`);
4279
+ console.log(`Size: ${result.metadata.size} bytes`);
4280
+ console.log(`Type: ${result.metadata.mimeType}`);
4281
+ console.log(`Markdown: ${md}`);
4282
+ }
4283
+ } catch (err) {
4284
+ spinner.stop();
4285
+ const message = err instanceof Error ? err.message : String(err);
4286
+ console.error(message);
4287
+ process.exitCode = 1;
4288
+ }
4289
+ });
4290
+
4291
+ issueFiles
4292
+ .command("download <url>")
4293
+ .description("download a file from storage")
4294
+ .option("-o, --output <path>", "output file path (default: derive from URL)")
4295
+ .option("--debug", "enable debug output")
4296
+ .action(async (fileUrl: string, opts: { output?: string; debug?: boolean }) => {
4297
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Downloading file...");
4298
+ try {
4299
+ const rootOpts = program.opts<CliOptions>();
4300
+ const cfg = config.readConfig();
4301
+ const { apiKey } = getConfig(rootOpts);
4302
+ if (!apiKey) {
4303
+ spinner.stop();
4304
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
4305
+ process.exitCode = 1;
4306
+ return;
4307
+ }
4308
+
4309
+ const { storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4310
+
4311
+ const result = await downloadFile({
4312
+ apiKey,
4313
+ storageBaseUrl,
4314
+ fileUrl,
4315
+ outputPath: opts.output,
4316
+ debug: !!opts.debug,
4317
+ });
4318
+ spinner.stop();
4319
+ console.log(`Saved: ${result.savedTo}`);
4320
+ } catch (err) {
4321
+ spinner.stop();
4322
+ const message = err instanceof Error ? err.message : String(err);
4323
+ console.error(message);
4324
+ process.exitCode = 1;
4325
+ }
4326
+ });
4327
+
3969
4328
  // Action Items management (subcommands of issues)
3970
4329
  issues
3971
4330
  .command("action-items <issueId>")
@@ -4190,6 +4549,228 @@ issues
4190
4549
  }
4191
4550
  });
4192
4551
 
4552
+ // Reports management
4553
+ const reports = program.command("reports").description("checkup reports management");
4554
+
4555
+ reports
4556
+ .command("list")
4557
+ .description("list checkup reports")
4558
+ .option("--project-id <id>", "filter by project id", (v: string) => parseInt(v, 10))
4559
+ .addOption(new Option("--status <status>", "filter by status (e.g., completed)").hideHelp())
4560
+ .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)); })
4561
+ .option("--before <date>", "show reports created before this date (YYYY-MM-DD, DD.MM.YYYY, etc.)")
4562
+ .option("--all", "fetch all reports (paginated automatically)")
4563
+ .addOption(new Option("--debug", "enable debug output").hideHelp())
4564
+ .option("--json", "output raw JSON")
4565
+ .action(async (opts: { projectId?: number; status?: string; limit?: number; before?: string; all?: boolean; debug?: boolean; json?: boolean }) => {
4566
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching reports...");
4567
+ try {
4568
+ const rootOpts = program.opts<CliOptions>();
4569
+ const cfg = config.readConfig();
4570
+ const { apiKey } = getConfig(rootOpts);
4571
+ if (!apiKey) {
4572
+ spinner.stop();
4573
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
4574
+ process.exitCode = 1;
4575
+ return;
4576
+ }
4577
+ if (opts.all && opts.before) {
4578
+ spinner.stop();
4579
+ console.error("--all and --before cannot be used together");
4580
+ process.exitCode = 1;
4581
+ return;
4582
+ }
4583
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4584
+
4585
+ let result;
4586
+ if (opts.all) {
4587
+ result = await fetchAllReports({
4588
+ apiKey,
4589
+ apiBaseUrl,
4590
+ projectId: opts.projectId,
4591
+ status: opts.status,
4592
+ limit: opts.limit,
4593
+ debug: !!opts.debug,
4594
+ });
4595
+ } else {
4596
+ result = await fetchReports({
4597
+ apiKey,
4598
+ apiBaseUrl,
4599
+ projectId: opts.projectId,
4600
+ status: opts.status,
4601
+ limit: opts.limit,
4602
+ beforeDate: opts.before ? parseFlexibleDate(opts.before) : undefined,
4603
+ debug: !!opts.debug,
4604
+ });
4605
+ }
4606
+ spinner.stop();
4607
+ printResult(result, opts.json);
4608
+ } catch (err) {
4609
+ spinner.stop();
4610
+ const message = err instanceof Error ? err.message : String(err);
4611
+ console.error(message);
4612
+ process.exitCode = 1;
4613
+ }
4614
+ });
4615
+
4616
+ reports
4617
+ .command("files [reportId]")
4618
+ .description("list files of a checkup report (metadata only, no content)")
4619
+ .option("--type <type>", "filter by file type: json, md")
4620
+ .option("--check-id <id>", "filter by check ID (e.g., H002)")
4621
+ .addOption(new Option("--debug", "enable debug output").hideHelp())
4622
+ .option("--json", "output raw JSON")
4623
+ .action(async (reportId: string | undefined, opts: { type?: "json" | "md"; checkId?: string; debug?: boolean; json?: boolean }) => {
4624
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching report files...");
4625
+ try {
4626
+ const rootOpts = program.opts<CliOptions>();
4627
+ const cfg = config.readConfig();
4628
+ const { apiKey } = getConfig(rootOpts);
4629
+ if (!apiKey) {
4630
+ spinner.stop();
4631
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
4632
+ process.exitCode = 1;
4633
+ return;
4634
+ }
4635
+ let numericId: number | undefined;
4636
+ if (reportId !== undefined) {
4637
+ numericId = parseInt(reportId, 10);
4638
+ if (isNaN(numericId)) {
4639
+ spinner.stop();
4640
+ console.error("reportId must be a number");
4641
+ process.exitCode = 1;
4642
+ return;
4643
+ }
4644
+ }
4645
+ if (numericId === undefined && !opts.checkId) {
4646
+ spinner.stop();
4647
+ console.error("Either reportId or --check-id is required");
4648
+ process.exitCode = 1;
4649
+ return;
4650
+ }
4651
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4652
+
4653
+ const result = await fetchReportFiles({
4654
+ apiKey,
4655
+ apiBaseUrl,
4656
+ reportId: numericId,
4657
+ type: opts.type,
4658
+ checkId: opts.checkId,
4659
+ debug: !!opts.debug,
4660
+ });
4661
+ spinner.stop();
4662
+ printResult(result, opts.json);
4663
+ } catch (err) {
4664
+ spinner.stop();
4665
+ const message = err instanceof Error ? err.message : String(err);
4666
+ console.error(message);
4667
+ process.exitCode = 1;
4668
+ }
4669
+ });
4670
+
4671
+ reports
4672
+ .command("data [reportId]")
4673
+ .description("get checkup report file data (includes content)")
4674
+ .option("--type <type>", "filter by file type: json, md")
4675
+ .option("--check-id <id>", "filter by check ID (e.g., H002)")
4676
+ .option("--formatted", "render markdown with ANSI styling (experimental)")
4677
+ .option("-o, --output <dir>", "save files to directory (uses original filenames)")
4678
+ .addOption(new Option("--debug", "enable debug output").hideHelp())
4679
+ .option("--json", "output raw JSON")
4680
+ .action(async (reportId: string | undefined, opts: { type?: "json" | "md"; checkId?: string; formatted?: boolean; output?: string; debug?: boolean; json?: boolean }) => {
4681
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching report data...");
4682
+ try {
4683
+ const rootOpts = program.opts<CliOptions>();
4684
+ const cfg = config.readConfig();
4685
+ const { apiKey } = getConfig(rootOpts);
4686
+ if (!apiKey) {
4687
+ spinner.stop();
4688
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
4689
+ process.exitCode = 1;
4690
+ return;
4691
+ }
4692
+ let numericId: number | undefined;
4693
+ if (reportId !== undefined) {
4694
+ numericId = parseInt(reportId, 10);
4695
+ if (isNaN(numericId)) {
4696
+ spinner.stop();
4697
+ console.error("reportId must be a number");
4698
+ process.exitCode = 1;
4699
+ return;
4700
+ }
4701
+ }
4702
+ if (numericId === undefined && !opts.checkId) {
4703
+ spinner.stop();
4704
+ console.error("Either reportId or --check-id is required");
4705
+ process.exitCode = 1;
4706
+ return;
4707
+ }
4708
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4709
+
4710
+ // Default to "md" for terminal output (human-readable); --json and --output get all types
4711
+ const effectiveType = opts.type ?? (!opts.json && !opts.output ? "md" as const : undefined);
4712
+ const result = await fetchReportFileData({
4713
+ apiKey,
4714
+ apiBaseUrl,
4715
+ reportId: numericId,
4716
+ type: effectiveType,
4717
+ checkId: opts.checkId,
4718
+ debug: !!opts.debug,
4719
+ });
4720
+ spinner.stop();
4721
+
4722
+ if (opts.output) {
4723
+ const dir = path.resolve(opts.output);
4724
+ fs.mkdirSync(dir, { recursive: true });
4725
+ for (const f of result) {
4726
+ const safeName = path.basename(f.filename);
4727
+ const filePath = path.join(dir, safeName);
4728
+ const content = f.type === "json"
4729
+ ? JSON.stringify(tryParseJson(f.data), null, 2)
4730
+ : f.data;
4731
+ fs.writeFileSync(filePath, content, "utf-8");
4732
+ console.log(filePath);
4733
+ }
4734
+ } else if (opts.json) {
4735
+ const processed = result.map((f) => ({
4736
+ ...f,
4737
+ data: f.type === "json" ? tryParseJson(f.data) : f.data,
4738
+ }));
4739
+ printResult(processed, true);
4740
+ } else if (opts.formatted && process.stdout.isTTY) {
4741
+ for (const f of result) {
4742
+ if (result.length > 1) {
4743
+ console.log(`\x1b[1m--- ${f.filename} (${f.check_id}, ${f.type}) ---\x1b[0m`);
4744
+ }
4745
+ if (f.type === "md") {
4746
+ console.log(renderMarkdownForTerminal(f.data));
4747
+ } else if (f.type === "json") {
4748
+ const parsed = tryParseJson(f.data);
4749
+ console.log(typeof parsed === "string" ? parsed : JSON.stringify(parsed, null, 2));
4750
+ } else {
4751
+ console.log(f.data);
4752
+ }
4753
+ }
4754
+ } else {
4755
+ for (const f of result) {
4756
+ if (result.length > 1) {
4757
+ console.log(`--- ${f.filename} (${f.check_id}, ${f.type}) ---`);
4758
+ }
4759
+ console.log(f.data);
4760
+ }
4761
+ }
4762
+ } catch (err) {
4763
+ spinner.stop();
4764
+ const message = err instanceof Error ? err.message : String(err);
4765
+ console.error(message);
4766
+ process.exitCode = 1;
4767
+ }
4768
+ });
4769
+
4770
+ function tryParseJson(s: string): unknown {
4771
+ try { return JSON.parse(s); } catch { return s; }
4772
+ }
4773
+
4193
4774
  // MCP server
4194
4775
  const mcp = program.command("mcp").description("MCP server integration");
4195
4776
 
@@ -4247,7 +4828,7 @@ mcp
4247
4828
  // Get the path to the current pgai executable
4248
4829
  let pgaiPath: string;
4249
4830
  try {
4250
- const execPath = await execPromise("which pgai");
4831
+ const execPath = await execFilePromise("which", ["pgai"]);
4251
4832
  pgaiPath = execPath.stdout.trim();
4252
4833
  } catch {
4253
4834
  // Fallback to just "pgai" if which fails
@@ -4259,8 +4840,8 @@ mcp
4259
4840
  console.log("Installing PostgresAI MCP server for Claude Code...");
4260
4841
 
4261
4842
  try {
4262
- const { stdout, stderr } = await execPromise(
4263
- `claude mcp add -s user postgresai ${pgaiPath} mcp start`
4843
+ const { stdout, stderr } = await execFilePromise(
4844
+ "claude", ["mcp", "add", "-s", "user", "postgresai", pgaiPath, "mcp", "start"]
4264
4845
  );
4265
4846
 
4266
4847
  if (stdout) console.log(stdout);
@@ -4355,4 +4936,3 @@ mcp
4355
4936
  program.parseAsync(process.argv).finally(() => {
4356
4937
  closeReadline();
4357
4938
  });
4358
-