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

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,404 @@
1
+ import * as readline from "readline";
2
+ import { URL } from "url";
3
+ import type { Client as PgClient } from "pg";
4
+
5
+ export type PgClientConfig = {
6
+ connectionString?: string;
7
+ host?: string;
8
+ port?: number;
9
+ user?: string;
10
+ password?: string;
11
+ database?: string;
12
+ ssl?: any;
13
+ };
14
+
15
+ export type AdminConnection = {
16
+ clientConfig: PgClientConfig;
17
+ display: string;
18
+ };
19
+
20
+ export type InitStep = {
21
+ name: string;
22
+ sql: string;
23
+ params?: unknown[];
24
+ optional?: boolean;
25
+ };
26
+
27
+ export type InitPlan = {
28
+ monitoringUser: string;
29
+ database: string;
30
+ steps: InitStep[];
31
+ };
32
+
33
+ function quoteIdent(ident: string): string {
34
+ // Always quote. Escape embedded quotes by doubling.
35
+ return `"${ident.replace(/"/g, "\"\"")}"`;
36
+ }
37
+
38
+ export function maskConnectionString(dbUrl: string): string {
39
+ // Hide password if present (postgresql://user:pass@host/db).
40
+ try {
41
+ const u = new URL(dbUrl);
42
+ if (u.password) u.password = "*****";
43
+ return u.toString();
44
+ } catch {
45
+ return dbUrl.replace(/\/\/([^:/?#]+):([^@/?#]+)@/g, "//$1:*****@");
46
+ }
47
+ }
48
+
49
+ function isLikelyUri(value: string): boolean {
50
+ return /^postgres(ql)?:\/\//i.test(value.trim());
51
+ }
52
+
53
+ function tokenizeConninfo(input: string): string[] {
54
+ const s = input.trim();
55
+ const tokens: string[] = [];
56
+ let i = 0;
57
+
58
+ const isSpace = (ch: string) => ch === " " || ch === "\t" || ch === "\n" || ch === "\r";
59
+
60
+ while (i < s.length) {
61
+ while (i < s.length && isSpace(s[i]!)) i++;
62
+ if (i >= s.length) break;
63
+
64
+ let tok = "";
65
+ let inSingle = false;
66
+ while (i < s.length) {
67
+ const ch = s[i]!;
68
+ if (!inSingle && isSpace(ch)) break;
69
+
70
+ if (ch === "'" && !inSingle) {
71
+ inSingle = true;
72
+ i++;
73
+ continue;
74
+ }
75
+ if (ch === "'" && inSingle) {
76
+ inSingle = false;
77
+ i++;
78
+ continue;
79
+ }
80
+
81
+ if (ch === "\\" && i + 1 < s.length) {
82
+ tok += s[i + 1]!;
83
+ i += 2;
84
+ continue;
85
+ }
86
+
87
+ tok += ch;
88
+ i++;
89
+ }
90
+
91
+ tokens.push(tok);
92
+ while (i < s.length && isSpace(s[i]!)) i++;
93
+ }
94
+
95
+ return tokens;
96
+ }
97
+
98
+ export function parseLibpqConninfo(input: string): PgClientConfig {
99
+ const tokens = tokenizeConninfo(input);
100
+ const cfg: PgClientConfig = {};
101
+
102
+ for (const t of tokens) {
103
+ const eq = t.indexOf("=");
104
+ if (eq <= 0) continue;
105
+ const key = t.slice(0, eq).trim();
106
+ const rawVal = t.slice(eq + 1);
107
+ const val = rawVal.trim();
108
+ if (!key) continue;
109
+
110
+ switch (key) {
111
+ case "host":
112
+ cfg.host = val;
113
+ break;
114
+ case "port": {
115
+ const p = Number(val);
116
+ if (Number.isFinite(p)) cfg.port = p;
117
+ break;
118
+ }
119
+ case "user":
120
+ cfg.user = val;
121
+ break;
122
+ case "password":
123
+ cfg.password = val;
124
+ break;
125
+ case "dbname":
126
+ case "database":
127
+ cfg.database = val;
128
+ break;
129
+ // ignore everything else (sslmode, options, application_name, etc.)
130
+ default:
131
+ break;
132
+ }
133
+ }
134
+
135
+ return cfg;
136
+ }
137
+
138
+ export function describePgConfig(cfg: PgClientConfig): string {
139
+ if (cfg.connectionString) return maskConnectionString(cfg.connectionString);
140
+ const user = cfg.user ? cfg.user : "<user>";
141
+ const host = cfg.host ? cfg.host : "<host>";
142
+ const port = cfg.port ? String(cfg.port) : "<port>";
143
+ const db = cfg.database ? cfg.database : "<db>";
144
+ // Don't include password
145
+ return `postgresql://${user}:*****@${host}:${port}/${db}`;
146
+ }
147
+
148
+ export function resolveAdminConnection(opts: {
149
+ conn?: string;
150
+ dbUrlFlag?: string;
151
+ host?: string;
152
+ port?: string | number;
153
+ username?: string;
154
+ dbname?: string;
155
+ adminPassword?: string;
156
+ envPassword?: string;
157
+ }): AdminConnection {
158
+ const conn = (opts.conn || "").trim();
159
+ const dbUrlFlag = (opts.dbUrlFlag || "").trim();
160
+
161
+ const hasPsqlParts =
162
+ !!(opts.host || opts.port || opts.username || opts.dbname || opts.adminPassword || opts.envPassword);
163
+
164
+ if (conn && dbUrlFlag) {
165
+ throw new Error("Provide either positional connection string or --db-url, not both");
166
+ }
167
+
168
+ if (conn || dbUrlFlag) {
169
+ const v = conn || dbUrlFlag;
170
+ if (isLikelyUri(v)) {
171
+ return { clientConfig: { connectionString: v }, display: maskConnectionString(v) };
172
+ }
173
+ // libpq conninfo (dbname=... host=...)
174
+ const cfg = parseLibpqConninfo(v);
175
+ if (opts.envPassword && !cfg.password) cfg.password = opts.envPassword;
176
+ return { clientConfig: cfg, display: describePgConfig(cfg) };
177
+ }
178
+
179
+ if (!hasPsqlParts) {
180
+ throw new Error(
181
+ "Connection is required. Provide a connection string/conninfo as a positional arg, or use --db-url, or use -h/-p/-U/-d."
182
+ );
183
+ }
184
+
185
+ const cfg: PgClientConfig = {};
186
+ if (opts.host) cfg.host = opts.host;
187
+ if (opts.port !== undefined && opts.port !== "") cfg.port = Number(opts.port);
188
+ if (opts.username) cfg.user = opts.username;
189
+ if (opts.dbname) cfg.database = opts.dbname;
190
+ if (opts.adminPassword) cfg.password = opts.adminPassword;
191
+ if (opts.envPassword && !cfg.password) cfg.password = opts.envPassword;
192
+ return { clientConfig: cfg, display: describePgConfig(cfg) };
193
+ }
194
+
195
+ export async function promptHidden(prompt: string): Promise<string> {
196
+ const rl = readline.createInterface({
197
+ input: process.stdin,
198
+ output: process.stdout,
199
+ terminal: true,
200
+ });
201
+
202
+ // Mask input by overriding internal write method.
203
+ const anyRl = rl as any;
204
+ const out = process.stdout as NodeJS.WriteStream;
205
+ anyRl._writeToOutput = (str: string) => {
206
+ // Keep newlines and carriage returns; mask everything else.
207
+ if (str === "\n" || str === "\r\n") {
208
+ out.write(str);
209
+ } else {
210
+ out.write("*");
211
+ }
212
+ };
213
+
214
+ try {
215
+ const answer = await new Promise<string>((resolve) => rl.question(prompt, resolve));
216
+ // Ensure we end the masked line cleanly.
217
+ process.stdout.write("\n");
218
+ return answer;
219
+ } finally {
220
+ rl.close();
221
+ }
222
+ }
223
+
224
+ export async function resolveMonitoringPassword(opts: {
225
+ passwordFlag?: string;
226
+ passwordEnv?: string;
227
+ prompt?: (prompt: string) => Promise<string>;
228
+ monitoringUser: string;
229
+ }): Promise<string> {
230
+ const fromFlag = (opts.passwordFlag || "").trim();
231
+ if (fromFlag) return fromFlag;
232
+
233
+ const fromEnv = (opts.passwordEnv || "").trim();
234
+ if (fromEnv) return fromEnv;
235
+
236
+ if (!process.stdin.isTTY) {
237
+ throw new Error(
238
+ "Monitoring user password is required in non-interactive mode (use --password or PGAI_MON_PASSWORD)"
239
+ );
240
+ }
241
+
242
+ const prompter = opts.prompt || promptHidden;
243
+ while (true) {
244
+ const pw = (await prompter(`Enter password for monitoring user ${opts.monitoringUser}: `)).trim();
245
+ if (pw) return pw;
246
+ // eslint-disable-next-line no-console
247
+ console.error("Password cannot be empty");
248
+ }
249
+ }
250
+
251
+ export async function buildInitPlan(params: {
252
+ database: string;
253
+ monitoringUser?: string;
254
+ monitoringPassword: string;
255
+ includeOptionalPermissions: boolean;
256
+ roleExists?: boolean;
257
+ }): Promise<InitPlan> {
258
+ const monitoringUser = params.monitoringUser || "postgres_ai_mon";
259
+ const database = params.database;
260
+
261
+ const qRole = quoteIdent(monitoringUser);
262
+ const qDb = quoteIdent(database);
263
+
264
+ const steps: InitStep[] = [];
265
+
266
+ // Role creation/update is done in two alternative steps. Caller decides by checking role existence.
267
+ if (params.roleExists === false) {
268
+ steps.push({
269
+ name: "create monitoring user",
270
+ sql: `create user ${qRole} with password $1;`,
271
+ params: [params.monitoringPassword],
272
+ });
273
+ } else if (params.roleExists === true) {
274
+ steps.push({
275
+ name: "update monitoring user password",
276
+ sql: `alter user ${qRole} with password $1;`,
277
+ params: [params.monitoringPassword],
278
+ });
279
+ } else {
280
+ // Unknown: caller will rebuild after probing role existence.
281
+ }
282
+
283
+ steps.push(
284
+ {
285
+ name: "grant connect on database",
286
+ sql: `grant connect on database ${qDb} to ${qRole};`,
287
+ },
288
+ {
289
+ name: "grant pg_monitor",
290
+ sql: `grant pg_monitor to ${qRole};`,
291
+ },
292
+ {
293
+ name: "grant select on pg_index",
294
+ sql: `grant select on pg_catalog.pg_index to ${qRole};`,
295
+ },
296
+ {
297
+ name: "create or replace public.pg_statistic view",
298
+ sql: `create or replace view public.pg_statistic as
299
+ select
300
+ n.nspname as schemaname,
301
+ c.relname as tablename,
302
+ a.attname,
303
+ s.stanullfrac as null_frac,
304
+ s.stawidth as avg_width,
305
+ false as inherited
306
+ from pg_catalog.pg_statistic s
307
+ join pg_catalog.pg_class c on c.oid = s.starelid
308
+ join pg_catalog.pg_namespace n on n.oid = c.relnamespace
309
+ join pg_catalog.pg_attribute a on a.attrelid = s.starelid and a.attnum = s.staattnum
310
+ where a.attnum > 0 and not a.attisdropped;`,
311
+ },
312
+ {
313
+ name: "grant select on public.pg_statistic",
314
+ sql: `grant select on public.pg_statistic to ${qRole};`,
315
+ },
316
+ {
317
+ name: "ensure access to public schema (for hardened clusters)",
318
+ sql: `grant usage on schema public to ${qRole};`,
319
+ },
320
+ {
321
+ name: "set monitoring user search_path",
322
+ sql: `alter user ${qRole} set search_path = "$user", public, pg_catalog;`,
323
+ }
324
+ );
325
+
326
+ if (params.includeOptionalPermissions) {
327
+ steps.push(
328
+ {
329
+ name: "create rds_tools extension (optional)",
330
+ sql: "create extension if not exists rds_tools;",
331
+ optional: true,
332
+ },
333
+ {
334
+ name: "grant rds_tools.pg_ls_multixactdir() (optional)",
335
+ sql: `grant execute on function rds_tools.pg_ls_multixactdir() to ${qRole};`,
336
+ optional: true,
337
+ },
338
+ {
339
+ name: "grant pg_stat_file(text) (optional)",
340
+ sql: `grant execute on function pg_catalog.pg_stat_file(text) to ${qRole};`,
341
+ optional: true,
342
+ },
343
+ {
344
+ name: "grant pg_stat_file(text, boolean) (optional)",
345
+ sql: `grant execute on function pg_catalog.pg_stat_file(text, boolean) to ${qRole};`,
346
+ optional: true,
347
+ },
348
+ {
349
+ name: "grant pg_ls_dir(text) (optional)",
350
+ sql: `grant execute on function pg_catalog.pg_ls_dir(text) to ${qRole};`,
351
+ optional: true,
352
+ },
353
+ {
354
+ name: "grant pg_ls_dir(text, boolean, boolean) (optional)",
355
+ sql: `grant execute on function pg_catalog.pg_ls_dir(text, boolean, boolean) to ${qRole};`,
356
+ optional: true,
357
+ }
358
+ );
359
+ }
360
+
361
+ return { monitoringUser, database, steps };
362
+ }
363
+
364
+ export async function applyInitPlan(params: {
365
+ client: PgClient;
366
+ plan: InitPlan;
367
+ verbose?: boolean;
368
+ }): Promise<{ applied: string[]; skippedOptional: string[] }> {
369
+ const applied: string[] = [];
370
+ const skippedOptional: string[] = [];
371
+
372
+ // Apply non-optional steps in a single transaction.
373
+ await params.client.query("begin;");
374
+ try {
375
+ for (const step of params.plan.steps.filter((s) => !s.optional)) {
376
+ try {
377
+ await params.client.query(step.sql, step.params as any);
378
+ applied.push(step.name);
379
+ } catch (e) {
380
+ const msg = e instanceof Error ? e.message : String(e);
381
+ throw new Error(`Failed at step "${step.name}": ${msg}`);
382
+ }
383
+ }
384
+ await params.client.query("commit;");
385
+ } catch (e) {
386
+ await params.client.query("rollback;");
387
+ throw e;
388
+ }
389
+
390
+ // Apply optional steps outside of the transaction so a failure doesn't abort everything.
391
+ for (const step of params.plan.steps.filter((s) => s.optional)) {
392
+ try {
393
+ await params.client.query(step.sql, step.params as any);
394
+ applied.push(step.name);
395
+ } catch {
396
+ skippedOptional.push(step.name);
397
+ // best-effort: ignore
398
+ }
399
+ }
400
+
401
+ return { applied, skippedOptional };
402
+ }
403
+
404
+
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.7",
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,269 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const fs = require("node:fs");
4
+ const os = require("node:os");
5
+ const path = require("node:path");
6
+ const net = require("node:net");
7
+ const { spawn, spawnSync } = require("node:child_process");
8
+
9
+ function findOnPath(cmd) {
10
+ const which = spawnSync("sh", ["-lc", `command -v ${cmd}`], { encoding: "utf8" });
11
+ if (which.status === 0) return String(which.stdout || "").trim();
12
+ return null;
13
+ }
14
+
15
+ function findPgBin(cmd) {
16
+ const p = findOnPath(cmd);
17
+ if (p) return p;
18
+
19
+ // Debian/Ubuntu (GitLab CI node:*-bullseye images): binaries usually live here.
20
+ // We avoid filesystem globbing in JS and just ask the shell.
21
+ const probe = spawnSync(
22
+ "sh",
23
+ [
24
+ "-lc",
25
+ `ls -1 /usr/lib/postgresql/*/bin/${cmd} 2>/dev/null | head -n 1 || true`,
26
+ ],
27
+ { encoding: "utf8" }
28
+ );
29
+ const out = String(probe.stdout || "").trim();
30
+ if (out) return out;
31
+
32
+ return null;
33
+ }
34
+
35
+ function havePostgresBinaries() {
36
+ return !!(findPgBin("initdb") && findPgBin("postgres"));
37
+ }
38
+
39
+ async function getFreePort() {
40
+ return await new Promise((resolve, reject) => {
41
+ const srv = net.createServer();
42
+ srv.listen(0, "127.0.0.1", () => {
43
+ const addr = srv.address();
44
+ srv.close((err) => {
45
+ if (err) return reject(err);
46
+ resolve(addr.port);
47
+ });
48
+ });
49
+ srv.on("error", reject);
50
+ });
51
+ }
52
+
53
+ async function waitFor(fn, { timeoutMs = 10000, intervalMs = 100 } = {}) {
54
+ const start = Date.now();
55
+ // eslint-disable-next-line no-constant-condition
56
+ while (true) {
57
+ try {
58
+ return await fn();
59
+ } catch (e) {
60
+ if (Date.now() - start > timeoutMs) throw e;
61
+ await new Promise((r) => setTimeout(r, intervalMs));
62
+ }
63
+ }
64
+ }
65
+
66
+ async function withTempPostgres(t) {
67
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "postgresai-init-"));
68
+ const dataDir = path.join(tmpRoot, "data");
69
+ const socketDir = path.join(tmpRoot, "sock");
70
+ fs.mkdirSync(socketDir, { recursive: true });
71
+
72
+ const initdb = findPgBin("initdb");
73
+ const postgresBin = findPgBin("postgres");
74
+ assert.ok(initdb && postgresBin, "PostgreSQL binaries not found (need initdb and postgres)");
75
+
76
+ const init = spawnSync(initdb, ["-D", dataDir, "-U", "postgres", "-A", "trust"], {
77
+ encoding: "utf8",
78
+ });
79
+ assert.equal(init.status, 0, init.stderr || init.stdout);
80
+
81
+ // Configure: local socket trust, TCP scram.
82
+ const hbaPath = path.join(dataDir, "pg_hba.conf");
83
+ fs.appendFileSync(
84
+ hbaPath,
85
+ "\n# Added by postgresai init integration tests\nlocal all all trust\nhost all all 127.0.0.1/32 scram-sha-256\nhost all all ::1/128 scram-sha-256\n",
86
+ "utf8"
87
+ );
88
+
89
+ const port = await getFreePort();
90
+
91
+ const postgresProc = spawn(postgresBin, ["-D", dataDir, "-k", socketDir, "-h", "127.0.0.1", "-p", String(port)], {
92
+ stdio: ["ignore", "pipe", "pipe"],
93
+ });
94
+
95
+ const { Client } = require("pg");
96
+
97
+ const connectLocal = async (database = "postgres") => {
98
+ // IMPORTANT: must match the port Postgres is started with; otherwise pg defaults to 5432 and the socket path won't exist.
99
+ const c = new Client({ host: socketDir, port, user: "postgres", database });
100
+ await c.connect();
101
+ return c;
102
+ };
103
+
104
+ await waitFor(async () => {
105
+ const c = await connectLocal();
106
+ await c.end();
107
+ });
108
+
109
+ const postgresPassword = "postgrespw";
110
+ {
111
+ const c = await connectLocal();
112
+ await c.query("alter user postgres password $1", [postgresPassword]);
113
+ await c.query("create database testdb");
114
+ await c.end();
115
+ }
116
+
117
+ t.after(async () => {
118
+ postgresProc.kill("SIGTERM");
119
+ try {
120
+ await waitFor(
121
+ async () => {
122
+ if (postgresProc.exitCode === null) throw new Error("still running");
123
+ },
124
+ { timeoutMs: 5000, intervalMs: 100 }
125
+ );
126
+ } catch {
127
+ postgresProc.kill("SIGKILL");
128
+ }
129
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
130
+ });
131
+
132
+ const adminUri = `postgresql://postgres:${postgresPassword}@127.0.0.1:${port}/testdb`;
133
+ return { port, socketDir, adminUri, postgresPassword };
134
+ }
135
+
136
+ async function runCliInit(args, env = {}) {
137
+ const node = process.execPath;
138
+ const cliPath = path.resolve(__dirname, "..", "dist", "bin", "postgres-ai.js");
139
+ const res = spawnSync(node, [cliPath, "init", ...args], {
140
+ encoding: "utf8",
141
+ env: { ...process.env, ...env },
142
+ });
143
+ return res;
144
+ }
145
+
146
+ test(
147
+ "integration: init supports URI / conninfo / psql-like connection styles",
148
+ { skip: !havePostgresBinaries() },
149
+ async (t) => {
150
+ const pg = await withTempPostgres(t);
151
+
152
+ // 1) positional URI
153
+ {
154
+ const r = await runCliInit([pg.adminUri, "--password", "monpw", "--skip-optional-permissions"]);
155
+ assert.equal(r.status, 0, r.stderr || r.stdout);
156
+ }
157
+
158
+ // 2) conninfo
159
+ {
160
+ const conninfo = `dbname=testdb host=127.0.0.1 port=${pg.port} user=postgres password=${pg.postgresPassword}`;
161
+ const r = await runCliInit([conninfo, "--password", "monpw2", "--skip-optional-permissions"]);
162
+ assert.equal(r.status, 0, r.stderr || r.stdout);
163
+ }
164
+
165
+ // 3) psql-like options (+ PGPASSWORD)
166
+ {
167
+ const r = await runCliInit(
168
+ [
169
+ "-h",
170
+ "127.0.0.1",
171
+ "-p",
172
+ String(pg.port),
173
+ "-U",
174
+ "postgres",
175
+ "-d",
176
+ "testdb",
177
+ "--password",
178
+ "monpw3",
179
+ "--skip-optional-permissions",
180
+ ],
181
+ { PGPASSWORD: pg.postgresPassword }
182
+ );
183
+ assert.equal(r.status, 0, r.stderr || r.stdout);
184
+ }
185
+ }
186
+ );
187
+
188
+ test(
189
+ "integration: init fixes slightly-off permissions idempotently",
190
+ { skip: !havePostgresBinaries() },
191
+ async (t) => {
192
+ const pg = await withTempPostgres(t);
193
+ const { Client } = require("pg");
194
+
195
+ // Create monitoring role with wrong password, no grants.
196
+ {
197
+ const c = new Client({ connectionString: pg.adminUri });
198
+ await c.connect();
199
+ await c.query(
200
+ "do $$ begin if not exists (select 1 from pg_roles where rolname='postgres_ai_mon') then create role postgres_ai_mon login password 'wrong'; end if; end $$;"
201
+ );
202
+ await c.end();
203
+ }
204
+
205
+ // Run init (should grant everything).
206
+ {
207
+ const r = await runCliInit([pg.adminUri, "--password", "correctpw", "--skip-optional-permissions"]);
208
+ assert.equal(r.status, 0, r.stderr || r.stdout);
209
+ }
210
+
211
+ // Verify privileges.
212
+ {
213
+ const c = new Client({ connectionString: pg.adminUri });
214
+ await c.connect();
215
+ const dbOk = await c.query(
216
+ "select has_database_privilege('postgres_ai_mon', current_database(), 'CONNECT') as ok"
217
+ );
218
+ assert.equal(dbOk.rows[0].ok, true);
219
+ const roleOk = await c.query("select pg_has_role('postgres_ai_mon', 'pg_monitor', 'member') as ok");
220
+ assert.equal(roleOk.rows[0].ok, true);
221
+ const idxOk = await c.query(
222
+ "select has_table_privilege('postgres_ai_mon', 'pg_catalog.pg_index', 'SELECT') as ok"
223
+ );
224
+ assert.equal(idxOk.rows[0].ok, true);
225
+ const viewOk = await c.query(
226
+ "select has_table_privilege('postgres_ai_mon', 'public.pg_statistic', 'SELECT') as ok"
227
+ );
228
+ assert.equal(viewOk.rows[0].ok, true);
229
+ const sp = await c.query("select rolconfig from pg_roles where rolname='postgres_ai_mon'");
230
+ assert.ok(Array.isArray(sp.rows[0].rolconfig));
231
+ assert.ok(sp.rows[0].rolconfig.some((v) => String(v).includes("search_path=")));
232
+ await c.end();
233
+ }
234
+
235
+ // Run init again (idempotent).
236
+ {
237
+ const r = await runCliInit([pg.adminUri, "--password", "correctpw", "--skip-optional-permissions"]);
238
+ assert.equal(r.status, 0, r.stderr || r.stdout);
239
+ }
240
+ }
241
+ );
242
+
243
+ test("integration: init reports nicely when lacking permissions", { skip: !havePostgresBinaries() }, async (t) => {
244
+ const pg = await withTempPostgres(t);
245
+ const { Client } = require("pg");
246
+
247
+ // Create limited user that can connect but cannot create roles / grant.
248
+ const limitedPw = "limitedpw";
249
+ {
250
+ const c = new Client({ connectionString: pg.adminUri });
251
+ await c.connect();
252
+ await c.query(
253
+ "do $$ begin if not exists (select 1 from pg_roles where rolname='limited') then create role limited login password $1; end if; end $$;",
254
+ [limitedPw]
255
+ );
256
+ await c.query("grant connect on database testdb to limited");
257
+ await c.end();
258
+ }
259
+
260
+ const limitedUri = `postgresql://limited:${limitedPw}@127.0.0.1:${pg.port}/testdb`;
261
+ const r = await runCliInit([limitedUri, "--password", "monpw", "--skip-optional-permissions"]);
262
+ assert.notEqual(r.status, 0);
263
+ assert.match(r.stderr, /init failed:/);
264
+ // Should include step context and hint.
265
+ assert.match(r.stderr, /Failed at step "/);
266
+ assert.match(r.stderr, /Hint: connect as a superuser/i);
267
+ });
268
+
269
+