postgresai 0.14.0-dev.55 → 0.14.0-dev.57
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/bin/postgres-ai.ts +201 -8
- package/dist/bin/postgres-ai.js +702 -89
- package/dist/sql/01.role.sql +16 -0
- package/dist/sql/02.permissions.sql +37 -0
- package/dist/sql/03.optional_rds.sql +6 -0
- package/dist/sql/04.optional_self_managed.sql +8 -0
- package/dist/sql/05.helpers.sql +439 -0
- package/dist/sql/sql/01.role.sql +16 -0
- package/dist/sql/sql/02.permissions.sql +37 -0
- package/dist/sql/sql/03.optional_rds.sql +6 -0
- package/dist/sql/sql/04.optional_self_managed.sql +8 -0
- package/dist/sql/sql/05.helpers.sql +439 -0
- package/lib/checkup.ts +3 -0
- package/lib/config.ts +4 -4
- package/lib/init.ts +9 -3
- package/lib/issues.ts +318 -0
- package/lib/mcp-server.ts +207 -73
- package/lib/metrics-embedded.ts +2 -2
- package/package.json +2 -2
- package/sql/05.helpers.sql +31 -7
- package/test/checkup.integration.test.ts +46 -0
- package/test/checkup.test.ts +3 -2
- package/test/init.integration.test.ts +98 -0
- package/test/init.test.ts +72 -0
- package/test/issues.cli.test.ts +314 -0
- package/test/issues.test.ts +456 -0
- package/test/mcp-server.test.ts +988 -0
- package/test/schema-validation.test.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "postgresai",
|
|
3
|
-
"version": "0.14.0-dev.
|
|
3
|
+
"version": "0.14.0-dev.57",
|
|
4
4
|
"description": "postgres_ai CLI",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"private": false,
|
|
@@ -26,7 +26,7 @@
|
|
|
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",
|
package/sql/05.helpers.sql
CHANGED
|
@@ -3,11 +3,17 @@
|
|
|
3
3
|
-- operations they don't have direct permissions for.
|
|
4
4
|
|
|
5
5
|
/*
|
|
6
|
-
*
|
|
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
|
|
68
|
-
--
|
|
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
|
-
|
|
72
|
-
|
|
96
|
+
execute 'explain (verbose, settings, generic_plan, format json) ' || v_clean_query
|
|
97
|
+
into result;
|
|
73
98
|
else
|
|
74
|
-
|
|
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');
|
|
@@ -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");
|
package/test/checkup.test.ts
CHANGED
|
@@ -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
|
});
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import { mkdtempSync } from "fs";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
|
|
6
|
+
function runCli(args: string[], env: Record<string, string> = {}) {
|
|
7
|
+
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
8
|
+
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
9
|
+
const result = Bun.spawnSync([bunBin, cliPath, ...args], {
|
|
10
|
+
env: { ...process.env, ...env },
|
|
11
|
+
});
|
|
12
|
+
return {
|
|
13
|
+
status: result.exitCode,
|
|
14
|
+
stdout: new TextDecoder().decode(result.stdout),
|
|
15
|
+
stderr: new TextDecoder().decode(result.stderr),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function runCliAsync(args: string[], env: Record<string, string> = {}) {
|
|
20
|
+
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
21
|
+
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
22
|
+
const proc = Bun.spawn([bunBin, cliPath, ...args], {
|
|
23
|
+
env: { ...process.env, ...env },
|
|
24
|
+
stdout: "pipe",
|
|
25
|
+
stderr: "pipe",
|
|
26
|
+
});
|
|
27
|
+
const [status, stdout, stderr] = await Promise.all([
|
|
28
|
+
proc.exited,
|
|
29
|
+
new Response(proc.stdout).text(),
|
|
30
|
+
new Response(proc.stderr).text(),
|
|
31
|
+
]);
|
|
32
|
+
return { status, stdout, stderr };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isolatedEnv(extra: Record<string, string> = {}) {
|
|
36
|
+
// Ensure tests do not depend on any real user config on the machine running them.
|
|
37
|
+
const cfgHome = mkdtempSync(resolve(tmpdir(), "postgresai-cli-test-"));
|
|
38
|
+
return {
|
|
39
|
+
XDG_CONFIG_HOME: cfgHome,
|
|
40
|
+
HOME: cfgHome,
|
|
41
|
+
...extra,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function startFakeApi() {
|
|
46
|
+
const requests: Array<{
|
|
47
|
+
method: string;
|
|
48
|
+
pathname: string;
|
|
49
|
+
headers: Record<string, string>;
|
|
50
|
+
bodyText: string;
|
|
51
|
+
bodyJson: any | null;
|
|
52
|
+
}> = [];
|
|
53
|
+
|
|
54
|
+
const server = Bun.serve({
|
|
55
|
+
hostname: "127.0.0.1",
|
|
56
|
+
port: 0,
|
|
57
|
+
async fetch(req) {
|
|
58
|
+
const url = new URL(req.url);
|
|
59
|
+
const headers: Record<string, string> = {};
|
|
60
|
+
for (const [k, v] of req.headers.entries()) headers[k.toLowerCase()] = v;
|
|
61
|
+
|
|
62
|
+
const bodyText = await req.text();
|
|
63
|
+
let bodyJson: any | null = null;
|
|
64
|
+
try {
|
|
65
|
+
bodyJson = bodyText ? JSON.parse(bodyText) : null;
|
|
66
|
+
} catch {
|
|
67
|
+
bodyJson = null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
requests.push({
|
|
71
|
+
method: req.method,
|
|
72
|
+
pathname: url.pathname,
|
|
73
|
+
headers,
|
|
74
|
+
bodyText,
|
|
75
|
+
bodyJson,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Minimal fake PostgREST RPC endpoints used by our CLI.
|
|
79
|
+
if (req.method === "POST" && url.pathname.endsWith("/rpc/issue_create")) {
|
|
80
|
+
return new Response(
|
|
81
|
+
JSON.stringify({
|
|
82
|
+
id: "issue-1",
|
|
83
|
+
title: bodyJson?.title ?? "",
|
|
84
|
+
description: bodyJson?.description ?? null,
|
|
85
|
+
created_at: "2025-01-01T00:00:00Z",
|
|
86
|
+
status: 0,
|
|
87
|
+
project_id: bodyJson?.project_id ?? null,
|
|
88
|
+
labels: bodyJson?.labels ?? null,
|
|
89
|
+
}),
|
|
90
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (req.method === "POST" && url.pathname.endsWith("/rpc/issue_update")) {
|
|
95
|
+
return new Response(
|
|
96
|
+
JSON.stringify({
|
|
97
|
+
id: bodyJson?.p_id ?? "issue-1",
|
|
98
|
+
title: bodyJson?.p_title ?? "unchanged",
|
|
99
|
+
description: bodyJson?.p_description ?? null,
|
|
100
|
+
status: bodyJson?.p_status ?? 0,
|
|
101
|
+
updated_at: "2025-01-02T00:00:00Z",
|
|
102
|
+
labels: bodyJson?.p_labels ?? null,
|
|
103
|
+
}),
|
|
104
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (req.method === "POST" && url.pathname.endsWith("/rpc/issue_comment_update")) {
|
|
109
|
+
return new Response(
|
|
110
|
+
JSON.stringify({
|
|
111
|
+
id: bodyJson?.p_id ?? "comment-1",
|
|
112
|
+
issue_id: "issue-1",
|
|
113
|
+
content: bodyJson?.p_content ?? "",
|
|
114
|
+
updated_at: "2025-01-02T00:00:00Z",
|
|
115
|
+
}),
|
|
116
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (req.method === "POST" && url.pathname.endsWith("/rpc/issue_comment_create")) {
|
|
121
|
+
return new Response(
|
|
122
|
+
JSON.stringify({
|
|
123
|
+
id: "comment-1",
|
|
124
|
+
issue_id: bodyJson?.issue_id ?? "issue-1",
|
|
125
|
+
author_id: 1,
|
|
126
|
+
parent_comment_id: bodyJson?.parent_comment_id ?? null,
|
|
127
|
+
content: bodyJson?.content ?? "",
|
|
128
|
+
created_at: "2025-01-01T00:00:00Z",
|
|
129
|
+
updated_at: "2025-01-01T00:00:00Z",
|
|
130
|
+
data: null,
|
|
131
|
+
}),
|
|
132
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return new Response("not found", { status: 404 });
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const baseUrl = `http://${server.hostname}:${server.port}/api/general`;
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
baseUrl,
|
|
144
|
+
requests,
|
|
145
|
+
stop: () => server.stop(true),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
describe("CLI issues command group", () => {
|
|
150
|
+
test("issues help exposes the canonical subcommands and no legacy names", () => {
|
|
151
|
+
const r = runCli(["issues", "--help"], isolatedEnv());
|
|
152
|
+
expect(r.status).toBe(0);
|
|
153
|
+
|
|
154
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
155
|
+
|
|
156
|
+
// Canonical subcommands
|
|
157
|
+
expect(out).toContain("create [options] <title>");
|
|
158
|
+
expect(out).toContain("update [options] <issueId>");
|
|
159
|
+
expect(out).toContain("update-comment [options] <commentId> <content>");
|
|
160
|
+
expect(out).toContain("post-comment [options] <issueId> <content>");
|
|
161
|
+
|
|
162
|
+
// Legacy / removed names
|
|
163
|
+
expect(out).not.toContain("create-issue");
|
|
164
|
+
expect(out).not.toContain("update-issue");
|
|
165
|
+
expect(out).not.toContain("update-issue-comment");
|
|
166
|
+
expect(out).not.toContain("post_comment");
|
|
167
|
+
expect(out).not.toContain("create_issue");
|
|
168
|
+
expect(out).not.toContain("update_issue");
|
|
169
|
+
expect(out).not.toContain("update_issue_comment");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("issues create fails fast when API key is missing", () => {
|
|
173
|
+
const r = runCli(["issues", "create", "Test issue"], isolatedEnv());
|
|
174
|
+
expect(r.status).toBe(1);
|
|
175
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("issues create fails fast when org id is missing (no config fallback)", () => {
|
|
179
|
+
const r = runCli(["issues", "create", "Test issue"], isolatedEnv({ PGAI_API_KEY: "test-key" }));
|
|
180
|
+
expect(r.status).toBe(1);
|
|
181
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("org_id is required");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("issues update fails fast when API key is missing", () => {
|
|
185
|
+
const r = runCli(["issues", "update", "00000000-0000-0000-0000-000000000000", "--title", "New title"], isolatedEnv());
|
|
186
|
+
expect(r.status).toBe(1);
|
|
187
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("issues update-comment fails fast when API key is missing", () => {
|
|
191
|
+
const r = runCli(["issues", "update-comment", "00000000-0000-0000-0000-000000000000", "hello"], isolatedEnv());
|
|
192
|
+
expect(r.status).toBe(1);
|
|
193
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("issues post-comment fails fast when API key is missing", () => {
|
|
197
|
+
const r = runCli(["issues", "post-comment", "00000000-0000-0000-0000-000000000000", "hello"], isolatedEnv());
|
|
198
|
+
expect(r.status).toBe(1);
|
|
199
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("issues create succeeds against a fake API and sends the expected request", async () => {
|
|
203
|
+
const api = await startFakeApi();
|
|
204
|
+
try {
|
|
205
|
+
const r = await runCliAsync(
|
|
206
|
+
["issues", "create", "Hello", "--org-id", "123", "--description", "line1\\nline2", "--label", "a", "--label", "b"],
|
|
207
|
+
isolatedEnv({
|
|
208
|
+
PGAI_API_KEY: "test-key",
|
|
209
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
210
|
+
})
|
|
211
|
+
);
|
|
212
|
+
expect(r.status).toBe(0);
|
|
213
|
+
|
|
214
|
+
const out = JSON.parse(r.stdout.trim());
|
|
215
|
+
expect(out.id).toBe("issue-1");
|
|
216
|
+
expect(out.title).toBe("Hello");
|
|
217
|
+
expect(out.description).toBe("line1\nline2");
|
|
218
|
+
expect(out.labels).toEqual(["a", "b"]);
|
|
219
|
+
|
|
220
|
+
const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_create"));
|
|
221
|
+
expect(req).toBeTruthy();
|
|
222
|
+
expect(req!.headers["access-token"]).toBe("test-key");
|
|
223
|
+
expect(req!.method).toBe("POST");
|
|
224
|
+
expect(req!.bodyJson.org_id).toBe(123);
|
|
225
|
+
expect(req!.bodyJson.title).toBe("Hello");
|
|
226
|
+
expect(req!.bodyJson.description).toBe("line1\nline2");
|
|
227
|
+
expect(req!.bodyJson.labels).toEqual(["a", "b"]);
|
|
228
|
+
} finally {
|
|
229
|
+
api.stop();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("issues update succeeds against a fake API (including status mapping)", async () => {
|
|
234
|
+
const api = await startFakeApi();
|
|
235
|
+
try {
|
|
236
|
+
const r = await runCliAsync(
|
|
237
|
+
["issues", "update", "issue-1", "--title", "New title", "--status", "closed"],
|
|
238
|
+
isolatedEnv({
|
|
239
|
+
PGAI_API_KEY: "test-key",
|
|
240
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
241
|
+
})
|
|
242
|
+
);
|
|
243
|
+
expect(r.status).toBe(0);
|
|
244
|
+
|
|
245
|
+
const out = JSON.parse(r.stdout.trim());
|
|
246
|
+
expect(out.id).toBe("issue-1");
|
|
247
|
+
expect(out.title).toBe("New title");
|
|
248
|
+
expect(out.status).toBe(1);
|
|
249
|
+
|
|
250
|
+
const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_update"));
|
|
251
|
+
expect(req).toBeTruthy();
|
|
252
|
+
expect(req!.headers["access-token"]).toBe("test-key");
|
|
253
|
+
expect(req!.bodyJson.p_id).toBe("issue-1");
|
|
254
|
+
expect(req!.bodyJson.p_title).toBe("New title");
|
|
255
|
+
expect(req!.bodyJson.p_status).toBe(1);
|
|
256
|
+
} finally {
|
|
257
|
+
api.stop();
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("issues update-comment succeeds against a fake API and decodes escapes", async () => {
|
|
262
|
+
const api = await startFakeApi();
|
|
263
|
+
try {
|
|
264
|
+
const r = await runCliAsync(
|
|
265
|
+
["issues", "update-comment", "comment-1", "hello\\nworld"],
|
|
266
|
+
isolatedEnv({
|
|
267
|
+
PGAI_API_KEY: "test-key",
|
|
268
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
269
|
+
})
|
|
270
|
+
);
|
|
271
|
+
expect(r.status).toBe(0);
|
|
272
|
+
|
|
273
|
+
const out = JSON.parse(r.stdout.trim());
|
|
274
|
+
expect(out.id).toBe("comment-1");
|
|
275
|
+
expect(out.content).toBe("hello\nworld");
|
|
276
|
+
|
|
277
|
+
const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_comment_update"));
|
|
278
|
+
expect(req).toBeTruthy();
|
|
279
|
+
expect(req!.headers["access-token"]).toBe("test-key");
|
|
280
|
+
expect(req!.bodyJson.p_id).toBe("comment-1");
|
|
281
|
+
expect(req!.bodyJson.p_content).toBe("hello\nworld");
|
|
282
|
+
} finally {
|
|
283
|
+
api.stop();
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("issues post-comment succeeds against a fake API and decodes escapes", async () => {
|
|
288
|
+
const api = await startFakeApi();
|
|
289
|
+
try {
|
|
290
|
+
const r = await runCliAsync(
|
|
291
|
+
["issues", "post-comment", "issue-1", "hello\\nworld"],
|
|
292
|
+
isolatedEnv({
|
|
293
|
+
PGAI_API_KEY: "test-key",
|
|
294
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
295
|
+
})
|
|
296
|
+
);
|
|
297
|
+
expect(r.status).toBe(0);
|
|
298
|
+
|
|
299
|
+
const out = JSON.parse(r.stdout.trim());
|
|
300
|
+
expect(out.id).toBe("comment-1");
|
|
301
|
+
expect(out.issue_id).toBe("issue-1");
|
|
302
|
+
expect(out.content).toBe("hello\nworld");
|
|
303
|
+
|
|
304
|
+
const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_comment_create"));
|
|
305
|
+
expect(req).toBeTruthy();
|
|
306
|
+
expect(req!.headers["access-token"]).toBe("test-key");
|
|
307
|
+
expect(req!.bodyJson.issue_id).toBe("issue-1");
|
|
308
|
+
expect(req!.bodyJson.content).toBe("hello\nworld");
|
|
309
|
+
} finally {
|
|
310
|
+
api.stop();
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|