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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.14.0-dev.55",
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",
@@ -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');
@@ -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
  });
@@ -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
+