postgresai 0.14.0-dev.56 → 0.14.0-dev.58
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 +693 -83
- package/dist/sql/05.helpers.sql +31 -7
- package/dist/sql/sql/05.helpers.sql +31 -7
- package/lib/config.ts +4 -4
- package/lib/issues.ts +318 -0
- package/lib/mcp-server.ts +207 -73
- package/lib/metrics-embedded.ts +1 -1
- package/package.json +1 -1
- package/sql/05.helpers.sql +31 -7
- 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/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
|
-
|
|
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();
|
package/lib/metrics-embedded.ts
CHANGED
package/package.json
CHANGED
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');
|
|
@@ -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
|
});
|