postgresai 0.14.0-dev.10 → 0.14.0-dev.12

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 CHANGED
@@ -340,6 +340,7 @@ export async function buildInitPlan(params: {
340
340
  const qRole = quoteIdent(monitoringUser);
341
341
  const qDb = quoteIdent(database);
342
342
  const qPw = quoteLiteral(params.monitoringPassword);
343
+ const qRoleNameLit = quoteLiteral(monitoringUser);
343
344
 
344
345
  const steps: InitStep[] = [];
345
346
 
@@ -348,21 +349,26 @@ export async function buildInitPlan(params: {
348
349
  DB_IDENT: qDb,
349
350
  };
350
351
 
351
- // Role creation/update is done in one template file; caller decides statement.
352
+ // Role creation/update is done in one template file.
353
+ // If roleExists is unknown, use a single DO block to create-or-alter safely.
354
+ let roleStmt: string | null = null;
352
355
  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 });
356
+ roleStmt = `create user ${qRole} with password ${qPw};`;
358
357
  } 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 });
358
+ roleStmt = `alter user ${qRole} with password ${qPw};`;
359
+ } else {
360
+ roleStmt = `do $$ begin
361
+ if not exists (select 1 from pg_catalog.pg_roles where rolname = ${qRoleNameLit}) then
362
+ create user ${qRole} with password ${qPw};
363
+ else
364
+ alter user ${qRole} with password ${qPw};
365
+ end if;
366
+ end $$;`;
364
367
  }
365
368
 
369
+ const roleSql = applyTemplate(loadSqlTemplate("01.role.sql"), { ...vars, ROLE_STMT: roleStmt });
370
+ steps.push({ name: "01.role", sql: roleSql });
371
+
366
372
  steps.push({
367
373
  name: "02.permissions",
368
374
  sql: applyTemplate(loadSqlTemplate("02.permissions.sql"), vars),
@@ -414,7 +420,12 @@ export async function applyInitPlan(params: {
414
420
  }
415
421
  await params.client.query("commit;");
416
422
  } catch (e) {
417
- await params.client.query("rollback;");
423
+ // Rollback errors should never mask the original failure.
424
+ try {
425
+ await params.client.query("rollback;");
426
+ } catch {
427
+ // ignore
428
+ }
418
429
  throw e;
419
430
  }
420
431
 
@@ -432,4 +443,123 @@ export async function applyInitPlan(params: {
432
443
  return { applied, skippedOptional };
433
444
  }
434
445
 
446
+ export type VerifyInitResult = {
447
+ ok: boolean;
448
+ missingRequired: string[];
449
+ missingOptional: string[];
450
+ };
451
+
452
+ export async function verifyInitSetup(params: {
453
+ client: PgClient;
454
+ database: string;
455
+ monitoringUser: string;
456
+ includeOptionalPermissions: boolean;
457
+ }): Promise<VerifyInitResult> {
458
+ const missingRequired: string[] = [];
459
+ const missingOptional: string[] = [];
460
+
461
+ const role = params.monitoringUser;
462
+ const db = params.database;
463
+
464
+ const roleRes = await params.client.query("select 1 from pg_catalog.pg_roles where rolname = $1", [role]);
465
+ const roleExists = (roleRes.rowCount ?? 0) > 0;
466
+ if (!roleExists) {
467
+ missingRequired.push(`role "${role}" does not exist`);
468
+ // If role is missing, other checks will error or be meaningless.
469
+ return { ok: false, missingRequired, missingOptional };
470
+ }
471
+
472
+ const connectRes = await params.client.query(
473
+ "select has_database_privilege($1, $2, 'CONNECT') as ok",
474
+ [role, db]
475
+ );
476
+ if (!connectRes.rows?.[0]?.ok) {
477
+ missingRequired.push(`CONNECT on database "${db}"`);
478
+ }
479
+
480
+ const pgMonitorRes = await params.client.query(
481
+ "select pg_has_role($1, 'pg_monitor', 'member') as ok",
482
+ [role]
483
+ );
484
+ if (!pgMonitorRes.rows?.[0]?.ok) {
485
+ missingRequired.push("membership in role pg_monitor");
486
+ }
487
+
488
+ const pgIndexRes = await params.client.query(
489
+ "select has_table_privilege($1, 'pg_catalog.pg_index', 'SELECT') as ok",
490
+ [role]
491
+ );
492
+ if (!pgIndexRes.rows?.[0]?.ok) {
493
+ missingRequired.push("SELECT on pg_catalog.pg_index");
494
+ }
495
+
496
+ const viewExistsRes = await params.client.query("select to_regclass('public.pg_statistic') is not null as ok");
497
+ if (!viewExistsRes.rows?.[0]?.ok) {
498
+ missingRequired.push("view public.pg_statistic exists");
499
+ } else {
500
+ const viewPrivRes = await params.client.query(
501
+ "select has_table_privilege($1, 'public.pg_statistic', 'SELECT') as ok",
502
+ [role]
503
+ );
504
+ if (!viewPrivRes.rows?.[0]?.ok) {
505
+ missingRequired.push("SELECT on view public.pg_statistic");
506
+ }
507
+ }
508
+
509
+ const schemaUsageRes = await params.client.query(
510
+ "select has_schema_privilege($1, 'public', 'USAGE') as ok",
511
+ [role]
512
+ );
513
+ if (!schemaUsageRes.rows?.[0]?.ok) {
514
+ missingRequired.push("USAGE on schema public");
515
+ }
516
+
517
+ const rolcfgRes = await params.client.query("select rolconfig from pg_catalog.pg_roles where rolname = $1", [role]);
518
+ const rolconfig = rolcfgRes.rows?.[0]?.rolconfig;
519
+ const spLine = Array.isArray(rolconfig) ? rolconfig.find((v: any) => String(v).startsWith("search_path=")) : undefined;
520
+ if (typeof spLine !== "string" || !spLine) {
521
+ missingRequired.push("role search_path is set");
522
+ } else {
523
+ // We accept any ordering as long as public and pg_catalog are included.
524
+ const sp = spLine.toLowerCase();
525
+ if (!sp.includes("public") || !sp.includes("pg_catalog")) {
526
+ missingRequired.push("role search_path includes public and pg_catalog");
527
+ }
528
+ }
529
+
530
+ if (params.includeOptionalPermissions) {
531
+ // Optional RDS/Aurora extras
532
+ {
533
+ const extRes = await params.client.query("select 1 from pg_extension where extname = 'rds_tools'");
534
+ if ((extRes.rowCount ?? 0) === 0) {
535
+ missingOptional.push("extension rds_tools");
536
+ } else {
537
+ const fnRes = await params.client.query(
538
+ "select has_function_privilege($1, 'rds_tools.pg_ls_multixactdir()', 'EXECUTE') as ok",
539
+ [role]
540
+ );
541
+ if (!fnRes.rows?.[0]?.ok) {
542
+ missingOptional.push("EXECUTE on rds_tools.pg_ls_multixactdir()");
543
+ }
544
+ }
545
+ }
546
+
547
+ // Optional self-managed extras
548
+ const optionalFns = [
549
+ "pg_catalog.pg_stat_file(text)",
550
+ "pg_catalog.pg_stat_file(text, boolean)",
551
+ "pg_catalog.pg_ls_dir(text)",
552
+ "pg_catalog.pg_ls_dir(text, boolean, boolean)",
553
+ ];
554
+ for (const fn of optionalFns) {
555
+ const fnRes = await params.client.query("select has_function_privilege($1, $2, 'EXECUTE') as ok", [role, fn]);
556
+ if (!fnRes.rows?.[0]?.ok) {
557
+ missingOptional.push(`EXECUTE on ${fn}`);
558
+ }
559
+ }
560
+ }
561
+
562
+ return { ok: missingRequired.length === 0, missingRequired, missingOptional };
563
+ }
564
+
435
565
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.14.0-dev.10",
3
+ "version": "0.14.0-dev.12",
4
4
  "description": "postgres_ai CLI (Node.js)",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
package/sql/01.role.sql CHANGED
@@ -1,4 +1,15 @@
1
1
  -- Role creation / password update (template-filled by cli/lib/init.ts)
2
+ --
3
+ -- Example expansions (for readability/review):
4
+ -- create user "postgres_ai_mon" with password '...';
5
+ -- alter user "postgres_ai_mon" with password '...';
6
+ -- do $$ begin
7
+ -- if not exists (select 1 from pg_catalog.pg_roles where rolname = 'postgres_ai_mon') then
8
+ -- create user "postgres_ai_mon" with password '...';
9
+ -- else
10
+ -- alter user "postgres_ai_mon" with password '...';
11
+ -- end if;
12
+ -- end $$;
2
13
  {{ROLE_STMT}}
3
14
 
4
15
 
@@ -190,6 +190,29 @@ test(
190
190
  }
191
191
  );
192
192
 
193
+ test(
194
+ "integration: init requires explicit monitoring password in non-interactive mode (unless --print-password)",
195
+ { skip: !havePostgresBinaries() },
196
+ async (t) => {
197
+ const pg = await withTempPostgres(t);
198
+
199
+ // spawnSync captures stdout/stderr (non-TTY). We should not print a generated password unless explicitly requested.
200
+ {
201
+ const r = await runCliInit([pg.adminUri, "--skip-optional-permissions"]);
202
+ assert.notEqual(r.status, 0);
203
+ assert.match(r.stderr, /not printed in non-interactive mode/i);
204
+ assert.match(r.stderr, /--print-password/);
205
+ }
206
+
207
+ // With explicit opt-in, it should succeed (and will print the generated password).
208
+ {
209
+ const r = await runCliInit([pg.adminUri, "--print-password", "--skip-optional-permissions"]);
210
+ assert.equal(r.status, 0, r.stderr || r.stdout);
211
+ assert.match(r.stdout, /Generated password for monitoring user/i);
212
+ }
213
+ }
214
+ );
215
+
193
216
  test(
194
217
  "integration: init fixes slightly-off permissions idempotently",
195
218
  { skip: !havePostgresBinaries() },
@@ -276,4 +299,67 @@ test("integration: init reports nicely when lacking permissions", { skip: !haveP
276
299
  assert.match(r.stderr, /Hint: connect as a superuser/i);
277
300
  });
278
301
 
302
+ test("integration: init --verify returns 0 when ok and non-zero when missing", { skip: !havePostgresBinaries() }, async (t) => {
303
+ const pg = await withTempPostgres(t);
304
+ const { Client } = require("pg");
305
+
306
+ // Prepare: run init
307
+ {
308
+ const r = await runCliInit([pg.adminUri, "--password", "monpw", "--skip-optional-permissions"]);
309
+ assert.equal(r.status, 0, r.stderr || r.stdout);
310
+ }
311
+
312
+ // Verify should pass
313
+ {
314
+ const r = await runCliInit([pg.adminUri, "--verify", "--skip-optional-permissions"]);
315
+ assert.equal(r.status, 0, r.stderr || r.stdout);
316
+ assert.match(r.stdout, /init verify: OK/i);
317
+ }
318
+
319
+ // Break a required privilege and ensure verify fails
320
+ {
321
+ const c = new Client({ connectionString: pg.adminUri });
322
+ await c.connect();
323
+ // pg_catalog tables are often readable via PUBLIC by default; revoke from PUBLIC too so the failure is deterministic.
324
+ await c.query("revoke select on pg_catalog.pg_index from public");
325
+ await c.query("revoke select on pg_catalog.pg_index from postgres_ai_mon");
326
+ await c.end();
327
+ }
328
+ {
329
+ const r = await runCliInit([pg.adminUri, "--verify", "--skip-optional-permissions"]);
330
+ assert.notEqual(r.status, 0);
331
+ assert.match(r.stderr, /init verify failed/i);
332
+ assert.match(r.stderr, /pg_catalog\.pg_index/i);
333
+ }
334
+ });
335
+
336
+ test("integration: init --reset-password updates the monitoring role login password", { skip: !havePostgresBinaries() }, async (t) => {
337
+ const pg = await withTempPostgres(t);
338
+ const { Client } = require("pg");
339
+
340
+ // Initial setup with password pw1
341
+ {
342
+ const r = await runCliInit([pg.adminUri, "--password", "pw1", "--skip-optional-permissions"]);
343
+ assert.equal(r.status, 0, r.stderr || r.stdout);
344
+ }
345
+
346
+ // Reset to pw2
347
+ {
348
+ const r = await runCliInit([pg.adminUri, "--reset-password", "--password", "pw2", "--skip-optional-permissions"]);
349
+ assert.equal(r.status, 0, r.stderr || r.stdout);
350
+ assert.match(r.stdout, /password reset/i);
351
+ }
352
+
353
+ // Connect as monitoring user with new password should work
354
+ {
355
+ const c = new Client({
356
+ connectionString: `postgresql://postgres_ai_mon:pw2@127.0.0.1:${pg.port}/testdb`,
357
+ });
358
+ await c.connect();
359
+ const ok = await c.query("select 1 as ok");
360
+ assert.equal(ok.rows[0].ok, 1);
361
+ await c.end();
362
+ }
363
+ });
364
+
279
365
 
@@ -5,6 +5,17 @@ const assert = require("node:assert/strict");
5
5
  // Run via: npm --prefix cli test
6
6
  const init = require("../dist/lib/init.js");
7
7
 
8
+ function runCli(args, env = {}) {
9
+ const { spawnSync } = require("node:child_process");
10
+ const path = require("node:path");
11
+ const node = process.execPath;
12
+ const cliPath = path.resolve(__dirname, "..", "dist", "bin", "postgres-ai.js");
13
+ return spawnSync(node, [cliPath, ...args], {
14
+ encoding: "utf8",
15
+ env: { ...process.env, ...env },
16
+ });
17
+ }
18
+
8
19
  test("maskConnectionString hides password when present", () => {
9
20
  const masked = init.maskConnectionString("postgresql://user:secret@localhost:5432/mydb");
10
21
  assert.match(masked, /postgresql:\/\/user:\*{5}@localhost:5432\/mydb/);
@@ -42,6 +53,18 @@ test("buildInitPlan includes create user when role does not exist", async () =>
42
53
  assert.ok(!plan.steps.some((s) => s.optional));
43
54
  });
44
55
 
56
+ test("buildInitPlan includes role step when roleExists is omitted", async () => {
57
+ const plan = await init.buildInitPlan({
58
+ database: "mydb",
59
+ monitoringUser: "postgres_ai_mon",
60
+ monitoringPassword: "pw",
61
+ includeOptionalPermissions: false,
62
+ });
63
+ const roleStep = plan.steps.find((s) => s.name === "01.role");
64
+ assert.ok(roleStep);
65
+ assert.match(roleStep.sql, /do\s+\$\$/i);
66
+ });
67
+
45
68
  test("buildInitPlan inlines password safely for CREATE/ALTER ROLE grammar", async () => {
46
69
  const plan = await init.buildInitPlan({
47
70
  database: "mydb",
@@ -113,4 +136,11 @@ test("print-sql redaction regex matches password literal with embedded quotes",
113
136
  assert.match(redacted, /password '<redacted>'/i);
114
137
  });
115
138
 
139
+ test("cli: init --print-sql works without connection (offline mode)", () => {
140
+ const r = runCli(["init", "--print-sql", "-d", "mydb", "--password", "monpw"]);
141
+ assert.equal(r.status, 0, r.stderr || r.stdout);
142
+ assert.match(r.stdout, /SQL plan \(offline; not connected\)/);
143
+ assert.match(r.stdout, /grant connect on database "mydb" to "postgres_ai_mon"/i);
144
+ });
145
+
116
146