postgresai 0.15.0-dev.4 → 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.
@@ -477,14 +477,14 @@ async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
477
477
  fs.mkdirSync(projectDir, { recursive: true, mode: 0o700 });
478
478
  }
479
479
 
480
- if (!fs.existsSync(composeFile)) {
481
- const refs = [
482
- process.env.PGAI_PROJECT_REF,
483
- pkg.version,
484
- `v${pkg.version}`,
485
- "main",
486
- ].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()));
487
486
 
487
+ if (!fs.existsSync(composeFile)) {
488
488
  let lastErr: unknown;
489
489
  for (const ref of refs) {
490
490
  const url = `https://gitlab.com/postgres-ai/postgres_ai/-/raw/${encodeURIComponent(ref)}/docker-compose.yml`;
@@ -503,6 +503,21 @@ async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
503
503
  }
504
504
  }
505
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
+
506
521
  // Ensure instances.yml exists as a FILE (avoid Docker creating a directory)
507
522
  if (!fs.existsSync(instancesFile)) {
508
523
  const header =
@@ -2071,13 +2086,16 @@ function isDockerRunning(): boolean {
2071
2086
  }
2072
2087
 
2073
2088
  /**
2074
- * 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).
2075
2093
  */
2076
2094
  function getComposeCmd(): string[] | null {
2077
2095
  const tryCmd = (cmd: string, args: string[]): boolean =>
2078
2096
  spawnSync(cmd, args, { stdio: "ignore", timeout: 5000 } as Parameters<typeof spawnSync>[2]).status === 0;
2079
- if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
2080
2097
  if (tryCmd("docker", ["compose", "version"])) return ["docker", "compose"];
2098
+ if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
2081
2099
  return null;
2082
2100
  }
2083
2101
 
@@ -2235,6 +2253,26 @@ async function runCompose(args: string[], grafanaPassword?: string): Promise<num
2235
2253
  }
2236
2254
  }
2237
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
+
2238
2276
  // On macOS, self-node-exporter can't mount host root filesystem - skip it
2239
2277
  const finalArgs = [...args];
2240
2278
  if (process.platform === "darwin" && args.includes("up")) {
@@ -2502,7 +2540,16 @@ mon
2502
2540
  }
2503
2541
  }
2504
2542
  } else {
2505
- 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
+ }
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
 
@@ -3625,6 +3723,19 @@ mon
3625
3723
  console.log(" URL: http://localhost:3000");
3626
3724
  console.log(" Username: monitor");
3627
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
+ }
3628
3739
  console.log("");
3629
3740
  });
3630
3741
 
@@ -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.5",
13155
13155
  description: "postgres_ai CLI",
13156
13156
  license: "Apache-2.0",
13157
13157
  private: false,
@@ -15981,7 +15981,7 @@ var Result = import_lib.default.Result;
15981
15981
  var TypeOverrides = import_lib.default.TypeOverrides;
15982
15982
  var defaults = import_lib.default.defaults;
15983
15983
  // package.json
15984
- var version = "0.15.0-dev.4";
15984
+ var version = "0.15.0-dev.5";
15985
15985
  var package_default2 = {
15986
15986
  name: "postgresai",
15987
15987
  version,
@@ -29867,13 +29867,13 @@ async function ensureDefaultMonitoringProject() {
29867
29867
  if (!fs6.existsSync(projectDir)) {
29868
29868
  fs6.mkdirSync(projectDir, { recursive: true, mode: 448 });
29869
29869
  }
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()));
29870
29876
  if (!fs6.existsSync(composeFile)) {
29871
- const refs = [
29872
- process.env.PGAI_PROJECT_REF,
29873
- package_default.version,
29874
- `v${package_default.version}`,
29875
- "main"
29876
- ].filter((v) => Boolean(v && v.trim()));
29877
29877
  let lastErr;
29878
29878
  for (const ref of refs) {
29879
29879
  const url = `https://gitlab.com/postgres-ai/postgres_ai/-/raw/${encodeURIComponent(ref)}/docker-compose.yml`;
@@ -29890,6 +29890,17 @@ async function ensureDefaultMonitoringProject() {
29890
29890
  throw new Error(`Failed to bootstrap docker-compose.yml: ${msg}`);
29891
29891
  }
29892
29892
  }
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
+ }
29903
+ }
29893
29904
  if (!fs6.existsSync(instancesFile)) {
29894
29905
  const header = `# PostgreSQL instances to monitor
29895
29906
  ` + `# Add your instances using: pgai mon targets add <connection-string> <name>
@@ -31138,10 +31149,10 @@ function isDockerRunning() {
31138
31149
  }
31139
31150
  function getComposeCmd() {
31140
31151
  const tryCmd = (cmd, args) => spawnSync2(cmd, args, { stdio: "ignore", timeout: 5000 }).status === 0;
31141
- if (tryCmd("docker-compose", ["version"]))
31142
- return ["docker-compose"];
31143
31152
  if (tryCmd("docker", ["compose", "version"]))
31144
31153
  return ["docker", "compose"];
31154
+ if (tryCmd("docker-compose", ["version"]))
31155
+ return ["docker-compose"];
31145
31156
  return null;
31146
31157
  }
31147
31158
  function checkRunningContainers() {
@@ -31259,6 +31270,26 @@ async function runCompose(args, grafanaPassword) {
31259
31270
  }
31260
31271
  }
31261
31272
  }
31273
+ const envFilePath = path6.resolve(projectDir, ".env");
31274
+ if (fs6.existsSync(envFilePath)) {
31275
+ try {
31276
+ const envContent = fs6.readFileSync(envFilePath, "utf8");
31277
+ if (!env.VM_AUTH_USERNAME) {
31278
+ const m = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
31279
+ if (m)
31280
+ env.VM_AUTH_USERNAME = m[1].trim().replace(/^["']|["']$/g, "");
31281
+ }
31282
+ if (!env.VM_AUTH_PASSWORD) {
31283
+ const m = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
31284
+ if (m)
31285
+ env.VM_AUTH_PASSWORD = m[1].trim().replace(/^["']|["']$/g, "");
31286
+ }
31287
+ } catch (err) {
31288
+ if (process.env.DEBUG) {
31289
+ console.warn(`Warning: Could not read VM auth from .env: ${err instanceof Error ? err.message : String(err)}`);
31290
+ }
31291
+ }
31292
+ }
31262
31293
  const finalArgs = [...args];
31263
31294
  if (process.platform === "darwin" && args.includes("up")) {
31264
31295
  finalArgs.push("--scale", "self-node-exporter=0");
@@ -31517,8 +31548,17 @@ You can provide either:`);
31517
31548
  }
31518
31549
  }
31519
31550
  } else {
31520
- console.log(`Step 2: Demo mode enabled - using included demo PostgreSQL database
31551
+ 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)) {
31555
+ fs6.copyFileSync(demoSrc, instancesPath);
31556
+ console.log(`\u2713 Demo monitoring target configured
31557
+ `);
31558
+ } else {
31559
+ console.error(`\u26A0 instances.demo.yml not found \u2014 demo target not configured
31521
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
  });
@@ -32426,6 +32510,18 @@ Grafana credentials:`);
32426
32510
  console.log(" URL: http://localhost:3000");
32427
32511
  console.log(" Username: monitor");
32428
32512
  console.log(` Password: ${password}`);
32513
+ const envFile = path6.resolve(projectDir, ".env");
32514
+ if (fs6.existsSync(envFile)) {
32515
+ const envContent = fs6.readFileSync(envFile, "utf8");
32516
+ const vmUser = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
32517
+ const vmPass = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
32518
+ if (vmUser && vmPass) {
32519
+ console.log(`
32520
+ VictoriaMetrics credentials:`);
32521
+ console.log(` Username: ${vmUser[1].trim().replace(/^["']|["']$/g, "")}`);
32522
+ console.log(` Password: ${vmPass[1].trim().replace(/^["']|["']$/g, "")}`);
32523
+ }
32524
+ }
32429
32525
  console.log("");
32430
32526
  });
32431
32527
  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.5",
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
+ });