postgresai 0.15.0-dev.3 → 0.15.0-dev.5

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.
@@ -13,6 +13,7 @@ import { startMcpServer } from "../lib/mcp-server";
13
13
  import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment, fetchActionItem, fetchActionItems, createActionItem, updateActionItem, type ConfigChange } from "../lib/issues";
14
14
  import { fetchReports, fetchAllReports, fetchReportFiles, fetchReportFileData, renderMarkdownForTerminal, parseFlexibleDate } from "../lib/reports";
15
15
  import { resolveBaseUrls } from "../lib/util";
16
+ import { uploadFile, downloadFile, buildMarkdownLink } from "../lib/storage";
16
17
  import { applyInitPlan, applyUninitPlan, buildInitPlan, buildUninitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, KNOWN_PROVIDERS, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, validateProvider, verifyInitSetup } from "../lib/init";
17
18
  import { SupabaseClient, resolveSupabaseConfig, extractProjectRefFromUrl, applyInitPlanViaSupabase, verifyInitSetupViaSupabase, fetchPoolerDatabaseUrl, type PgCompatibleError } from "../lib/supabase";
18
19
  import * as pkce from "../lib/pkce";
@@ -412,6 +413,7 @@ interface CliOptions {
412
413
  apiKey?: string;
413
414
  apiBaseUrl?: string;
414
415
  uiBaseUrl?: string;
416
+ storageBaseUrl?: string;
415
417
  }
416
418
 
417
419
  /**
@@ -475,14 +477,14 @@ async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
475
477
  fs.mkdirSync(projectDir, { recursive: true, mode: 0o700 });
476
478
  }
477
479
 
478
- if (!fs.existsSync(composeFile)) {
479
- const refs = [
480
- process.env.PGAI_PROJECT_REF,
481
- pkg.version,
482
- `v${pkg.version}`,
483
- "main",
484
- ].filter((v): v is string => Boolean(v && v.trim()));
480
+ const refs = [
481
+ process.env.PGAI_PROJECT_REF,
482
+ pkg.version,
483
+ `v${pkg.version}`,
484
+ "main",
485
+ ].filter((v): v is string => Boolean(v && v.trim()));
485
486
 
487
+ if (!fs.existsSync(composeFile)) {
486
488
  let lastErr: unknown;
487
489
  for (const ref of refs) {
488
490
  const url = `https://gitlab.com/postgres-ai/postgres_ai/-/raw/${encodeURIComponent(ref)}/docker-compose.yml`;
@@ -501,6 +503,21 @@ async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
501
503
  }
502
504
  }
503
505
 
506
+ // Download instances.demo.yml (demo target template) if not present
507
+ const demoFile = path.resolve(projectDir, "instances.demo.yml");
508
+ if (!fs.existsSync(demoFile)) {
509
+ for (const ref of refs) {
510
+ const url = `https://gitlab.com/postgres-ai/postgres_ai/-/raw/${encodeURIComponent(ref)}/instances.demo.yml`;
511
+ try {
512
+ const text = await downloadText(url);
513
+ fs.writeFileSync(demoFile, text, { encoding: "utf8", mode: 0o600 });
514
+ break;
515
+ } catch {
516
+ // non-fatal — demo file is optional
517
+ }
518
+ }
519
+ }
520
+
504
521
  // Ensure instances.yml exists as a FILE (avoid Docker creating a directory)
505
522
  if (!fs.existsSync(instancesFile)) {
506
523
  const header =
@@ -580,6 +597,10 @@ program
580
597
  .option(
581
598
  "--ui-base-url <url>",
582
599
  "UI base URL for browser routes (overrides PGAI_UI_BASE_URL)"
600
+ )
601
+ .option(
602
+ "--storage-base-url <url>",
603
+ "Storage base URL for file uploads (overrides PGAI_STORAGE_BASE_URL)"
583
604
  );
584
605
 
585
606
  program
@@ -596,6 +617,27 @@ program
596
617
  console.log(`Default project saved: ${value}`);
597
618
  });
598
619
 
620
+ program
621
+ .command("set-storage-url <url>")
622
+ .description("store storage base URL for file uploads")
623
+ .action(async (url: string) => {
624
+ const value = (url || "").trim();
625
+ if (!value) {
626
+ console.error("Error: url is required");
627
+ process.exitCode = 1;
628
+ return;
629
+ }
630
+ try {
631
+ const { normalizeBaseUrl } = await import("../lib/util");
632
+ const normalized = normalizeBaseUrl(value);
633
+ config.writeConfig({ storageBaseUrl: normalized });
634
+ console.log(`Storage URL saved: ${normalized}`);
635
+ } catch {
636
+ console.error(`Error: invalid URL: ${value}`);
637
+ process.exitCode = 1;
638
+ }
639
+ });
640
+
599
641
  program
600
642
  .command("prepare-db [conn]")
601
643
  .description("prepare database for monitoring: create monitoring user, required view(s), and grant permissions (idempotent)")
@@ -841,8 +883,8 @@ program
841
883
  } else {
842
884
  console.log("✓ prepare-db verify: OK");
843
885
  if (v.missingOptional.length > 0) {
844
- console.log("⚠ Optional items missing:");
845
- for (const m of v.missingOptional) console.log(`- ${m}`);
886
+ console.error("⚠ Optional items missing:");
887
+ for (const m of v.missingOptional) console.error(`- ${m}`);
846
888
  }
847
889
  }
848
890
  return;
@@ -973,8 +1015,8 @@ program
973
1015
  } else {
974
1016
  console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
975
1017
  if (skippedOptional.length > 0) {
976
- console.log("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
977
- for (const s of skippedOptional) console.log(`- ${s}`);
1018
+ console.error("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
1019
+ for (const s of skippedOptional) console.error(`- ${s}`);
978
1020
  }
979
1021
  if (process.stdout.isTTY) {
980
1022
  console.log(`Applied ${applied.length} steps`);
@@ -1155,8 +1197,8 @@ program
1155
1197
  } else {
1156
1198
  console.log(`✓ prepare-db verify: OK${opts.provider ? ` (provider: ${opts.provider})` : ""}`);
1157
1199
  if (v.missingOptional.length > 0) {
1158
- console.log("⚠ Optional items missing:");
1159
- for (const m of v.missingOptional) console.log(`- ${m}`);
1200
+ console.error("⚠ Optional items missing:");
1201
+ for (const m of v.missingOptional) console.error(`- ${m}`);
1160
1202
  }
1161
1203
  }
1162
1204
  return;
@@ -1283,8 +1325,8 @@ program
1283
1325
  } else {
1284
1326
  console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
1285
1327
  if (skippedOptional.length > 0) {
1286
- console.log("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
1287
- for (const s of skippedOptional) console.log(`- ${s}`);
1328
+ console.error("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
1329
+ for (const s of skippedOptional) console.error(`- ${s}`);
1288
1330
  }
1289
1331
  // Keep output compact but still useful
1290
1332
  if (process.stdout.isTTY) {
@@ -1600,11 +1642,11 @@ program
1600
1642
  console.log("✓ unprepare-db completed");
1601
1643
  console.log(`Applied ${applied.length} steps`);
1602
1644
  } else {
1603
- console.log("⚠ unprepare-db completed with errors");
1645
+ console.error("⚠ unprepare-db completed with errors");
1604
1646
  console.log(`Applied ${applied.length} steps`);
1605
- console.log("Errors:");
1647
+ console.error("Errors:");
1606
1648
  for (const err of errors) {
1607
- console.log(` - ${err}`);
1649
+ console.error(` - ${err}`);
1608
1650
  }
1609
1651
  process.exitCode = 1;
1610
1652
  }
@@ -2044,13 +2086,16 @@ function isDockerRunning(): boolean {
2044
2086
  }
2045
2087
 
2046
2088
  /**
2047
- * Get docker compose command
2089
+ * Get docker compose command.
2090
+ * Prefer "docker compose" (V2 plugin) over legacy "docker-compose" (V1 standalone)
2091
+ * because docker-compose V1 (<=1.29) is incompatible with modern Docker engines
2092
+ * (KeyError: 'ContainerConfig' on container recreation).
2048
2093
  */
2049
2094
  function getComposeCmd(): string[] | null {
2050
2095
  const tryCmd = (cmd: string, args: string[]): boolean =>
2051
2096
  spawnSync(cmd, args, { stdio: "ignore", timeout: 5000 } as Parameters<typeof spawnSync>[2]).status === 0;
2052
- if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
2053
2097
  if (tryCmd("docker", ["compose", "version"])) return ["docker", "compose"];
2098
+ if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
2054
2099
  return null;
2055
2100
  }
2056
2101
 
@@ -2089,9 +2134,9 @@ function registerMonitoringInstance(
2089
2134
  const debug = opts?.debug;
2090
2135
 
2091
2136
  if (debug) {
2092
- console.log(`\nDebug: Registering monitoring instance...`);
2093
- console.log(`Debug: POST ${url}`);
2094
- console.log(`Debug: project_name=${projectName}`);
2137
+ console.error(`\nDebug: Registering monitoring instance...`);
2138
+ console.error(`Debug: POST ${url}`);
2139
+ console.error(`Debug: project_name=${projectName}`);
2095
2140
  }
2096
2141
 
2097
2142
  // Fire and forget - don't block the main flow
@@ -2109,18 +2154,18 @@ function registerMonitoringInstance(
2109
2154
  const body = await res.text().catch(() => "");
2110
2155
  if (!res.ok) {
2111
2156
  if (debug) {
2112
- console.log(`Debug: Monitoring registration failed: HTTP ${res.status}`);
2113
- console.log(`Debug: Response: ${body}`);
2157
+ console.error(`Debug: Monitoring registration failed: HTTP ${res.status}`);
2158
+ console.error(`Debug: Response: ${body}`);
2114
2159
  }
2115
2160
  return;
2116
2161
  }
2117
2162
  if (debug) {
2118
- console.log(`Debug: Monitoring registration response: ${body}`);
2163
+ console.error(`Debug: Monitoring registration response: ${body}`);
2119
2164
  }
2120
2165
  })
2121
2166
  .catch((err) => {
2122
2167
  if (debug) {
2123
- console.log(`Debug: Monitoring registration error: ${err.message}`);
2168
+ console.error(`Debug: Monitoring registration error: ${err.message}`);
2124
2169
  }
2125
2170
  });
2126
2171
  }
@@ -2208,6 +2253,26 @@ async function runCompose(args: string[], grafanaPassword?: string): Promise<num
2208
2253
  }
2209
2254
  }
2210
2255
 
2256
+ // Load VM auth credentials from .env if not already set
2257
+ const envFilePath = path.resolve(projectDir, ".env");
2258
+ if (fs.existsSync(envFilePath)) {
2259
+ try {
2260
+ const envContent = fs.readFileSync(envFilePath, "utf8");
2261
+ if (!env.VM_AUTH_USERNAME) {
2262
+ const m = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
2263
+ if (m) env.VM_AUTH_USERNAME = m[1].trim().replace(/^["']|["']$/g, '');
2264
+ }
2265
+ if (!env.VM_AUTH_PASSWORD) {
2266
+ const m = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
2267
+ if (m) env.VM_AUTH_PASSWORD = m[1].trim().replace(/^["']|["']$/g, '');
2268
+ }
2269
+ } catch (err) {
2270
+ if (process.env.DEBUG) {
2271
+ console.warn(`Warning: Could not read VM auth from .env: ${err instanceof Error ? err.message : String(err)}`);
2272
+ }
2273
+ }
2274
+ }
2275
+
2211
2276
  // On macOS, self-node-exporter can't mount host root filesystem - skip it
2212
2277
  const finalArgs = [...args];
2213
2278
  if (process.platform === "darwin" && args.includes("up")) {
@@ -2300,8 +2365,8 @@ mon
2300
2365
 
2301
2366
  // Validate conflicting options
2302
2367
  if (opts.demo && opts.dbUrl) {
2303
- console.log("⚠ Both --demo and --db-url provided. Demo mode includes its own database.");
2304
- console.log("⚠ The --db-url will be ignored in demo mode.\n");
2368
+ console.error("⚠ Both --demo and --db-url provided. Demo mode includes its own database.");
2369
+ console.error("⚠ The --db-url will be ignored in demo mode.\n");
2305
2370
  opts.dbUrl = undefined;
2306
2371
  }
2307
2372
 
@@ -2317,7 +2382,7 @@ mon
2317
2382
  // Check if containers are already running
2318
2383
  const { running, containers } = checkRunningContainers();
2319
2384
  if (running) {
2320
- console.log(`⚠ Monitoring services are already running: ${containers.join(", ")}`);
2385
+ console.error(`⚠ Monitoring services are already running: ${containers.join(", ")}`);
2321
2386
  console.log("Use 'postgres-ai mon restart' to restart them\n");
2322
2387
  return;
2323
2388
  }
@@ -2336,7 +2401,7 @@ mon
2336
2401
  } else if (opts.yes) {
2337
2402
  // Auto-yes mode without API key - skip API key setup
2338
2403
  console.log("Auto-yes mode: no API key provided, skipping API key setup");
2339
- console.log("⚠ Reports will be generated locally only");
2404
+ console.error("⚠ Reports will be generated locally only");
2340
2405
  console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
2341
2406
  } else {
2342
2407
  const answer = await question("Do you have a Postgres AI API key? (Y/n): ");
@@ -2356,16 +2421,16 @@ mon
2356
2421
  break;
2357
2422
  }
2358
2423
 
2359
- console.log("⚠ API key cannot be empty");
2424
+ console.error("⚠ API key cannot be empty");
2360
2425
  const retry = await question("Try again or skip API key setup, retry? (Y/n): ");
2361
2426
  if (retry.toLowerCase() === "n") {
2362
- console.log("⚠ Skipping API key setup - reports will be generated locally only");
2427
+ console.error("⚠ Skipping API key setup - reports will be generated locally only");
2363
2428
  console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
2364
2429
  break;
2365
2430
  }
2366
2431
  }
2367
2432
  } else {
2368
- console.log("⚠ Skipping API key setup - reports will be generated locally only");
2433
+ console.error("⚠ Skipping API key setup - reports will be generated locally only");
2369
2434
  console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
2370
2435
  }
2371
2436
  }
@@ -2425,7 +2490,7 @@ mon
2425
2490
  } else if (opts.yes) {
2426
2491
  // Auto-yes mode without database URL - skip database setup
2427
2492
  console.log("Auto-yes mode: no database URL provided, skipping database setup");
2428
- console.log("⚠ No PostgreSQL instance added");
2493
+ console.error("⚠ No PostgreSQL instance added");
2429
2494
  console.log("You can add one later with: postgres-ai mon targets add\n");
2430
2495
  } else {
2431
2496
  console.log("You need to add at least one PostgreSQL instance to monitor");
@@ -2443,7 +2508,7 @@ mon
2443
2508
  const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
2444
2509
  if (!m) {
2445
2510
  console.error("✗ Invalid connection string format");
2446
- console.log("⚠ Continuing without adding instance\n");
2511
+ console.error("⚠ Continuing without adding instance\n");
2447
2512
  } else {
2448
2513
  const host = m[3];
2449
2514
  const db = m[5];
@@ -2468,14 +2533,23 @@ mon
2468
2533
  }
2469
2534
  }
2470
2535
  } else {
2471
- console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
2536
+ console.error("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
2472
2537
  }
2473
2538
  } else {
2474
- console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
2539
+ console.error("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
2475
2540
  }
2476
2541
  }
2477
2542
  } else {
2478
- console.log("Step 2: Demo mode enabled - using included demo PostgreSQL database\n");
2543
+ // Demo mode: copy instances.demo.yml instances.yml so the demo target is active
2544
+ console.log("Step 2: Demo mode enabled - using included demo PostgreSQL database");
2545
+ const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
2546
+ const demoSrc = path.resolve(projectDir, "instances.demo.yml");
2547
+ if (fs.existsSync(demoSrc)) {
2548
+ fs.copyFileSync(demoSrc, instancesPath);
2549
+ console.log("✓ Demo monitoring target configured\n");
2550
+ } else {
2551
+ console.error("⚠ instances.demo.yml not found — demo target not configured\n");
2552
+ }
2479
2553
  }
2480
2554
 
2481
2555
  // Step 3: Update configuration
@@ -2491,6 +2565,8 @@ mon
2491
2565
  console.log(opts.demo ? "Step 4: Configuring Grafana security..." : "Step 4: Configuring Grafana security...");
2492
2566
  const cfgPath = path.resolve(projectDir, ".pgwatch-config");
2493
2567
  let grafanaPassword = "";
2568
+ let vmAuthUsername = "";
2569
+ let vmAuthPassword = "";
2494
2570
 
2495
2571
  try {
2496
2572
  if (fs.existsSync(cfgPath)) {
@@ -2524,12 +2600,58 @@ mon
2524
2600
 
2525
2601
  console.log("✓ Grafana password configured\n");
2526
2602
  } catch (error) {
2527
- console.log("⚠ Could not generate Grafana password automatically");
2603
+ console.error("⚠ Could not generate Grafana password automatically");
2528
2604
  console.log("Using default password: demo\n");
2529
2605
  grafanaPassword = "demo";
2530
2606
  }
2531
2607
 
2608
+ // Generate VictoriaMetrics auth credentials
2609
+ try {
2610
+ const envFile = path.resolve(projectDir, ".env");
2611
+
2612
+ // Read existing VM auth from .env if present
2613
+ if (fs.existsSync(envFile)) {
2614
+ const envContent = fs.readFileSync(envFile, "utf8");
2615
+ const userMatch = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
2616
+ const passMatch = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
2617
+ if (userMatch) vmAuthUsername = userMatch[1].trim().replace(/^["']|["']$/g, '');
2618
+ if (passMatch) vmAuthPassword = passMatch[1].trim().replace(/^["']|["']$/g, '');
2619
+ }
2620
+
2621
+ if (!vmAuthUsername || !vmAuthPassword) {
2622
+ console.log("Generating VictoriaMetrics auth credentials...");
2623
+ vmAuthUsername = vmAuthUsername || "vmauth";
2624
+ if (!vmAuthPassword) {
2625
+ const { stdout: vmPass } = await execPromise("openssl rand -base64 12 | tr -d '\n'");
2626
+ vmAuthPassword = vmPass.trim();
2627
+ }
2628
+
2629
+ // Update .env file with VM auth credentials
2630
+ let envContent = "";
2631
+ if (fs.existsSync(envFile)) {
2632
+ envContent = fs.readFileSync(envFile, "utf8");
2633
+ }
2634
+ const envLines = envContent.split(/\r?\n/)
2635
+ .filter((l) => !/^VM_AUTH_USERNAME=/.test(l) && !/^VM_AUTH_PASSWORD=/.test(l))
2636
+ .filter((l, i, arr) => !(i === arr.length - 1 && l === ''));
2637
+ envLines.push(`VM_AUTH_USERNAME=${vmAuthUsername}`);
2638
+ envLines.push(`VM_AUTH_PASSWORD=${vmAuthPassword}`);
2639
+ fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
2640
+ }
2641
+
2642
+ console.log("✓ VictoriaMetrics auth configured\n");
2643
+ } catch (error) {
2644
+ console.error("⚠ Could not generate VictoriaMetrics auth credentials automatically");
2645
+ if (process.env.DEBUG) {
2646
+ console.warn(` ${error instanceof Error ? error.message : String(error)}`);
2647
+ }
2648
+ }
2649
+
2532
2650
  // Step 5: Start services
2651
+ // Remove stopped containers left by "run --rm" dependencies (e.g. config-init)
2652
+ // to avoid docker-compose v1 'ContainerConfig' error on recreation.
2653
+ // Best-effort: ignore exit code — container may not exist, failure here is non-fatal.
2654
+ await runCompose(["rm", "-f", "-s", "config-init"]);
2533
2655
  console.log("Step 5: Starting monitoring services...");
2534
2656
  const code2 = await runCompose(["up", "-d", "--force-recreate"], grafanaPassword);
2535
2657
  if (code2 !== 0) {
@@ -2579,6 +2701,9 @@ mon
2579
2701
  console.log("🚀 MAIN ACCESS POINT - Start here:");
2580
2702
  console.log(" Grafana Dashboard: http://localhost:3000");
2581
2703
  console.log(` Login: monitor / ${grafanaPassword}`);
2704
+ if (vmAuthUsername && vmAuthPassword) {
2705
+ console.log(` VictoriaMetrics Auth: ${vmAuthUsername} / ${vmAuthPassword}`);
2706
+ }
2582
2707
  console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
2583
2708
  });
2584
2709
 
@@ -2916,7 +3041,7 @@ mon
2916
3041
  if (downCode === 0) {
2917
3042
  console.log("✓ Monitoring services stopped and removed");
2918
3043
  } else {
2919
- console.log("⚠ Could not stop services (may not be running)");
3044
+ console.error("⚠ Could not stop services (may not be running)");
2920
3045
  }
2921
3046
 
2922
3047
  // Remove any orphaned containers that docker compose down missed
@@ -3229,8 +3354,8 @@ auth
3229
3354
  const { apiBaseUrl, uiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3230
3355
 
3231
3356
  if (opts.debug) {
3232
- console.log(`Debug: Resolved API base URL: ${apiBaseUrl}`);
3233
- console.log(`Debug: Resolved UI base URL: ${uiBaseUrl}`);
3357
+ console.error(`Debug: Resolved API base URL: ${apiBaseUrl}`);
3358
+ console.error(`Debug: Resolved UI base URL: ${uiBaseUrl}`);
3234
3359
  }
3235
3360
 
3236
3361
  try {
@@ -3260,8 +3385,8 @@ auth
3260
3385
  const initUrl = new URL(`${apiBaseUrl}/rpc/oauth_init`);
3261
3386
 
3262
3387
  if (opts.debug) {
3263
- console.log(`Debug: Trying to POST to: ${initUrl.toString()}`);
3264
- console.log(`Debug: Request data: ${initData}`);
3388
+ console.error(`Debug: Trying to POST to: ${initUrl.toString()}`);
3389
+ console.error(`Debug: Request data: ${initData}`);
3265
3390
  }
3266
3391
 
3267
3392
  // Step 2: Initialize OAuth session on backend using fetch
@@ -3307,7 +3432,7 @@ auth
3307
3432
  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)}`;
3308
3433
 
3309
3434
  if (opts.debug) {
3310
- console.log(`Debug: Auth URL: ${authUrl}`);
3435
+ console.error(`Debug: Auth URL: ${authUrl}`);
3311
3436
  }
3312
3437
 
3313
3438
  console.log(`\nOpening browser for authentication...`);
@@ -3598,6 +3723,19 @@ mon
3598
3723
  console.log(" URL: http://localhost:3000");
3599
3724
  console.log(" Username: monitor");
3600
3725
  console.log(` Password: ${password}`);
3726
+
3727
+ // Show VM auth credentials from .env
3728
+ const envFile = path.resolve(projectDir, ".env");
3729
+ if (fs.existsSync(envFile)) {
3730
+ const envContent = fs.readFileSync(envFile, "utf8");
3731
+ const vmUser = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
3732
+ const vmPass = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
3733
+ if (vmUser && vmPass) {
3734
+ console.log("\nVictoriaMetrics credentials:");
3735
+ console.log(` Username: ${vmUser[1].trim().replace(/^["']|["']$/g, '')}`);
3736
+ console.log(` Password: ${vmPass[1].trim().replace(/^["']|["']$/g, '')}`);
3737
+ }
3738
+ }
3601
3739
  console.log("");
3602
3740
  });
3603
3741
 
@@ -3731,12 +3869,12 @@ issues
3731
3869
  // Interpret escape sequences in content (e.g., \n -> newline)
3732
3870
  if (opts.debug) {
3733
3871
  // eslint-disable-next-line no-console
3734
- console.log(`Debug: Original content: ${JSON.stringify(content)}`);
3872
+ console.error(`Debug: Original content: ${JSON.stringify(content)}`);
3735
3873
  }
3736
3874
  content = interpretEscapes(content);
3737
3875
  if (opts.debug) {
3738
3876
  // eslint-disable-next-line no-console
3739
- console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
3877
+ console.error(`Debug: Interpreted content: ${JSON.stringify(content)}`);
3740
3878
  }
3741
3879
 
3742
3880
  const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Posting comment...");
@@ -3930,12 +4068,12 @@ issues
3930
4068
  .action(async (commentId: string, content: string, opts: { debug?: boolean; json?: boolean }) => {
3931
4069
  if (opts.debug) {
3932
4070
  // eslint-disable-next-line no-console
3933
- console.log(`Debug: Original content: ${JSON.stringify(content)}`);
4071
+ console.error(`Debug: Original content: ${JSON.stringify(content)}`);
3934
4072
  }
3935
4073
  content = interpretEscapes(content);
3936
4074
  if (opts.debug) {
3937
4075
  // eslint-disable-next-line no-console
3938
- console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
4076
+ console.error(`Debug: Interpreted content: ${JSON.stringify(content)}`);
3939
4077
  }
3940
4078
 
3941
4079
  const rootOpts = program.opts<CliOptions>();
@@ -3968,6 +4106,93 @@ issues
3968
4106
  }
3969
4107
  });
3970
4108
 
4109
+ // File upload/download (subcommands of issues)
4110
+ const issueFiles = issues.command("files").description("upload and download files for issues");
4111
+
4112
+ issueFiles
4113
+ .command("upload <path>")
4114
+ .description("upload a file to storage and get a markdown link")
4115
+ .option("--debug", "enable debug output")
4116
+ .option("--json", "output raw JSON")
4117
+ .action(async (filePath: string, opts: { debug?: boolean; json?: boolean }) => {
4118
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Uploading file...");
4119
+ try {
4120
+ const rootOpts = program.opts<CliOptions>();
4121
+ const cfg = config.readConfig();
4122
+ const { apiKey } = getConfig(rootOpts);
4123
+ if (!apiKey) {
4124
+ spinner.stop();
4125
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
4126
+ process.exitCode = 1;
4127
+ return;
4128
+ }
4129
+
4130
+ const { storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4131
+
4132
+ const result = await uploadFile({
4133
+ apiKey,
4134
+ storageBaseUrl,
4135
+ filePath,
4136
+ debug: !!opts.debug,
4137
+ });
4138
+ spinner.stop();
4139
+
4140
+ if (opts.json) {
4141
+ printResult(result, true);
4142
+ } else {
4143
+ const md = buildMarkdownLink(result.url, storageBaseUrl, result.metadata.originalName);
4144
+ const displayUrl = result.url.startsWith("/") ? `${storageBaseUrl}${result.url}` : `${storageBaseUrl}/${result.url}`;
4145
+ console.log(`URL: ${displayUrl}`);
4146
+ console.log(`File: ${result.metadata.originalName}`);
4147
+ console.log(`Size: ${result.metadata.size} bytes`);
4148
+ console.log(`Type: ${result.metadata.mimeType}`);
4149
+ console.log(`Markdown: ${md}`);
4150
+ }
4151
+ } catch (err) {
4152
+ spinner.stop();
4153
+ const message = err instanceof Error ? err.message : String(err);
4154
+ console.error(message);
4155
+ process.exitCode = 1;
4156
+ }
4157
+ });
4158
+
4159
+ issueFiles
4160
+ .command("download <url>")
4161
+ .description("download a file from storage")
4162
+ .option("-o, --output <path>", "output file path (default: derive from URL)")
4163
+ .option("--debug", "enable debug output")
4164
+ .action(async (fileUrl: string, opts: { output?: string; debug?: boolean }) => {
4165
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Downloading file...");
4166
+ try {
4167
+ const rootOpts = program.opts<CliOptions>();
4168
+ const cfg = config.readConfig();
4169
+ const { apiKey } = getConfig(rootOpts);
4170
+ if (!apiKey) {
4171
+ spinner.stop();
4172
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
4173
+ process.exitCode = 1;
4174
+ return;
4175
+ }
4176
+
4177
+ const { storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4178
+
4179
+ const result = await downloadFile({
4180
+ apiKey,
4181
+ storageBaseUrl,
4182
+ fileUrl,
4183
+ outputPath: opts.output,
4184
+ debug: !!opts.debug,
4185
+ });
4186
+ spinner.stop();
4187
+ console.log(`Saved: ${result.savedTo}`);
4188
+ } catch (err) {
4189
+ spinner.stop();
4190
+ const message = err instanceof Error ? err.message : String(err);
4191
+ console.error(message);
4192
+ process.exitCode = 1;
4193
+ }
4194
+ });
4195
+
3971
4196
  // Action Items management (subcommands of issues)
3972
4197
  issues
3973
4198
  .command("action-items <issueId>")