postgresai 0.15.0-dev.1 → 0.15.0-dev.10

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.
@@ -15,6 +15,16 @@ export interface MockClientOptions {
15
15
  invalidIndexesRows?: any[];
16
16
  unusedIndexesRows?: any[];
17
17
  redundantIndexesRows?: any[];
18
+ tableBloatRows?: any[];
19
+ indexBloatRows?: any[];
20
+ vacuumStatsRows?: any[];
21
+ deadlockStatsRows?: any[];
22
+ pgStatStatementsExtensionRows?: any[];
23
+ pgStatStatementsStatsRows?: any[];
24
+ pgStatStatementsSampleRows?: any[];
25
+ pgStatKcacheExtensionRows?: any[];
26
+ pgStatKcacheStatsRows?: any[];
27
+ pgStatKcacheSampleRows?: any[];
18
28
  sensitiveColumnsRows?: any[];
19
29
  }
20
30
 
@@ -46,6 +56,16 @@ export function createMockClient(options: MockClientOptions = {}) {
46
56
  invalidIndexesRows = [],
47
57
  unusedIndexesRows = [],
48
58
  redundantIndexesRows = [],
59
+ tableBloatRows = [],
60
+ indexBloatRows = [],
61
+ vacuumStatsRows = [],
62
+ deadlockStatsRows = [{ deadlocks: "0", conflicts: "0", stats_reset: null }],
63
+ pgStatStatementsExtensionRows = [],
64
+ pgStatStatementsStatsRows = [],
65
+ pgStatStatementsSampleRows = [],
66
+ pgStatKcacheExtensionRows = [],
67
+ pgStatKcacheStatsRows = [],
68
+ pgStatKcacheSampleRows = [],
49
69
  sensitiveColumnsRows = [],
50
70
  } = options;
51
71
 
@@ -99,13 +119,42 @@ export function createMockClient(options: MockClientOptions = {}) {
99
119
  if (sql.includes("redundant_indexes_grouped") && sql.includes("columns like")) {
100
120
  return { rows: redundantIndexesRows };
101
121
  }
122
+ // F004/F005: bloat metrics from metrics.yml
123
+ if (sql.includes("tag_idxname") && sql.includes("bloat_size")) {
124
+ return { rows: indexBloatRows };
125
+ }
126
+ if (sql.includes("tag_tblname") && sql.includes("bloat_size")) {
127
+ return { rows: tableBloatRows };
128
+ }
129
+ // Vacuum stats used by F004/F005
130
+ if (sql.includes("pg_stat_user_tables") && sql.includes("last_vacuum")) {
131
+ return { rows: vacuumStatsRows };
132
+ }
133
+ // G003: Deadlock/conflict stats
134
+ if (sql.includes("coalesce(sum(deadlocks)") && sql.includes("current_database()")) {
135
+ return { rows: deadlockStatsRows };
136
+ }
102
137
  // D004: pg_stat_statements extension check
103
138
  if (sql.includes("pg_extension") && sql.includes("pg_stat_statements")) {
104
- return { rows: [] };
139
+ return { rows: pgStatStatementsExtensionRows };
140
+ }
141
+ // D004: pg_stat_statements aggregate and sample queries
142
+ if (sql.includes("from pg_stat_statements") && sql.includes("count(*) as cnt")) {
143
+ return { rows: pgStatStatementsStatsRows };
144
+ }
145
+ if (sql.includes("from pg_stat_statements s") && sql.includes("order by calls desc")) {
146
+ return { rows: pgStatStatementsSampleRows };
105
147
  }
106
148
  // D004: pg_stat_kcache extension check
107
149
  if (sql.includes("pg_extension") && sql.includes("pg_stat_kcache")) {
108
- return { rows: [] };
150
+ return { rows: pgStatKcacheExtensionRows };
151
+ }
152
+ // D004: pg_stat_kcache aggregate and sample queries
153
+ if (sql.includes("from pg_stat_kcache") && sql.includes("count(*) as cnt")) {
154
+ return { rows: pgStatKcacheStatsRows };
155
+ }
156
+ if (sql.includes("from pg_stat_kcache k") && sql.includes("order by")) {
157
+ return { rows: pgStatKcacheSampleRows };
109
158
  }
110
159
  // G001: Memory settings query
111
160
  if (sql.includes("pg_size_bytes") && sql.includes("shared_buffers") && sql.includes("work_mem")) {
@@ -0,0 +1,422 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "bun:test";
2
+ import { resolve } from "path";
3
+ import * as fs from "fs";
4
+ import * as os from "os";
5
+
6
+ // 30 seconds timeout for tests that spawn CLI processes
7
+ // This accommodates file I/O operations and process startup overhead in CI environments
8
+ const TEST_TIMEOUT = 30000;
9
+
10
+ /**
11
+ * Run CLI command in a specific directory.
12
+ *
13
+ * @param args - CLI arguments to pass to postgres-ai (e.g., ["mon", "local-install", "--yes"])
14
+ * @param cwd - Working directory where the command should run
15
+ * @param env - Optional environment variables to override (merged with process.env)
16
+ * @returns Object containing:
17
+ * - status: Process exit code (0 = success)
18
+ * - stdout: Standard output as string
19
+ * - stderr: Standard error as string
20
+ */
21
+ function runCliInDir(args: string[], cwd: string, env: Record<string, string | undefined> = {}) {
22
+ const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
23
+ const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
24
+ const result = Bun.spawnSync([bunBin, cliPath, ...args], {
25
+ env: { ...process.env, ...env },
26
+ cwd,
27
+ });
28
+ return {
29
+ status: result.exitCode,
30
+ stdout: new TextDecoder().decode(result.stdout),
31
+ stderr: new TextDecoder().decode(result.stderr),
32
+ };
33
+ }
34
+
35
+ describe("upgrade workflow", () => {
36
+ /**
37
+ * These tests verify the upgrade process documented in README.md:
38
+ * 1. Update CLI (npm install -g postgresai@latest)
39
+ * 2. Stop services (postgresai mon stop)
40
+ * 3. Re-run local-install which updates .env with new version
41
+ * 4. Verify services (postgresai mon status/health)
42
+ *
43
+ * Since Docker can't run in unit tests, we focus on testing the
44
+ * configuration update behavior which is the core of the upgrade process.
45
+ */
46
+
47
+ let tempDir: string;
48
+
49
+ beforeAll(() => {
50
+ tempDir = fs.mkdtempSync(resolve(os.tmpdir(), "pgai-upgrade-test-"));
51
+ });
52
+
53
+ afterAll(() => {
54
+ if (tempDir && fs.existsSync(tempDir)) {
55
+ fs.rmSync(tempDir, { recursive: true, force: true });
56
+ }
57
+ });
58
+
59
+ test("upgrade updates PGAI_TAG from old version to CLI version", () => {
60
+ // Simulate existing installation with old version
61
+ const testDir = resolve(tempDir, "upgrade-tag-test");
62
+ fs.mkdirSync(testDir, { recursive: true });
63
+
64
+ // Create .env with old version (simulating pre-upgrade state)
65
+ fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.13.0\n");
66
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
67
+ fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
68
+
69
+ // Run local-install (simulating upgrade after CLI update)
70
+ // The --yes flag skips interactive prompts
71
+ // Note: Command will fail at Docker step (no Docker in CI), but .env is updated before that
72
+ runCliInDir(
73
+ ["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
74
+ testDir,
75
+ { PGAI_TAG: undefined }
76
+ );
77
+
78
+ // Read the updated .env (written before Docker operations)
79
+ const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
80
+
81
+ // The old version should be replaced with the CLI version
82
+ expect(envContent).not.toMatch(/PGAI_TAG=0\.13\.0/);
83
+ // Should have a valid version tag (either semver or dev version)
84
+ expect(envContent).toMatch(/PGAI_TAG=\d+\.\d+\.\d+|PGAI_TAG=0\.0\.0-dev/);
85
+ }, { timeout: TEST_TIMEOUT });
86
+
87
+ test("upgrade preserves Grafana password", () => {
88
+ const testDir = resolve(tempDir, "upgrade-password-test");
89
+ fs.mkdirSync(testDir, { recursive: true });
90
+
91
+ // Simulate existing installation with password
92
+ fs.writeFileSync(resolve(testDir, ".env"),
93
+ "PGAI_TAG=0.12.0\nGF_SECURITY_ADMIN_PASSWORD=my-secure-password-123\n");
94
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
95
+ fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
96
+
97
+ // Run local-install (upgrade)
98
+ // Note: Command will fail at Docker step (no Docker in CI), but .env is updated before that
99
+ runCliInDir(
100
+ ["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
101
+ testDir,
102
+ { PGAI_TAG: undefined }
103
+ );
104
+
105
+ const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
106
+
107
+ // Password should be preserved
108
+ expect(envContent).toMatch(/GF_SECURITY_ADMIN_PASSWORD=my-secure-password-123/);
109
+ // Tag should be updated
110
+ expect(envContent).not.toMatch(/PGAI_TAG=0\.12\.0/);
111
+ }, { timeout: TEST_TIMEOUT });
112
+
113
+ test("upgrade preserves custom registry", () => {
114
+ const testDir = resolve(tempDir, "upgrade-registry-test");
115
+ fs.mkdirSync(testDir, { recursive: true });
116
+
117
+ // Simulate existing installation with custom registry
118
+ fs.writeFileSync(resolve(testDir, ".env"),
119
+ "PGAI_TAG=0.11.0\nPGAI_REGISTRY=registry.example.com/postgres-ai\n");
120
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
121
+ fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
122
+
123
+ // Run local-install (upgrade)
124
+ // Note: Command will fail at Docker step (no Docker in CI), but .env is updated before that
125
+ runCliInDir(
126
+ ["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
127
+ testDir,
128
+ { PGAI_TAG: undefined }
129
+ );
130
+
131
+ const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
132
+
133
+ // Registry should be preserved
134
+ expect(envContent).toMatch(/PGAI_REGISTRY=registry\.example\.com\/postgres-ai/);
135
+ // Tag should be updated
136
+ expect(envContent).not.toMatch(/PGAI_TAG=0\.11\.0/);
137
+ }, { timeout: TEST_TIMEOUT });
138
+
139
+ test("upgrade preserves all settings together", () => {
140
+ const testDir = resolve(tempDir, "upgrade-all-settings-test");
141
+ fs.mkdirSync(testDir, { recursive: true });
142
+
143
+ // Simulate existing installation with all settings
144
+ fs.writeFileSync(resolve(testDir, ".env"),
145
+ "PGAI_TAG=0.10.0\nPGAI_REGISTRY=my.registry.io\nGF_SECURITY_ADMIN_PASSWORD=super-secret\n");
146
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
147
+ fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
148
+
149
+ // Run local-install (upgrade)
150
+ // Note: Command will fail at Docker step (no Docker in CI), but .env is updated before that
151
+ runCliInDir(
152
+ ["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
153
+ testDir,
154
+ { PGAI_TAG: undefined }
155
+ );
156
+
157
+ const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
158
+
159
+ // All settings should be preserved
160
+ expect(envContent).toMatch(/PGAI_REGISTRY=my\.registry\.io/);
161
+ expect(envContent).toMatch(/GF_SECURITY_ADMIN_PASSWORD=super-secret/);
162
+ // Tag should be updated to new version
163
+ expect(envContent).not.toMatch(/PGAI_TAG=0\.10\.0/);
164
+ expect(envContent).toMatch(/PGAI_TAG=/);
165
+ }, { timeout: TEST_TIMEOUT });
166
+
167
+ test("upgrade with --tag flag uses specified version", () => {
168
+ const testDir = resolve(tempDir, "upgrade-custom-tag-test");
169
+ fs.mkdirSync(testDir, { recursive: true });
170
+
171
+ // Simulate existing installation
172
+ fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.9.0\n");
173
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
174
+ fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
175
+
176
+ // Run local-install with specific tag (for rollback or specific version upgrade)
177
+ // Note: Command will fail at Docker step (no Docker in CI), but .env is updated before that
178
+ const result = runCliInDir(
179
+ ["mon", "local-install", "--tag", "0.14.0-beta.5", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
180
+ testDir,
181
+ { PGAI_TAG: undefined }
182
+ );
183
+
184
+ const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
185
+
186
+ // Should use the specified tag
187
+ expect(envContent).toMatch(/PGAI_TAG=0\.14\.0-beta\.5/);
188
+ // Stdout should confirm the tag being used (happens before Docker step)
189
+ expect(result.stdout).toMatch(/Using image tag: 0\.14\.0-beta\.5/);
190
+ }, { timeout: TEST_TIMEOUT });
191
+
192
+ test("upgrade preserves .pgwatch-config file", () => {
193
+ const testDir = resolve(tempDir, "upgrade-config-test");
194
+ fs.mkdirSync(testDir, { recursive: true });
195
+
196
+ // Simulate existing installation with config file
197
+ fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.8.0\n");
198
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
199
+ fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
200
+ fs.writeFileSync(resolve(testDir, ".pgwatch-config"),
201
+ "api_key=test-api-key-12345\ngrafana_password=existing-password\n");
202
+
203
+ // Run local-install (upgrade)
204
+ // Note: Command will fail at Docker step (no Docker in CI), but config file is preserved
205
+ runCliInDir(
206
+ ["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
207
+ testDir,
208
+ { PGAI_TAG: undefined }
209
+ );
210
+
211
+ // Config file should still exist
212
+ expect(fs.existsSync(resolve(testDir, ".pgwatch-config"))).toBe(true);
213
+
214
+ const configContent = fs.readFileSync(resolve(testDir, ".pgwatch-config"), "utf8");
215
+ // Grafana password should be preserved (not overwritten since it exists)
216
+ expect(configContent).toMatch(/grafana_password=existing-password/);
217
+ }, { timeout: TEST_TIMEOUT });
218
+
219
+ test("instances.yml is preserved during upgrade", () => {
220
+ const testDir = resolve(tempDir, "upgrade-instances-test");
221
+ fs.mkdirSync(testDir, { recursive: true });
222
+
223
+ // Simulate existing installation with instances
224
+ fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.7.0\n");
225
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
226
+
227
+ const instancesContent = `# PostgreSQL instances to monitor
228
+ - name: production-db
229
+ conn_str: postgresql://monitor:pass@prod.example.com:5432/mydb
230
+ preset_metrics: full
231
+ is_enabled: true
232
+ `;
233
+ fs.writeFileSync(resolve(testDir, "instances.yml"), instancesContent);
234
+
235
+ // Note: local-install in production mode clears instances.yml
236
+ // This is intentional behavior - upgrade should use 'mon start' not 'local-install'
237
+ // for preserving instances. Testing that the file exists after operation.
238
+ // Note: Command will fail at Docker step (no Docker in CI), but file is created
239
+ runCliInDir(
240
+ ["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
241
+ testDir,
242
+ { PGAI_TAG: undefined }
243
+ );
244
+
245
+ // instances.yml should exist (content may be reset by local-install)
246
+ expect(fs.existsSync(resolve(testDir, "instances.yml"))).toBe(true);
247
+ }, { timeout: TEST_TIMEOUT });
248
+ });
249
+
250
+ describe("upgrade error handling", () => {
251
+ /**
252
+ * Tests for edge cases and error scenarios in the upgrade workflow.
253
+ * These ensure the CLI handles incomplete or malformed configurations gracefully.
254
+ */
255
+
256
+ let tempDir: string;
257
+
258
+ beforeAll(() => {
259
+ tempDir = fs.mkdtempSync(resolve(os.tmpdir(), "pgai-upgrade-error-test-"));
260
+ });
261
+
262
+ afterAll(() => {
263
+ if (tempDir && fs.existsSync(tempDir)) {
264
+ fs.rmSync(tempDir, { recursive: true, force: true });
265
+ }
266
+ });
267
+
268
+ test("local-install creates .env if missing", () => {
269
+ const testDir = resolve(tempDir, "missing-env-test");
270
+ fs.mkdirSync(testDir, { recursive: true });
271
+
272
+ // Only create docker-compose.yml (no .env)
273
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
274
+
275
+ // Run local-install without existing .env
276
+ // Note: Command will fail at Docker step (no Docker in CI), but .env is created before that
277
+ runCliInDir(
278
+ ["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
279
+ testDir,
280
+ { PGAI_TAG: undefined }
281
+ );
282
+
283
+ // .env should be created (before Docker operations fail)
284
+ expect(fs.existsSync(resolve(testDir, ".env"))).toBe(true);
285
+
286
+ const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
287
+ expect(envContent).toMatch(/PGAI_TAG=/);
288
+ expect(envContent).toMatch(/REPLICATOR_PASSWORD=[a-f0-9]{64}/);
289
+ expect(envContent).toMatch(/VM_AUTH_USERNAME=vmauth/);
290
+ expect(envContent).toMatch(/^VM_AUTH_PASSWORD=[A-Za-z0-9+/]+={0,2}\s*$/m);
291
+ }, { timeout: TEST_TIMEOUT });
292
+
293
+ test("local-install handles .env without PGAI_TAG line", () => {
294
+ const testDir = resolve(tempDir, "no-tag-line-test");
295
+ fs.mkdirSync(testDir, { recursive: true });
296
+
297
+ // Create .env without PGAI_TAG (only has other settings)
298
+ fs.writeFileSync(resolve(testDir, ".env"), "GF_SECURITY_ADMIN_PASSWORD=old-password\nREPLICATOR_PASSWORD=existing-repl\nVM_AUTH_USERNAME=existing-vm-user\nVM_AUTH_PASSWORD=existing-vm-pass\n");
299
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
300
+ fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
301
+
302
+ // Run local-install
303
+ // Note: Command will fail at Docker step (no Docker in CI), but .env is updated before that
304
+ runCliInDir(
305
+ ["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
306
+ testDir,
307
+ { PGAI_TAG: undefined }
308
+ );
309
+
310
+ const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
311
+ // Should add PGAI_TAG to the file
312
+ expect(envContent).toMatch(/PGAI_TAG=/);
313
+ // Should preserve existing settings
314
+ expect(envContent).toMatch(/GF_SECURITY_ADMIN_PASSWORD=old-password/);
315
+ expect(envContent).toMatch(/REPLICATOR_PASSWORD=existing-repl/);
316
+ expect(envContent).toMatch(/VM_AUTH_USERNAME=existing-vm-user/);
317
+ expect(envContent).toMatch(/VM_AUTH_PASSWORD=existing-vm-pass/);
318
+ }, { timeout: TEST_TIMEOUT });
319
+
320
+ test("local-install strips only matching quotes from VM auth values", () => {
321
+ const testDir = resolve(tempDir, "quoted-vm-auth-test");
322
+ fs.mkdirSync(testDir, { recursive: true });
323
+
324
+ fs.writeFileSync(
325
+ resolve(testDir, ".env"),
326
+ "VM_AUTH_USERNAME=\"quoted-vm-user\"\nVM_AUTH_PASSWORD='quoted-vm-pass'\n"
327
+ );
328
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
329
+ fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
330
+
331
+ runCliInDir(
332
+ ["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
333
+ testDir,
334
+ { PGAI_TAG: undefined }
335
+ );
336
+
337
+ const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
338
+ expect(envContent).toMatch(/VM_AUTH_USERNAME=quoted-vm-user/);
339
+ expect(envContent).toMatch(/VM_AUTH_PASSWORD=quoted-vm-pass/);
340
+ }, { timeout: TEST_TIMEOUT });
341
+
342
+ test("local-install handles same version (no-op scenario)", () => {
343
+ const testDir = resolve(tempDir, "same-version-test");
344
+ fs.mkdirSync(testDir, { recursive: true });
345
+
346
+ // First, run local-install to get the current CLI version
347
+ fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.0.0-placeholder\n");
348
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
349
+ fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
350
+
351
+ // Note: Command will fail at Docker step (no Docker in CI), but .env is updated before that
352
+ runCliInDir(
353
+ ["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
354
+ testDir,
355
+ { PGAI_TAG: undefined }
356
+ );
357
+
358
+ const firstEnv = fs.readFileSync(resolve(testDir, ".env"), "utf8");
359
+ expect(firstEnv).toMatch(/PGAI_TAG=/);
360
+
361
+ // Run again with same version - should update .env identically
362
+ runCliInDir(
363
+ ["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
364
+ testDir,
365
+ { PGAI_TAG: undefined }
366
+ );
367
+
368
+ // .env should still have a valid tag
369
+ const finalEnv = fs.readFileSync(resolve(testDir, ".env"), "utf8");
370
+ expect(finalEnv).toMatch(/PGAI_TAG=/);
371
+ }, { timeout: TEST_TIMEOUT });
372
+ });
373
+
374
+ describe("upgrade CLI commands", () => {
375
+ test("mon stop command exists and shows help", () => {
376
+ const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
377
+ const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
378
+ const result = Bun.spawnSync([bunBin, cliPath, "mon", "stop", "--help"], {
379
+ env: process.env,
380
+ });
381
+
382
+ expect(result.exitCode).toBe(0);
383
+ const stdout = new TextDecoder().decode(result.stdout);
384
+ expect(stdout).toMatch(/stop monitoring services/i);
385
+ }, { timeout: TEST_TIMEOUT });
386
+
387
+ test("mon start command exists and shows help", () => {
388
+ const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
389
+ const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
390
+ const result = Bun.spawnSync([bunBin, cliPath, "mon", "start", "--help"], {
391
+ env: process.env,
392
+ });
393
+
394
+ expect(result.exitCode).toBe(0);
395
+ const stdout = new TextDecoder().decode(result.stdout);
396
+ expect(stdout).toMatch(/start monitoring services/i);
397
+ }, { timeout: TEST_TIMEOUT });
398
+
399
+ test("mon status command exists and shows help", () => {
400
+ const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
401
+ const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
402
+ const result = Bun.spawnSync([bunBin, cliPath, "mon", "status", "--help"], {
403
+ env: process.env,
404
+ });
405
+
406
+ expect(result.exitCode).toBe(0);
407
+ const stdout = new TextDecoder().decode(result.stdout);
408
+ expect(stdout).toMatch(/status/i);
409
+ }, { timeout: TEST_TIMEOUT });
410
+
411
+ test("mon health command exists and shows help", () => {
412
+ const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
413
+ const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
414
+ const result = Bun.spawnSync([bunBin, cliPath, "mon", "health", "--help"], {
415
+ env: process.env,
416
+ });
417
+
418
+ expect(result.exitCode).toBe(0);
419
+ const stdout = new TextDecoder().decode(result.stdout);
420
+ expect(stdout).toMatch(/health/i);
421
+ }, { timeout: TEST_TIMEOUT });
422
+ });