postgresai 0.15.0-dev.4 → 0.15.0-dev.6

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.
@@ -7,6 +7,7 @@ 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";
@@ -503,7 +504,11 @@ async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
503
504
  }
504
505
  }
505
506
 
506
- // Ensure instances.yml exists as a FILE (avoid Docker creating a directory)
507
+ // Ensure instances.yml exists as a FILE (avoid Docker creating a directory).
508
+ // Docker bind-mounts create missing paths as directories; replace if so.
509
+ if (fs.existsSync(instancesFile) && fs.statSync(instancesFile).isDirectory()) {
510
+ fs.rmSync(instancesFile, { recursive: true, force: true });
511
+ }
507
512
  if (!fs.existsSync(instancesFile)) {
508
513
  const header =
509
514
  "# PostgreSQL instances to monitor\n" +
@@ -2071,13 +2076,16 @@ function isDockerRunning(): boolean {
2071
2076
  }
2072
2077
 
2073
2078
  /**
2074
- * Get docker compose command
2079
+ * Get docker compose command.
2080
+ * Prefer "docker compose" (V2 plugin) over legacy "docker-compose" (V1 standalone)
2081
+ * because docker-compose V1 (<=1.29) is incompatible with modern Docker engines
2082
+ * (KeyError: 'ContainerConfig' on container recreation).
2075
2083
  */
2076
2084
  function getComposeCmd(): string[] | null {
2077
2085
  const tryCmd = (cmd: string, args: string[]): boolean =>
2078
2086
  spawnSync(cmd, args, { stdio: "ignore", timeout: 5000 } as Parameters<typeof spawnSync>[2]).status === 0;
2079
- if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
2080
2087
  if (tryCmd("docker", ["compose", "version"])) return ["docker", "compose"];
2088
+ if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
2081
2089
  return null;
2082
2090
  }
2083
2091
 
@@ -2235,6 +2243,26 @@ async function runCompose(args: string[], grafanaPassword?: string): Promise<num
2235
2243
  }
2236
2244
  }
2237
2245
 
2246
+ // Load VM auth credentials from .env if not already set
2247
+ const envFilePath = path.resolve(projectDir, ".env");
2248
+ if (fs.existsSync(envFilePath)) {
2249
+ try {
2250
+ const envContent = fs.readFileSync(envFilePath, "utf8");
2251
+ if (!env.VM_AUTH_USERNAME) {
2252
+ const m = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
2253
+ if (m) env.VM_AUTH_USERNAME = m[1].trim().replace(/^["']|["']$/g, '');
2254
+ }
2255
+ if (!env.VM_AUTH_PASSWORD) {
2256
+ const m = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
2257
+ if (m) env.VM_AUTH_PASSWORD = m[1].trim().replace(/^["']|["']$/g, '');
2258
+ }
2259
+ } catch (err) {
2260
+ if (process.env.DEBUG) {
2261
+ console.warn(`Warning: Could not read VM auth from .env: ${err instanceof Error ? err.message : String(err)}`);
2262
+ }
2263
+ }
2264
+ }
2265
+
2238
2266
  // On macOS, self-node-exporter can't mount host root filesystem - skip it
2239
2267
  const finalArgs = [...args];
2240
2268
  if (process.platform === "darwin" && args.includes("up")) {
@@ -2279,7 +2307,7 @@ mon
2279
2307
  console.log("This will install, configure, and start the monitoring system\n");
2280
2308
 
2281
2309
  // Ensure we have a project directory with docker-compose.yml even if running from elsewhere
2282
- const { projectDir } = await resolveOrInitPaths();
2310
+ const { projectDir, instancesFile: instancesPath } = await resolveOrInitPaths();
2283
2311
  console.log(`Project directory: ${projectDir}\n`);
2284
2312
 
2285
2313
  // Save project name to .pgwatch-config if provided (used by reporter container)
@@ -2502,7 +2530,26 @@ mon
2502
2530
  }
2503
2531
  }
2504
2532
  } else {
2505
- console.log("Step 2: Demo mode enabled - using included demo PostgreSQL database\n");
2533
+ // Demo mode: copy bundled instances.demo.yml instances.yml so the demo target is active
2534
+ console.log("Step 2: Demo mode enabled - using included demo PostgreSQL database");
2535
+ // Use import.meta.url instead of __dirname — bundlers bake in __dirname at build time.
2536
+ // Check multiple candidate paths (npm package vs repo dev layout).
2537
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
2538
+ const demoCandidates = [
2539
+ path.resolve(currentDir, "..", "..", "instances.demo.yml"), // npm: dist/bin -> package root
2540
+ path.resolve(currentDir, "..", "..", "..", "instances.demo.yml"), // dev: cli/bin -> repo root
2541
+ ];
2542
+ const demoSrc = demoCandidates.find(p => fs.existsSync(p));
2543
+ if (demoSrc) {
2544
+ // Remove directory artifact left by Docker bind-mounts
2545
+ if (fs.existsSync(instancesPath) && fs.statSync(instancesPath).isDirectory()) {
2546
+ fs.rmSync(instancesPath, { recursive: true, force: true });
2547
+ }
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 (searched: ${demoCandidates.join(", ")})\n`);
2552
+ }
2506
2553
  }
2507
2554
 
2508
2555
  // Step 3: Update configuration
@@ -2518,6 +2565,8 @@ mon
2518
2565
  console.log(opts.demo ? "Step 4: Configuring Grafana security..." : "Step 4: Configuring Grafana security...");
2519
2566
  const cfgPath = path.resolve(projectDir, ".pgwatch-config");
2520
2567
  let grafanaPassword = "";
2568
+ let vmAuthUsername = "";
2569
+ let vmAuthPassword = "";
2521
2570
 
2522
2571
  try {
2523
2572
  if (fs.existsSync(cfgPath)) {
@@ -2556,7 +2605,53 @@ mon
2556
2605
  grafanaPassword = "demo";
2557
2606
  }
2558
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
+
2559
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"]);
2560
2655
  console.log("Step 5: Starting monitoring services...");
2561
2656
  const code2 = await runCompose(["up", "-d", "--force-recreate"], grafanaPassword);
2562
2657
  if (code2 !== 0) {
@@ -2606,6 +2701,9 @@ mon
2606
2701
  console.log("🚀 MAIN ACCESS POINT - Start here:");
2607
2702
  console.log(" Grafana Dashboard: http://localhost:3000");
2608
2703
  console.log(` Login: monitor / ${grafanaPassword}`);
2704
+ if (vmAuthUsername && vmAuthPassword) {
2705
+ console.log(` VictoriaMetrics Auth: ${vmAuthUsername} / ${vmAuthPassword}`);
2706
+ }
2609
2707
  console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
2610
2708
  });
2611
2709
 
@@ -2806,7 +2904,7 @@ mon
2806
2904
  console.log(`Project Directory: ${projectDir}`);
2807
2905
  console.log(`Docker Compose File: ${composeFile}`);
2808
2906
  console.log(`Instances File: ${instancesFile}`);
2809
- if (fs.existsSync(instancesFile)) {
2907
+ if (fs.existsSync(instancesFile) && !fs.statSync(instancesFile).isDirectory()) {
2810
2908
  console.log("\nInstances configuration:\n");
2811
2909
  const text = fs.readFileSync(instancesFile, "utf8");
2812
2910
  process.stdout.write(text);
@@ -3022,7 +3120,7 @@ targets
3022
3120
  .description("list monitoring target databases")
3023
3121
  .action(async () => {
3024
3122
  const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
3025
- if (!fs.existsSync(instancesPath)) {
3123
+ if (!fs.existsSync(instancesPath) || fs.statSync(instancesPath).isDirectory()) {
3026
3124
  console.error(`instances.yml not found in ${projectDir}`);
3027
3125
  process.exitCode = 1;
3028
3126
  return;
@@ -3088,7 +3186,7 @@ targets
3088
3186
 
3089
3187
  // Check if instance already exists
3090
3188
  try {
3091
- if (fs.existsSync(file)) {
3189
+ if (fs.existsSync(file) && !fs.statSync(file).isDirectory()) {
3092
3190
  const content = fs.readFileSync(file, "utf8");
3093
3191
  const instances = yaml.load(content) as Instance[] | null || [];
3094
3192
  if (Array.isArray(instances)) {
@@ -3102,7 +3200,8 @@ targets
3102
3200
  }
3103
3201
  } catch (err) {
3104
3202
  // If YAML parsing fails, fall back to simple check
3105
- const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
3203
+ const isFile = fs.existsSync(file) && !fs.statSync(file).isDirectory();
3204
+ const content = isFile ? fs.readFileSync(file, "utf8") : "";
3106
3205
  if (new RegExp(`^- name: ${instanceName}$`, "m").test(content)) {
3107
3206
  console.error(`Monitoring target '${instanceName}' already exists`);
3108
3207
  process.exitCode = 1;
@@ -3110,7 +3209,10 @@ targets
3110
3209
  }
3111
3210
  }
3112
3211
 
3113
- // Add new instance
3212
+ // Add new instance — if instances.yml is a directory (Docker artifact), replace it with a file
3213
+ if (fs.existsSync(file) && fs.statSync(file).isDirectory()) {
3214
+ fs.rmSync(file, { recursive: true, force: true });
3215
+ }
3114
3216
  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`;
3115
3217
  const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
3116
3218
  fs.appendFileSync(file, (content && !/\n$/.test(content) ? "\n" : "") + body, "utf8");
@@ -3121,7 +3223,7 @@ targets
3121
3223
  .description("remove monitoring target database")
3122
3224
  .action(async (name: string) => {
3123
3225
  const { instancesFile: file } = await resolveOrInitPaths();
3124
- if (!fs.existsSync(file)) {
3226
+ if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) {
3125
3227
  console.error("instances.yml not found");
3126
3228
  process.exitCode = 1;
3127
3229
  return;
@@ -3158,7 +3260,7 @@ targets
3158
3260
  .description("test monitoring target database connectivity")
3159
3261
  .action(async (name: string) => {
3160
3262
  const { instancesFile: instancesPath } = await resolveOrInitPaths();
3161
- if (!fs.existsSync(instancesPath)) {
3263
+ if (!fs.existsSync(instancesPath) || fs.statSync(instancesPath).isDirectory()) {
3162
3264
  console.error("instances.yml not found");
3163
3265
  process.exitCode = 1;
3164
3266
  return;
@@ -3625,6 +3727,19 @@ mon
3625
3727
  console.log(" URL: http://localhost:3000");
3626
3728
  console.log(" Username: monitor");
3627
3729
  console.log(` Password: ${password}`);
3730
+
3731
+ // Show VM auth credentials from .env
3732
+ const envFile = path.resolve(projectDir, ".env");
3733
+ if (fs.existsSync(envFile)) {
3734
+ const envContent = fs.readFileSync(envFile, "utf8");
3735
+ const vmUser = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
3736
+ const vmPass = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
3737
+ if (vmUser && vmPass) {
3738
+ console.log("\nVictoriaMetrics credentials:");
3739
+ console.log(` Username: ${vmUser[1].trim().replace(/^["']|["']$/g, '')}`);
3740
+ console.log(` Password: ${vmPass[1].trim().replace(/^["']|["']$/g, '')}`);
3741
+ }
3742
+ }
3628
3743
  console.log("");
3629
3744
  });
3630
3745
 
@@ -13151,7 +13151,7 @@ var {
13151
13151
  // package.json
13152
13152
  var package_default = {
13153
13153
  name: "postgresai",
13154
- version: "0.15.0-dev.4",
13154
+ version: "0.15.0-dev.6",
13155
13155
  description: "postgres_ai CLI",
13156
13156
  license: "Apache-2.0",
13157
13157
  private: false,
@@ -15965,6 +15965,7 @@ var safeDump = renamed("safeDump", "dump");
15965
15965
  import * as fs6 from "fs";
15966
15966
  import * as path6 from "path";
15967
15967
  import * as os3 from "os";
15968
+ import { fileURLToPath as fileURLToPath2 } from "url";
15968
15969
  import * as crypto2 from "crypto";
15969
15970
 
15970
15971
  // node_modules/pg/esm/index.mjs
@@ -15981,7 +15982,7 @@ var Result = import_lib.default.Result;
15981
15982
  var TypeOverrides = import_lib.default.TypeOverrides;
15982
15983
  var defaults = import_lib.default.defaults;
15983
15984
  // package.json
15984
- var version = "0.15.0-dev.4";
15985
+ var version = "0.15.0-dev.6";
15985
15986
  var package_default2 = {
15986
15987
  name: "postgresai",
15987
15988
  version,
@@ -29890,6 +29891,9 @@ async function ensureDefaultMonitoringProject() {
29890
29891
  throw new Error(`Failed to bootstrap docker-compose.yml: ${msg}`);
29891
29892
  }
29892
29893
  }
29894
+ if (fs6.existsSync(instancesFile) && fs6.statSync(instancesFile).isDirectory()) {
29895
+ fs6.rmSync(instancesFile, { recursive: true, force: true });
29896
+ }
29893
29897
  if (!fs6.existsSync(instancesFile)) {
29894
29898
  const header = `# PostgreSQL instances to monitor
29895
29899
  ` + `# Add your instances using: pgai mon targets add <connection-string> <name>
@@ -31138,10 +31142,10 @@ function isDockerRunning() {
31138
31142
  }
31139
31143
  function getComposeCmd() {
31140
31144
  const tryCmd = (cmd, args) => spawnSync2(cmd, args, { stdio: "ignore", timeout: 5000 }).status === 0;
31141
- if (tryCmd("docker-compose", ["version"]))
31142
- return ["docker-compose"];
31143
31145
  if (tryCmd("docker", ["compose", "version"]))
31144
31146
  return ["docker", "compose"];
31147
+ if (tryCmd("docker-compose", ["version"]))
31148
+ return ["docker-compose"];
31145
31149
  return null;
31146
31150
  }
31147
31151
  function checkRunningContainers() {
@@ -31259,6 +31263,26 @@ async function runCompose(args, grafanaPassword) {
31259
31263
  }
31260
31264
  }
31261
31265
  }
31266
+ const envFilePath = path6.resolve(projectDir, ".env");
31267
+ if (fs6.existsSync(envFilePath)) {
31268
+ try {
31269
+ const envContent = fs6.readFileSync(envFilePath, "utf8");
31270
+ if (!env.VM_AUTH_USERNAME) {
31271
+ const m = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
31272
+ if (m)
31273
+ env.VM_AUTH_USERNAME = m[1].trim().replace(/^["']|["']$/g, "");
31274
+ }
31275
+ if (!env.VM_AUTH_PASSWORD) {
31276
+ const m = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
31277
+ if (m)
31278
+ env.VM_AUTH_PASSWORD = m[1].trim().replace(/^["']|["']$/g, "");
31279
+ }
31280
+ } catch (err) {
31281
+ if (process.env.DEBUG) {
31282
+ console.warn(`Warning: Could not read VM auth from .env: ${err instanceof Error ? err.message : String(err)}`);
31283
+ }
31284
+ }
31285
+ }
31262
31286
  const finalArgs = [...args];
31263
31287
  if (process.platform === "darwin" && args.includes("up")) {
31264
31288
  finalArgs.push("--scale", "self-node-exporter=0");
@@ -31286,7 +31310,7 @@ mon.command("local-install").description("install local monitoring stack (genera
31286
31310
  `);
31287
31311
  console.log(`This will install, configure, and start the monitoring system
31288
31312
  `);
31289
- const { projectDir } = await resolveOrInitPaths();
31313
+ const { projectDir, instancesFile: instancesPath } = await resolveOrInitPaths();
31290
31314
  console.log(`Project directory: ${projectDir}
31291
31315
  `);
31292
31316
  if (opts.project) {
@@ -31397,13 +31421,13 @@ Use demo mode without API key: postgres-ai mon local-install --demo`);
31397
31421
  if (!opts.demo) {
31398
31422
  console.log(`Step 2: Add PostgreSQL Instance to Monitor
31399
31423
  `);
31400
- const { instancesFile: instancesPath, projectDir: projectDir2 } = await resolveOrInitPaths();
31424
+ const { instancesFile: instancesPath2, projectDir: projectDir2 } = await resolveOrInitPaths();
31401
31425
  const emptyInstancesContent = `# PostgreSQL instances to monitor
31402
31426
  # Add your instances using: postgres-ai mon targets add
31403
31427
 
31404
31428
  `;
31405
- fs6.writeFileSync(instancesPath, emptyInstancesContent, "utf8");
31406
- console.log(`Instances file: ${instancesPath}`);
31429
+ fs6.writeFileSync(instancesPath2, emptyInstancesContent, "utf8");
31430
+ console.log(`Instances file: ${instancesPath2}`);
31407
31431
  console.log(`Project directory: ${projectDir2}
31408
31432
  `);
31409
31433
  if (opts.dbUrl) {
@@ -31434,7 +31458,7 @@ Use demo mode without API key: postgres-ai mon local-install --demo`);
31434
31458
  node_name: ${instanceName}
31435
31459
  sink_type: ~sink_type~
31436
31460
  `;
31437
- fs6.appendFileSync(instancesPath, body, "utf8");
31461
+ fs6.appendFileSync(instancesPath2, body, "utf8");
31438
31462
  console.log(`\u2713 Monitoring target '${instanceName}' added
31439
31463
  `);
31440
31464
  console.log("Testing connection to the added instance...");
@@ -31489,7 +31513,7 @@ You can provide either:`);
31489
31513
  node_name: ${instanceName}
31490
31514
  sink_type: ~sink_type~
31491
31515
  `;
31492
- fs6.appendFileSync(instancesPath, body, "utf8");
31516
+ fs6.appendFileSync(instancesPath2, body, "utf8");
31493
31517
  console.log(`\u2713 Monitoring target '${instanceName}' added
31494
31518
  `);
31495
31519
  console.log("Testing connection to the added instance...");
@@ -31517,8 +31541,24 @@ You can provide either:`);
31517
31541
  }
31518
31542
  }
31519
31543
  } else {
31520
- console.log(`Step 2: Demo mode enabled - using included demo PostgreSQL database
31544
+ console.log("Step 2: Demo mode enabled - using included demo PostgreSQL database");
31545
+ const currentDir = path6.dirname(fileURLToPath2(import.meta.url));
31546
+ const demoCandidates = [
31547
+ path6.resolve(currentDir, "..", "..", "instances.demo.yml"),
31548
+ path6.resolve(currentDir, "..", "..", "..", "instances.demo.yml")
31549
+ ];
31550
+ const demoSrc = demoCandidates.find((p) => fs6.existsSync(p));
31551
+ if (demoSrc) {
31552
+ if (fs6.existsSync(instancesPath) && fs6.statSync(instancesPath).isDirectory()) {
31553
+ fs6.rmSync(instancesPath, { recursive: true, force: true });
31554
+ }
31555
+ fs6.copyFileSync(demoSrc, instancesPath);
31556
+ console.log(`\u2713 Demo monitoring target configured
31521
31557
  `);
31558
+ } else {
31559
+ console.error(`\u26A0 instances.demo.yml not found \u2014 demo target not configured (searched: ${demoCandidates.join(", ")})
31560
+ `);
31561
+ }
31522
31562
  }
31523
31563
  console.log(opts.demo ? "Step 3: Updating configuration..." : "Step 3: Updating configuration...");
31524
31564
  const code1 = await runCompose(["run", "--rm", "sources-generator"]);
@@ -31531,6 +31571,8 @@ You can provide either:`);
31531
31571
  console.log(opts.demo ? "Step 4: Configuring Grafana security..." : "Step 4: Configuring Grafana security...");
31532
31572
  const cfgPath = path6.resolve(projectDir, ".pgwatch-config");
31533
31573
  let grafanaPassword = "";
31574
+ let vmAuthUsername = "";
31575
+ let vmAuthPassword = "";
31534
31576
  try {
31535
31577
  if (fs6.existsSync(cfgPath)) {
31536
31578
  const stats = fs6.statSync(cfgPath);
@@ -31568,6 +31610,45 @@ You can provide either:`);
31568
31610
  `);
31569
31611
  grafanaPassword = "demo";
31570
31612
  }
31613
+ try {
31614
+ const envFile2 = path6.resolve(projectDir, ".env");
31615
+ if (fs6.existsSync(envFile2)) {
31616
+ const envContent = fs6.readFileSync(envFile2, "utf8");
31617
+ const userMatch = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
31618
+ const passMatch = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
31619
+ if (userMatch)
31620
+ vmAuthUsername = userMatch[1].trim().replace(/^["']|["']$/g, "");
31621
+ if (passMatch)
31622
+ vmAuthPassword = passMatch[1].trim().replace(/^["']|["']$/g, "");
31623
+ }
31624
+ if (!vmAuthUsername || !vmAuthPassword) {
31625
+ console.log("Generating VictoriaMetrics auth credentials...");
31626
+ vmAuthUsername = vmAuthUsername || "vmauth";
31627
+ if (!vmAuthPassword) {
31628
+ const { stdout: vmPass } = await execPromise(`openssl rand -base64 12 | tr -d '
31629
+ '`);
31630
+ vmAuthPassword = vmPass.trim();
31631
+ }
31632
+ let envContent = "";
31633
+ if (fs6.existsSync(envFile2)) {
31634
+ envContent = fs6.readFileSync(envFile2, "utf8");
31635
+ }
31636
+ const envLines2 = envContent.split(/\r?\n/).filter((l) => !/^VM_AUTH_USERNAME=/.test(l) && !/^VM_AUTH_PASSWORD=/.test(l)).filter((l, i2, arr) => !(i2 === arr.length - 1 && l === ""));
31637
+ envLines2.push(`VM_AUTH_USERNAME=${vmAuthUsername}`);
31638
+ envLines2.push(`VM_AUTH_PASSWORD=${vmAuthPassword}`);
31639
+ fs6.writeFileSync(envFile2, envLines2.join(`
31640
+ `) + `
31641
+ `, { encoding: "utf8", mode: 384 });
31642
+ }
31643
+ console.log(`\u2713 VictoriaMetrics auth configured
31644
+ `);
31645
+ } catch (error2) {
31646
+ console.error("\u26A0 Could not generate VictoriaMetrics auth credentials automatically");
31647
+ if (process.env.DEBUG) {
31648
+ console.warn(` ${error2 instanceof Error ? error2.message : String(error2)}`);
31649
+ }
31650
+ }
31651
+ await runCompose(["rm", "-f", "-s", "config-init"]);
31571
31652
  console.log("Step 5: Starting monitoring services...");
31572
31653
  const code2 = await runCompose(["up", "-d", "--force-recreate"], grafanaPassword);
31573
31654
  if (code2 !== 0) {
@@ -31615,6 +31696,9 @@ You can provide either:`);
31615
31696
  console.log("\uD83D\uDE80 MAIN ACCESS POINT - Start here:");
31616
31697
  console.log(" Grafana Dashboard: http://localhost:3000");
31617
31698
  console.log(` Login: monitor / ${grafanaPassword}`);
31699
+ if (vmAuthUsername && vmAuthPassword) {
31700
+ console.log(` VictoriaMetrics Auth: ${vmAuthUsername} / ${vmAuthPassword}`);
31701
+ }
31618
31702
  console.log(`\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
31619
31703
  `);
31620
31704
  });
@@ -31761,7 +31845,7 @@ mon.command("config").description("show monitoring services configuration").acti
31761
31845
  console.log(`Project Directory: ${projectDir}`);
31762
31846
  console.log(`Docker Compose File: ${composeFile}`);
31763
31847
  console.log(`Instances File: ${instancesFile}`);
31764
- if (fs6.existsSync(instancesFile)) {
31848
+ if (fs6.existsSync(instancesFile) && !fs6.statSync(instancesFile).isDirectory()) {
31765
31849
  console.log(`
31766
31850
  Instances configuration:
31767
31851
  `);
@@ -31936,7 +32020,7 @@ mon.command("check").description("monitoring services system readiness check").a
31936
32020
  var targets = mon.command("targets").description("manage databases to monitor");
31937
32021
  targets.command("list").description("list monitoring target databases").action(async () => {
31938
32022
  const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
31939
- if (!fs6.existsSync(instancesPath)) {
32023
+ if (!fs6.existsSync(instancesPath) || fs6.statSync(instancesPath).isDirectory()) {
31940
32024
  console.error(`instances.yml not found in ${projectDir}`);
31941
32025
  process.exitCode = 1;
31942
32026
  return;
@@ -31991,7 +32075,7 @@ targets.command("add [connStr] [name]").description("add monitoring target datab
31991
32075
  const db = m[5];
31992
32076
  const instanceName = name && name.trim() ? name.trim() : `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
31993
32077
  try {
31994
- if (fs6.existsSync(file)) {
32078
+ if (fs6.existsSync(file) && !fs6.statSync(file).isDirectory()) {
31995
32079
  const content2 = fs6.readFileSync(file, "utf8");
31996
32080
  const instances = load(content2) || [];
31997
32081
  if (Array.isArray(instances)) {
@@ -32004,13 +32088,17 @@ targets.command("add [connStr] [name]").description("add monitoring target datab
32004
32088
  }
32005
32089
  }
32006
32090
  } catch (err) {
32007
- const content2 = fs6.existsSync(file) ? fs6.readFileSync(file, "utf8") : "";
32091
+ const isFile = fs6.existsSync(file) && !fs6.statSync(file).isDirectory();
32092
+ const content2 = isFile ? fs6.readFileSync(file, "utf8") : "";
32008
32093
  if (new RegExp(`^- name: ${instanceName}$`, "m").test(content2)) {
32009
32094
  console.error(`Monitoring target '${instanceName}' already exists`);
32010
32095
  process.exitCode = 1;
32011
32096
  return;
32012
32097
  }
32013
32098
  }
32099
+ if (fs6.existsSync(file) && fs6.statSync(file).isDirectory()) {
32100
+ fs6.rmSync(file, { recursive: true, force: true });
32101
+ }
32014
32102
  const body = `- name: ${instanceName}
32015
32103
  conn_str: ${connStr}
32016
32104
  preset_metrics: full
@@ -32030,7 +32118,7 @@ targets.command("add [connStr] [name]").description("add monitoring target datab
32030
32118
  });
32031
32119
  targets.command("remove <name>").description("remove monitoring target database").action(async (name) => {
32032
32120
  const { instancesFile: file } = await resolveOrInitPaths();
32033
- if (!fs6.existsSync(file)) {
32121
+ if (!fs6.existsSync(file) || fs6.statSync(file).isDirectory()) {
32034
32122
  console.error("instances.yml not found");
32035
32123
  process.exitCode = 1;
32036
32124
  return;
@@ -32059,7 +32147,7 @@ targets.command("remove <name>").description("remove monitoring target database"
32059
32147
  });
32060
32148
  targets.command("test <name>").description("test monitoring target database connectivity").action(async (name) => {
32061
32149
  const { instancesFile: instancesPath } = await resolveOrInitPaths();
32062
- if (!fs6.existsSync(instancesPath)) {
32150
+ if (!fs6.existsSync(instancesPath) || fs6.statSync(instancesPath).isDirectory()) {
32063
32151
  console.error("instances.yml not found");
32064
32152
  process.exitCode = 1;
32065
32153
  return;
@@ -32426,6 +32514,18 @@ Grafana credentials:`);
32426
32514
  console.log(" URL: http://localhost:3000");
32427
32515
  console.log(" Username: monitor");
32428
32516
  console.log(` Password: ${password}`);
32517
+ const envFile = path6.resolve(projectDir, ".env");
32518
+ if (fs6.existsSync(envFile)) {
32519
+ const envContent = fs6.readFileSync(envFile, "utf8");
32520
+ const vmUser = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
32521
+ const vmPass = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
32522
+ if (vmUser && vmPass) {
32523
+ console.log(`
32524
+ VictoriaMetrics credentials:`);
32525
+ console.log(` Username: ${vmUser[1].trim().replace(/^["']|["']$/g, "")}`);
32526
+ console.log(` Password: ${vmPass[1].trim().replace(/^["']|["']$/g, "")}`);
32527
+ }
32528
+ }
32429
32529
  console.log("");
32430
32530
  });
32431
32531
  function interpretEscapes2(str2) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.15.0-dev.4",
3
+ "version": "0.15.0-dev.6",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -0,0 +1,120 @@
1
+ import { describe, test, expect } from "bun:test";
2
+
3
+ /**
4
+ * Test getComposeCmd() selection logic.
5
+ * WARNING: This replicates the logic from postgres-ai.ts. If the production
6
+ * function changes, this replica must be updated to match.
7
+ * Since the function is internal to postgres-ai.ts, we replicate its logic
8
+ * with an injectable command checker (same pattern as monitoring.test.ts).
9
+ */
10
+ function getComposeCmd(
11
+ tryCmd: (cmd: string, args: string[]) => boolean,
12
+ ): string[] | null {
13
+ if (tryCmd("docker", ["compose", "version"])) return ["docker", "compose"];
14
+ if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
15
+ return null;
16
+ }
17
+
18
+ describe("getComposeCmd", () => {
19
+ test("prefers docker compose V2 when both are available", () => {
20
+ const result = getComposeCmd(() => true);
21
+ expect(result).toEqual(["docker", "compose"]);
22
+ });
23
+
24
+ test("falls back to docker-compose V1 when V2 is unavailable", () => {
25
+ const result = getComposeCmd((cmd, args) => {
26
+ // V2 plugin fails, V1 standalone succeeds
27
+ if (cmd === "docker" && args[0] === "compose") return false;
28
+ if (cmd === "docker-compose") return true;
29
+ return false;
30
+ });
31
+ expect(result).toEqual(["docker-compose"]);
32
+ });
33
+
34
+ test("returns null when neither is available", () => {
35
+ const result = getComposeCmd(() => false);
36
+ expect(result).toBeNull();
37
+ });
38
+
39
+ test("does not check V1 when V2 succeeds", () => {
40
+ const calls: Array<{ cmd: string; args: string[] }> = [];
41
+ getComposeCmd((cmd, args) => {
42
+ calls.push({ cmd, args });
43
+ return cmd === "docker" && args[0] === "compose";
44
+ });
45
+ expect(calls).toHaveLength(1);
46
+ expect(calls[0]).toEqual({ cmd: "docker", args: ["compose", "version"] });
47
+ });
48
+
49
+ test("checks V2 first, then V1", () => {
50
+ const calls: Array<{ cmd: string; args: string[] }> = [];
51
+ getComposeCmd((cmd, args) => {
52
+ calls.push({ cmd, args });
53
+ return false;
54
+ });
55
+ expect(calls).toHaveLength(2);
56
+ expect(calls[0]).toEqual({ cmd: "docker", args: ["compose", "version"] });
57
+ expect(calls[1]).toEqual({ cmd: "docker-compose", args: ["version"] });
58
+ });
59
+ });
60
+
61
+ /**
62
+ * Test the monitoring startup sequence's container cleanup logic.
63
+ * Before "up --force-recreate", stopped containers from "run --rm" dependencies
64
+ * (e.g. config-init) must be removed to avoid docker-compose v1's
65
+ * KeyError: 'ContainerConfig' bug.
66
+ *
67
+ * We replicate the relevant sequence from the monitoring start command
68
+ * with an injectable runCompose to verify ordering and error tolerance.
69
+ */
70
+ async function monitoringStartSequence(
71
+ runCompose: (args: string[]) => Promise<number>,
72
+ ): Promise<number> {
73
+ // Best-effort: remove stopped containers left by "run --rm" dependencies
74
+ await runCompose(["rm", "-f", "-s", "config-init"]);
75
+ // Start services
76
+ const code = await runCompose(["up", "-d", "--force-recreate"]);
77
+ return code;
78
+ }
79
+
80
+ describe("monitoring start: config-init cleanup", () => {
81
+ test("calls rm before up", async () => {
82
+ const calls: string[][] = [];
83
+ await monitoringStartSequence(async (args) => {
84
+ calls.push(args);
85
+ return 0;
86
+ });
87
+ expect(calls).toHaveLength(2);
88
+ expect(calls[0]).toEqual(["rm", "-f", "-s", "config-init"]);
89
+ expect(calls[1]).toEqual(["up", "-d", "--force-recreate"]);
90
+ });
91
+
92
+ test("continues to up even when rm fails", async () => {
93
+ const calls: string[][] = [];
94
+ await monitoringStartSequence(async (args) => {
95
+ calls.push(args);
96
+ // rm returns non-zero (container doesn't exist)
97
+ if (args[0] === "rm") return 1;
98
+ return 0;
99
+ });
100
+ expect(calls).toHaveLength(2);
101
+ expect(calls[0][0]).toBe("rm");
102
+ expect(calls[1][0]).toBe("up");
103
+ });
104
+
105
+ test("returns up exit code, not rm exit code", async () => {
106
+ // rm fails but up succeeds → overall success
107
+ const result1 = await monitoringStartSequence(async (args) => {
108
+ if (args[0] === "rm") return 1;
109
+ return 0;
110
+ });
111
+ expect(result1).toBe(0);
112
+
113
+ // rm succeeds but up fails → overall failure
114
+ const result2 = await monitoringStartSequence(async (args) => {
115
+ if (args[0] === "up") return 2;
116
+ return 0;
117
+ });
118
+ expect(result2).toBe(2);
119
+ });
120
+ });
package/test/init.test.ts CHANGED
@@ -1070,6 +1070,14 @@ describe("CLI commands", () => {
1070
1070
  expect(r.stderr).toMatch(/Reports will be generated locally only/);
1071
1071
  });
1072
1072
 
1073
+ test("cli: mon local-install --demo configures demo monitoring target", () => {
1074
+ // --demo should copy instances.demo.yml to instances.yml and print confirmation.
1075
+ // The command will fail later (no Docker), but we verify the demo target step succeeded.
1076
+ const r = runCli(["mon", "local-install", "--demo"]);
1077
+ expect(r.stdout).toMatch(/Demo mode enabled/);
1078
+ expect(r.stdout).toMatch(/Demo monitoring target configured/);
1079
+ });
1080
+
1073
1081
  test("cli: mon local-install --demo with global --api-key shows error", () => {
1074
1082
  // When --demo is used with global --api-key, it should still be detected and error
1075
1083
  const r = runCli([
@@ -259,3 +259,81 @@ describe("registerMonitoringInstance", () => {
259
259
  expect(fetchCalls[0].url).toBe("https://custom.api.com/v2/rpc/monitoring_instance_register");
260
260
  });
261
261
  });
262
+
263
+ describe("demo mode instances.demo.yml", () => {
264
+ const repoRoot = path.resolve(import.meta.dir, "..", "..");
265
+
266
+ test("instances.demo.yml exists in repo root", () => {
267
+ const demoFile = path.join(repoRoot, "instances.demo.yml");
268
+ expect(fs.existsSync(demoFile)).toBe(true);
269
+ });
270
+
271
+ test("instances.demo.yml contains demo target connection", () => {
272
+ const demoFile = path.join(repoRoot, "instances.demo.yml");
273
+ const content = fs.readFileSync(demoFile, "utf8");
274
+ expect(content).toContain("name: target_database");
275
+ expect(content).toContain("conn_str: postgresql://monitor:monitor_pass@target-db:5432/target_database");
276
+ expect(content).toContain("is_enabled: true");
277
+ expect(content).toContain("preset_metrics: full");
278
+ });
279
+
280
+ test("instances.demo.yml has required YAML structure", () => {
281
+ const demoFile = path.join(repoRoot, "instances.demo.yml");
282
+ const content = fs.readFileSync(demoFile, "utf8");
283
+ // Verify it's a YAML list (starts with "- name:")
284
+ expect(content).toMatch(/^- name: target_database/m);
285
+ // Verify required fields are present with correct indentation
286
+ expect(content).toMatch(/^\s+conn_str:/m);
287
+ expect(content).toMatch(/^\s+preset_metrics: full/m);
288
+ expect(content).toMatch(/^\s+is_enabled: true/m);
289
+ // ~sink_type~ is a placeholder replaced per-sink by generate-pgwatch-sources.sh
290
+ expect(content).toMatch(/^\s+sink_type: ~sink_type~/m);
291
+ });
292
+
293
+ test("instances.yml is gitignored (not tracked)", () => {
294
+ const gitignore = fs.readFileSync(path.join(repoRoot, ".gitignore"), "utf8");
295
+ expect(gitignore).toContain("instances.yml");
296
+ });
297
+
298
+ test("demo config can be copied to instances.yml in temp dir", () => {
299
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "demo-install-test-"));
300
+ try {
301
+ const demoSrc = path.join(repoRoot, "instances.demo.yml");
302
+ const instancesDest = path.join(tempDir, "instances.yml");
303
+
304
+ fs.copyFileSync(demoSrc, instancesDest);
305
+
306
+ expect(fs.existsSync(instancesDest)).toBe(true);
307
+ const content = fs.readFileSync(instancesDest, "utf8");
308
+ expect(content).toContain("name: target_database");
309
+ expect(content).toContain("conn_str: postgresql://monitor:monitor_pass@target-db:5432/target_database");
310
+ } finally {
311
+ fs.rmSync(tempDir, { recursive: true, force: true });
312
+ }
313
+ });
314
+
315
+ test("demo config copy overwrites directory at instances.yml path", () => {
316
+ // Docker bind-mounts create missing paths as directories; the copy must handle this
317
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "demo-eisdir-test-"));
318
+ try {
319
+ const demoSrc = path.join(repoRoot, "instances.demo.yml");
320
+ const instancesDest = path.join(tempDir, "instances.yml");
321
+
322
+ // Simulate Docker creating a directory at instances.yml path
323
+ fs.mkdirSync(instancesDest);
324
+ expect(fs.statSync(instancesDest).isDirectory()).toBe(true);
325
+
326
+ // The fix: remove directory then copy
327
+ if (fs.statSync(instancesDest).isDirectory()) {
328
+ fs.rmSync(instancesDest, { recursive: true, force: true });
329
+ }
330
+ fs.copyFileSync(demoSrc, instancesDest);
331
+
332
+ expect(fs.statSync(instancesDest).isFile()).toBe(true);
333
+ const content = fs.readFileSync(instancesDest, "utf8");
334
+ expect(content).toContain("name: target_database");
335
+ } finally {
336
+ fs.rmSync(tempDir, { recursive: true, force: true });
337
+ }
338
+ });
339
+ });