postgresai 0.14.0-dev.43 → 0.14.0-dev.45

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.
Files changed (58) hide show
  1. package/bin/postgres-ai.ts +649 -310
  2. package/bun.lock +258 -0
  3. package/dist/bin/postgres-ai.js +29491 -1910
  4. package/dist/sql/01.role.sql +16 -0
  5. package/dist/sql/02.permissions.sql +37 -0
  6. package/dist/sql/03.optional_rds.sql +6 -0
  7. package/dist/sql/04.optional_self_managed.sql +8 -0
  8. package/dist/sql/05.helpers.sql +415 -0
  9. package/lib/auth-server.ts +58 -97
  10. package/lib/checkup-api.ts +175 -0
  11. package/lib/checkup.ts +837 -0
  12. package/lib/config.ts +3 -0
  13. package/lib/init.ts +106 -74
  14. package/lib/issues.ts +121 -194
  15. package/lib/mcp-server.ts +6 -17
  16. package/lib/metrics-loader.ts +156 -0
  17. package/package.json +13 -9
  18. package/sql/02.permissions.sql +9 -5
  19. package/sql/05.helpers.sql +415 -0
  20. package/test/checkup.test.ts +953 -0
  21. package/test/init.integration.test.ts +396 -0
  22. package/test/init.test.ts +345 -0
  23. package/test/schema-validation.test.ts +188 -0
  24. package/tsconfig.json +12 -20
  25. package/dist/bin/postgres-ai.d.ts +0 -3
  26. package/dist/bin/postgres-ai.d.ts.map +0 -1
  27. package/dist/bin/postgres-ai.js.map +0 -1
  28. package/dist/lib/auth-server.d.ts +0 -31
  29. package/dist/lib/auth-server.d.ts.map +0 -1
  30. package/dist/lib/auth-server.js +0 -263
  31. package/dist/lib/auth-server.js.map +0 -1
  32. package/dist/lib/config.d.ts +0 -45
  33. package/dist/lib/config.d.ts.map +0 -1
  34. package/dist/lib/config.js +0 -181
  35. package/dist/lib/config.js.map +0 -1
  36. package/dist/lib/init.d.ts +0 -85
  37. package/dist/lib/init.d.ts.map +0 -1
  38. package/dist/lib/init.js +0 -644
  39. package/dist/lib/init.js.map +0 -1
  40. package/dist/lib/issues.d.ts +0 -75
  41. package/dist/lib/issues.d.ts.map +0 -1
  42. package/dist/lib/issues.js +0 -336
  43. package/dist/lib/issues.js.map +0 -1
  44. package/dist/lib/mcp-server.d.ts +0 -9
  45. package/dist/lib/mcp-server.d.ts.map +0 -1
  46. package/dist/lib/mcp-server.js +0 -168
  47. package/dist/lib/mcp-server.js.map +0 -1
  48. package/dist/lib/pkce.d.ts +0 -32
  49. package/dist/lib/pkce.d.ts.map +0 -1
  50. package/dist/lib/pkce.js +0 -101
  51. package/dist/lib/pkce.js.map +0 -1
  52. package/dist/lib/util.d.ts +0 -27
  53. package/dist/lib/util.d.ts.map +0 -1
  54. package/dist/lib/util.js +0 -46
  55. package/dist/lib/util.js.map +0 -1
  56. package/dist/package.json +0 -46
  57. package/test/init.integration.test.cjs +0 -382
  58. package/test/init.test.cjs +0 -392
@@ -1,392 +0,0 @@
1
- const test = require("node:test");
2
- const assert = require("node:assert/strict");
3
-
4
- // These tests intentionally import the compiled JS output.
5
- // Run via: npm --prefix cli test
6
- const init = require("../dist/lib/init.js");
7
- const DEFAULT_MONITORING_USER = init.DEFAULT_MONITORING_USER;
8
-
9
- function runCli(args, env = {}) {
10
- const { spawnSync } = require("node:child_process");
11
- const path = require("node:path");
12
- const node = process.execPath;
13
- const cliPath = path.resolve(__dirname, "..", "dist", "bin", "postgres-ai.js");
14
- return spawnSync(node, [cliPath, ...args], {
15
- encoding: "utf8",
16
- env: { ...process.env, ...env },
17
- });
18
- }
19
-
20
- function runPgai(args, env = {}) {
21
- const { spawnSync } = require("node:child_process");
22
- const path = require("node:path");
23
- const node = process.execPath;
24
- const pgaiPath = path.resolve(__dirname, "..", "..", "pgai", "bin", "pgai.js");
25
- return spawnSync(node, [pgaiPath, ...args], {
26
- encoding: "utf8",
27
- env: { ...process.env, ...env },
28
- });
29
- }
30
-
31
- test("maskConnectionString hides password when present", () => {
32
- const masked = init.maskConnectionString("postgresql://user:secret@localhost:5432/mydb");
33
- assert.match(masked, /postgresql:\/\/user:\*{5}@localhost:5432\/mydb/);
34
- assert.doesNotMatch(masked, /secret/);
35
- });
36
-
37
- test("parseLibpqConninfo parses basic host/dbname/user/port/password", () => {
38
- const cfg = init.parseLibpqConninfo("dbname=mydb host=localhost user=alice port=5432 password=secret");
39
- assert.equal(cfg.database, "mydb");
40
- assert.equal(cfg.host, "localhost");
41
- assert.equal(cfg.user, "alice");
42
- assert.equal(cfg.port, 5432);
43
- assert.equal(cfg.password, "secret");
44
- });
45
-
46
- test("parseLibpqConninfo supports quoted values", () => {
47
- const cfg = init.parseLibpqConninfo("dbname='my db' host='local host'");
48
- assert.equal(cfg.database, "my db");
49
- assert.equal(cfg.host, "local host");
50
- });
51
-
52
- test("buildInitPlan includes a race-safe role DO block", async () => {
53
- const plan = await init.buildInitPlan({
54
- database: "mydb",
55
- monitoringUser: DEFAULT_MONITORING_USER,
56
- monitoringPassword: "pw",
57
- includeOptionalPermissions: false,
58
- });
59
-
60
- assert.equal(plan.database, "mydb");
61
- const roleStep = plan.steps.find((s) => s.name === "01.role");
62
- assert.ok(roleStep);
63
- assert.match(roleStep.sql, /do\s+\$\$/i);
64
- assert.match(roleStep.sql, /create\s+user/i);
65
- assert.match(roleStep.sql, /alter\s+user/i);
66
- assert.ok(!plan.steps.some((s) => s.optional));
67
- });
68
-
69
- test("buildInitPlan handles special characters in monitoring user and database identifiers", async () => {
70
- const monitoringUser = 'user "with" quotes ✓';
71
- const database = 'db name "with" quotes ✓';
72
- const plan = await init.buildInitPlan({
73
- database,
74
- monitoringUser,
75
- monitoringPassword: "pw",
76
- includeOptionalPermissions: false,
77
- });
78
-
79
- const roleStep = plan.steps.find((s) => s.name === "01.role");
80
- assert.ok(roleStep);
81
- // Double quotes inside identifiers must be doubled.
82
- assert.match(roleStep.sql, /create\s+user\s+"user ""with"" quotes ✓"/i);
83
- assert.match(roleStep.sql, /alter\s+user\s+"user ""with"" quotes ✓"/i);
84
-
85
- const permStep = plan.steps.find((s) => s.name === "02.permissions");
86
- assert.ok(permStep);
87
- assert.match(permStep.sql, /grant connect on database "db name ""with"" quotes ✓" to "user ""with"" quotes ✓"/i);
88
- });
89
-
90
- test("buildInitPlan keeps backslashes in passwords (no unintended escaping)", async () => {
91
- const pw = String.raw`pw\with\backslash`;
92
- const plan = await init.buildInitPlan({
93
- database: "mydb",
94
- monitoringUser: DEFAULT_MONITORING_USER,
95
- monitoringPassword: pw,
96
- includeOptionalPermissions: false,
97
- });
98
- const roleStep = plan.steps.find((s) => s.name === "01.role");
99
- assert.ok(roleStep);
100
- assert.ok(roleStep.sql.includes(`password '${pw}'`));
101
- });
102
-
103
- test("buildInitPlan rejects identifiers with null bytes", async () => {
104
- await assert.rejects(
105
- () =>
106
- init.buildInitPlan({
107
- database: "mydb",
108
- monitoringUser: "bad\0user",
109
- monitoringPassword: "pw",
110
- includeOptionalPermissions: false,
111
- }),
112
- /Identifier cannot contain null bytes/
113
- );
114
- });
115
-
116
- test("buildInitPlan rejects literals with null bytes", async () => {
117
- await assert.rejects(
118
- () =>
119
- init.buildInitPlan({
120
- database: "mydb",
121
- monitoringUser: DEFAULT_MONITORING_USER,
122
- monitoringPassword: "pw\0bad",
123
- includeOptionalPermissions: false,
124
- }),
125
- /Literal cannot contain null bytes/
126
- );
127
- });
128
-
129
- test("buildInitPlan inlines password safely for CREATE/ALTER ROLE grammar", async () => {
130
- const plan = await init.buildInitPlan({
131
- database: "mydb",
132
- monitoringUser: DEFAULT_MONITORING_USER,
133
- monitoringPassword: "pa'ss",
134
- includeOptionalPermissions: false,
135
- });
136
- const step = plan.steps.find((s) => s.name === "01.role");
137
- assert.ok(step);
138
- assert.match(step.sql, /password 'pa''ss'/);
139
- assert.equal(step.params, undefined);
140
- });
141
-
142
- test("buildInitPlan includes optional steps when enabled", async () => {
143
- const plan = await init.buildInitPlan({
144
- database: "mydb",
145
- monitoringUser: DEFAULT_MONITORING_USER,
146
- monitoringPassword: "pw",
147
- includeOptionalPermissions: true,
148
- });
149
- assert.ok(plan.steps.some((s) => s.optional));
150
- });
151
-
152
- test("resolveAdminConnection accepts positional URI", () => {
153
- const r = init.resolveAdminConnection({ conn: "postgresql://u:p@h:5432/d" });
154
- assert.ok(r.clientConfig.connectionString);
155
- assert.doesNotMatch(r.display, /:p@/);
156
- });
157
-
158
- test("resolveAdminConnection accepts positional conninfo", () => {
159
- const r = init.resolveAdminConnection({ conn: "dbname=mydb host=localhost user=alice" });
160
- assert.equal(r.clientConfig.database, "mydb");
161
- assert.equal(r.clientConfig.host, "localhost");
162
- assert.equal(r.clientConfig.user, "alice");
163
- });
164
-
165
- test("resolveAdminConnection rejects invalid psql-like port", () => {
166
- assert.throws(
167
- () => init.resolveAdminConnection({ host: "localhost", port: "abc", username: "u", dbname: "d" }),
168
- /Invalid port value/
169
- );
170
- });
171
-
172
- test("resolveAdminConnection rejects when only PGPASSWORD is provided (no connection details)", () => {
173
- assert.throws(() => init.resolveAdminConnection({ envPassword: "pw" }), /Connection is required/);
174
- });
175
-
176
- test("resolveAdminConnection rejects when connection is missing", () => {
177
- assert.throws(() => init.resolveAdminConnection({}), /Connection is required/);
178
- });
179
-
180
- test("cli: prepare-db with missing connection prints help/options", () => {
181
- const r = runCli(["prepare-db"]);
182
- assert.notEqual(r.status, 0);
183
- // We should show options, not just the error message.
184
- assert.match(r.stderr, /--print-sql/);
185
- assert.match(r.stderr, /--monitoring-user/);
186
- });
187
-
188
- test("resolveMonitoringPassword auto-generates a strong, URL-safe password by default", async () => {
189
- const r = await init.resolveMonitoringPassword({ monitoringUser: DEFAULT_MONITORING_USER });
190
- assert.equal(r.generated, true);
191
- assert.ok(typeof r.password === "string" && r.password.length >= 30);
192
- assert.match(r.password, /^[A-Za-z0-9_-]+$/);
193
- });
194
-
195
- test("applyInitPlan preserves Postgres error fields on step failures", async () => {
196
- const plan = {
197
- monitoringUser: DEFAULT_MONITORING_USER,
198
- database: "mydb",
199
- steps: [{ name: "01.role", sql: "select 1" }],
200
- };
201
-
202
- const pgErr = Object.assign(new Error("permission denied to create role"), {
203
- code: "42501",
204
- detail: "some detail",
205
- hint: "some hint",
206
- schema: "pg_catalog",
207
- table: "pg_roles",
208
- constraint: "some_constraint",
209
- routine: "aclcheck_error",
210
- });
211
-
212
- const calls = [];
213
- const client = {
214
- query: async (sql) => {
215
- calls.push(sql);
216
- if (sql === "begin;") return { rowCount: 1 };
217
- if (sql === "rollback;") return { rowCount: 1 };
218
- if (sql === "select 1") throw pgErr;
219
- throw new Error(`unexpected sql: ${sql}`);
220
- },
221
- };
222
-
223
- await assert.rejects(
224
- () => init.applyInitPlan({ client, plan }),
225
- (e) => {
226
- assert.ok(e instanceof Error);
227
- assert.match(e.message, /Failed at step "01\.role":/);
228
- assert.equal(e.code, "42501");
229
- assert.equal(e.detail, "some detail");
230
- assert.equal(e.hint, "some hint");
231
- assert.equal(e.schema, "pg_catalog");
232
- assert.equal(e.table, "pg_roles");
233
- assert.equal(e.constraint, "some_constraint");
234
- assert.equal(e.routine, "aclcheck_error");
235
- return true;
236
- }
237
- );
238
-
239
- assert.deepEqual(calls, ["begin;", "select 1", "rollback;"]);
240
- });
241
-
242
- test("verifyInitSetup runs inside a repeatable read snapshot and rolls back", async () => {
243
- const calls = [];
244
- const client = {
245
- query: async (sql, params) => {
246
- calls.push(String(sql));
247
-
248
- if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
249
- return { rowCount: 1, rows: [] };
250
- }
251
- if (String(sql).toLowerCase() === "rollback;") {
252
- return { rowCount: 1, rows: [] };
253
- }
254
- if (String(sql).includes("select rolconfig")) {
255
- return { rowCount: 1, rows: [{ rolconfig: ['search_path="$user", public, pg_catalog'] }] };
256
- }
257
- if (String(sql).includes("from pg_catalog.pg_roles")) {
258
- return { rowCount: 1, rows: [] };
259
- }
260
- if (String(sql).includes("has_database_privilege")) {
261
- return { rowCount: 1, rows: [{ ok: true }] };
262
- }
263
- if (String(sql).includes("pg_has_role")) {
264
- return { rowCount: 1, rows: [{ ok: true }] };
265
- }
266
- if (String(sql).includes("has_table_privilege") && String(sql).includes("pg_catalog.pg_index")) {
267
- return { rowCount: 1, rows: [{ ok: true }] };
268
- }
269
- if (String(sql).includes("to_regclass('public.pg_statistic')")) {
270
- return { rowCount: 1, rows: [{ ok: true }] };
271
- }
272
- if (String(sql).includes("has_table_privilege") && String(sql).includes("public.pg_statistic")) {
273
- return { rowCount: 1, rows: [{ ok: true }] };
274
- }
275
- if (String(sql).includes("has_schema_privilege")) {
276
- return { rowCount: 1, rows: [{ ok: true }] };
277
- }
278
-
279
- throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
280
- },
281
- };
282
-
283
- const r = await init.verifyInitSetup({
284
- client,
285
- database: "mydb",
286
- monitoringUser: DEFAULT_MONITORING_USER,
287
- includeOptionalPermissions: false,
288
- });
289
- assert.equal(r.ok, true);
290
- assert.equal(r.missingRequired.length, 0);
291
-
292
- assert.ok(calls.length > 2);
293
- assert.match(calls[0].toLowerCase(), /^begin isolation level repeatable read/);
294
- assert.equal(calls[calls.length - 1].toLowerCase(), "rollback;");
295
- });
296
-
297
- test("redactPasswordsInSql redacts password literals with embedded quotes", async () => {
298
- const plan = await init.buildInitPlan({
299
- database: "mydb",
300
- monitoringUser: DEFAULT_MONITORING_USER,
301
- monitoringPassword: "pa'ss",
302
- includeOptionalPermissions: false,
303
- });
304
- const step = plan.steps.find((s) => s.name === "01.role");
305
- assert.ok(step);
306
- const redacted = init.redactPasswordsInSql(step.sql);
307
- assert.match(redacted, /password '<redacted>'/i);
308
- });
309
-
310
- test("cli: prepare-db --print-sql works without connection (offline mode)", () => {
311
- const r = runCli(["prepare-db", "--print-sql", "-d", "mydb", "--password", "monpw"]);
312
- assert.equal(r.status, 0, r.stderr || r.stdout);
313
- assert.match(r.stdout, /SQL plan \(offline; not connected\)/);
314
- assert.match(r.stdout, new RegExp(`grant connect on database "mydb" to "${DEFAULT_MONITORING_USER}"`, "i"));
315
- });
316
-
317
- test("pgai wrapper forwards to postgresai CLI", () => {
318
- const r = runPgai(["--help"]);
319
- assert.equal(r.status, 0, r.stderr || r.stdout);
320
- assert.match(r.stdout, /postgresai|PostgresAI/i);
321
- });
322
-
323
- test("cli: prepare-db command exists and shows help", () => {
324
- const r = runCli(["prepare-db", "--help"]);
325
- assert.equal(r.status, 0, r.stderr || r.stdout);
326
- assert.match(r.stdout, /monitoring user/i);
327
- assert.match(r.stdout, /--print-sql/);
328
- });
329
-
330
- test("cli: prepare-db with missing connection prints help/options", () => {
331
- const r = runCli(["prepare-db"]);
332
- assert.notEqual(r.status, 0);
333
- assert.match(r.stderr, /--print-sql/);
334
- assert.match(r.stderr, /--monitoring-user/);
335
- });
336
-
337
- test("cli: prepare-db --print-sql works without connection (offline mode)", () => {
338
- const r = runCli(["prepare-db", "--print-sql", "-d", "mydb", "--password", "monpw"]);
339
- assert.equal(r.status, 0, r.stderr || r.stdout);
340
- assert.match(r.stdout, /SQL plan \(offline; not connected\)/);
341
- assert.match(r.stdout, new RegExp(`grant connect on database "mydb" to "${DEFAULT_MONITORING_USER}"`, "i"));
342
- });
343
-
344
- test("cli: mon local-install command exists and shows help", () => {
345
- const r = runCli(["mon", "local-install", "--help"]);
346
- assert.equal(r.status, 0, r.stderr || r.stdout);
347
- assert.match(r.stdout, /--demo/);
348
- assert.match(r.stdout, /--api-key/);
349
- });
350
-
351
- // Auth --set-key tests
352
- test("cli: auth --set-key stores key without OAuth", () => {
353
- const fs = require("node:fs");
354
- const path = require("node:path");
355
- const os = require("node:os");
356
-
357
- // Use a temp directory for config to avoid modifying user's actual config
358
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pgai-auth-test-"));
359
-
360
- try {
361
- // Create the postgresai subdirectory so we know exactly where config goes
362
- const postgresaiDir = path.join(tmpDir, "postgresai");
363
- fs.mkdirSync(postgresaiDir, { recursive: true });
364
-
365
- // Set XDG_CONFIG_HOME to redirect config to temp dir
366
- const r = runCli(["auth", "--set-key", "test-api-key-12345"], {
367
- XDG_CONFIG_HOME: tmpDir,
368
- // Also clear HOME to prevent fallbacks
369
- HOME: tmpDir,
370
- });
371
-
372
- assert.equal(r.status, 0, r.stderr || r.stdout);
373
- assert.match(r.stdout, /API key saved/i);
374
-
375
- // Verify the config file was created with the API key
376
- const actualConfigPath = path.join(postgresaiDir, "config.json");
377
- assert.ok(fs.existsSync(actualConfigPath), "Config file should exist at " + actualConfigPath);
378
-
379
- const config = JSON.parse(fs.readFileSync(actualConfigPath, "utf8"));
380
- assert.equal(config.apiKey, "test-api-key-12345");
381
- } finally {
382
- // Cleanup
383
- fs.rmSync(tmpDir, { recursive: true, force: true });
384
- }
385
- });
386
-
387
- test("cli: auth --help shows --set-key option", () => {
388
- const r = runCli(["auth", "--help"]);
389
- assert.equal(r.status, 0, r.stderr || r.stdout);
390
- assert.match(r.stdout, /--set-key/);
391
- });
392
-