postgresai 0.15.0-dev.5 → 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.
@@ -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";
@@ -477,14 +478,14 @@ async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
477
478
  fs.mkdirSync(projectDir, { recursive: true, mode: 0o700 });
478
479
  }
479
480
 
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()));
486
-
487
481
  if (!fs.existsSync(composeFile)) {
482
+ const refs = [
483
+ process.env.PGAI_PROJECT_REF,
484
+ pkg.version,
485
+ `v${pkg.version}`,
486
+ "main",
487
+ ].filter((v): v is string => Boolean(v && v.trim()));
488
+
488
489
  let lastErr: unknown;
489
490
  for (const ref of refs) {
490
491
  const url = `https://gitlab.com/postgres-ai/postgres_ai/-/raw/${encodeURIComponent(ref)}/docker-compose.yml`;
@@ -503,22 +504,11 @@ async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
503
504
  }
504
505
  }
505
506
 
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
- }
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.lstatSync(instancesFile).isDirectory()) {
510
+ fs.rmSync(instancesFile, { recursive: true, force: true });
519
511
  }
520
-
521
- // Ensure instances.yml exists as a FILE (avoid Docker creating a directory)
522
512
  if (!fs.existsSync(instancesFile)) {
523
513
  const header =
524
514
  "# PostgreSQL instances to monitor\n" +
@@ -2317,7 +2307,7 @@ mon
2317
2307
  console.log("This will install, configure, and start the monitoring system\n");
2318
2308
 
2319
2309
  // Ensure we have a project directory with docker-compose.yml even if running from elsewhere
2320
- const { projectDir } = await resolveOrInitPaths();
2310
+ const { projectDir, instancesFile: instancesPath } = await resolveOrInitPaths();
2321
2311
  console.log(`Project directory: ${projectDir}\n`);
2322
2312
 
2323
2313
  // Save project name to .pgwatch-config if provided (used by reporter container)
@@ -2540,15 +2530,37 @@ mon
2540
2530
  }
2541
2531
  }
2542
2532
  } else {
2543
- // Demo mode: copy 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
2544
2547
  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
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
2549
+ const demoCandidates = [
2550
+ path.resolve(currentDir, "..", "..", "instances.demo.yml"), // npm: dist/bin -> package root
2551
+ path.resolve(currentDir, "..", "..", "..", "instances.demo.yml"), // dev: cli/bin -> repo root
2552
+ ];
2553
+ const demoSrc = demoCandidates.find(p => fs.existsSync(p));
2554
+ if (demoSrc) {
2555
+ // Remove directory artifact left by Docker bind-mounts before copying
2556
+ if (fs.existsSync(instancesPath) && fs.lstatSync(instancesPath).isDirectory()) {
2557
+ fs.rmSync(instancesPath, { recursive: true, force: true });
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\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)) {
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)) {
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)) {
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,8 @@ targets
3200
3212
  }
3201
3213
  } catch (err) {
3202
3214
  // If YAML parsing fails, fall back to simple check
3203
- const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
3215
+ const isFile = fs.existsSync(file) && !fs.lstatSync(file).isDirectory();
3216
+ const content = isFile ? fs.readFileSync(file, "utf8") : "";
3204
3217
  if (new RegExp(`^- name: ${instanceName}$`, "m").test(content)) {
3205
3218
  console.error(`Monitoring target '${instanceName}' already exists`);
3206
3219
  process.exitCode = 1;
@@ -3208,7 +3221,10 @@ targets
3208
3221
  }
3209
3222
  }
3210
3223
 
3211
- // Add new instance
3224
+ // Add new instance — if instances.yml is a directory (Docker artifact), replace it with a file
3225
+ if (fs.existsSync(file) && fs.lstatSync(file).isDirectory()) {
3226
+ fs.rmSync(file, { recursive: true, force: true });
3227
+ }
3212
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`;
3213
3229
  const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
3214
3230
  fs.appendFileSync(file, (content && !/\n$/.test(content) ? "\n" : "") + body, "utf8");
@@ -3219,7 +3235,7 @@ targets
3219
3235
  .description("remove monitoring target database")
3220
3236
  .action(async (name: string) => {
3221
3237
  const { instancesFile: file } = await resolveOrInitPaths();
3222
- if (!fs.existsSync(file)) {
3238
+ if (!fs.existsSync(file) || fs.lstatSync(file).isDirectory()) {
3223
3239
  console.error("instances.yml not found");
3224
3240
  process.exitCode = 1;
3225
3241
  return;
@@ -3256,7 +3272,7 @@ targets
3256
3272
  .description("test monitoring target database connectivity")
3257
3273
  .action(async (name: string) => {
3258
3274
  const { instancesFile: instancesPath } = await resolveOrInitPaths();
3259
- if (!fs.existsSync(instancesPath)) {
3275
+ if (!fs.existsSync(instancesPath) || fs.lstatSync(instancesPath).isDirectory()) {
3260
3276
  console.error("instances.yml not found");
3261
3277
  process.exitCode = 1;
3262
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.5",
13154
+ version: "0.15.0-dev.7",
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.5";
15985
+ var version = "0.15.0-dev.7";
15985
15986
  var package_default2 = {
15986
15987
  name: "postgresai",
15987
15988
  version,
@@ -29867,13 +29868,13 @@ async function ensureDefaultMonitoringProject() {
29867
29868
  if (!fs6.existsSync(projectDir)) {
29868
29869
  fs6.mkdirSync(projectDir, { recursive: true, mode: 448 });
29869
29870
  }
29870
- const refs = [
29871
- process.env.PGAI_PROJECT_REF,
29872
- package_default.version,
29873
- `v${package_default.version}`,
29874
- "main"
29875
- ].filter((v) => Boolean(v && v.trim()));
29876
29871
  if (!fs6.existsSync(composeFile)) {
29872
+ const refs = [
29873
+ process.env.PGAI_PROJECT_REF,
29874
+ package_default.version,
29875
+ `v${package_default.version}`,
29876
+ "main"
29877
+ ].filter((v) => Boolean(v && v.trim()));
29877
29878
  let lastErr;
29878
29879
  for (const ref of refs) {
29879
29880
  const url = `https://gitlab.com/postgres-ai/postgres_ai/-/raw/${encodeURIComponent(ref)}/docker-compose.yml`;
@@ -29890,16 +29891,8 @@ async function ensureDefaultMonitoringProject() {
29890
29891
  throw new Error(`Failed to bootstrap docker-compose.yml: ${msg}`);
29891
29892
  }
29892
29893
  }
29893
- const demoFile = path6.resolve(projectDir, "instances.demo.yml");
29894
- if (!fs6.existsSync(demoFile)) {
29895
- for (const ref of refs) {
29896
- const url = `https://gitlab.com/postgres-ai/postgres_ai/-/raw/${encodeURIComponent(ref)}/instances.demo.yml`;
29897
- try {
29898
- const text = await downloadText(url);
29899
- fs6.writeFileSync(demoFile, text, { encoding: "utf8", mode: 384 });
29900
- break;
29901
- } catch {}
29902
- }
29894
+ if (fs6.existsSync(instancesFile) && fs6.lstatSync(instancesFile).isDirectory()) {
29895
+ fs6.rmSync(instancesFile, { recursive: true, force: true });
29903
29896
  }
29904
29897
  if (!fs6.existsSync(instancesFile)) {
29905
29898
  const header = `# PostgreSQL instances to monitor
@@ -31317,7 +31310,7 @@ mon.command("local-install").description("install local monitoring stack (genera
31317
31310
  `);
31318
31311
  console.log(`This will install, configure, and start the monitoring system
31319
31312
  `);
31320
- const { projectDir } = await resolveOrInitPaths();
31313
+ const { projectDir, instancesFile: instancesPath } = await resolveOrInitPaths();
31321
31314
  console.log(`Project directory: ${projectDir}
31322
31315
  `);
31323
31316
  if (opts.project) {
@@ -31428,13 +31421,13 @@ Use demo mode without API key: postgres-ai mon local-install --demo`);
31428
31421
  if (!opts.demo) {
31429
31422
  console.log(`Step 2: Add PostgreSQL Instance to Monitor
31430
31423
  `);
31431
- const { instancesFile: instancesPath, projectDir: projectDir2 } = await resolveOrInitPaths();
31424
+ const { instancesFile: instancesPath2, projectDir: projectDir2 } = await resolveOrInitPaths();
31432
31425
  const emptyInstancesContent = `# PostgreSQL instances to monitor
31433
31426
  # Add your instances using: postgres-ai mon targets add
31434
31427
 
31435
31428
  `;
31436
- fs6.writeFileSync(instancesPath, emptyInstancesContent, "utf8");
31437
- console.log(`Instances file: ${instancesPath}`);
31429
+ fs6.writeFileSync(instancesPath2, emptyInstancesContent, "utf8");
31430
+ console.log(`Instances file: ${instancesPath2}`);
31438
31431
  console.log(`Project directory: ${projectDir2}
31439
31432
  `);
31440
31433
  if (opts.dbUrl) {
@@ -31465,7 +31458,7 @@ Use demo mode without API key: postgres-ai mon local-install --demo`);
31465
31458
  node_name: ${instanceName}
31466
31459
  sink_type: ~sink_type~
31467
31460
  `;
31468
- fs6.appendFileSync(instancesPath, body, "utf8");
31461
+ fs6.appendFileSync(instancesPath2, body, "utf8");
31469
31462
  console.log(`\u2713 Monitoring target '${instanceName}' added
31470
31463
  `);
31471
31464
  console.log("Testing connection to the added instance...");
@@ -31520,7 +31513,7 @@ You can provide either:`);
31520
31513
  node_name: ${instanceName}
31521
31514
  sink_type: ~sink_type~
31522
31515
  `;
31523
- fs6.appendFileSync(instancesPath, body, "utf8");
31516
+ fs6.appendFileSync(instancesPath2, body, "utf8");
31524
31517
  console.log(`\u2713 Monitoring target '${instanceName}' added
31525
31518
  `);
31526
31519
  console.log("Testing connection to the added instance...");
@@ -31549,15 +31542,24 @@ You can provide either:`);
31549
31542
  }
31550
31543
  } else {
31551
31544
  console.log("Step 2: Demo mode enabled - using included demo PostgreSQL database");
31552
- const { instancesFile: instancesPath, projectDir: projectDir2 } = await resolveOrInitPaths();
31553
- const demoSrc = path6.resolve(projectDir2, "instances.demo.yml");
31554
- if (fs6.existsSync(demoSrc)) {
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.lstatSync(instancesPath).isDirectory()) {
31553
+ fs6.rmSync(instancesPath, { recursive: true, force: true });
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
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)) {
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)) {
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)) {
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,13 +32090,17 @@ targets.command("add [connStr] [name]").description("add monitoring target datab
32088
32090
  }
32089
32091
  }
32090
32092
  } catch (err) {
32091
- const content2 = fs6.existsSync(file) ? fs6.readFileSync(file, "utf8") : "";
32093
+ const isFile = fs6.existsSync(file) && !fs6.lstatSync(file).isDirectory();
32094
+ const content2 = isFile ? fs6.readFileSync(file, "utf8") : "";
32092
32095
  if (new RegExp(`^- name: ${instanceName}$`, "m").test(content2)) {
32093
32096
  console.error(`Monitoring target '${instanceName}' already exists`);
32094
32097
  process.exitCode = 1;
32095
32098
  return;
32096
32099
  }
32097
32100
  }
32101
+ if (fs6.existsSync(file) && fs6.lstatSync(file).isDirectory()) {
32102
+ fs6.rmSync(file, { recursive: true, force: true });
32103
+ }
32098
32104
  const body = `- name: ${instanceName}
32099
32105
  conn_str: ${connStr}
32100
32106
  preset_metrics: full
@@ -32114,7 +32120,7 @@ targets.command("add [connStr] [name]").description("add monitoring target datab
32114
32120
  });
32115
32121
  targets.command("remove <name>").description("remove monitoring target database").action(async (name) => {
32116
32122
  const { instancesFile: file } = await resolveOrInitPaths();
32117
- if (!fs6.existsSync(file)) {
32123
+ if (!fs6.existsSync(file) || fs6.lstatSync(file).isDirectory()) {
32118
32124
  console.error("instances.yml not found");
32119
32125
  process.exitCode = 1;
32120
32126
  return;
@@ -32143,7 +32149,7 @@ targets.command("remove <name>").description("remove monitoring target database"
32143
32149
  });
32144
32150
  targets.command("test <name>").description("test monitoring target database connectivity").action(async (name) => {
32145
32151
  const { instancesFile: instancesPath } = await resolveOrInitPaths();
32146
- if (!fs6.existsSync(instancesPath)) {
32152
+ if (!fs6.existsSync(instancesPath) || fs6.lstatSync(instancesPath).isDirectory()) {
32147
32153
  console.error("instances.yml not found");
32148
32154
  process.exitCode = 1;
32149
32155
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.15.0-dev.5",
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
 
@@ -1070,6 +1070,69 @@ 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
+ // 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
+ }
1134
+ });
1135
+
1073
1136
  test("cli: mon local-install --demo with global --api-key shows error", () => {
1074
1137
  // When --demo is used with global --api-key, it should still be detected and error
1075
1138
  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 sed token substituted by generate-pgwatch-sources.sh; values: postgres, prometheus
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).toMatch(/^instances\.yml$/m);
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
+ });