postgresai 0.12.0-beta.7 → 0.14.0-dev.10

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/init.ts ADDED
@@ -0,0 +1,435 @@
1
+ import * as readline from "readline";
2
+ import { randomBytes } from "crypto";
3
+ import { URL } from "url";
4
+ import type { Client as PgClient } from "pg";
5
+ import * as fs from "fs";
6
+ import * as path from "path";
7
+
8
+ export type PgClientConfig = {
9
+ connectionString?: string;
10
+ host?: string;
11
+ port?: number;
12
+ user?: string;
13
+ password?: string;
14
+ database?: string;
15
+ ssl?: any;
16
+ };
17
+
18
+ export type AdminConnection = {
19
+ clientConfig: PgClientConfig;
20
+ display: string;
21
+ };
22
+
23
+ export type InitStep = {
24
+ name: string;
25
+ sql: string;
26
+ params?: unknown[];
27
+ optional?: boolean;
28
+ };
29
+
30
+ export type InitPlan = {
31
+ monitoringUser: string;
32
+ database: string;
33
+ steps: InitStep[];
34
+ };
35
+
36
+ function packageRootDirFromCompiled(): string {
37
+ // dist/lib/init.js -> <pkg>/dist/lib ; package root is ../..
38
+ return path.resolve(__dirname, "..", "..");
39
+ }
40
+
41
+ function sqlDir(): string {
42
+ return path.join(packageRootDirFromCompiled(), "sql");
43
+ }
44
+
45
+ function loadSqlTemplate(filename: string): string {
46
+ const p = path.join(sqlDir(), filename);
47
+ return fs.readFileSync(p, "utf8");
48
+ }
49
+
50
+ function applyTemplate(sql: string, vars: Record<string, string>): string {
51
+ return sql.replace(/\{\{([A-Z0-9_]+)\}\}/g, (_, key) => {
52
+ const v = vars[key];
53
+ if (v === undefined) throw new Error(`Missing SQL template var: ${key}`);
54
+ return v;
55
+ });
56
+ }
57
+
58
+ function quoteIdent(ident: string): string {
59
+ // Always quote. Escape embedded quotes by doubling.
60
+ return `"${ident.replace(/"/g, "\"\"")}"`;
61
+ }
62
+
63
+ function quoteLiteral(value: string): string {
64
+ // Single-quote and escape embedded quotes by doubling.
65
+ // This is used where Postgres grammar requires a literal (e.g., CREATE/ALTER ROLE PASSWORD).
66
+ return `'${value.replace(/'/g, "''")}'`;
67
+ }
68
+
69
+ export function maskConnectionString(dbUrl: string): string {
70
+ // Hide password if present (postgresql://user:pass@host/db).
71
+ try {
72
+ const u = new URL(dbUrl);
73
+ if (u.password) u.password = "*****";
74
+ return u.toString();
75
+ } catch {
76
+ return dbUrl.replace(/\/\/([^:/?#]+):([^@/?#]+)@/g, "//$1:*****@");
77
+ }
78
+ }
79
+
80
+ function isLikelyUri(value: string): boolean {
81
+ return /^postgres(ql)?:\/\//i.test(value.trim());
82
+ }
83
+
84
+ function tokenizeConninfo(input: string): string[] {
85
+ const s = input.trim();
86
+ const tokens: string[] = [];
87
+ let i = 0;
88
+
89
+ const isSpace = (ch: string) => ch === " " || ch === "\t" || ch === "\n" || ch === "\r";
90
+
91
+ while (i < s.length) {
92
+ while (i < s.length && isSpace(s[i]!)) i++;
93
+ if (i >= s.length) break;
94
+
95
+ let tok = "";
96
+ let inSingle = false;
97
+ while (i < s.length) {
98
+ const ch = s[i]!;
99
+ if (!inSingle && isSpace(ch)) break;
100
+
101
+ if (ch === "'" && !inSingle) {
102
+ inSingle = true;
103
+ i++;
104
+ continue;
105
+ }
106
+ if (ch === "'" && inSingle) {
107
+ inSingle = false;
108
+ i++;
109
+ continue;
110
+ }
111
+
112
+ if (ch === "\\" && i + 1 < s.length) {
113
+ tok += s[i + 1]!;
114
+ i += 2;
115
+ continue;
116
+ }
117
+
118
+ tok += ch;
119
+ i++;
120
+ }
121
+
122
+ tokens.push(tok);
123
+ while (i < s.length && isSpace(s[i]!)) i++;
124
+ }
125
+
126
+ return tokens;
127
+ }
128
+
129
+ export function parseLibpqConninfo(input: string): PgClientConfig {
130
+ const tokens = tokenizeConninfo(input);
131
+ const cfg: PgClientConfig = {};
132
+
133
+ for (const t of tokens) {
134
+ const eq = t.indexOf("=");
135
+ if (eq <= 0) continue;
136
+ const key = t.slice(0, eq).trim();
137
+ const rawVal = t.slice(eq + 1);
138
+ const val = rawVal.trim();
139
+ if (!key) continue;
140
+
141
+ switch (key) {
142
+ case "host":
143
+ cfg.host = val;
144
+ break;
145
+ case "port": {
146
+ const p = Number(val);
147
+ if (Number.isFinite(p)) cfg.port = p;
148
+ break;
149
+ }
150
+ case "user":
151
+ cfg.user = val;
152
+ break;
153
+ case "password":
154
+ cfg.password = val;
155
+ break;
156
+ case "dbname":
157
+ case "database":
158
+ cfg.database = val;
159
+ break;
160
+ // ignore everything else (sslmode, options, application_name, etc.)
161
+ default:
162
+ break;
163
+ }
164
+ }
165
+
166
+ return cfg;
167
+ }
168
+
169
+ export function describePgConfig(cfg: PgClientConfig): string {
170
+ if (cfg.connectionString) return maskConnectionString(cfg.connectionString);
171
+ const user = cfg.user ? cfg.user : "<user>";
172
+ const host = cfg.host ? cfg.host : "<host>";
173
+ const port = cfg.port ? String(cfg.port) : "<port>";
174
+ const db = cfg.database ? cfg.database : "<db>";
175
+ // Don't include password
176
+ return `postgresql://${user}:*****@${host}:${port}/${db}`;
177
+ }
178
+
179
+ export function resolveAdminConnection(opts: {
180
+ conn?: string;
181
+ dbUrlFlag?: string;
182
+ host?: string;
183
+ port?: string | number;
184
+ username?: string;
185
+ dbname?: string;
186
+ adminPassword?: string;
187
+ envPassword?: string;
188
+ }): AdminConnection {
189
+ const conn = (opts.conn || "").trim();
190
+ const dbUrlFlag = (opts.dbUrlFlag || "").trim();
191
+
192
+ // NOTE: passwords alone (PGPASSWORD / --admin-password) do NOT constitute a connection.
193
+ // We require at least some connection addressing (host/port/user/db) if no positional arg / --db-url is provided.
194
+ const hasConnDetails = !!(opts.host || opts.port || opts.username || opts.dbname);
195
+
196
+ if (conn && dbUrlFlag) {
197
+ throw new Error("Provide either positional connection string or --db-url, not both");
198
+ }
199
+
200
+ if (conn || dbUrlFlag) {
201
+ const v = conn || dbUrlFlag;
202
+ if (isLikelyUri(v)) {
203
+ return { clientConfig: { connectionString: v }, display: maskConnectionString(v) };
204
+ }
205
+ // libpq conninfo (dbname=... host=...)
206
+ const cfg = parseLibpqConninfo(v);
207
+ if (opts.envPassword && !cfg.password) cfg.password = opts.envPassword;
208
+ return { clientConfig: cfg, display: describePgConfig(cfg) };
209
+ }
210
+
211
+ if (!hasConnDetails) {
212
+ throw new Error(
213
+ [
214
+ "Connection is required.",
215
+ "",
216
+ "Examples:",
217
+ " postgresai init postgresql://admin@host:5432/dbname",
218
+ " postgresai init \"dbname=dbname host=host user=admin\"",
219
+ " postgresai init -h host -p 5432 -U admin -d dbname",
220
+ "",
221
+ "Admin password:",
222
+ " --admin-password <password> (or set PGPASSWORD)",
223
+ ].join("\n")
224
+ );
225
+ }
226
+
227
+ const cfg: PgClientConfig = {};
228
+ if (opts.host) cfg.host = opts.host;
229
+ if (opts.port !== undefined && opts.port !== "") {
230
+ const p = Number(opts.port);
231
+ if (!Number.isFinite(p) || !Number.isInteger(p) || p <= 0 || p > 65535) {
232
+ throw new Error(`Invalid port value: ${String(opts.port)}`);
233
+ }
234
+ cfg.port = p;
235
+ }
236
+ if (opts.username) cfg.user = opts.username;
237
+ if (opts.dbname) cfg.database = opts.dbname;
238
+ if (opts.adminPassword) cfg.password = opts.adminPassword;
239
+ if (opts.envPassword && !cfg.password) cfg.password = opts.envPassword;
240
+ return { clientConfig: cfg, display: describePgConfig(cfg) };
241
+ }
242
+
243
+ export async function promptHidden(prompt: string): Promise<string> {
244
+ // Implement our own hidden input reader so:
245
+ // - prompt text is visible
246
+ // - only user input is masked
247
+ // - we don't rely on non-public readline internals
248
+ if (!process.stdin.isTTY) {
249
+ throw new Error("Cannot prompt for password in non-interactive mode");
250
+ }
251
+
252
+ const stdin = process.stdin;
253
+ const stdout = process.stdout as NodeJS.WriteStream;
254
+
255
+ stdout.write(prompt);
256
+
257
+ return await new Promise<string>((resolve, reject) => {
258
+ let value = "";
259
+
260
+ const cleanup = () => {
261
+ try {
262
+ stdin.setRawMode(false);
263
+ } catch {
264
+ // ignore
265
+ }
266
+ stdin.removeListener("keypress", onKeypress);
267
+ };
268
+
269
+ const onKeypress = (str: string, key: any) => {
270
+ if (key?.ctrl && key?.name === "c") {
271
+ stdout.write("\n");
272
+ cleanup();
273
+ reject(new Error("Cancelled"));
274
+ return;
275
+ }
276
+
277
+ if (key?.name === "return" || key?.name === "enter") {
278
+ stdout.write("\n");
279
+ cleanup();
280
+ resolve(value);
281
+ return;
282
+ }
283
+
284
+ if (key?.name === "backspace") {
285
+ if (value.length > 0) {
286
+ value = value.slice(0, -1);
287
+ // Erase one mask char.
288
+ stdout.write("\b \b");
289
+ }
290
+ return;
291
+ }
292
+
293
+ // Ignore other control keys.
294
+ if (key?.ctrl || key?.meta) return;
295
+
296
+ if (typeof str === "string" && str.length > 0) {
297
+ value += str;
298
+ stdout.write("*");
299
+ }
300
+ };
301
+
302
+ readline.emitKeypressEvents(stdin);
303
+ stdin.setRawMode(true);
304
+ stdin.on("keypress", onKeypress);
305
+ stdin.resume();
306
+ });
307
+ }
308
+
309
+ function generateMonitoringPassword(): string {
310
+ // URL-safe and easy to copy/paste; length ~32 chars.
311
+ return randomBytes(24).toString("base64url");
312
+ }
313
+
314
+ export async function resolveMonitoringPassword(opts: {
315
+ passwordFlag?: string;
316
+ passwordEnv?: string;
317
+ prompt?: (prompt: string) => Promise<string>;
318
+ monitoringUser: string;
319
+ }): Promise<{ password: string; generated: boolean }> {
320
+ const fromFlag = (opts.passwordFlag || "").trim();
321
+ if (fromFlag) return { password: fromFlag, generated: false };
322
+
323
+ const fromEnv = (opts.passwordEnv || "").trim();
324
+ if (fromEnv) return { password: fromEnv, generated: false };
325
+
326
+ // Default: auto-generate (safer than prompting; works in non-interactive mode).
327
+ return { password: generateMonitoringPassword(), generated: true };
328
+ }
329
+
330
+ export async function buildInitPlan(params: {
331
+ database: string;
332
+ monitoringUser?: string;
333
+ monitoringPassword: string;
334
+ includeOptionalPermissions: boolean;
335
+ roleExists?: boolean;
336
+ }): Promise<InitPlan> {
337
+ const monitoringUser = params.monitoringUser || "postgres_ai_mon";
338
+ const database = params.database;
339
+
340
+ const qRole = quoteIdent(monitoringUser);
341
+ const qDb = quoteIdent(database);
342
+ const qPw = quoteLiteral(params.monitoringPassword);
343
+
344
+ const steps: InitStep[] = [];
345
+
346
+ const vars = {
347
+ ROLE_IDENT: qRole,
348
+ DB_IDENT: qDb,
349
+ };
350
+
351
+ // Role creation/update is done in one template file; caller decides statement.
352
+ if (params.roleExists === false) {
353
+ const roleSql = applyTemplate(loadSqlTemplate("01.role.sql"), {
354
+ ...vars,
355
+ ROLE_STMT: `create user ${qRole} with password ${qPw};`,
356
+ });
357
+ steps.push({ name: "01.role", sql: roleSql });
358
+ } else if (params.roleExists === true) {
359
+ const roleSql = applyTemplate(loadSqlTemplate("01.role.sql"), {
360
+ ...vars,
361
+ ROLE_STMT: `alter user ${qRole} with password ${qPw};`,
362
+ });
363
+ steps.push({ name: "01.role", sql: roleSql });
364
+ }
365
+
366
+ steps.push({
367
+ name: "02.permissions",
368
+ sql: applyTemplate(loadSqlTemplate("02.permissions.sql"), vars),
369
+ });
370
+
371
+ if (params.includeOptionalPermissions) {
372
+ steps.push(
373
+ {
374
+ name: "03.optional_rds",
375
+ sql: applyTemplate(loadSqlTemplate("03.optional_rds.sql"), vars),
376
+ optional: true,
377
+ },
378
+ {
379
+ name: "04.optional_self_managed",
380
+ sql: applyTemplate(loadSqlTemplate("04.optional_self_managed.sql"), vars),
381
+ optional: true,
382
+ }
383
+ );
384
+ }
385
+
386
+ return { monitoringUser, database, steps };
387
+ }
388
+
389
+ export async function applyInitPlan(params: {
390
+ client: PgClient;
391
+ plan: InitPlan;
392
+ verbose?: boolean;
393
+ }): Promise<{ applied: string[]; skippedOptional: string[] }> {
394
+ const applied: string[] = [];
395
+ const skippedOptional: string[] = [];
396
+
397
+ // Apply non-optional steps in a single transaction.
398
+ await params.client.query("begin;");
399
+ try {
400
+ for (const step of params.plan.steps.filter((s) => !s.optional)) {
401
+ try {
402
+ await params.client.query(step.sql, step.params as any);
403
+ applied.push(step.name);
404
+ } catch (e) {
405
+ const msg = e instanceof Error ? e.message : String(e);
406
+ const errAny = e as any;
407
+ const wrapped: any = new Error(`Failed at step "${step.name}": ${msg}`);
408
+ // Preserve Postgres error code so callers can provide better hints (e.g., 42501 insufficient_privilege).
409
+ if (errAny && typeof errAny === "object" && typeof errAny.code === "string") {
410
+ wrapped.code = errAny.code;
411
+ }
412
+ throw wrapped;
413
+ }
414
+ }
415
+ await params.client.query("commit;");
416
+ } catch (e) {
417
+ await params.client.query("rollback;");
418
+ throw e;
419
+ }
420
+
421
+ // Apply optional steps outside of the transaction so a failure doesn't abort everything.
422
+ for (const step of params.plan.steps.filter((s) => s.optional)) {
423
+ try {
424
+ await params.client.query(step.sql, step.params as any);
425
+ applied.push(step.name);
426
+ } catch {
427
+ skippedOptional.push(step.name);
428
+ // best-effort: ignore
429
+ }
430
+ }
431
+
432
+ return { applied, skippedOptional };
433
+ }
434
+
435
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.12.0-beta.7",
3
+ "version": "0.14.0-dev.10",
4
4
  "description": "postgres_ai CLI (Node.js)",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -25,7 +25,8 @@
25
25
  "build": "tsc",
26
26
  "prepare": "npm run build",
27
27
  "start": "node ./dist/bin/postgres-ai.js --help",
28
- "dev": "tsc --watch"
28
+ "dev": "tsc --watch",
29
+ "test": "npm run build && node --test test/*.test.cjs"
29
30
  },
30
31
  "dependencies": {
31
32
  "@modelcontextprotocol/sdk": "^1.20.2",
@@ -0,0 +1,4 @@
1
+ -- Role creation / password update (template-filled by cli/lib/init.ts)
2
+ {{ROLE_STMT}}
3
+
4
+
@@ -0,0 +1,33 @@
1
+ -- Required permissions for postgres_ai monitoring user (template-filled by cli/lib/init.ts)
2
+
3
+ -- Allow connect
4
+ grant connect on database {{DB_IDENT}} to {{ROLE_IDENT}};
5
+
6
+ -- Standard monitoring privileges
7
+ grant pg_monitor to {{ROLE_IDENT}};
8
+ grant select on pg_catalog.pg_index to {{ROLE_IDENT}};
9
+
10
+ -- Optional, for bloat analysis: expose pg_statistic via a view
11
+ create or replace view public.pg_statistic as
12
+ select
13
+ n.nspname as schemaname,
14
+ c.relname as tablename,
15
+ a.attname,
16
+ s.stanullfrac as null_frac,
17
+ s.stawidth as avg_width,
18
+ false as inherited
19
+ from pg_catalog.pg_statistic s
20
+ join pg_catalog.pg_class c on c.oid = s.starelid
21
+ join pg_catalog.pg_namespace n on n.oid = c.relnamespace
22
+ join pg_catalog.pg_attribute a on a.attrelid = s.starelid and a.attnum = s.staattnum
23
+ where a.attnum > 0 and not a.attisdropped;
24
+
25
+ grant select on public.pg_statistic to {{ROLE_IDENT}};
26
+
27
+ -- Hardened clusters sometimes revoke PUBLIC on schema public
28
+ grant usage on schema public to {{ROLE_IDENT}};
29
+
30
+ -- Keep search_path predictable
31
+ alter user {{ROLE_IDENT}} set search_path = "$user", public, pg_catalog;
32
+
33
+
@@ -0,0 +1,6 @@
1
+ -- Optional permissions for RDS Postgres / Aurora (best effort)
2
+
3
+ create extension if not exists rds_tools;
4
+ grant execute on function rds_tools.pg_ls_multixactdir() to {{ROLE_IDENT}};
5
+
6
+
@@ -0,0 +1,8 @@
1
+ -- Optional permissions for self-managed Postgres (best effort)
2
+
3
+ grant execute on function pg_catalog.pg_stat_file(text) to {{ROLE_IDENT}};
4
+ grant execute on function pg_catalog.pg_stat_file(text, boolean) to {{ROLE_IDENT}};
5
+ grant execute on function pg_catalog.pg_ls_dir(text) to {{ROLE_IDENT}};
6
+ grant execute on function pg_catalog.pg_ls_dir(text, boolean, boolean) to {{ROLE_IDENT}};
7
+
8
+