postgresai 0.14.0-beta.2 → 0.14.0-beta.4
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/README.md +53 -45
- package/bin/postgres-ai.ts +953 -353
- package/bun.lock +258 -0
- package/bunfig.toml +11 -0
- package/dist/bin/postgres-ai.js +27868 -1781
- package/lib/auth-server.ts +124 -106
- package/lib/checkup-api.ts +386 -0
- package/lib/checkup.ts +1327 -0
- package/lib/config.ts +3 -0
- package/lib/init.ts +283 -158
- package/lib/issues.ts +86 -195
- package/lib/mcp-server.ts +6 -17
- package/lib/metrics-embedded.ts +79 -0
- package/lib/metrics-loader.ts +127 -0
- package/lib/util.ts +61 -0
- package/package.json +18 -10
- package/packages/postgres-ai/README.md +26 -0
- package/packages/postgres-ai/bin/postgres-ai.js +27 -0
- package/packages/postgres-ai/package.json +27 -0
- package/scripts/embed-metrics.ts +154 -0
- package/sql/02.permissions.sql +9 -5
- package/sql/05.helpers.sql +415 -0
- package/test/checkup.integration.test.ts +273 -0
- package/test/checkup.test.ts +890 -0
- package/test/init.integration.test.ts +399 -0
- package/test/init.test.ts +345 -0
- package/test/schema-validation.test.ts +81 -0
- package/test/test-utils.ts +122 -0
- package/tsconfig.json +12 -20
- package/dist/bin/postgres-ai.d.ts +0 -3
- package/dist/bin/postgres-ai.d.ts.map +0 -1
- package/dist/bin/postgres-ai.js.map +0 -1
- package/dist/lib/auth-server.d.ts +0 -31
- package/dist/lib/auth-server.d.ts.map +0 -1
- package/dist/lib/auth-server.js +0 -263
- package/dist/lib/auth-server.js.map +0 -1
- package/dist/lib/config.d.ts +0 -45
- package/dist/lib/config.d.ts.map +0 -1
- package/dist/lib/config.js +0 -181
- package/dist/lib/config.js.map +0 -1
- package/dist/lib/init.d.ts +0 -77
- package/dist/lib/init.d.ts.map +0 -1
- package/dist/lib/init.js +0 -550
- package/dist/lib/init.js.map +0 -1
- package/dist/lib/issues.d.ts +0 -75
- package/dist/lib/issues.d.ts.map +0 -1
- package/dist/lib/issues.js +0 -336
- package/dist/lib/issues.js.map +0 -1
- package/dist/lib/mcp-server.d.ts +0 -9
- package/dist/lib/mcp-server.d.ts.map +0 -1
- package/dist/lib/mcp-server.js +0 -168
- package/dist/lib/mcp-server.js.map +0 -1
- package/dist/lib/pkce.d.ts +0 -32
- package/dist/lib/pkce.d.ts.map +0 -1
- package/dist/lib/pkce.js +0 -101
- package/dist/lib/pkce.js.map +0 -1
- package/dist/lib/util.d.ts +0 -27
- package/dist/lib/util.d.ts.map +0 -1
- package/dist/lib/util.js +0 -46
- package/dist/lib/util.js.map +0 -1
- package/dist/package.json +0 -46
- package/test/init.integration.test.cjs +0 -382
- package/test/init.test.cjs +0 -323
package/lib/config.ts
CHANGED
|
@@ -9,6 +9,7 @@ export interface Config {
|
|
|
9
9
|
apiKey: string | null;
|
|
10
10
|
baseUrl: string | null;
|
|
11
11
|
orgId: number | null;
|
|
12
|
+
defaultProject: string | null;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -46,6 +47,7 @@ export function readConfig(): Config {
|
|
|
46
47
|
apiKey: null,
|
|
47
48
|
baseUrl: null,
|
|
48
49
|
orgId: null,
|
|
50
|
+
defaultProject: null,
|
|
49
51
|
};
|
|
50
52
|
|
|
51
53
|
// Try user-level config first
|
|
@@ -57,6 +59,7 @@ export function readConfig(): Config {
|
|
|
57
59
|
config.apiKey = parsed.apiKey || null;
|
|
58
60
|
config.baseUrl = parsed.baseUrl || null;
|
|
59
61
|
config.orgId = parsed.orgId || null;
|
|
62
|
+
config.defaultProject = parsed.defaultProject || null;
|
|
60
63
|
return config;
|
|
61
64
|
} catch (err) {
|
|
62
65
|
const message = err instanceof Error ? err.message : String(err);
|
package/lib/init.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import * as readline from "readline";
|
|
2
1
|
import { randomBytes } from "crypto";
|
|
3
2
|
import { URL } from "url";
|
|
4
3
|
import type { ConnectionOptions as TlsConnectionOptions } from "tls";
|
|
@@ -18,11 +17,135 @@ export type PgClientConfig = {
|
|
|
18
17
|
ssl?: boolean | TlsConnectionOptions;
|
|
19
18
|
};
|
|
20
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Convert PostgreSQL sslmode to node-postgres ssl config.
|
|
22
|
+
*/
|
|
23
|
+
function sslModeToConfig(mode: string): boolean | TlsConnectionOptions {
|
|
24
|
+
if (mode.toLowerCase() === "disable") return false;
|
|
25
|
+
if (mode.toLowerCase() === "verify-full" || mode.toLowerCase() === "verify-ca") return true;
|
|
26
|
+
// For require/prefer/allow: encrypt without certificate verification
|
|
27
|
+
return { rejectUnauthorized: false };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Extract sslmode from a PostgreSQL connection URI. */
|
|
31
|
+
function extractSslModeFromUri(uri: string): string | undefined {
|
|
32
|
+
try {
|
|
33
|
+
return new URL(uri).searchParams.get("sslmode") ?? undefined;
|
|
34
|
+
} catch {
|
|
35
|
+
return uri.match(/[?&]sslmode=([^&]+)/i)?.[1];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Remove sslmode parameter from a PostgreSQL connection URI. */
|
|
40
|
+
function stripSslModeFromUri(uri: string): string {
|
|
41
|
+
try {
|
|
42
|
+
const u = new URL(uri);
|
|
43
|
+
u.searchParams.delete("sslmode");
|
|
44
|
+
return u.toString();
|
|
45
|
+
} catch {
|
|
46
|
+
// Fallback regex for malformed URIs
|
|
47
|
+
return uri
|
|
48
|
+
.replace(/[?&]sslmode=[^&]*/gi, "")
|
|
49
|
+
.replace(/\?&/, "?")
|
|
50
|
+
.replace(/\?$/, "");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
21
54
|
export type AdminConnection = {
|
|
22
55
|
clientConfig: PgClientConfig;
|
|
23
56
|
display: string;
|
|
57
|
+
/** True if SSL fallback is enabled (try SSL first, fall back to non-SSL on failure). */
|
|
58
|
+
sslFallbackEnabled?: boolean;
|
|
24
59
|
};
|
|
25
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Check if an error indicates SSL negotiation failed and fallback to non-SSL should be attempted.
|
|
63
|
+
* This mimics libpq's sslmode=prefer behavior.
|
|
64
|
+
*
|
|
65
|
+
* IMPORTANT: This should NOT match certificate errors (expired, invalid, self-signed)
|
|
66
|
+
* as those are real errors the user needs to fix, not negotiation failures.
|
|
67
|
+
*/
|
|
68
|
+
function isSslNegotiationError(err: unknown): boolean {
|
|
69
|
+
if (!err || typeof err !== "object") return false;
|
|
70
|
+
const e = err as any;
|
|
71
|
+
const msg = typeof e.message === "string" ? e.message.toLowerCase() : "";
|
|
72
|
+
const code = typeof e.code === "string" ? e.code : "";
|
|
73
|
+
|
|
74
|
+
// Specific patterns that indicate server doesn't support SSL (should fallback)
|
|
75
|
+
const fallbackPatterns = [
|
|
76
|
+
"the server does not support ssl",
|
|
77
|
+
"ssl off",
|
|
78
|
+
"server does not support ssl connections",
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
for (const pattern of fallbackPatterns) {
|
|
82
|
+
if (msg.includes(pattern)) return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// PostgreSQL error code 08P01 (protocol violation) during initial connection
|
|
86
|
+
// often indicates SSL negotiation mismatch, but only if the message suggests it
|
|
87
|
+
if (code === "08P01" && (msg.includes("ssl") || msg.includes("unsupported"))) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Connect to PostgreSQL with sslmode=prefer-like behavior.
|
|
96
|
+
* If sslFallbackEnabled is true, tries SSL first, then falls back to non-SSL on failure.
|
|
97
|
+
*/
|
|
98
|
+
export async function connectWithSslFallback(
|
|
99
|
+
ClientClass: new (config: PgClientConfig) => PgClient,
|
|
100
|
+
adminConn: AdminConnection,
|
|
101
|
+
verbose?: boolean
|
|
102
|
+
): Promise<{ client: PgClient; usedSsl: boolean }> {
|
|
103
|
+
const tryConnect = async (config: PgClientConfig): Promise<PgClient> => {
|
|
104
|
+
const client = new ClientClass(config);
|
|
105
|
+
await client.connect();
|
|
106
|
+
return client;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// If SSL was explicitly set or no SSL configured, just try once
|
|
110
|
+
if (!adminConn.sslFallbackEnabled) {
|
|
111
|
+
const client = await tryConnect(adminConn.clientConfig);
|
|
112
|
+
return { client, usedSsl: !!adminConn.clientConfig.ssl };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// sslmode=prefer behavior: try SSL first, fallback to non-SSL
|
|
116
|
+
try {
|
|
117
|
+
const client = await tryConnect(adminConn.clientConfig);
|
|
118
|
+
return { client, usedSsl: true };
|
|
119
|
+
} catch (sslErr) {
|
|
120
|
+
if (!isSslNegotiationError(sslErr)) {
|
|
121
|
+
// Not an SSL error, don't retry
|
|
122
|
+
throw sslErr;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (verbose) {
|
|
126
|
+
console.log("SSL connection failed, retrying without SSL...");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Retry without SSL
|
|
130
|
+
const noSslConfig: PgClientConfig = { ...adminConn.clientConfig, ssl: false };
|
|
131
|
+
try {
|
|
132
|
+
const client = await tryConnect(noSslConfig);
|
|
133
|
+
return { client, usedSsl: false };
|
|
134
|
+
} catch (noSslErr) {
|
|
135
|
+
// If non-SSL also fails, check if it's "SSL required" - throw that instead
|
|
136
|
+
if (isSslNegotiationError(noSslErr)) {
|
|
137
|
+
const msg = (noSslErr as any)?.message || "";
|
|
138
|
+
if (msg.toLowerCase().includes("ssl") && msg.toLowerCase().includes("required")) {
|
|
139
|
+
// Server requires SSL but SSL attempt failed - throw original SSL error
|
|
140
|
+
throw sslErr;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Throw the non-SSL error (it's more relevant since SSL attempt also failed)
|
|
144
|
+
throw noSslErr;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
26
149
|
export type InitStep = {
|
|
27
150
|
name: string;
|
|
28
151
|
sql: string;
|
|
@@ -36,13 +159,21 @@ export type InitPlan = {
|
|
|
36
159
|
steps: InitStep[];
|
|
37
160
|
};
|
|
38
161
|
|
|
39
|
-
function packageRootDirFromCompiled(): string {
|
|
40
|
-
// dist/lib/init.js -> <pkg>/dist/lib ; package root is ../..
|
|
41
|
-
return path.resolve(__dirname, "..", "..");
|
|
42
|
-
}
|
|
43
|
-
|
|
44
162
|
function sqlDir(): string {
|
|
45
|
-
|
|
163
|
+
// Handle both development and production paths
|
|
164
|
+
// Development: lib/init.ts -> ../sql
|
|
165
|
+
// Production (bundled): dist/bin/postgres-ai.js -> ../sql (copied during build)
|
|
166
|
+
const candidates = [
|
|
167
|
+
path.resolve(__dirname, "..", "sql"), // bundled: dist/bin -> dist/sql
|
|
168
|
+
path.resolve(__dirname, "..", "..", "sql"), // dev from lib: lib -> ../sql
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
for (const candidate of candidates) {
|
|
172
|
+
if (fs.existsSync(candidate)) {
|
|
173
|
+
return candidate;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
throw new Error(`SQL directory not found. Searched: ${candidates.join(", ")}`);
|
|
46
177
|
}
|
|
47
178
|
|
|
48
179
|
function loadSqlTemplate(filename: string): string {
|
|
@@ -143,6 +274,7 @@ function tokenizeConninfo(input: string): string[] {
|
|
|
143
274
|
export function parseLibpqConninfo(input: string): PgClientConfig {
|
|
144
275
|
const tokens = tokenizeConninfo(input);
|
|
145
276
|
const cfg: PgClientConfig = {};
|
|
277
|
+
let sslmode: string | undefined;
|
|
146
278
|
|
|
147
279
|
for (const t of tokens) {
|
|
148
280
|
const eq = t.indexOf("=");
|
|
@@ -171,12 +303,20 @@ export function parseLibpqConninfo(input: string): PgClientConfig {
|
|
|
171
303
|
case "database":
|
|
172
304
|
cfg.database = val;
|
|
173
305
|
break;
|
|
174
|
-
|
|
306
|
+
case "sslmode":
|
|
307
|
+
sslmode = val;
|
|
308
|
+
break;
|
|
309
|
+
// ignore everything else (options, application_name, etc.)
|
|
175
310
|
default:
|
|
176
311
|
break;
|
|
177
312
|
}
|
|
178
313
|
}
|
|
179
314
|
|
|
315
|
+
// Apply SSL configuration based on sslmode
|
|
316
|
+
if (sslmode) {
|
|
317
|
+
cfg.ssl = sslModeToConfig(sslmode);
|
|
318
|
+
}
|
|
319
|
+
|
|
180
320
|
return cfg;
|
|
181
321
|
}
|
|
182
322
|
|
|
@@ -203,6 +343,9 @@ export function resolveAdminConnection(opts: {
|
|
|
203
343
|
const conn = (opts.conn || "").trim();
|
|
204
344
|
const dbUrlFlag = (opts.dbUrlFlag || "").trim();
|
|
205
345
|
|
|
346
|
+
// Resolve explicit SSL setting from environment (undefined = auto-detect)
|
|
347
|
+
const explicitSsl = process.env.PGSSLMODE;
|
|
348
|
+
|
|
206
349
|
// NOTE: passwords alone (PGPASSWORD / --admin-password) do NOT constitute a connection.
|
|
207
350
|
// We require at least some connection addressing (host/port/user/db) if no positional arg / --db-url is provided.
|
|
208
351
|
const hasConnDetails = !!(opts.host || opts.port || opts.username || opts.dbname);
|
|
@@ -214,28 +357,45 @@ export function resolveAdminConnection(opts: {
|
|
|
214
357
|
if (conn || dbUrlFlag) {
|
|
215
358
|
const v = conn || dbUrlFlag;
|
|
216
359
|
if (isLikelyUri(v)) {
|
|
217
|
-
|
|
360
|
+
const urlSslMode = extractSslModeFromUri(v);
|
|
361
|
+
const effectiveSslMode = explicitSsl || urlSslMode;
|
|
362
|
+
// SSL priority: PGSSLMODE env > URL param > auto (sslmode=prefer behavior)
|
|
363
|
+
const sslConfig = effectiveSslMode
|
|
364
|
+
? sslModeToConfig(effectiveSslMode)
|
|
365
|
+
: { rejectUnauthorized: false }; // Default: try SSL (with fallback)
|
|
366
|
+
// Enable fallback for: no explicit mode OR explicit "prefer"/"allow"
|
|
367
|
+
const shouldFallback = !effectiveSslMode ||
|
|
368
|
+
effectiveSslMode.toLowerCase() === "prefer" ||
|
|
369
|
+
effectiveSslMode.toLowerCase() === "allow";
|
|
370
|
+
// Strip sslmode from URI so pg uses our ssl config object instead
|
|
371
|
+
const cleanUri = stripSslModeFromUri(v);
|
|
372
|
+
return {
|
|
373
|
+
clientConfig: { connectionString: cleanUri, ssl: sslConfig },
|
|
374
|
+
display: maskConnectionString(v),
|
|
375
|
+
sslFallbackEnabled: shouldFallback,
|
|
376
|
+
};
|
|
218
377
|
}
|
|
219
378
|
// libpq conninfo (dbname=... host=...)
|
|
220
379
|
const cfg = parseLibpqConninfo(v);
|
|
221
380
|
if (opts.envPassword && !cfg.password) cfg.password = opts.envPassword;
|
|
222
|
-
|
|
381
|
+
const cfgHadSsl = cfg.ssl !== undefined;
|
|
382
|
+
if (cfg.ssl === undefined) {
|
|
383
|
+
if (explicitSsl) cfg.ssl = sslModeToConfig(explicitSsl);
|
|
384
|
+
else cfg.ssl = { rejectUnauthorized: false }; // Default: try SSL (with fallback)
|
|
385
|
+
}
|
|
386
|
+
// Enable fallback for: no explicit mode OR explicit "prefer"/"allow"
|
|
387
|
+
const shouldFallback = (!explicitSsl && !cfgHadSsl) ||
|
|
388
|
+
(!!explicitSsl && (explicitSsl.toLowerCase() === "prefer" || explicitSsl.toLowerCase() === "allow"));
|
|
389
|
+
return {
|
|
390
|
+
clientConfig: cfg,
|
|
391
|
+
display: describePgConfig(cfg),
|
|
392
|
+
sslFallbackEnabled: shouldFallback,
|
|
393
|
+
};
|
|
223
394
|
}
|
|
224
395
|
|
|
225
396
|
if (!hasConnDetails) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
"Connection is required.",
|
|
229
|
-
"",
|
|
230
|
-
"Examples:",
|
|
231
|
-
" postgresai init postgresql://admin@host:5432/dbname",
|
|
232
|
-
" postgresai init \"dbname=dbname host=host user=admin\"",
|
|
233
|
-
" postgresai init -h host -p 5432 -U admin -d dbname",
|
|
234
|
-
"",
|
|
235
|
-
"Admin password:",
|
|
236
|
-
" --admin-password <password> (or set PGPASSWORD)",
|
|
237
|
-
].join("\n")
|
|
238
|
-
);
|
|
397
|
+
// Keep this message short: the CLI prints full help (including examples) on this error.
|
|
398
|
+
throw new Error("Connection is required.");
|
|
239
399
|
}
|
|
240
400
|
|
|
241
401
|
const cfg: PgClientConfig = {};
|
|
@@ -251,73 +411,15 @@ export function resolveAdminConnection(opts: {
|
|
|
251
411
|
if (opts.dbname) cfg.database = opts.dbname;
|
|
252
412
|
if (opts.adminPassword) cfg.password = opts.adminPassword;
|
|
253
413
|
if (opts.envPassword && !cfg.password) cfg.password = opts.envPassword;
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
// - prompt text is visible
|
|
260
|
-
// - only user input is masked
|
|
261
|
-
// - we don't rely on non-public readline internals
|
|
262
|
-
if (!process.stdin.isTTY) {
|
|
263
|
-
throw new Error("Cannot prompt for password in non-interactive mode");
|
|
414
|
+
if (explicitSsl) {
|
|
415
|
+
cfg.ssl = sslModeToConfig(explicitSsl);
|
|
416
|
+
// Enable fallback for explicit "prefer"/"allow"
|
|
417
|
+
const shouldFallback = explicitSsl.toLowerCase() === "prefer" || explicitSsl.toLowerCase() === "allow";
|
|
418
|
+
return { clientConfig: cfg, display: describePgConfig(cfg), sslFallbackEnabled: shouldFallback };
|
|
264
419
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
stdout.write(prompt);
|
|
270
|
-
|
|
271
|
-
return await new Promise<string>((resolve, reject) => {
|
|
272
|
-
let value = "";
|
|
273
|
-
|
|
274
|
-
const cleanup = () => {
|
|
275
|
-
try {
|
|
276
|
-
stdin.setRawMode(false);
|
|
277
|
-
} catch {
|
|
278
|
-
// ignore
|
|
279
|
-
}
|
|
280
|
-
stdin.removeListener("keypress", onKeypress);
|
|
281
|
-
};
|
|
282
|
-
|
|
283
|
-
const onKeypress = (str: string, key: any) => {
|
|
284
|
-
if (key?.ctrl && key?.name === "c") {
|
|
285
|
-
stdout.write("\n");
|
|
286
|
-
cleanup();
|
|
287
|
-
reject(new Error("Cancelled"));
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
if (key?.name === "return" || key?.name === "enter") {
|
|
292
|
-
stdout.write("\n");
|
|
293
|
-
cleanup();
|
|
294
|
-
resolve(value);
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
if (key?.name === "backspace") {
|
|
299
|
-
if (value.length > 0) {
|
|
300
|
-
value = value.slice(0, -1);
|
|
301
|
-
// Erase one mask char.
|
|
302
|
-
stdout.write("\b \b");
|
|
303
|
-
}
|
|
304
|
-
return;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// Ignore other control keys.
|
|
308
|
-
if (key?.ctrl || key?.meta) return;
|
|
309
|
-
|
|
310
|
-
if (typeof str === "string" && str.length > 0) {
|
|
311
|
-
value += str;
|
|
312
|
-
stdout.write("*");
|
|
313
|
-
}
|
|
314
|
-
};
|
|
315
|
-
|
|
316
|
-
readline.emitKeypressEvents(stdin);
|
|
317
|
-
stdin.setRawMode(true);
|
|
318
|
-
stdin.on("keypress", onKeypress);
|
|
319
|
-
stdin.resume();
|
|
320
|
-
});
|
|
420
|
+
// Default: try SSL with fallback (sslmode=prefer behavior)
|
|
421
|
+
cfg.ssl = { rejectUnauthorized: false };
|
|
422
|
+
return { clientConfig: cfg, display: describePgConfig(cfg), sslFallbackEnabled: true };
|
|
321
423
|
}
|
|
322
424
|
|
|
323
425
|
function generateMonitoringPassword(): string {
|
|
@@ -333,7 +435,6 @@ function generateMonitoringPassword(): string {
|
|
|
333
435
|
export async function resolveMonitoringPassword(opts: {
|
|
334
436
|
passwordFlag?: string;
|
|
335
437
|
passwordEnv?: string;
|
|
336
|
-
prompt?: (prompt: string) => Promise<string>;
|
|
337
438
|
monitoringUser: string;
|
|
338
439
|
}): Promise<{ password: string; generated: boolean }> {
|
|
339
440
|
const fromFlag = (opts.passwordFlag || "").trim();
|
|
@@ -392,6 +493,12 @@ end $$;`;
|
|
|
392
493
|
sql: applyTemplate(loadSqlTemplate("02.permissions.sql"), vars),
|
|
393
494
|
});
|
|
394
495
|
|
|
496
|
+
// Helper functions (SECURITY DEFINER) for plan analysis and table info
|
|
497
|
+
steps.push({
|
|
498
|
+
name: "05.helpers",
|
|
499
|
+
sql: applyTemplate(loadSqlTemplate("05.helpers.sql"), vars),
|
|
500
|
+
});
|
|
501
|
+
|
|
395
502
|
if (params.includeOptionalPermissions) {
|
|
396
503
|
steps.push(
|
|
397
504
|
{
|
|
@@ -418,78 +525,70 @@ export async function applyInitPlan(params: {
|
|
|
418
525
|
const applied: string[] = [];
|
|
419
526
|
const skippedOptional: string[] = [];
|
|
420
527
|
|
|
421
|
-
//
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
528
|
+
// Helper to wrap a step execution in begin/commit
|
|
529
|
+
const executeStep = async (step: InitStep): Promise<void> => {
|
|
530
|
+
await params.client.query("begin;");
|
|
531
|
+
try {
|
|
532
|
+
await params.client.query(step.sql, step.params as any);
|
|
533
|
+
await params.client.query("commit;");
|
|
534
|
+
} catch (e) {
|
|
535
|
+
// Rollback errors should never mask the original failure.
|
|
425
536
|
try {
|
|
426
|
-
await params.client.query(
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
430
|
-
const errAny = e as any;
|
|
431
|
-
const wrapped: any = new Error(`Failed at step "${step.name}": ${msg}`);
|
|
432
|
-
// Preserve useful Postgres error fields so callers can provide better hints / diagnostics.
|
|
433
|
-
const pgErrorFields = [
|
|
434
|
-
"code",
|
|
435
|
-
"detail",
|
|
436
|
-
"hint",
|
|
437
|
-
"position",
|
|
438
|
-
"internalPosition",
|
|
439
|
-
"internalQuery",
|
|
440
|
-
"where",
|
|
441
|
-
"schema",
|
|
442
|
-
"table",
|
|
443
|
-
"column",
|
|
444
|
-
"dataType",
|
|
445
|
-
"constraint",
|
|
446
|
-
"file",
|
|
447
|
-
"line",
|
|
448
|
-
"routine",
|
|
449
|
-
] as const;
|
|
450
|
-
if (errAny && typeof errAny === "object") {
|
|
451
|
-
for (const field of pgErrorFields) {
|
|
452
|
-
if (errAny[field] !== undefined) wrapped[field] = errAny[field];
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
if (e instanceof Error && e.stack) {
|
|
456
|
-
wrapped.stack = e.stack;
|
|
457
|
-
}
|
|
458
|
-
throw wrapped;
|
|
537
|
+
await params.client.query("rollback;");
|
|
538
|
+
} catch {
|
|
539
|
+
// ignore
|
|
459
540
|
}
|
|
541
|
+
throw e;
|
|
460
542
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
// Apply non-optional steps, each in its own transaction
|
|
546
|
+
for (const step of params.plan.steps.filter((s) => !s.optional)) {
|
|
464
547
|
try {
|
|
465
|
-
await
|
|
466
|
-
|
|
467
|
-
|
|
548
|
+
await executeStep(step);
|
|
549
|
+
applied.push(step.name);
|
|
550
|
+
} catch (e) {
|
|
551
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
552
|
+
const errAny = e as any;
|
|
553
|
+
const wrapped: any = new Error(`Failed at step "${step.name}": ${msg}`);
|
|
554
|
+
// Preserve useful Postgres error fields so callers can provide better hints / diagnostics.
|
|
555
|
+
const pgErrorFields = [
|
|
556
|
+
"code",
|
|
557
|
+
"detail",
|
|
558
|
+
"hint",
|
|
559
|
+
"position",
|
|
560
|
+
"internalPosition",
|
|
561
|
+
"internalQuery",
|
|
562
|
+
"where",
|
|
563
|
+
"schema",
|
|
564
|
+
"table",
|
|
565
|
+
"column",
|
|
566
|
+
"dataType",
|
|
567
|
+
"constraint",
|
|
568
|
+
"file",
|
|
569
|
+
"line",
|
|
570
|
+
"routine",
|
|
571
|
+
] as const;
|
|
572
|
+
if (errAny && typeof errAny === "object") {
|
|
573
|
+
for (const field of pgErrorFields) {
|
|
574
|
+
if (errAny[field] !== undefined) wrapped[field] = errAny[field];
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (e instanceof Error && e.stack) {
|
|
578
|
+
wrapped.stack = e.stack;
|
|
579
|
+
}
|
|
580
|
+
throw wrapped;
|
|
468
581
|
}
|
|
469
|
-
throw e;
|
|
470
582
|
}
|
|
471
583
|
|
|
472
|
-
// Apply optional steps
|
|
584
|
+
// Apply optional steps, each in its own transaction (failure doesn't abort)
|
|
473
585
|
for (const step of params.plan.steps.filter((s) => s.optional)) {
|
|
474
586
|
try {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
try {
|
|
478
|
-
await params.client.query(step.sql, step.params as any);
|
|
479
|
-
await params.client.query("commit;");
|
|
480
|
-
applied.push(step.name);
|
|
481
|
-
} catch {
|
|
482
|
-
try {
|
|
483
|
-
await params.client.query("rollback;");
|
|
484
|
-
} catch {
|
|
485
|
-
// ignore rollback errors
|
|
486
|
-
}
|
|
487
|
-
skippedOptional.push(step.name);
|
|
488
|
-
// best-effort: ignore
|
|
489
|
-
}
|
|
587
|
+
await executeStep(step);
|
|
588
|
+
applied.push(step.name);
|
|
490
589
|
} catch {
|
|
491
|
-
// If we can't even begin/commit, treat as skipped.
|
|
492
590
|
skippedOptional.push(step.name);
|
|
591
|
+
// best-effort: ignore
|
|
493
592
|
}
|
|
494
593
|
}
|
|
495
594
|
|
|
@@ -549,16 +648,25 @@ export async function verifyInitSetup(params: {
|
|
|
549
648
|
missingRequired.push("SELECT on pg_catalog.pg_index");
|
|
550
649
|
}
|
|
551
650
|
|
|
552
|
-
|
|
651
|
+
// Check postgres_ai schema exists and is usable
|
|
652
|
+
const schemaExistsRes = await params.client.query(
|
|
653
|
+
"select has_schema_privilege($1, 'postgres_ai', 'USAGE') as ok",
|
|
654
|
+
[role]
|
|
655
|
+
);
|
|
656
|
+
if (!schemaExistsRes.rows?.[0]?.ok) {
|
|
657
|
+
missingRequired.push("USAGE on schema postgres_ai");
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const viewExistsRes = await params.client.query("select to_regclass('postgres_ai.pg_statistic') is not null as ok");
|
|
553
661
|
if (!viewExistsRes.rows?.[0]?.ok) {
|
|
554
|
-
missingRequired.push("view
|
|
662
|
+
missingRequired.push("view postgres_ai.pg_statistic exists");
|
|
555
663
|
} else {
|
|
556
664
|
const viewPrivRes = await params.client.query(
|
|
557
|
-
"select has_table_privilege($1, '
|
|
665
|
+
"select has_table_privilege($1, 'postgres_ai.pg_statistic', 'SELECT') as ok",
|
|
558
666
|
[role]
|
|
559
667
|
);
|
|
560
668
|
if (!viewPrivRes.rows?.[0]?.ok) {
|
|
561
|
-
missingRequired.push("SELECT on view
|
|
669
|
+
missingRequired.push("SELECT on view postgres_ai.pg_statistic");
|
|
562
670
|
}
|
|
563
671
|
}
|
|
564
672
|
|
|
@@ -576,13 +684,30 @@ export async function verifyInitSetup(params: {
|
|
|
576
684
|
if (typeof spLine !== "string" || !spLine) {
|
|
577
685
|
missingRequired.push("role search_path is set");
|
|
578
686
|
} else {
|
|
579
|
-
// We accept any ordering as long as public and pg_catalog are included.
|
|
687
|
+
// We accept any ordering as long as postgres_ai, public, and pg_catalog are included.
|
|
580
688
|
const sp = spLine.toLowerCase();
|
|
581
|
-
if (!sp.includes("public") || !sp.includes("pg_catalog")) {
|
|
582
|
-
missingRequired.push("role search_path includes public and pg_catalog");
|
|
689
|
+
if (!sp.includes("postgres_ai") || !sp.includes("public") || !sp.includes("pg_catalog")) {
|
|
690
|
+
missingRequired.push("role search_path includes postgres_ai, public and pg_catalog");
|
|
583
691
|
}
|
|
584
692
|
}
|
|
585
693
|
|
|
694
|
+
// Check for helper functions
|
|
695
|
+
const explainFnRes = await params.client.query(
|
|
696
|
+
"select has_function_privilege($1, 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok",
|
|
697
|
+
[role]
|
|
698
|
+
);
|
|
699
|
+
if (!explainFnRes.rows?.[0]?.ok) {
|
|
700
|
+
missingRequired.push("EXECUTE on postgres_ai.explain_generic(text, text, text)");
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const tableDescribeFnRes = await params.client.query(
|
|
704
|
+
"select has_function_privilege($1, 'postgres_ai.table_describe(text)', 'EXECUTE') as ok",
|
|
705
|
+
[role]
|
|
706
|
+
);
|
|
707
|
+
if (!tableDescribeFnRes.rows?.[0]?.ok) {
|
|
708
|
+
missingRequired.push("EXECUTE on postgres_ai.table_describe(text)");
|
|
709
|
+
}
|
|
710
|
+
|
|
586
711
|
if (params.includeOptionalPermissions) {
|
|
587
712
|
// Optional RDS/Aurora extras
|
|
588
713
|
{
|