postgresai 0.14.0-beta.4 → 0.14.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.14.0-beta.4",
3
+ "version": "0.14.0-beta.6",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -26,12 +26,14 @@
26
26
  },
27
27
  "scripts": {
28
28
  "embed-metrics": "bun run scripts/embed-metrics.ts",
29
- "build": "bun run embed-metrics && bun build ./bin/postgres-ai.ts --outdir ./dist/bin --target node && node -e \"const fs=require('fs');const f='./dist/bin/postgres-ai.js';fs.writeFileSync(f,fs.readFileSync(f,'utf8').replace('#!/usr/bin/env bun','#!/usr/bin/env node'))\"",
29
+ "build": "bun run embed-metrics && bun build ./bin/postgres-ai.ts --outdir ./dist/bin --target node && node -e \"const fs=require('fs');const f='./dist/bin/postgres-ai.js';fs.writeFileSync(f,fs.readFileSync(f,'utf8').replace('#!/usr/bin/env bun','#!/usr/bin/env node'))\" && cp -r ./sql ./dist/sql",
30
30
  "prepublishOnly": "npm run build",
31
31
  "start": "bun ./bin/postgres-ai.ts --help",
32
32
  "start:node": "node ./dist/bin/postgres-ai.js --help",
33
33
  "dev": "bun run embed-metrics && bun --watch ./bin/postgres-ai.ts",
34
34
  "test": "bun run embed-metrics && bun test",
35
+ "test:fast": "bun run embed-metrics && bun test --coverage=false",
36
+ "test:coverage": "bun run embed-metrics && bun test --coverage && echo 'Coverage report: cli/coverage/lcov-report/index.html'",
35
37
  "typecheck": "bun run embed-metrics && bunx tsc --noEmit"
36
38
  },
37
39
  "dependencies": {
@@ -3,11 +3,17 @@
3
3
  -- operations they don't have direct permissions for.
4
4
 
5
5
  /*
6
- * pgai_explain_generic
6
+ * explain_generic
7
7
  *
8
8
  * Function to get generic explain plans with optional HypoPG index testing.
9
9
  * Requires: PostgreSQL 16+ (for generic_plan option), HypoPG extension (optional).
10
10
  *
11
+ * Security notes:
12
+ * - EXPLAIN without ANALYZE is read-only (plans but doesn't execute the query)
13
+ * - PostgreSQL's EXPLAIN only accepts a single statement (primary protection)
14
+ * - Input validation uses a simple heuristic to detect multiple statements
15
+ * (Note: may reject valid queries containing semicolons in string literals)
16
+ *
11
17
  * Usage examples:
12
18
  * -- Basic generic plan
13
19
  * select postgres_ai.explain_generic('select * from users where id = $1');
@@ -39,6 +45,7 @@ declare
39
45
  v_hypo_result record;
40
46
  v_version int;
41
47
  v_hypopg_available boolean;
48
+ v_clean_query text;
42
49
  begin
43
50
  -- Check PostgreSQL version (generic_plan requires 16+)
44
51
  select current_setting('server_version_num')::int into v_version;
@@ -48,6 +55,24 @@ begin
48
55
  current_setting('server_version');
49
56
  end if;
50
57
 
58
+ -- Input validation: reject empty queries
59
+ if query is null or trim(query) = '' then
60
+ raise exception 'query cannot be empty';
61
+ end if;
62
+
63
+ -- Input validation: detect multiple statements (defense-in-depth)
64
+ -- Note: This is a simple heuristic - EXPLAIN itself only accepts single statements
65
+ -- Limitation: Queries with semicolons inside string literals will be rejected
66
+ v_clean_query := trim(query);
67
+ if v_clean_query like '%;%' then
68
+ -- Strip trailing semicolon if present (common user convenience)
69
+ v_clean_query := regexp_replace(v_clean_query, ';\s*$', '');
70
+ -- If there's still a semicolon, reject (likely multiple statements or semicolon in string)
71
+ if v_clean_query like '%;%' then
72
+ raise exception 'query contains semicolon (multiple statements not allowed; note: semicolons in string literals are also not supported)';
73
+ end if;
74
+ end if;
75
+
51
76
  -- Check if HypoPG extension is available
52
77
  if hypopg_index is not null then
53
78
  select exists(
@@ -64,15 +89,14 @@ begin
64
89
  v_hypo_result.indexname, v_hypo_result.indexrelid;
65
90
  end if;
66
91
 
67
- -- Build and execute explain query based on format
68
- -- Output is preserved exactly as EXPLAIN returns it
92
+ -- Build and execute EXPLAIN query
93
+ -- Note: EXPLAIN is read-only (plans but doesn't execute), making this safe
69
94
  begin
70
95
  if lower(format) = 'json' then
71
- v_explain_query := 'explain (verbose, settings, generic_plan, format json) ' || query;
72
- execute v_explain_query into result;
96
+ execute 'explain (verbose, settings, generic_plan, format json) ' || v_clean_query
97
+ into result;
73
98
  else
74
- v_explain_query := 'explain (verbose, settings, generic_plan) ' || query;
75
- for v_line in execute v_explain_query loop
99
+ for v_line in execute 'explain (verbose, settings, generic_plan) ' || v_clean_query loop
76
100
  v_lines := array_append(v_lines, v_line."QUERY PLAN");
77
101
  end loop;
78
102
  result := array_to_string(v_lines, e'\n');
@@ -0,0 +1,258 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { resolve } from "path";
3
+
4
+ import * as util from "../lib/util";
5
+ import * as pkce from "../lib/pkce";
6
+ import * as authServer from "../lib/auth-server";
7
+
8
+ function runCli(args: string[], env: Record<string, string> = {}) {
9
+ const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
10
+ const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
11
+ const result = Bun.spawnSync([bunBin, cliPath, ...args], {
12
+ env: { ...process.env, ...env },
13
+ });
14
+ return {
15
+ status: result.exitCode,
16
+ stdout: new TextDecoder().decode(result.stdout),
17
+ stderr: new TextDecoder().decode(result.stderr),
18
+ };
19
+ }
20
+
21
+ describe("URL resolution", () => {
22
+ test("resolveBaseUrls returns correct production defaults", () => {
23
+ const result = util.resolveBaseUrls();
24
+ expect(result.apiBaseUrl).toBe("https://postgres.ai/api/general");
25
+ expect(result.uiBaseUrl).toBe("https://console.postgres.ai");
26
+ });
27
+
28
+ test("resolveBaseUrls strips trailing slashes", () => {
29
+ const result = util.resolveBaseUrls({
30
+ apiBaseUrl: "https://example.com/api/",
31
+ uiBaseUrl: "https://example.com/",
32
+ });
33
+ expect(result.apiBaseUrl).toBe("https://example.com/api");
34
+ expect(result.uiBaseUrl).toBe("https://example.com");
35
+ });
36
+
37
+ test("resolveBaseUrls respects environment variables", () => {
38
+ const originalApiUrl = process.env.PGAI_API_BASE_URL;
39
+ const originalUiUrl = process.env.PGAI_UI_BASE_URL;
40
+
41
+ try {
42
+ process.env.PGAI_API_BASE_URL = "https://custom-api.example.com/api/";
43
+ process.env.PGAI_UI_BASE_URL = "https://custom-ui.example.com/";
44
+
45
+ const result = util.resolveBaseUrls();
46
+ expect(result.apiBaseUrl).toBe("https://custom-api.example.com/api");
47
+ expect(result.uiBaseUrl).toBe("https://custom-ui.example.com");
48
+ } finally {
49
+ if (originalApiUrl === undefined) {
50
+ delete process.env.PGAI_API_BASE_URL;
51
+ } else {
52
+ process.env.PGAI_API_BASE_URL = originalApiUrl;
53
+ }
54
+ if (originalUiUrl === undefined) {
55
+ delete process.env.PGAI_UI_BASE_URL;
56
+ } else {
57
+ process.env.PGAI_UI_BASE_URL = originalUiUrl;
58
+ }
59
+ }
60
+ });
61
+
62
+ test("resolveBaseUrls prefers CLI options over env vars", () => {
63
+ const originalApiUrl = process.env.PGAI_API_BASE_URL;
64
+
65
+ try {
66
+ process.env.PGAI_API_BASE_URL = "https://env.example.com/api/";
67
+
68
+ const result = util.resolveBaseUrls({
69
+ apiBaseUrl: "https://cli-option.example.com/api/",
70
+ });
71
+ expect(result.apiBaseUrl).toBe("https://cli-option.example.com/api");
72
+ } finally {
73
+ if (originalApiUrl === undefined) {
74
+ delete process.env.PGAI_API_BASE_URL;
75
+ } else {
76
+ process.env.PGAI_API_BASE_URL = originalApiUrl;
77
+ }
78
+ }
79
+ });
80
+
81
+ test("resolveBaseUrls uses config baseUrl for API", () => {
82
+ const result = util.resolveBaseUrls({}, { baseUrl: "https://config.example.com/api/" });
83
+ expect(result.apiBaseUrl).toBe("https://config.example.com/api");
84
+ // UI should still use default since config doesn't have uiBaseUrl
85
+ expect(result.uiBaseUrl).toBe("https://console.postgres.ai");
86
+ });
87
+
88
+ test("normalizeBaseUrl throws on invalid URL", () => {
89
+ expect(() => util.normalizeBaseUrl("not-a-url")).toThrow(/Invalid base URL/);
90
+ });
91
+
92
+ test("normalizeBaseUrl accepts valid URLs", () => {
93
+ expect(util.normalizeBaseUrl("https://example.com")).toBe("https://example.com");
94
+ expect(util.normalizeBaseUrl("https://example.com/")).toBe("https://example.com");
95
+ expect(util.normalizeBaseUrl("https://example.com/api/")).toBe("https://example.com/api");
96
+ });
97
+ });
98
+
99
+ describe("PKCE module", () => {
100
+ test("generateCodeVerifier returns correct length string", () => {
101
+ const verifier = pkce.generateCodeVerifier();
102
+ expect(typeof verifier).toBe("string");
103
+ expect(verifier.length).toBeGreaterThanOrEqual(43);
104
+ expect(verifier.length).toBeLessThanOrEqual(128);
105
+ });
106
+
107
+ test("generateCodeChallenge returns base64url encoded SHA256", () => {
108
+ const verifier = pkce.generateCodeVerifier();
109
+ const challenge = pkce.generateCodeChallenge(verifier);
110
+ expect(typeof challenge).toBe("string");
111
+ expect(challenge.length).toBeGreaterThan(0);
112
+ // Base64url encoding should not contain + or / characters
113
+ expect(challenge).not.toMatch(/[+/]/);
114
+ });
115
+
116
+ test("generateState returns random string", () => {
117
+ const state1 = pkce.generateState();
118
+ const state2 = pkce.generateState();
119
+ expect(typeof state1).toBe("string");
120
+ expect(state1.length).toBeGreaterThan(0);
121
+ expect(state1).not.toBe(state2); // Should be random
122
+ });
123
+
124
+ test("generatePKCEParams returns all required parameters", () => {
125
+ const params = pkce.generatePKCEParams();
126
+ expect(params.codeVerifier).toBeTruthy();
127
+ expect(params.codeChallenge).toBeTruthy();
128
+ expect(params.codeChallengeMethod).toBe("S256");
129
+ expect(params.state).toBeTruthy();
130
+ });
131
+ });
132
+
133
+ describe("Auth callback server", () => {
134
+ test("createCallbackServer returns correct interface", () => {
135
+ const server = authServer.createCallbackServer(0, "test-state", 1000);
136
+ expect(server.server).toBeTruthy();
137
+ expect(server.server.stop).toBeInstanceOf(Function);
138
+ expect(server.promise).toBeInstanceOf(Promise);
139
+ expect(server.ready).toBeInstanceOf(Promise);
140
+ expect(server.getPort).toBeInstanceOf(Function);
141
+
142
+ // Clean up
143
+ server.server.stop();
144
+ });
145
+
146
+ test("createCallbackServer binds to a port", async () => {
147
+ const server = authServer.createCallbackServer(0, "test-state", 5000);
148
+ const port = await server.ready;
149
+ expect(typeof port).toBe("number");
150
+ expect(port).toBeGreaterThan(0);
151
+
152
+ // Clean up
153
+ server.server.stop();
154
+ });
155
+
156
+ test("createCallbackServer responds to callback requests", async () => {
157
+ const testState = "test-state-" + Math.random().toString(36).substring(7);
158
+ const server = authServer.createCallbackServer(0, testState, 5000);
159
+ const port = await server.ready;
160
+
161
+ // Simulate OAuth callback
162
+ const testCode = "test-auth-code";
163
+ const callbackUrl = `http://127.0.0.1:${port}/callback?code=${testCode}&state=${testState}`;
164
+
165
+ const fetchPromise = fetch(callbackUrl);
166
+ const result = await server.promise;
167
+
168
+ expect(result.code).toBe(testCode);
169
+ expect(result.state).toBe(testState);
170
+
171
+ // Check response
172
+ const response = await fetchPromise;
173
+ expect(response.status).toBe(200);
174
+ const text = await response.text();
175
+ expect(text).toMatch(/Authentication successful/);
176
+ });
177
+
178
+ test("createCallbackServer rejects on state mismatch", async () => {
179
+ const server = authServer.createCallbackServer(0, "expected-state", 5000);
180
+ const port = await server.ready;
181
+
182
+ const callbackUrl = `http://127.0.0.1:${port}/callback?code=test-code&state=wrong-state`;
183
+
184
+ const fetchPromise = fetch(callbackUrl);
185
+
186
+ await expect(server.promise).rejects.toThrow(/State mismatch/);
187
+
188
+ const response = await fetchPromise;
189
+ expect(response.status).toBe(400);
190
+ });
191
+
192
+ test("createCallbackServer handles OAuth errors", async () => {
193
+ const server = authServer.createCallbackServer(0, "test-state", 5000);
194
+ const port = await server.ready;
195
+
196
+ const callbackUrl = `http://127.0.0.1:${port}/callback?error=access_denied&error_description=User%20denied%20access`;
197
+
198
+ const fetchPromise = fetch(callbackUrl);
199
+
200
+ await expect(server.promise).rejects.toThrow(/OAuth error: access_denied/);
201
+
202
+ const response = await fetchPromise;
203
+ expect(response.status).toBe(400);
204
+ });
205
+
206
+ test("createCallbackServer times out", async () => {
207
+ const server = authServer.createCallbackServer(0, "test-state", 100); // 100ms timeout
208
+ await server.ready;
209
+
210
+ await expect(server.promise).rejects.toThrow(/timeout/i);
211
+ });
212
+ });
213
+
214
+ describe("CLI auth commands", () => {
215
+ test("cli: auth login --help shows all options", () => {
216
+ const r = runCli(["auth", "login", "--help"]);
217
+ expect(r.status).toBe(0);
218
+ expect(r.stdout).toMatch(/--set-key/);
219
+ expect(r.stdout).toMatch(/--debug/);
220
+ });
221
+
222
+ test("cli: auth show-key --help works", () => {
223
+ const r = runCli(["auth", "show-key", "--help"]);
224
+ expect(r.status).toBe(0);
225
+ expect(r.stdout).toMatch(/show.*key/i);
226
+ });
227
+
228
+ test("cli: auth remove-key --help works", () => {
229
+ const r = runCli(["auth", "remove-key", "--help"]);
230
+ expect(r.status).toBe(0);
231
+ expect(r.stdout).toMatch(/remove.*key/i);
232
+ });
233
+ });
234
+
235
+ describe("maskSecret utility", () => {
236
+ test("masks short secrets completely", () => {
237
+ expect(util.maskSecret("abc")).toBe("****");
238
+ expect(util.maskSecret("12345678")).toBe("****");
239
+ });
240
+
241
+ test("masks medium secrets with visible ends", () => {
242
+ const masked = util.maskSecret("1234567890123456");
243
+ // maskSecret shows first 4 chars, middle masked, last 4 chars for 16-char strings
244
+ expect(masked).toMatch(/^1234\*+3456$/);
245
+ });
246
+
247
+ test("masks long secrets appropriately", () => {
248
+ const secret = "abcdefghij1234567890klmnopqrstuvwxyz";
249
+ const masked = util.maskSecret(secret);
250
+ expect(masked.startsWith("abcdefghij12")).toBe(true);
251
+ expect(masked.endsWith("wxyz")).toBe(true);
252
+ expect(masked).toMatch(/\*+/);
253
+ });
254
+
255
+ test("handles empty string", () => {
256
+ expect(util.maskSecret("")).toBe("");
257
+ });
258
+ });
@@ -253,6 +253,52 @@ describe.skipIf(!!skipReason)("checkup integration: express mode schema compatib
253
253
  expect(typeof nodeResult.data).toBe("object");
254
254
  });
255
255
 
256
+ test("H001 returns index_definition with CREATE INDEX statement", async () => {
257
+ // Create a table and an index, then mark the index as invalid
258
+ await client.query(`
259
+ CREATE TABLE IF NOT EXISTS test_invalid_idx_table (id serial PRIMARY KEY, value text);
260
+ CREATE INDEX IF NOT EXISTS test_invalid_idx ON test_invalid_idx_table(value);
261
+ `);
262
+
263
+ // Mark the index as invalid (simulating a failed CONCURRENTLY build)
264
+ await client.query(`
265
+ UPDATE pg_index SET indisvalid = false
266
+ WHERE indexrelid = 'test_invalid_idx'::regclass;
267
+ `);
268
+
269
+ try {
270
+ const report = await checkup.generateH001(client, "test-node");
271
+ validateAgainstSchema(report, "H001");
272
+
273
+ const nodeResult = report.results["test-node"];
274
+ const dbName = Object.keys(nodeResult.data)[0];
275
+ expect(dbName).toBeTruthy();
276
+
277
+ const dbData = nodeResult.data[dbName] as any;
278
+ expect(dbData.invalid_indexes).toBeDefined();
279
+ expect(dbData.invalid_indexes.length).toBeGreaterThan(0);
280
+
281
+ // Find our test index
282
+ const testIndex = dbData.invalid_indexes.find(
283
+ (idx: any) => idx.index_name === "test_invalid_idx"
284
+ );
285
+ expect(testIndex).toBeDefined();
286
+
287
+ // Verify index_definition contains the actual CREATE INDEX statement
288
+ expect(testIndex.index_definition).toMatch(/^CREATE INDEX/);
289
+ expect(testIndex.index_definition).toContain("test_invalid_idx");
290
+ expect(testIndex.index_definition).toContain("test_invalid_idx_table");
291
+ } finally {
292
+ // Cleanup: restore the index and drop test objects
293
+ await client.query(`
294
+ UPDATE pg_index SET indisvalid = true
295
+ WHERE indexrelid = 'test_invalid_idx'::regclass;
296
+ DROP INDEX IF EXISTS test_invalid_idx;
297
+ DROP TABLE IF EXISTS test_invalid_idx_table;
298
+ `);
299
+ }
300
+ });
301
+
256
302
  test("H002 (unused indexes) has correct data structure", async () => {
257
303
  const report = await checkup.generateH002(client, "test-node");
258
304
  validateAgainstSchema(report, "H002");
@@ -480,7 +480,7 @@ describe("H001 - Invalid indexes", () => {
480
480
  test("getInvalidIndexes returns invalid indexes", async () => {
481
481
  const mockClient = createMockClient({
482
482
  invalidIndexesRows: [
483
- { schema_name: "public", table_name: "users", index_name: "users_email_idx", relation_name: "users", index_size_bytes: "1048576", supports_fk: false },
483
+ { schema_name: "public", table_name: "users", index_name: "users_email_idx", relation_name: "users", index_size_bytes: "1048576", index_definition: "CREATE INDEX users_email_idx ON public.users USING btree (email)", supports_fk: false },
484
484
  ],
485
485
  });
486
486
 
@@ -491,6 +491,7 @@ describe("H001 - Invalid indexes", () => {
491
491
  expect(indexes[0].index_name).toBe("users_email_idx");
492
492
  expect(indexes[0].index_size_bytes).toBe(1048576);
493
493
  expect(indexes[0].index_size_pretty).toBeTruthy();
494
+ expect(indexes[0].index_definition).toMatch(/^CREATE INDEX/);
494
495
  expect(indexes[0].relation_name).toBe("users");
495
496
  expect(indexes[0].supports_fk).toBe(false);
496
497
  });
@@ -502,7 +503,7 @@ describe("H001 - Invalid indexes", () => {
502
503
  { name: "server_version_num", setting: "160003" },
503
504
  ],
504
505
  invalidIndexesRows: [
505
- { schema_name: "public", table_name: "orders", index_name: "orders_status_idx", relation_name: "orders", index_size_bytes: "2097152", supports_fk: false },
506
+ { schema_name: "public", table_name: "orders", index_name: "orders_status_idx", relation_name: "orders", index_size_bytes: "2097152", index_definition: "CREATE INDEX orders_status_idx ON public.orders USING btree (status)", supports_fk: false },
506
507
  ],
507
508
  }
508
509
  );
@@ -396,4 +396,102 @@ describe.skipIf(skipTests)("integration: prepare-db", () => {
396
396
  await pg.cleanup();
397
397
  }
398
398
  });
399
+
400
+ test("explain_generic validates input and prevents SQL injection", async () => {
401
+ pg = await createTempPostgres();
402
+
403
+ try {
404
+ // Run init first
405
+ {
406
+ const r = runCliInit([pg.adminUri, "--password", "pw1", "--skip-optional-permissions"]);
407
+ expect(r.status).toBe(0);
408
+ }
409
+
410
+ const c = new Client({ connectionString: pg.adminUri });
411
+ await c.connect();
412
+
413
+ try {
414
+ // Check PostgreSQL version - generic_plan requires 16+
415
+ const versionRes = await c.query("show server_version_num");
416
+ const version = parseInt(versionRes.rows[0].server_version_num, 10);
417
+
418
+ if (version < 160000) {
419
+ // Skip this test on older PostgreSQL versions
420
+ console.log("Skipping explain_generic tests: requires PostgreSQL 16+");
421
+ return;
422
+ }
423
+
424
+ // Test 1: Empty query should be rejected
425
+ await expect(
426
+ c.query("select postgres_ai.explain_generic('')")
427
+ ).rejects.toThrow(/query cannot be empty/);
428
+
429
+ // Test 2: Null query should be rejected
430
+ await expect(
431
+ c.query("select postgres_ai.explain_generic(null)")
432
+ ).rejects.toThrow(/query cannot be empty/);
433
+
434
+ // Test 3: Multiple statements (semicolon in middle) should be rejected
435
+ await expect(
436
+ c.query("select postgres_ai.explain_generic('select 1; select 2')")
437
+ ).rejects.toThrow(/semicolon|multiple statements/i);
438
+
439
+ // Test 4: Trailing semicolon should be stripped and work
440
+ {
441
+ const res = await c.query("select postgres_ai.explain_generic('select 1;') as result");
442
+ expect(res.rows[0].result).toBeTruthy();
443
+ expect(res.rows[0].result).toMatch(/Result/i);
444
+ }
445
+
446
+ // Test 5: Valid query should work
447
+ {
448
+ const res = await c.query("select postgres_ai.explain_generic('select $1::int', 'text') as result");
449
+ expect(res.rows[0].result).toBeTruthy();
450
+ }
451
+
452
+ // Test 6: JSON format should work
453
+ {
454
+ const res = await c.query("select postgres_ai.explain_generic('select 1', 'json') as result");
455
+ const plan = JSON.parse(res.rows[0].result);
456
+ expect(Array.isArray(plan)).toBe(true);
457
+ expect(plan[0].Plan).toBeTruthy();
458
+ }
459
+
460
+ // Test 7: Whitespace-only query should be rejected
461
+ await expect(
462
+ c.query("select postgres_ai.explain_generic(' ')")
463
+ ).rejects.toThrow(/query cannot be empty/);
464
+
465
+ // Test 8: Semicolon in string literal is rejected (documented limitation)
466
+ // Note: This is a known limitation - the simple heuristic cannot parse SQL strings
467
+ await expect(
468
+ c.query("select postgres_ai.explain_generic('select ''hello;world''')")
469
+ ).rejects.toThrow(/semicolon/i);
470
+
471
+ // Test 9: SQL comments should work (no semicolons)
472
+ {
473
+ const res = await c.query("select postgres_ai.explain_generic('select 1 -- comment') as result");
474
+ expect(res.rows[0].result).toBeTruthy();
475
+ }
476
+
477
+ // Test 10: Escaped quotes should work (no semicolons)
478
+ {
479
+ const res = await c.query("select postgres_ai.explain_generic('select ''test''''s value''') as result");
480
+ expect(res.rows[0].result).toBeTruthy();
481
+ }
482
+
483
+ // Test 11: Case-insensitive format parameter
484
+ {
485
+ const res = await c.query("select postgres_ai.explain_generic('select 1', 'JSON') as result");
486
+ const plan = JSON.parse(res.rows[0].result);
487
+ expect(Array.isArray(plan)).toBe(true);
488
+ }
489
+
490
+ } finally {
491
+ await c.end();
492
+ }
493
+ } finally {
494
+ await pg.cleanup();
495
+ }
496
+ });
399
497
  });
package/test/init.test.ts CHANGED
@@ -337,9 +337,81 @@ describe("CLI commands", () => {
337
337
  expect(r.stdout).toMatch(/--api-key/);
338
338
  });
339
339
 
340
+ test("cli: mon local-install --api-key and --db-url skip interactive prompts", () => {
341
+ // This test verifies that when --api-key and --db-url are provided,
342
+ // the CLI uses them directly without prompting for input.
343
+ // The command will fail later (no Docker, invalid DB), but we check
344
+ // that the options were parsed and used correctly.
345
+ const r = runCli([
346
+ "mon", "local-install",
347
+ "--api-key", "test-api-key-12345",
348
+ "--db-url", "postgresql://user:pass@localhost:5432/testdb"
349
+ ]);
350
+
351
+ // Should show that API key was provided via CLI option (not prompting)
352
+ expect(r.stdout).toMatch(/Using API key provided via --api-key parameter/);
353
+ // Should show that DB URL was provided via CLI option (not prompting)
354
+ expect(r.stdout).toMatch(/Using database URL provided via --db-url parameter/);
355
+ });
356
+
340
357
  test("cli: auth login --help shows --set-key option", () => {
341
358
  const r = runCli(["auth", "login", "--help"]);
342
359
  expect(r.status).toBe(0);
343
360
  expect(r.stdout).toMatch(/--set-key/);
344
361
  });
362
+
363
+ test("cli: mon local-install reads global --api-key option", () => {
364
+ // The fix ensures --api-key works when passed as a global option (before subcommand)
365
+ // Commander.js routes global options to program.opts(), not subcommand opts
366
+ const r = runCli([
367
+ "--api-key", "global-api-key-test",
368
+ "mon", "local-install",
369
+ "--db-url", "postgresql://user:pass@localhost:5432/testdb"
370
+ ]);
371
+
372
+ // Should detect the API key from global options
373
+ expect(r.stdout).toMatch(/Using API key provided via --api-key parameter/);
374
+ });
375
+
376
+ test("cli: mon local-install works with --api-key after subcommand", () => {
377
+ // Test that --api-key works when passed after the subcommand
378
+ // Note: Commander.js routes --api-key to global opts, the fix reads from both
379
+ const r = runCli([
380
+ "mon", "local-install",
381
+ "--api-key", "test-key-after-subcommand",
382
+ "--db-url", "postgresql://user:pass@localhost:5432/testdb"
383
+ ]);
384
+
385
+ // Should detect the API key regardless of position
386
+ expect(r.stdout).toMatch(/Using API key provided via --api-key parameter/);
387
+ // Verify the key was saved
388
+ expect(r.stdout).toMatch(/API key saved/);
389
+ });
390
+
391
+ test("cli: mon local-install with --yes and no --api-key skips API setup", () => {
392
+ // When --yes is provided without --api-key, the CLI should skip
393
+ // the interactive prompt and proceed without API key
394
+ const r = runCli([
395
+ "mon", "local-install",
396
+ "--db-url", "postgresql://user:pass@localhost:5432/testdb",
397
+ "--yes"
398
+ ]);
399
+
400
+ // Should indicate auto-yes mode without API key
401
+ expect(r.stdout).toMatch(/Auto-yes mode: no API key provided/);
402
+ expect(r.stdout).toMatch(/Reports will be generated locally only/);
403
+ });
404
+
405
+ test("cli: mon local-install --demo with global --api-key shows error", () => {
406
+ // When --demo is used with global --api-key, it should still be detected and error
407
+ const r = runCli([
408
+ "--api-key", "global-api-key-test",
409
+ "mon", "local-install",
410
+ "--demo"
411
+ ]);
412
+
413
+ // Should reject demo mode with API key (from global option)
414
+ expect(r.status).not.toBe(0);
415
+ expect(r.stderr).toMatch(/Cannot use --api-key with --demo mode/);
416
+ });
345
417
  });