postgresai 0.14.0-dev.56 → 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/lib/mcp-server.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import pkg from "../package.json";
2
2
  import * as config from "./config";
3
- import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue } from "./issues";
3
+ import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment } from "./issues";
4
4
  import { resolveBaseUrls } from "./util";
5
5
 
6
6
  // MCP SDK imports - Bun handles these directly
@@ -8,27 +8,165 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
9
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
10
10
 
11
- interface RootOptsLike {
11
+ export interface RootOptsLike {
12
12
  apiKey?: string;
13
13
  apiBaseUrl?: string;
14
14
  }
15
15
 
16
+ // Interpret escape sequences (e.g., \n -> newline). Input comes from JSON, but
17
+ // we still normalize common escapes for consistency.
18
+ export const interpretEscapes = (str: string): string =>
19
+ (str || "")
20
+ .replace(/\\n/g, "\n")
21
+ .replace(/\\t/g, "\t")
22
+ .replace(/\\r/g, "\r")
23
+ .replace(/\\"/g, '"')
24
+ .replace(/\\'/g, "'");
25
+
26
+ export interface McpToolRequest {
27
+ params: {
28
+ name: string;
29
+ arguments?: Record<string, unknown>;
30
+ };
31
+ }
32
+
33
+ export interface McpToolResponse {
34
+ content: Array<{ type: string; text: string }>;
35
+ isError?: boolean;
36
+ }
37
+
38
+ /** Handle MCP tool calls - exported for testing */
39
+ export async function handleToolCall(
40
+ req: McpToolRequest,
41
+ rootOpts?: RootOptsLike,
42
+ extra?: { debug?: boolean }
43
+ ): Promise<McpToolResponse> {
44
+ const toolName = req.params.name;
45
+ const args = (req.params.arguments as Record<string, unknown>) || {};
46
+
47
+ const cfg = config.readConfig();
48
+ const apiKey = (rootOpts?.apiKey || process.env.PGAI_API_KEY || cfg.apiKey || "").toString();
49
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
50
+
51
+ const debug = Boolean(args.debug ?? extra?.debug);
52
+
53
+ if (!apiKey) {
54
+ return {
55
+ content: [
56
+ {
57
+ type: "text",
58
+ text: "API key is required. Run 'pgai auth' or set PGAI_API_KEY.",
59
+ },
60
+ ],
61
+ isError: true,
62
+ };
63
+ }
64
+
65
+ try {
66
+ if (toolName === "list_issues") {
67
+ const issues = await fetchIssues({ apiKey, apiBaseUrl, debug });
68
+ return { content: [{ type: "text", text: JSON.stringify(issues, null, 2) }] };
69
+ }
70
+
71
+ if (toolName === "view_issue") {
72
+ const issueId = String(args.issue_id || "").trim();
73
+ if (!issueId) {
74
+ return { content: [{ type: "text", text: "issue_id is required" }], isError: true };
75
+ }
76
+ const issue = await fetchIssue({ apiKey, apiBaseUrl, issueId, debug });
77
+ if (!issue) {
78
+ return { content: [{ type: "text", text: "Issue not found" }], isError: true };
79
+ }
80
+ const comments = await fetchIssueComments({ apiKey, apiBaseUrl, issueId, debug });
81
+ const combined = { issue, comments };
82
+ return { content: [{ type: "text", text: JSON.stringify(combined, null, 2) }] };
83
+ }
84
+
85
+ if (toolName === "post_issue_comment") {
86
+ const issueId = String(args.issue_id || "").trim();
87
+ const rawContent = String(args.content || "");
88
+ const parentCommentId = args.parent_comment_id ? String(args.parent_comment_id) : undefined;
89
+ if (!issueId) {
90
+ return { content: [{ type: "text", text: "issue_id is required" }], isError: true };
91
+ }
92
+ if (!rawContent) {
93
+ return { content: [{ type: "text", text: "content is required" }], isError: true };
94
+ }
95
+ const content = interpretEscapes(rawContent);
96
+ const result = await createIssueComment({ apiKey, apiBaseUrl, issueId, content, parentCommentId, debug });
97
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
98
+ }
99
+
100
+ if (toolName === "create_issue") {
101
+ const rawTitle = String(args.title || "").trim();
102
+ if (!rawTitle) {
103
+ return { content: [{ type: "text", text: "title is required" }], isError: true };
104
+ }
105
+ const title = interpretEscapes(rawTitle);
106
+ const rawDescription = args.description ? String(args.description) : undefined;
107
+ const description = rawDescription ? interpretEscapes(rawDescription) : undefined;
108
+ const projectId = args.project_id !== undefined ? Number(args.project_id) : undefined;
109
+ const labels = Array.isArray(args.labels) ? args.labels.map(String) : undefined;
110
+ // Get orgId from args or fall back to config
111
+ const orgId = args.org_id !== undefined ? Number(args.org_id) : cfg.orgId;
112
+ // Note: orgId=0 is technically valid (though unlikely), so don't use falsy check
113
+ if (orgId === undefined || orgId === null || Number.isNaN(orgId)) {
114
+ return { content: [{ type: "text", text: "org_id is required. Either provide it as a parameter or run 'pgai auth' to set it in config." }], isError: true };
115
+ }
116
+ const result = await createIssue({ apiKey, apiBaseUrl, title, orgId, description, projectId, labels, debug });
117
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
118
+ }
119
+
120
+ if (toolName === "update_issue") {
121
+ const issueId = String(args.issue_id || "").trim();
122
+ if (!issueId) {
123
+ return { content: [{ type: "text", text: "issue_id is required" }], isError: true };
124
+ }
125
+ const rawTitle = args.title !== undefined ? String(args.title) : undefined;
126
+ const title = rawTitle !== undefined ? interpretEscapes(rawTitle) : undefined;
127
+ const rawDescription = args.description !== undefined ? String(args.description) : undefined;
128
+ const description = rawDescription !== undefined ? interpretEscapes(rawDescription) : undefined;
129
+ const status = args.status !== undefined ? Number(args.status) : undefined;
130
+ const labels = Array.isArray(args.labels) ? args.labels.map(String) : undefined;
131
+ // Validate that at least one update field is provided
132
+ if (title === undefined && description === undefined && status === undefined && labels === undefined) {
133
+ return { content: [{ type: "text", text: "At least one field to update is required (title, description, status, or labels)" }], isError: true };
134
+ }
135
+ // Validate status value if provided (check for NaN and valid values)
136
+ if (status !== undefined && (Number.isNaN(status) || (status !== 0 && status !== 1))) {
137
+ return { content: [{ type: "text", text: "status must be 0 (open) or 1 (closed)" }], isError: true };
138
+ }
139
+ const result = await updateIssue({ apiKey, apiBaseUrl, issueId, title, description, status, labels, debug });
140
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
141
+ }
142
+
143
+ if (toolName === "update_issue_comment") {
144
+ const commentId = String(args.comment_id || "").trim();
145
+ const rawContent = String(args.content || "");
146
+ if (!commentId) {
147
+ return { content: [{ type: "text", text: "comment_id is required" }], isError: true };
148
+ }
149
+ if (!rawContent.trim()) {
150
+ return { content: [{ type: "text", text: "content is required" }], isError: true };
151
+ }
152
+ const content = interpretEscapes(rawContent);
153
+ const result = await updateIssueComment({ apiKey, apiBaseUrl, commentId, content, debug });
154
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
155
+ }
156
+
157
+ throw new Error(`Unknown tool: ${toolName}`);
158
+ } catch (err) {
159
+ const message = err instanceof Error ? err.message : String(err);
160
+ return { content: [{ type: "text", text: message }], isError: true };
161
+ }
162
+ }
163
+
16
164
  export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?: boolean }): Promise<void> {
17
165
  const server = new Server(
18
166
  { name: "postgresai-mcp", version: pkg.version },
19
167
  { capabilities: { tools: {} } }
20
168
  );
21
169
 
22
- // Interpret escape sequences (e.g., \n -> newline). Input comes from JSON, but
23
- // we still normalize common escapes for consistency.
24
- const interpretEscapes = (str: string): string =>
25
- (str || "")
26
- .replace(/\\n/g, "\n")
27
- .replace(/\\t/g, "\t")
28
- .replace(/\\r/g, "\r")
29
- .replace(/\\"/g, '"')
30
- .replace(/\\'/g, "'");
31
-
32
170
  server.setRequestHandler(ListToolsRequestSchema, async () => {
33
171
  return {
34
172
  tools: [
@@ -71,73 +209,69 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
71
209
  additionalProperties: false,
72
210
  },
73
211
  },
212
+ {
213
+ name: "create_issue",
214
+ description: "Create a new issue in PostgresAI",
215
+ inputSchema: {
216
+ type: "object",
217
+ properties: {
218
+ title: { type: "string", description: "Issue title (required)" },
219
+ description: { type: "string", description: "Issue description (supports \\n as newline)" },
220
+ org_id: { type: "number", description: "Organization ID (uses config value if not provided)" },
221
+ project_id: { type: "number", description: "Project ID to associate the issue with" },
222
+ labels: {
223
+ type: "array",
224
+ items: { type: "string" },
225
+ description: "Labels to apply to the issue",
226
+ },
227
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
228
+ },
229
+ required: ["title"],
230
+ additionalProperties: false,
231
+ },
232
+ },
233
+ {
234
+ name: "update_issue",
235
+ description: "Update an existing issue (title, description, status, labels). Use status=1 to close, status=0 to reopen.",
236
+ inputSchema: {
237
+ type: "object",
238
+ properties: {
239
+ issue_id: { type: "string", description: "Issue ID (UUID)" },
240
+ title: { type: "string", description: "New title (supports \\n as newline)" },
241
+ description: { type: "string", description: "New description (supports \\n as newline)" },
242
+ status: { type: "number", description: "Status: 0=open, 1=closed" },
243
+ labels: {
244
+ type: "array",
245
+ items: { type: "string" },
246
+ description: "Labels to set on the issue",
247
+ },
248
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
249
+ },
250
+ required: ["issue_id"],
251
+ additionalProperties: false,
252
+ },
253
+ },
254
+ {
255
+ name: "update_issue_comment",
256
+ description: "Update an existing issue comment",
257
+ inputSchema: {
258
+ type: "object",
259
+ properties: {
260
+ comment_id: { type: "string", description: "Comment ID (UUID)" },
261
+ content: { type: "string", description: "New comment text (supports \\n as newline)" },
262
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
263
+ },
264
+ required: ["comment_id", "content"],
265
+ additionalProperties: false,
266
+ },
267
+ },
74
268
  ],
75
269
  };
76
270
  });
77
271
 
78
272
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
273
  server.setRequestHandler(CallToolRequestSchema, async (req: any) => {
80
- const toolName = req.params.name;
81
- const args = (req.params.arguments as Record<string, unknown>) || {};
82
-
83
- const cfg = config.readConfig();
84
- const apiKey = (rootOpts?.apiKey || process.env.PGAI_API_KEY || cfg.apiKey || "").toString();
85
- const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
86
-
87
- const debug = Boolean(args.debug ?? extra?.debug);
88
-
89
- if (!apiKey) {
90
- return {
91
- content: [
92
- {
93
- type: "text",
94
- text: "API key is required. Run 'pgai auth' or set PGAI_API_KEY.",
95
- },
96
- ],
97
- isError: true,
98
- };
99
- }
100
-
101
- try {
102
- if (toolName === "list_issues") {
103
- const issues = await fetchIssues({ apiKey, apiBaseUrl, debug });
104
- return { content: [{ type: "text", text: JSON.stringify(issues, null, 2) }] };
105
- }
106
-
107
- if (toolName === "view_issue") {
108
- const issueId = String(args.issue_id || "").trim();
109
- if (!issueId) {
110
- return { content: [{ type: "text", text: "issue_id is required" }], isError: true };
111
- }
112
- const issue = await fetchIssue({ apiKey, apiBaseUrl, issueId, debug });
113
- if (!issue) {
114
- return { content: [{ type: "text", text: "Issue not found" }], isError: true };
115
- }
116
- const comments = await fetchIssueComments({ apiKey, apiBaseUrl, issueId, debug });
117
- const combined = { issue, comments };
118
- return { content: [{ type: "text", text: JSON.stringify(combined, null, 2) }] };
119
- }
120
-
121
- if (toolName === "post_issue_comment") {
122
- const issueId = String(args.issue_id || "").trim();
123
- const rawContent = String(args.content || "");
124
- const parentCommentId = args.parent_comment_id ? String(args.parent_comment_id) : undefined;
125
- if (!issueId) {
126
- return { content: [{ type: "text", text: "issue_id is required" }], isError: true };
127
- }
128
- if (!rawContent) {
129
- return { content: [{ type: "text", text: "content is required" }], isError: true };
130
- }
131
- const content = interpretEscapes(rawContent);
132
- const result = await createIssueComment({ apiKey, apiBaseUrl, issueId, content, parentCommentId, debug });
133
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
134
- }
135
-
136
- throw new Error(`Unknown tool: ${toolName}`);
137
- } catch (err) {
138
- const message = err instanceof Error ? err.message : String(err);
139
- return { content: [{ type: "text", text: message }], isError: true };
140
- }
274
+ return handleToolCall(req, rootOpts, extra);
141
275
  });
142
276
 
143
277
  const transport = new StdioServerTransport();
@@ -1,6 +1,6 @@
1
1
  // AUTO-GENERATED FILE - DO NOT EDIT
2
2
  // Generated from config/pgwatch-prometheus/metrics.yml by scripts/embed-metrics.ts
3
- // Generated at: 2025-12-29T19:46:00.537Z
3
+ // Generated at: 2025-12-29T21:47:36.417Z
4
4
 
5
5
  /**
6
6
  * Metric definition from metrics.yml
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.14.0-dev.56",
3
+ "version": "0.14.0-dev.57",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -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');
@@ -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
  });