postgresai 0.15.0-dev.6 → 0.15.0-dev.7

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.
@@ -506,7 +506,7 @@ async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
506
506
 
507
507
  // Ensure instances.yml exists as a FILE (avoid Docker creating a directory).
508
508
  // Docker bind-mounts create missing paths as directories; replace if so.
509
- if (fs.existsSync(instancesFile) && fs.statSync(instancesFile).isDirectory()) {
509
+ if (fs.existsSync(instancesFile) && fs.lstatSync(instancesFile).isDirectory()) {
510
510
  fs.rmSync(instancesFile, { recursive: true, force: true });
511
511
  }
512
512
  if (!fs.existsSync(instancesFile)) {
@@ -2530,10 +2530,21 @@ mon
2530
2530
  }
2531
2531
  }
2532
2532
  } else {
2533
- // Demo mode: copy bundled instances.demo.yml instances.yml so the demo target is active
2533
+ // Demo mode: configure instances.yml from the bundled demo template.
2534
+ //
2535
+ // Side effects:
2536
+ // - Writes instancesPath (instances.yml next to docker-compose.yml)
2537
+ // - If Docker previously bind-mounted instances.yml as a directory, removes it first.
2538
+ //
2539
+ // Failure modes:
2540
+ // - Exits with code 1 if instances.demo.yml is not found in any candidate path.
2541
+ // This is fatal because starting without a target produces empty dashboards that
2542
+ // look like a bug rather than a misconfiguration.
2543
+ //
2544
+ // Template search order (import.meta.url is resolved at runtime, not baked in at build):
2545
+ // 1. npm layout: dist/bin/../../instances.demo.yml → package-root/instances.demo.yml
2546
+ // 2. dev layout: cli/bin/../../../instances.demo.yml → repo-root/instances.demo.yml
2534
2547
  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
2548
  const currentDir = path.dirname(fileURLToPath(import.meta.url));
2538
2549
  const demoCandidates = [
2539
2550
  path.resolve(currentDir, "..", "..", "instances.demo.yml"), // npm: dist/bin -> package root
@@ -2541,14 +2552,15 @@ mon
2541
2552
  ];
2542
2553
  const demoSrc = demoCandidates.find(p => fs.existsSync(p));
2543
2554
  if (demoSrc) {
2544
- // Remove directory artifact left by Docker bind-mounts
2545
- if (fs.existsSync(instancesPath) && fs.statSync(instancesPath).isDirectory()) {
2555
+ // Remove directory artifact left by Docker bind-mounts before copying
2556
+ if (fs.existsSync(instancesPath) && fs.lstatSync(instancesPath).isDirectory()) {
2546
2557
  fs.rmSync(instancesPath, { recursive: true, force: true });
2547
2558
  }
2548
2559
  fs.copyFileSync(demoSrc, instancesPath);
2549
2560
  console.log("✓ Demo monitoring target configured\n");
2550
2561
  } else {
2551
- console.error(`⚠ instances.demo.yml not found — demo target not configured (searched: ${demoCandidates.join(", ")})\n`);
2562
+ console.error(`Error: instances.demo.yml not found — cannot configure demo target.\nSearched: ${demoCandidates.join(", ")}\n`);
2563
+ process.exit(1);
2552
2564
  }
2553
2565
  }
2554
2566
 
@@ -2904,7 +2916,7 @@ mon
2904
2916
  console.log(`Project Directory: ${projectDir}`);
2905
2917
  console.log(`Docker Compose File: ${composeFile}`);
2906
2918
  console.log(`Instances File: ${instancesFile}`);
2907
- if (fs.existsSync(instancesFile) && !fs.statSync(instancesFile).isDirectory()) {
2919
+ if (fs.existsSync(instancesFile) && !fs.lstatSync(instancesFile).isDirectory()) {
2908
2920
  console.log("\nInstances configuration:\n");
2909
2921
  const text = fs.readFileSync(instancesFile, "utf8");
2910
2922
  process.stdout.write(text);
@@ -3120,7 +3132,7 @@ targets
3120
3132
  .description("list monitoring target databases")
3121
3133
  .action(async () => {
3122
3134
  const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
3123
- if (!fs.existsSync(instancesPath) || fs.statSync(instancesPath).isDirectory()) {
3135
+ if (!fs.existsSync(instancesPath) || fs.lstatSync(instancesPath).isDirectory()) {
3124
3136
  console.error(`instances.yml not found in ${projectDir}`);
3125
3137
  process.exitCode = 1;
3126
3138
  return;
@@ -3186,7 +3198,7 @@ targets
3186
3198
 
3187
3199
  // Check if instance already exists
3188
3200
  try {
3189
- if (fs.existsSync(file) && !fs.statSync(file).isDirectory()) {
3201
+ if (fs.existsSync(file) && !fs.lstatSync(file).isDirectory()) {
3190
3202
  const content = fs.readFileSync(file, "utf8");
3191
3203
  const instances = yaml.load(content) as Instance[] | null || [];
3192
3204
  if (Array.isArray(instances)) {
@@ -3200,7 +3212,7 @@ targets
3200
3212
  }
3201
3213
  } catch (err) {
3202
3214
  // If YAML parsing fails, fall back to simple check
3203
- const isFile = fs.existsSync(file) && !fs.statSync(file).isDirectory();
3215
+ const isFile = fs.existsSync(file) && !fs.lstatSync(file).isDirectory();
3204
3216
  const content = isFile ? fs.readFileSync(file, "utf8") : "";
3205
3217
  if (new RegExp(`^- name: ${instanceName}$`, "m").test(content)) {
3206
3218
  console.error(`Monitoring target '${instanceName}' already exists`);
@@ -3210,7 +3222,7 @@ targets
3210
3222
  }
3211
3223
 
3212
3224
  // 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()) {
3225
+ if (fs.existsSync(file) && fs.lstatSync(file).isDirectory()) {
3214
3226
  fs.rmSync(file, { recursive: true, force: true });
3215
3227
  }
3216
3228
  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`;
@@ -3223,7 +3235,7 @@ targets
3223
3235
  .description("remove monitoring target database")
3224
3236
  .action(async (name: string) => {
3225
3237
  const { instancesFile: file } = await resolveOrInitPaths();
3226
- if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) {
3238
+ if (!fs.existsSync(file) || fs.lstatSync(file).isDirectory()) {
3227
3239
  console.error("instances.yml not found");
3228
3240
  process.exitCode = 1;
3229
3241
  return;
@@ -3260,7 +3272,7 @@ targets
3260
3272
  .description("test monitoring target database connectivity")
3261
3273
  .action(async (name: string) => {
3262
3274
  const { instancesFile: instancesPath } = await resolveOrInitPaths();
3263
- if (!fs.existsSync(instancesPath) || fs.statSync(instancesPath).isDirectory()) {
3275
+ if (!fs.existsSync(instancesPath) || fs.lstatSync(instancesPath).isDirectory()) {
3264
3276
  console.error("instances.yml not found");
3265
3277
  process.exitCode = 1;
3266
3278
  return;
@@ -13151,7 +13151,7 @@ var {
13151
13151
  // package.json
13152
13152
  var package_default = {
13153
13153
  name: "postgresai",
13154
- version: "0.15.0-dev.6",
13154
+ version: "0.15.0-dev.7",
13155
13155
  description: "postgres_ai CLI",
13156
13156
  license: "Apache-2.0",
13157
13157
  private: false,
@@ -15982,7 +15982,7 @@ var Result = import_lib.default.Result;
15982
15982
  var TypeOverrides = import_lib.default.TypeOverrides;
15983
15983
  var defaults = import_lib.default.defaults;
15984
15984
  // package.json
15985
- var version = "0.15.0-dev.6";
15985
+ var version = "0.15.0-dev.7";
15986
15986
  var package_default2 = {
15987
15987
  name: "postgresai",
15988
15988
  version,
@@ -29891,7 +29891,7 @@ async function ensureDefaultMonitoringProject() {
29891
29891
  throw new Error(`Failed to bootstrap docker-compose.yml: ${msg}`);
29892
29892
  }
29893
29893
  }
29894
- if (fs6.existsSync(instancesFile) && fs6.statSync(instancesFile).isDirectory()) {
29894
+ if (fs6.existsSync(instancesFile) && fs6.lstatSync(instancesFile).isDirectory()) {
29895
29895
  fs6.rmSync(instancesFile, { recursive: true, force: true });
29896
29896
  }
29897
29897
  if (!fs6.existsSync(instancesFile)) {
@@ -31549,15 +31549,17 @@ You can provide either:`);
31549
31549
  ];
31550
31550
  const demoSrc = demoCandidates.find((p) => fs6.existsSync(p));
31551
31551
  if (demoSrc) {
31552
- if (fs6.existsSync(instancesPath) && fs6.statSync(instancesPath).isDirectory()) {
31552
+ if (fs6.existsSync(instancesPath) && fs6.lstatSync(instancesPath).isDirectory()) {
31553
31553
  fs6.rmSync(instancesPath, { recursive: true, force: true });
31554
31554
  }
31555
31555
  fs6.copyFileSync(demoSrc, instancesPath);
31556
31556
  console.log(`\u2713 Demo monitoring target configured
31557
31557
  `);
31558
31558
  } else {
31559
- console.error(`\u26A0 instances.demo.yml not found \u2014 demo target not configured (searched: ${demoCandidates.join(", ")})
31559
+ console.error(`Error: instances.demo.yml not found \u2014 cannot configure demo target.
31560
+ Searched: ${demoCandidates.join(", ")}
31560
31561
  `);
31562
+ process.exit(1);
31561
31563
  }
31562
31564
  }
31563
31565
  console.log(opts.demo ? "Step 3: Updating configuration..." : "Step 3: Updating configuration...");
@@ -31845,7 +31847,7 @@ mon.command("config").description("show monitoring services configuration").acti
31845
31847
  console.log(`Project Directory: ${projectDir}`);
31846
31848
  console.log(`Docker Compose File: ${composeFile}`);
31847
31849
  console.log(`Instances File: ${instancesFile}`);
31848
- if (fs6.existsSync(instancesFile) && !fs6.statSync(instancesFile).isDirectory()) {
31850
+ if (fs6.existsSync(instancesFile) && !fs6.lstatSync(instancesFile).isDirectory()) {
31849
31851
  console.log(`
31850
31852
  Instances configuration:
31851
31853
  `);
@@ -32020,7 +32022,7 @@ mon.command("check").description("monitoring services system readiness check").a
32020
32022
  var targets = mon.command("targets").description("manage databases to monitor");
32021
32023
  targets.command("list").description("list monitoring target databases").action(async () => {
32022
32024
  const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
32023
- if (!fs6.existsSync(instancesPath) || fs6.statSync(instancesPath).isDirectory()) {
32025
+ if (!fs6.existsSync(instancesPath) || fs6.lstatSync(instancesPath).isDirectory()) {
32024
32026
  console.error(`instances.yml not found in ${projectDir}`);
32025
32027
  process.exitCode = 1;
32026
32028
  return;
@@ -32075,7 +32077,7 @@ targets.command("add [connStr] [name]").description("add monitoring target datab
32075
32077
  const db = m[5];
32076
32078
  const instanceName = name && name.trim() ? name.trim() : `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
32077
32079
  try {
32078
- if (fs6.existsSync(file) && !fs6.statSync(file).isDirectory()) {
32080
+ if (fs6.existsSync(file) && !fs6.lstatSync(file).isDirectory()) {
32079
32081
  const content2 = fs6.readFileSync(file, "utf8");
32080
32082
  const instances = load(content2) || [];
32081
32083
  if (Array.isArray(instances)) {
@@ -32088,7 +32090,7 @@ targets.command("add [connStr] [name]").description("add monitoring target datab
32088
32090
  }
32089
32091
  }
32090
32092
  } catch (err) {
32091
- const isFile = fs6.existsSync(file) && !fs6.statSync(file).isDirectory();
32093
+ const isFile = fs6.existsSync(file) && !fs6.lstatSync(file).isDirectory();
32092
32094
  const content2 = isFile ? fs6.readFileSync(file, "utf8") : "";
32093
32095
  if (new RegExp(`^- name: ${instanceName}$`, "m").test(content2)) {
32094
32096
  console.error(`Monitoring target '${instanceName}' already exists`);
@@ -32096,7 +32098,7 @@ targets.command("add [connStr] [name]").description("add monitoring target datab
32096
32098
  return;
32097
32099
  }
32098
32100
  }
32099
- if (fs6.existsSync(file) && fs6.statSync(file).isDirectory()) {
32101
+ if (fs6.existsSync(file) && fs6.lstatSync(file).isDirectory()) {
32100
32102
  fs6.rmSync(file, { recursive: true, force: true });
32101
32103
  }
32102
32104
  const body = `- name: ${instanceName}
@@ -32118,7 +32120,7 @@ targets.command("add [connStr] [name]").description("add monitoring target datab
32118
32120
  });
32119
32121
  targets.command("remove <name>").description("remove monitoring target database").action(async (name) => {
32120
32122
  const { instancesFile: file } = await resolveOrInitPaths();
32121
- if (!fs6.existsSync(file) || fs6.statSync(file).isDirectory()) {
32123
+ if (!fs6.existsSync(file) || fs6.lstatSync(file).isDirectory()) {
32122
32124
  console.error("instances.yml not found");
32123
32125
  process.exitCode = 1;
32124
32126
  return;
@@ -32147,7 +32149,7 @@ targets.command("remove <name>").description("remove monitoring target database"
32147
32149
  });
32148
32150
  targets.command("test <name>").description("test monitoring target database connectivity").action(async (name) => {
32149
32151
  const { instancesFile: instancesPath } = await resolveOrInitPaths();
32150
- if (!fs6.existsSync(instancesPath) || fs6.statSync(instancesPath).isDirectory()) {
32152
+ if (!fs6.existsSync(instancesPath) || fs6.lstatSync(instancesPath).isDirectory()) {
32151
32153
  console.error("instances.yml not found");
32152
32154
  process.exitCode = 1;
32153
32155
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.15.0-dev.6",
3
+ "version": "0.15.0-dev.7",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
package/test/init.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect, beforeAll, afterAll } from "bun:test";
2
- import { resolve } from "path";
2
+ import path, { resolve } from "path";
3
3
  import * as fs from "fs";
4
4
  import * as os from "os";
5
5
 
@@ -1073,9 +1073,64 @@ describe("CLI commands", () => {
1073
1073
  test("cli: mon local-install --demo configures demo monitoring target", () => {
1074
1074
  // --demo should copy instances.demo.yml to instances.yml and print confirmation.
1075
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/);
1076
+ // resolvePaths() walks cwd() up to find docker-compose.yml, so instances.yml
1077
+ // is written next to docker-compose.yml in the repo root.
1078
+ const repoRoot = resolve(import.meta.dir, "..", "..");
1079
+ const instancesPath = path.join(repoRoot, "instances.yml");
1080
+ // Remove instances.yml if it exists — use rmSync to handle both files and
1081
+ // directories (the EISDIR test may have left a directory here if it failed).
1082
+ if (fs.existsSync(instancesPath)) fs.rmSync(instancesPath, { recursive: true, force: true });
1083
+ try {
1084
+ const r = runCli(["mon", "local-install", "--demo"]);
1085
+ expect(r.stdout).toMatch(/Demo mode enabled/);
1086
+ expect(r.stdout).toMatch(/Demo monitoring target configured/);
1087
+ // Verify instances.yml was actually written with the demo target
1088
+ expect(fs.existsSync(instancesPath)).toBe(true);
1089
+ const content = fs.readFileSync(instancesPath, "utf8");
1090
+ expect(content).toContain("name: target_database");
1091
+ expect(content).toContain("conn_str: postgresql://monitor:monitor_pass@target-db:5432/target_database");
1092
+ } finally {
1093
+ // Clean up — instances.yml is gitignored so safe to remove
1094
+ if (fs.existsSync(instancesPath)) fs.rmSync(instancesPath, { recursive: true, force: true });
1095
+ }
1096
+ });
1097
+
1098
+ test("cli: mon local-install --demo exits with code 1 when instances.demo.yml is missing", () => {
1099
+ // Regression: if instances.demo.yml cannot be found in any candidate path, the CLI
1100
+ // must exit with a non-zero code and a descriptive error (not silently create empty dashboards).
1101
+ const repoRoot = resolve(import.meta.dir, "..", "..");
1102
+ const demoFile = path.join(repoRoot, "instances.demo.yml");
1103
+ const tempBackup = path.join(os.tmpdir(), `instances.demo.yml.test-backup-${Date.now()}`);
1104
+ // Temporarily move instances.demo.yml so neither candidate path resolves
1105
+ fs.copyFileSync(demoFile, tempBackup);
1106
+ fs.unlinkSync(demoFile);
1107
+ try {
1108
+ const r = runCli(["mon", "local-install", "--demo"]);
1109
+ expect(r.status).not.toBe(0);
1110
+ expect(r.stderr).toContain("instances.demo.yml not found");
1111
+ } finally {
1112
+ // Restore the file — critical to do before any assertion can throw
1113
+ if (!fs.existsSync(demoFile)) fs.copyFileSync(tempBackup, demoFile);
1114
+ fs.rmSync(tempBackup, { force: true });
1115
+ }
1116
+ });
1117
+
1118
+ test("cli: mon local-install --demo with EISDIR recovers instances.yml", () => {
1119
+ // Docker bind-mounts create missing paths as directories; the CLI must handle this.
1120
+ const repoRoot = resolve(import.meta.dir, "..", "..");
1121
+ const instancesPath = path.join(repoRoot, "instances.yml");
1122
+ // Create instances.yml as a directory (simulating Docker artifact)
1123
+ if (fs.existsSync(instancesPath)) fs.rmSync(instancesPath, { recursive: true, force: true });
1124
+ fs.mkdirSync(instancesPath);
1125
+ try {
1126
+ const r = runCli(["mon", "local-install", "--demo"]);
1127
+ expect(r.stdout).toMatch(/Demo monitoring target configured/);
1128
+ expect(fs.statSync(instancesPath).isFile()).toBe(true);
1129
+ const content = fs.readFileSync(instancesPath, "utf8");
1130
+ expect(content).toContain("name: target_database");
1131
+ } finally {
1132
+ if (fs.existsSync(instancesPath)) fs.rmSync(instancesPath, { recursive: true, force: true });
1133
+ }
1079
1134
  });
1080
1135
 
1081
1136
  test("cli: mon local-install --demo with global --api-key shows error", () => {
@@ -286,13 +286,13 @@ describe("demo mode instances.demo.yml", () => {
286
286
  expect(content).toMatch(/^\s+conn_str:/m);
287
287
  expect(content).toMatch(/^\s+preset_metrics: full/m);
288
288
  expect(content).toMatch(/^\s+is_enabled: true/m);
289
- // ~sink_type~ is a placeholder replaced per-sink by generate-pgwatch-sources.sh
289
+ // ~sink_type~ is a sed token substituted by generate-pgwatch-sources.sh; values: postgres, prometheus
290
290
  expect(content).toMatch(/^\s+sink_type: ~sink_type~/m);
291
291
  });
292
292
 
293
293
  test("instances.yml is gitignored (not tracked)", () => {
294
294
  const gitignore = fs.readFileSync(path.join(repoRoot, ".gitignore"), "utf8");
295
- expect(gitignore).toContain("instances.yml");
295
+ expect(gitignore).toMatch(/^instances\.yml$/m);
296
296
  });
297
297
 
298
298
  test("demo config can be copied to instances.yml in temp dir", () => {