postgresai 0.17.0-dev.7 → 0.17.0-dev.9

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 CHANGED
@@ -140,10 +140,20 @@ Before any grant runs, one `-- scope:` line lists exactly what the run grants th
140
140
  monitoring role, derived from the steps about to run (so it shrinks under `--skip-optional-permissions`
141
141
  and is not printed on `--reset-password`, which grants nothing). With `--json` it goes to stderr.
142
142
 
143
- `channel_binding=require` in a URI or conninfo string enables SCRAM-SHA-256-PLUS when the server
143
+ For `prepare-db`, `channel_binding=require` in a URI or conninfo string enables SCRAM-SHA-256-PLUS when the server
144
144
  offers it, disables the plaintext retry that `sslmode=prefer` would otherwise do, and is rejected
145
145
  together with `sslmode=disable`. The mechanism actually negotiated is not enforced by the driver.
146
146
 
147
+ The collector cannot do channel binding. `mon targets add`, `mon local-install --db-url`, and
148
+ `pgai connect` remove `channel_binding` from the saved/box URL. With `require`, they refuse
149
+ `sslmode=disable` or `allow`; keep `verify-full` with a warning; and upgrade `require`, `prefer`,
150
+ `verify-ca` or unset to `verify-full` after testing certificate and hostname verification with
151
+ Node's default trust store (or the CA file in `sslrootcert`). Authentication or other errors after
152
+ a verified TLS handshake do not block the upgrade. If verification fails, provide
153
+ `sslrootcert=<CA file>` with `sslmode=verify-full`, or explicitly accept the downgrade by removing
154
+ `channel_binding=require`. Successful upgrades print the collector warning and
155
+ `upgraded to sslmode=verify-full`. `channel_binding=prefer` or `disable` is removed with a note.
156
+
147
157
  ### ClickHouse Cloud host metrics
148
158
 
149
159
  For a ClickHouse Managed Postgres target, `mon local-install --db-url` and `mon targets add` also set up
@@ -215,9 +225,13 @@ terminal, or with `--json`, it only points to `pgai connect`.
215
225
  | `--self-hosted` | run the stack on this machine (`mon local-install`) instead of PostgresAI Cloud |
216
226
  | `--reset-password` | `postgres_ai_mon` exists and its password is lost: set a new one (see below) |
217
227
  | `--wait <minutes>` | how long to wait for the monitoring box; `0` does not wait (default 20) |
218
- | `-y, --yes` | never prompt (`connect` does not start the browser sign-in; `disconnect` does not ask) |
228
+ | `-y, --yes` | never prompt; accept a billed box's price (`connect` does not start the browser sign-in; `disconnect` does not ask) |
229
+ | `--coupon <code>` | promotion code for the organization's monitoring subscription |
219
230
  | `--json` | JSON output, also on a terminal |
220
231
 
232
+ Without `--yes`, a non-interactive run for a billed box returns `action_required` with the price
233
+ and asks you to re-run with `--yes` to accept it.
234
+
221
235
  | Environment | |
222
236
  |---|---|
223
237
  | `PGAI_API_KEY` | the API key, instead of signing in (agents, CI) |
@@ -250,13 +264,19 @@ used: set `PGAI_MON_PASSWORD`, or turn TLS on; if nobody has the password, disco
250
264
  other databases, then `--reset-password` with `PGAI_MON_PASSWORD` set to a new one, and connect
251
265
  them again with it (`connect` names them).
252
266
 
253
- When the role exists (an admin URL with `PGAI_MON_PASSWORD`, or the role's own URL), `connect`
254
- also logs in once as `postgres_ai_mon` with a random password, to learn whether the server checks
255
- passwords for this host at all. On a server that does, this is one
256
- `password authentication failed for user "postgres_ai_mon"` line in the server log per run, and it
257
- counts toward failed-login policies (credcheck, fail2ban). On a server that does not (`trust`),
258
- `next` says that the password was not checked; a password from `PGPASSWORD` is then not used:
259
- put it in the URL.
267
+ When preparing an existing role with a supplied password (an admin URL with `PGAI_MON_PASSWORD`,
268
+ or the role's own URL), `connect` also tries a random password as `postgres_ai_mon` to learn
269
+ whether the server checks passwords for this host. Successful PostgresAI Cloud preparation makes
270
+ two attempts: one in the check before the price, and one in preparation. Stopping after the check
271
+ (for example, without accepting the price) makes one. Successful `--self-hosted` preparation
272
+ makes one. Creating or resetting the role with an admin URL, using the platform's stored password,
273
+ or returning an existing box's status makes no random-password attempts. A rejected supplied
274
+ password stops before the random-password probe; that rejection is itself a failed login.
275
+ On a server that checks passwords, each random-password attempt adds one
276
+ `password authentication failed for user "postgres_ai_mon"` line to the server log and counts
277
+ toward failed-login policies (credcheck, fail2ban). On a server that does not (`trust`), `next`
278
+ says that the password was not checked; a password from `PGPASSWORD` is then not used: put it
279
+ in the URL.
260
280
 
261
281
  A role that is not a superuser creates `postgres_ai_mon` only if it can run the whole
262
282
  preparation: `CREATEROLE`, `CREATE` on the database, `pg_stat_statements` already installed, and
@@ -264,7 +284,8 @@ on PostgreSQL 16+ `ADMIN OPTION` on `pg_monitor` and `pg_read_all_stats`. Otherw
264
284
  printed and nothing is created.
265
285
 
266
286
  The host and the port are the ones in the URL: `host` or `port` in the query string is refused.
267
- Of the query string, the monitoring box gets `sslmode`, `channel_binding` and `application_name`.
287
+ Of the query string, the monitoring box gets `sslmode` and `application_name`; `channel_binding`
288
+ is removed using the collector rule above, before a price is quoted or anything is changed.
268
289
  Certificate files (`sslrootcert`, `sslcert`, `sslkey`) are used for the connections from this
269
290
  machine only; with `sslmode=verify-ca` or `verify-full` and a private CA in `sslrootcert`, `next`
270
291
  says that the box has no copy of that CA.
@@ -506,7 +527,7 @@ Tools exposed:
506
527
  - `update_issue_comment`: update an existing comment (args: `{ comment_id, org_id, content?, attachments?, debug? }`).
507
528
  - `upload_file`: upload a local file and return the storage URL plus a ready-to-paste markdown link (args: `{ path, org_id, debug? }`).
508
529
  - `download_file`: download a file from storage (args: `{ url, org_id, output_path?, debug? }`).
509
- - `connect_database`: `pgai connect` as a tool (args: `{ database_url, org_id, provider?, clickhouse_key?, debug? }`). Returns the same JSON (`status`: `connected`, `provisioning`, `disconnecting`, `action_required` or `failed`; `dashboard_url`; `next`). It does not wait for the monitoring box: call it again to see the status. `database_url` must carry its password, and only the query parameters `sslmode`, `channel_binding`, `application_name` (and `password`); `PGAI_MON_PASSWORD`, `PGPASSWORD` and `CLICKHOUSE_KEY_ID` + `CLICKHOUSE_KEY_SECRET` of the server process are not used, a TLS failure is not retried in plaintext, and `--self-hosted` is CLI-only. For another database on a server PostgresAI already monitors for the organization, an admin `database_url` with `sslmode=require` (or `verify-*`) is enough: PostgresAI fills in the password of `postgres_ai_mon` it keeps.
530
+ - `connect_database`: `pgai connect` as a tool (args: `{ database_url, org_id, provider?, clickhouse_key?, yes?, coupon?, debug? }`). Returns the same JSON (`status`: `connected`, `provisioning`, `disconnecting`, `action_required` or `failed`; `dashboard_url`; `next`). For a billed box, the tool first returns the price with `action_required`; an explicit call with `yes: true` accepts it. `coupon` supplies a promotion code. It does not wait for the monitoring box: call it again to see the status. `database_url` must carry its password, and only the query parameters `sslmode`, `channel_binding`, `application_name` (and `password`); `PGAI_MON_PASSWORD`, `PGPASSWORD` and `CLICKHOUSE_KEY_ID` + `CLICKHOUSE_KEY_SECRET` of the server process are not used, a TLS failure is not retried in plaintext, and `--self-hosted` is CLI-only. For another database on a server PostgresAI already monitors for the organization, an admin `database_url` with `sslmode=require` (or `verify-*`) is enough: PostgresAI fills in the password of `postgres_ai_mon` it keeps.
510
531
 
511
532
  #### `attachments` parameter (issue/comment tools)
512
533
 
@@ -13533,7 +13533,7 @@ var {
13533
13533
  // package.json
13534
13534
  var package_default = {
13535
13535
  name: "postgresai",
13536
- version: "0.17.0-dev.7",
13536
+ version: "0.17.0-dev.9",
13537
13537
  description: "postgres_ai CLI",
13538
13538
  license: "Apache-2.0",
13539
13539
  private: false,
@@ -16893,7 +16893,7 @@ var Result = import_lib.default.Result;
16893
16893
  var TypeOverrides = import_lib.default.TypeOverrides;
16894
16894
  var defaults = import_lib.default.defaults;
16895
16895
  // package.json
16896
- var version = "0.17.0-dev.7";
16896
+ var version = "0.17.0-dev.9";
16897
16897
  var package_default2 = {
16898
16898
  name: "postgresai",
16899
16899
  version,
@@ -18501,6 +18501,13 @@ var Result2 = import_lib2.default.Result;
18501
18501
  var TypeOverrides2 = import_lib2.default.TypeOverrides;
18502
18502
  var defaults2 = import_lib2.default.defaults;
18503
18503
 
18504
+ // node_modules/pg-connection-string/esm/index.mjs
18505
+ var import___ = __toESM(require_pg_connection_string(), 1);
18506
+ var esm_default = import___.default.parse;
18507
+ var parse = import___.default.parse;
18508
+ var toClientConfig = import___.default.toClientConfig;
18509
+ var parseIntoClientConfig = import___.default.parseIntoClientConfig;
18510
+
18504
18511
  // lib/checkup.ts
18505
18512
  import * as fs4 from "fs";
18506
18513
  import * as path4 from "path";
@@ -22577,12 +22584,13 @@ async function generateD004(client, nodeName) {
22577
22584
  let pgssError = null;
22578
22585
  const pgssSampleQueries = [];
22579
22586
  try {
22580
- const extCheck = await client.query("select 1 from pg_extension where extname = 'pg_stat_statements'");
22587
+ const extCheck = await client.query("select n.nspname as schema from pg_catalog.pg_extension e join pg_catalog.pg_namespace n on n.oid = e.extnamespace where e.extname = 'pg_stat_statements'");
22581
22588
  if (extCheck.rows.length > 0) {
22582
22589
  pgssAvailable = true;
22590
+ const pgssView = quoteIdent(extCheck.rows[0].schema, "pg_stat_statements");
22583
22591
  const statsResult = await client.query(`
22584
22592
  select count(*) as cnt, coalesce(sum(calls), 0) as total_calls
22585
- from pg_stat_statements
22593
+ from ${pgssView}
22586
22594
  `);
22587
22595
  pgssMetricsCount = parseInt(statsResult.rows[0]?.cnt || "0", 10);
22588
22596
  pgssTotalCalls = parseInt(statsResult.rows[0]?.total_calls || "0", 10);
@@ -22592,7 +22600,7 @@ async function generateD004(client, nodeName) {
22592
22600
  coalesce(usename, 'unknown') as "user",
22593
22601
  coalesce(datname, 'unknown') as database,
22594
22602
  calls
22595
- from pg_stat_statements s
22603
+ from ${pgssView} s
22596
22604
  left join pg_database d on s.dbid = d.oid
22597
22605
  left join pg_user u on s.userid = u.usesysid
22598
22606
  order by calls desc
@@ -22620,16 +22628,17 @@ async function generateD004(client, nodeName) {
22620
22628
  let kcacheError = null;
22621
22629
  const kcacheSampleQueries = [];
22622
22630
  try {
22623
- const extCheck = await client.query("select 1 from pg_extension where extname = 'pg_stat_kcache'");
22631
+ const extCheck = await client.query("select n.nspname as schema from pg_catalog.pg_extension e join pg_catalog.pg_namespace n on n.oid = e.extnamespace where e.extname = 'pg_stat_kcache'");
22624
22632
  if (extCheck.rows.length > 0) {
22625
22633
  kcacheAvailable = true;
22634
+ const kcacheView = quoteIdent(extCheck.rows[0].schema, "pg_stat_kcache");
22626
22635
  const statsResult = await client.query(`
22627
22636
  select
22628
22637
  count(*) as cnt,
22629
22638
  coalesce(sum(exec_user_time + exec_system_time), 0) as total_exec_time,
22630
22639
  coalesce(sum(exec_user_time), 0) as total_user_time,
22631
22640
  coalesce(sum(exec_system_time), 0) as total_system_time
22632
- from pg_stat_kcache
22641
+ from ${kcacheView}
22633
22642
  `);
22634
22643
  kcacheMetricsCount = parseInt(statsResult.rows[0]?.cnt || "0", 10);
22635
22644
  kcacheTotalExecTime = parseFloat(statsResult.rows[0]?.total_exec_time || "0");
@@ -22640,7 +22649,7 @@ async function generateD004(client, nodeName) {
22640
22649
  queryid::text as queryid,
22641
22650
  coalesce(usename, 'unknown') as "user",
22642
22651
  (exec_user_time + exec_system_time) as exec_total_time
22643
- from pg_stat_kcache k
22652
+ from ${kcacheView} k
22644
22653
  left join pg_user u on k.userid = u.usesysid
22645
22654
  order by (exec_user_time + exec_system_time) desc
22646
22655
  limit 5
@@ -27216,15 +27225,6 @@ import * as path5 from "path";
27216
27225
 
27217
27226
  // lib/instances.ts
27218
27227
  import * as fs5 from "fs";
27219
-
27220
- // node_modules/pg-connection-string/esm/index.mjs
27221
- var import___ = __toESM(require_pg_connection_string(), 1);
27222
- var esm_default = import___.default.parse;
27223
- var parse = import___.default.parse;
27224
- var toClientConfig = import___.default.toClientConfig;
27225
- var parseIntoClientConfig = import___.default.parseIntoClientConfig;
27226
-
27227
- // lib/instances.ts
27228
27228
  class InstancesParseError extends Error {
27229
27229
  constructor(file, cause) {
27230
27230
  const yamlErr = cause;
@@ -27254,7 +27254,68 @@ function loadInstances(file) {
27254
27254
  }
27255
27255
  return parsed;
27256
27256
  }
27257
+ function extractSslmode(connStr) {
27258
+ try {
27259
+ return (new URL(connStr).searchParams.get("sslmode") || "").toLowerCase();
27260
+ } catch {
27261
+ return "";
27262
+ }
27263
+ }
27264
+ function sslOptionFromSslmode(sslmode) {
27265
+ switch (sslmode) {
27266
+ case "disable":
27267
+ return false;
27268
+ case "verify-ca":
27269
+ return { rejectUnauthorized: true, checkServerIdentity: () => {
27270
+ return;
27271
+ } };
27272
+ case "verify-full":
27273
+ return { rejectUnauthorized: true };
27274
+ case "allow":
27275
+ case "prefer":
27276
+ case "require":
27277
+ case "no-verify":
27278
+ case "":
27279
+ default:
27280
+ return { rejectUnauthorized: false };
27281
+ }
27282
+ }
27257
27283
  var LAX_SSLMODES = new Set(["", "allow", "prefer", "require"]);
27284
+ function buildClientConfig(connStr, extra = {}) {
27285
+ const sslmode = extractSslmode(connStr);
27286
+ const channelBinding = splitChannelBinding(connStr);
27287
+ const enableChannelBinding = requireChannelBinding(channelBinding.value, sslmode === "disable");
27288
+ const parsed = parse(withoutSslmode(channelBinding.uri));
27289
+ return {
27290
+ host: parsed.host || undefined,
27291
+ port: parsed.port ? Number(parsed.port) : undefined,
27292
+ user: parsed.user,
27293
+ password: parsed.password,
27294
+ database: databaseName(withoutSslmode(channelBinding.uri), parsed.database),
27295
+ ssl: sslOptionFromSslmode(sslmode),
27296
+ ...enableChannelBinding ? { enableChannelBinding: true } : {},
27297
+ ...extra
27298
+ };
27299
+ }
27300
+ function databaseName(connStr, parsed) {
27301
+ if (!parsed)
27302
+ return;
27303
+ try {
27304
+ const u = new URL(connStr);
27305
+ if (/^postgres(ql)?:$/.test(u.protocol))
27306
+ return decodeURIComponent(u.pathname.slice(1));
27307
+ } catch {}
27308
+ return parsed;
27309
+ }
27310
+ function withoutSslmode(connStr) {
27311
+ try {
27312
+ const u = new URL(connStr);
27313
+ u.searchParams.delete("sslmode");
27314
+ return u.toString();
27315
+ } catch {
27316
+ return connStr;
27317
+ }
27318
+ }
27258
27319
  function splitChannelBinding(uri) {
27259
27320
  let value = null;
27260
27321
  try {
@@ -27268,6 +27329,47 @@ function splitChannelBinding(uri) {
27268
27329
  const params = uri.slice(start + 1, end).split("&").filter((param) => !new URLSearchParams(param).has("channel_binding"));
27269
27330
  return { uri: uri.slice(0, start) + (params.length ? `?${params.join("&")}` : "") + uri.slice(end), value };
27270
27331
  }
27332
+ async function verifyCollectorTls(url) {
27333
+ const u = new URL(url);
27334
+ const ca = u.searchParams.get("sslrootcert");
27335
+ for (const key of ["sslrootcert", "sslcert", "sslkey", "uselibpqcompat"])
27336
+ u.searchParams.delete(key);
27337
+ const client = new Client2({
27338
+ ...buildClientConfig(splitChannelBinding(u.toString()).uri, { connectionTimeoutMillis: 1e4 }),
27339
+ ssl: { rejectUnauthorized: true, servername: u.hostname, ...ca ? { ca: fs5.readFileSync(ca, "utf8") } : {} }
27340
+ });
27341
+ try {
27342
+ await client.connect();
27343
+ } catch (err) {
27344
+ if (!client.connection.stream.authorized)
27345
+ throw err;
27346
+ } finally {
27347
+ await client.end().catch(() => {});
27348
+ }
27349
+ }
27350
+ async function collectorConnection(url, verifyTls = verifyCollectorTls) {
27351
+ const binding = splitChannelBinding(url);
27352
+ const sslmode = extractSslmode(url);
27353
+ if (binding.value === "require") {
27354
+ if (sslmode === "disable" || sslmode === "allow") {
27355
+ throw new Error(`channel_binding=require needs TLS, but sslmode=${sslmode} is set`);
27356
+ }
27357
+ let collectorUrl = binding.uri;
27358
+ if (sslmode !== "verify-full") {
27359
+ const u = new URL(collectorUrl);
27360
+ u.searchParams.set("sslmode", "verify-full");
27361
+ collectorUrl = u.toString();
27362
+ try {
27363
+ await verifyTls(collectorUrl);
27364
+ } catch (err) {
27365
+ const why = err instanceof Error && err.message ? ` (${err.message})` : "";
27366
+ throw new Error(`Could not verify the TLS certificate for channel_binding=require${why}. Provide sslrootcert=<CA file> with sslmode=verify-full, or explicitly accept the downgrade by removing channel_binding=require from the URL`);
27367
+ }
27368
+ }
27369
+ return { url: collectorUrl, note: "the collector can't do channel binding; it connects with TLS and full certificate verification" + (sslmode !== "verify-full" ? "; upgraded to sslmode=verify-full" : "") };
27370
+ }
27371
+ return { url: binding.uri, ...binding.value !== null ? { note: "removed channel_binding from the connection string; the collector does not support it (TLS is kept)" } : {} };
27372
+ }
27271
27373
  function requireChannelBinding(value, sslDisabled) {
27272
27374
  if (value !== "require")
27273
27375
  return false;
@@ -28005,7 +28107,7 @@ function detectCloudProvider(url) {
28005
28107
  return "supabase";
28006
28108
  return "self-managed";
28007
28109
  }
28008
- function databaseName(url) {
28110
+ function databaseName2(url) {
28009
28111
  const u = new URL(url);
28010
28112
  const port = u.port && u.port !== "5432" ? `:${u.port}` : "";
28011
28113
  let db = decodeURIComponent(u.pathname.replace(/^\//, ""));
@@ -28046,15 +28148,33 @@ function clusterOf(url) {
28046
28148
  return u?.hostname ? `${u.hostname.toLowerCase()}:${u.port || "5432"}` : undefined;
28047
28149
  }
28048
28150
  var errorText = (err) => err instanceof AggregateError && !err.message ? err.errors.map(errorText).join("; ") : err instanceof Error ? err.message : String(err);
28049
- var urlPassword = (u) => decodeURIComponent(u.password) || u.searchParams.get("password") || "";
28151
+ var urlPassword = (u) => {
28152
+ for (const key of [...u.searchParams.keys()])
28153
+ if (key !== "password")
28154
+ u.searchParams.delete(key);
28155
+ return parse(u.toString()).password || "";
28156
+ };
28050
28157
  var URL_PARAMS_KEPT = ["sslmode", "channel_binding", "application_name"];
28051
28158
  var URL_PARAMS_TLS = ["sslrootcert", "sslcert", "sslkey", "uselibpqcompat"];
28052
28159
  function checkUrlParams(url, agent) {
28053
- const keys = [...new Set(new URL(url).searchParams.keys())];
28160
+ const u = new URL(url);
28161
+ const keys = [...new Set(u.searchParams.keys())];
28054
28162
  if (agent) {
28163
+ for (const key of ["password", "user", "host", "port", "dbname"]) {
28164
+ if (u.searchParams.getAll(key).length > 1)
28165
+ throw new Error(`database_url query parameter ${key} must appear only once`);
28166
+ }
28167
+ if (u.password && u.searchParams.has("password"))
28168
+ throw new Error("database_url must give the password only once, in the authority or in ?password=");
28055
28169
  const refused = keys.filter((k) => k !== "password" && !URL_PARAMS_KEPT.includes(k));
28056
28170
  if (refused.length)
28057
28171
  throw new Error(`database_url may carry only these query parameters: ${URL_PARAMS_KEPT.join(", ")} (got: ${refused.join(", ")})`);
28172
+ u.searchParams.delete("sslmode");
28173
+ u.searchParams.delete("channel_binding");
28174
+ const config = parseIntoClientConfig(u.toString());
28175
+ if (!/^postgres(ql)?:$/.test(u.protocol) || typeof config.password !== "string" || !config.password)
28176
+ throw new Error("database_url must be postgresql://user:password@host:5432/dbname, with the password in it");
28177
+ return { ...config, password: config.password };
28058
28178
  }
28059
28179
  const moved = keys.filter((k) => k === "host" || k === "port");
28060
28180
  if (moved.length)
@@ -28087,7 +28207,7 @@ function roleUrlFor(url, db, password, kept) {
28087
28207
  u.search = u.search.replace(/@/g, "%40");
28088
28208
  return u.toString();
28089
28209
  }
28090
- var monitoringUrlFor = (url, db, password) => roleUrlFor(url, db, password, URL_PARAMS_KEPT);
28210
+ var monitoringUrlFor = (url, db, password) => roleUrlFor(url, db, password, URL_PARAMS_KEPT.filter((p) => p !== "channel_binding"));
28091
28211
  var loginUrlFor = (url, db, password) => roleUrlFor(url, db, password, [...URL_PARAMS_KEPT, ...URL_PARAMS_TLS]);
28092
28212
  function caNote(url) {
28093
28213
  const q = new URL(url).searchParams;
@@ -28163,7 +28283,19 @@ async function connect(url, opts, deps) {
28163
28283
  if (!PROVIDERS.includes(provider))
28164
28284
  throw new Error(`--provider must be one of: ${PROVIDERS.join(", ")}`);
28165
28285
  checkUrlParams(url, opts.agent);
28166
- const name = databaseName(url);
28286
+ const name = databaseName2(url);
28287
+ try {
28288
+ const collector = await collectorConnection(url, deps.verifyTls);
28289
+ const required = splitChannelBinding(url).value === "require";
28290
+ url = collector.url;
28291
+ if (collector.note)
28292
+ progress("preparing", `${required ? "Warning" : "Note"}: ${collector.note}`);
28293
+ if (required && caNote(url))
28294
+ progress("preparing", `Note: ${caNote(url)}`);
28295
+ } catch (err) {
28296
+ return { status: "action_required", provider, name, next: err instanceof Error ? err.message : String(err) };
28297
+ }
28298
+ const cluster = clusterOf(url);
28167
28299
  const key = parseClickhouseKey(opts.clickhouseKey, provider === "clickhouse" && !opts.agent ? process.env : {});
28168
28300
  if (key && provider !== "clickhouse")
28169
28301
  throw new Error("--clickhouse-key applies to ClickHouse Managed Postgres only");
@@ -28176,8 +28308,12 @@ async function connect(url, opts, deps) {
28176
28308
  if (opts.selfHosted && deps.localStackRunning()) {
28177
28309
  return { status: "action_required", provider, name, next: "A monitoring stack already runs on this machine: add the database with PGAI_DB_URL='<postgres_ai_mon URL>' pgai mon targets add" };
28178
28310
  }
28311
+ const findDatabase = (list) => {
28312
+ const live = list.filter((d) => !disconnecting(d.status));
28313
+ return live.find((d) => d.cluster === cluster && d.database != null && decodeURIComponent(d.database) === name.split("/").slice(1).join("/")) ?? live.find((d) => (d.cluster == null || d.database == null) && d.name === name);
28314
+ };
28179
28315
  const rows = opts.selfHosted ? [] : await deps.list();
28180
- let row = rows.find((d) => d.name === name && !disconnecting(d.status));
28316
+ let row = findDatabase(rows);
28181
28317
  const fresh = !row;
28182
28318
  let note = "";
28183
28319
  let checkup;
@@ -28219,9 +28355,10 @@ async function connect(url, opts, deps) {
28219
28355
  await deps.unprepare(url).catch(() => false);
28220
28356
  };
28221
28357
  let lockId;
28222
- const unread = (d) => /^Monitoring [0-9a-f]{6}$/.test(d.name);
28223
- const othersOnServer = (list) => list.filter((d) => d.name !== name && !disconnecting(d.status) && (unread(d) || serverOf(d.name) === serverOf(name)));
28224
- const storedPassword = rows.some((d) => d.monitoring_password_stored && !/delet/.test(d.status ?? "") && serverOf(d.name) === serverOf(name)) || undefined;
28358
+ const unread = (d) => !d.cluster && !/^[A-Za-z0-9._\[\]:-]{1,253}(?::[0-9]+)?(?:\/[A-Za-z0-9._-]*)?$/.test(d.name);
28359
+ const onServer = (d) => d.cluster ? d.cluster === cluster : !unread(d) && serverOf(d.name) === serverOf(name);
28360
+ const othersOnServer = (list) => list.filter((d) => !disconnecting(d.status) && (unread(d) || onServer(d)));
28361
+ const storedPassword = rows.some((d) => d.monitoring_password_stored && !/delet/.test(d.status ?? "") && onServer(d)) || undefined;
28225
28362
  const cutOff = (others) => {
28226
28363
  const on = others.filter((d) => !unread(d)).map((d) => d.name).join(", ");
28227
28364
  const maybe = others.filter(unread).map((d) => d.name).join(", ");
@@ -28242,8 +28379,13 @@ async function connect(url, opts, deps) {
28242
28379
  const prepOpts = { resetPassword: opts.resetPassword, ...storedPassword ? { storedPassword, others } : {} };
28243
28380
  if (!opts.selfHosted) {
28244
28381
  const probe = await deps.prepare(url, provider, { ...prepOpts, check: true });
28245
- if ("next" in probe)
28246
- return { status: "action_required", provider, name, ...probe };
28382
+ if ("next" in probe) {
28383
+ const { resettable, ...refusal } = probe;
28384
+ if (resettable && !opts.resetPassword && !opts.agent && await deps.confirm("A monitoring role from an earlier connection exists, and its password isn't stored. Reset it now? Anything else using this role will need the new password. [y/N] ")) {
28385
+ return connect(url, { ...opts, resetPassword: true }, deps);
28386
+ }
28387
+ return { status: "action_required", provider, name, ...refusal };
28388
+ }
28247
28389
  const b = await billingFor(await deps.quote(opts.coupon, clusterOf(url)), name, opts, deps, (price) => progress("billing", `Billing: ${price}`));
28248
28390
  if (b.stop)
28249
28391
  return { status: "action_required", provider, name, ...b.stop };
@@ -28252,10 +28394,10 @@ async function connect(url, opts, deps) {
28252
28394
  if (opts.resetPassword) {
28253
28395
  let lock;
28254
28396
  try {
28255
- lock = await deps.resetLock(serverOf(name));
28397
+ lock = await deps.resetLock(cluster);
28256
28398
  } catch (err) {
28257
28399
  if (err instanceof HttpStatusError && err.status === 409) {
28258
- return { status: "action_required", provider, name, ...billing, next: `Another pgai connect --reset-password for ${serverOf(name)} is running: wait for it to finish, then re-run (a run that stopped frees the server 15 minutes after it started)` };
28400
+ return { status: "action_required", provider, name, ...billing, next: `Another pgai connect --reset-password for ${cluster} is running: wait for it to finish, then re-run (a run that stopped frees the server 15 minutes after it started)` };
28259
28401
  }
28260
28402
  if (err instanceof HttpStatusError && err.status === 404) {
28261
28403
  return { status: "action_required", provider, name, ...billing, next: `This platform cannot lock the server for --reset-password yet: set PGAI_MON_PASSWORD to ${DEFAULT_MONITORING_USER}'s password instead` };
@@ -28272,7 +28414,7 @@ async function connect(url, opts, deps) {
28272
28414
  try {
28273
28415
  if (lockId) {
28274
28416
  const now = await deps.list();
28275
- const same = now.find((d) => d.name === name && !disconnecting(d.status));
28417
+ const same = findDatabase(now);
28276
28418
  if (same)
28277
28419
  return { status: "action_required", provider, name, id: same.id, ...billing, next: `${name} is already connected, and its monitoring uses the current password: pgai disconnect ${name} --yes first` };
28278
28420
  const others = othersOnServer(now);
@@ -28281,8 +28423,10 @@ async function connect(url, opts, deps) {
28281
28423
  }
28282
28424
  progress("preparing", `Preparing ${maskConnectionString(url)}`);
28283
28425
  prepared = await deps.prepare(url, provider, prepOpts);
28284
- if ("next" in prepared)
28285
- return { status: "action_required", provider, name, ...billing, ...prepared };
28426
+ if ("next" in prepared) {
28427
+ const { resettable, ...refusal } = prepared;
28428
+ return { status: "action_required", provider, name, ...billing, ...refusal };
28429
+ }
28286
28430
  if (!("monitoringUrl" in prepared))
28287
28431
  throw new Error("prepare returned no monitoring URL");
28288
28432
  if (prepared.note)
@@ -28343,8 +28487,9 @@ async function connect(url, opts, deps) {
28343
28487
  }
28344
28488
  let shown;
28345
28489
  for (const deadline = deps.now() + opts.waitMs;; ) {
28346
- if (undoRole && row.billing_error && disconnecting(row.status)) {
28347
- await undoRole();
28490
+ if (row.billing_error && disconnecting(row.status)) {
28491
+ if (undoRole)
28492
+ await undoRole();
28348
28493
  return {
28349
28494
  status: "action_required",
28350
28495
  provider,
@@ -28352,7 +28497,7 @@ async function connect(url, opts, deps) {
28352
28497
  id: row.id,
28353
28498
  ...billing,
28354
28499
  ...checkup ? { checkup } : {},
28355
- next: `The first charge failed (${row.billing_error}): the box was removed and nothing is billed. Update the payment method at ${billingUrl}, then re-run`
28500
+ next: `The first charge failed (${row.billing_error}): the box is being removed and nothing is billed. Update the payment method${billingUrl ? ` at ${billingUrl}` : ""}, then re-run`
28356
28501
  };
28357
28502
  }
28358
28503
  const result = connectStatus(row, provider, fresh);
@@ -28380,9 +28525,15 @@ async function connect(url, opts, deps) {
28380
28525
  }
28381
28526
  }
28382
28527
  async function openConnection(url, opts) {
28528
+ const config = checkUrlParams(url, opts.agent);
28383
28529
  const conn = resolveAdminConnection({ conn: url });
28530
+ if (config) {
28531
+ const { connectionString, ...explicit } = conn.clientConfig;
28532
+ conn.clientConfig = { ...config, ...explicit };
28533
+ }
28384
28534
  const fallback = !opts.agent && !!conn.sslFallbackEnabled;
28385
28535
  const { client, usedSsl } = await connectWithSslFallback(opts.Client ?? Client2, { ...conn, sslFallbackEnabled: fallback });
28536
+ await client.query("set search_path = pg_catalog, pg_temp");
28386
28537
  return { client, usedSsl, refusedTls: fallback && !usedSsl };
28387
28538
  }
28388
28539
  async function dropMonitoringRole(client) {
@@ -28483,7 +28634,7 @@ async function prepareDatabase(url, provider, opts = {}) {
28483
28634
  const reset = !!opts.resetPassword && me.mon_exists;
28484
28635
  if (me.mon_exists && !reset) {
28485
28636
  if (!process.env.PGAI_MON_PASSWORD?.trim())
28486
- return { next: `${exists}. ${setPassword}` };
28637
+ return { next: `${exists}. ${setPassword}`, resettable: true };
28487
28638
  let accepted;
28488
28639
  try {
28489
28640
  accepted = await logsIn(loginUrlFor(url, me.db, password));
@@ -28596,6 +28747,7 @@ async function clickhouseOrgFor(host, keyId, keySecret) {
28596
28747
  function platformDeps(p) {
28597
28748
  const rpc = (fn, body = {}) => callRpc({ apiKey: p.apiKey, apiBaseUrl: p.apiBaseUrl, fn, body, operation: fn.replace(/_/g, " "), debug: p.debug, orgScope: p.orgScope });
28598
28749
  return {
28750
+ verifyTls: verifyCollectorTls,
28599
28751
  list: () => rpc("cloud_monitoring_list"),
28600
28752
  create: (body) => rpc("cloud_monitoring_connect", body),
28601
28753
  disconnect: (id) => rpc("cloud_monitoring_disconnect", { instance_id: id }),
@@ -35294,7 +35446,7 @@ async function handleToolCall(req, rootOpts, extra) {
35294
35446
  return scope.error;
35295
35447
  const url = String(args.database_url ?? "");
35296
35448
  const u = parseUrl(url);
35297
- if (!u || !/^postgres(ql)?:$/.test(u.protocol) || !(u.password || u.searchParams.get("password"))) {
35449
+ if (!u || !/^postgres(ql)?:$/.test(u.protocol)) {
35298
35450
  throw new Error("database_url must be postgresql://user:password@host:5432/dbname, with the password in it");
35299
35451
  }
35300
35452
  const result = await connect(url, {
@@ -36827,7 +36979,7 @@ function detectCloudProvider2(url) {
36827
36979
  return "supabase";
36828
36980
  return "self-managed";
36829
36981
  }
36830
- function databaseName2(url) {
36982
+ function databaseName3(url) {
36831
36983
  const u = new URL(url);
36832
36984
  const port = u.port && u.port !== "5432" ? `:${u.port}` : "";
36833
36985
  let db = decodeURIComponent(u.pathname.replace(/^\//, ""));
@@ -36868,15 +37020,33 @@ function clusterOf2(url) {
36868
37020
  return u?.hostname ? `${u.hostname.toLowerCase()}:${u.port || "5432"}` : undefined;
36869
37021
  }
36870
37022
  var errorText2 = (err) => err instanceof AggregateError && !err.message ? err.errors.map(errorText2).join("; ") : err instanceof Error ? err.message : String(err);
36871
- var urlPassword2 = (u) => decodeURIComponent(u.password) || u.searchParams.get("password") || "";
37023
+ var urlPassword2 = (u) => {
37024
+ for (const key of [...u.searchParams.keys()])
37025
+ if (key !== "password")
37026
+ u.searchParams.delete(key);
37027
+ return parse(u.toString()).password || "";
37028
+ };
36872
37029
  var URL_PARAMS_KEPT2 = ["sslmode", "channel_binding", "application_name"];
36873
37030
  var URL_PARAMS_TLS2 = ["sslrootcert", "sslcert", "sslkey", "uselibpqcompat"];
36874
37031
  function checkUrlParams2(url, agent) {
36875
- const keys = [...new Set(new URL(url).searchParams.keys())];
37032
+ const u = new URL(url);
37033
+ const keys = [...new Set(u.searchParams.keys())];
36876
37034
  if (agent) {
37035
+ for (const key of ["password", "user", "host", "port", "dbname"]) {
37036
+ if (u.searchParams.getAll(key).length > 1)
37037
+ throw new Error(`database_url query parameter ${key} must appear only once`);
37038
+ }
37039
+ if (u.password && u.searchParams.has("password"))
37040
+ throw new Error("database_url must give the password only once, in the authority or in ?password=");
36877
37041
  const refused = keys.filter((k) => k !== "password" && !URL_PARAMS_KEPT2.includes(k));
36878
37042
  if (refused.length)
36879
37043
  throw new Error(`database_url may carry only these query parameters: ${URL_PARAMS_KEPT2.join(", ")} (got: ${refused.join(", ")})`);
37044
+ u.searchParams.delete("sslmode");
37045
+ u.searchParams.delete("channel_binding");
37046
+ const config = parseIntoClientConfig(u.toString());
37047
+ if (!/^postgres(ql)?:$/.test(u.protocol) || typeof config.password !== "string" || !config.password)
37048
+ throw new Error("database_url must be postgresql://user:password@host:5432/dbname, with the password in it");
37049
+ return { ...config, password: config.password };
36880
37050
  }
36881
37051
  const moved = keys.filter((k) => k === "host" || k === "port");
36882
37052
  if (moved.length)
@@ -36909,7 +37079,7 @@ function roleUrlFor2(url, db, password, kept) {
36909
37079
  u.search = u.search.replace(/@/g, "%40");
36910
37080
  return u.toString();
36911
37081
  }
36912
- var monitoringUrlFor2 = (url, db, password) => roleUrlFor2(url, db, password, URL_PARAMS_KEPT2);
37082
+ var monitoringUrlFor2 = (url, db, password) => roleUrlFor2(url, db, password, URL_PARAMS_KEPT2.filter((p) => p !== "channel_binding"));
36913
37083
  var loginUrlFor2 = (url, db, password) => roleUrlFor2(url, db, password, [...URL_PARAMS_KEPT2, ...URL_PARAMS_TLS2]);
36914
37084
  function caNote2(url) {
36915
37085
  const q = new URL(url).searchParams;
@@ -36994,7 +37164,19 @@ async function connect2(url, opts, deps) {
36994
37164
  if (!PROVIDERS2.includes(provider))
36995
37165
  throw new Error(`--provider must be one of: ${PROVIDERS2.join(", ")}`);
36996
37166
  checkUrlParams2(url, opts.agent);
36997
- const name = databaseName2(url);
37167
+ const name = databaseName3(url);
37168
+ try {
37169
+ const collector = await collectorConnection(url, deps.verifyTls);
37170
+ const required = splitChannelBinding(url).value === "require";
37171
+ url = collector.url;
37172
+ if (collector.note)
37173
+ progress("preparing", `${required ? "Warning" : "Note"}: ${collector.note}`);
37174
+ if (required && caNote2(url))
37175
+ progress("preparing", `Note: ${caNote2(url)}`);
37176
+ } catch (err) {
37177
+ return { status: "action_required", provider, name, next: err instanceof Error ? err.message : String(err) };
37178
+ }
37179
+ const cluster = clusterOf2(url);
36998
37180
  const key = parseClickhouseKey2(opts.clickhouseKey, provider === "clickhouse" && !opts.agent ? process.env : {});
36999
37181
  if (key && provider !== "clickhouse")
37000
37182
  throw new Error("--clickhouse-key applies to ClickHouse Managed Postgres only");
@@ -37007,8 +37189,12 @@ async function connect2(url, opts, deps) {
37007
37189
  if (opts.selfHosted && deps.localStackRunning()) {
37008
37190
  return { status: "action_required", provider, name, next: "A monitoring stack already runs on this machine: add the database with PGAI_DB_URL='<postgres_ai_mon URL>' pgai mon targets add" };
37009
37191
  }
37192
+ const findDatabase = (list) => {
37193
+ const live = list.filter((d) => !disconnecting2(d.status));
37194
+ return live.find((d) => d.cluster === cluster && d.database != null && decodeURIComponent(d.database) === name.split("/").slice(1).join("/")) ?? live.find((d) => (d.cluster == null || d.database == null) && d.name === name);
37195
+ };
37010
37196
  const rows = opts.selfHosted ? [] : await deps.list();
37011
- let row = rows.find((d) => d.name === name && !disconnecting2(d.status));
37197
+ let row = findDatabase(rows);
37012
37198
  const fresh = !row;
37013
37199
  let note = "";
37014
37200
  let checkup;
@@ -37050,9 +37236,10 @@ async function connect2(url, opts, deps) {
37050
37236
  await deps.unprepare(url).catch(() => false);
37051
37237
  };
37052
37238
  let lockId;
37053
- const unread = (d) => /^Monitoring [0-9a-f]{6}$/.test(d.name);
37054
- const othersOnServer = (list) => list.filter((d) => d.name !== name && !disconnecting2(d.status) && (unread(d) || serverOf2(d.name) === serverOf2(name)));
37055
- const storedPassword = rows.some((d) => d.monitoring_password_stored && !/delet/.test(d.status ?? "") && serverOf2(d.name) === serverOf2(name)) || undefined;
37239
+ const unread = (d) => !d.cluster && !/^[A-Za-z0-9._\[\]:-]{1,253}(?::[0-9]+)?(?:\/[A-Za-z0-9._-]*)?$/.test(d.name);
37240
+ const onServer = (d) => d.cluster ? d.cluster === cluster : !unread(d) && serverOf2(d.name) === serverOf2(name);
37241
+ const othersOnServer = (list) => list.filter((d) => !disconnecting2(d.status) && (unread(d) || onServer(d)));
37242
+ const storedPassword = rows.some((d) => d.monitoring_password_stored && !/delet/.test(d.status ?? "") && onServer(d)) || undefined;
37056
37243
  const cutOff = (others) => {
37057
37244
  const on = others.filter((d) => !unread(d)).map((d) => d.name).join(", ");
37058
37245
  const maybe = others.filter(unread).map((d) => d.name).join(", ");
@@ -37073,8 +37260,13 @@ async function connect2(url, opts, deps) {
37073
37260
  const prepOpts = { resetPassword: opts.resetPassword, ...storedPassword ? { storedPassword, others } : {} };
37074
37261
  if (!opts.selfHosted) {
37075
37262
  const probe = await deps.prepare(url, provider, { ...prepOpts, check: true });
37076
- if ("next" in probe)
37077
- return { status: "action_required", provider, name, ...probe };
37263
+ if ("next" in probe) {
37264
+ const { resettable, ...refusal } = probe;
37265
+ if (resettable && !opts.resetPassword && !opts.agent && await deps.confirm("A monitoring role from an earlier connection exists, and its password isn't stored. Reset it now? Anything else using this role will need the new password. [y/N] ")) {
37266
+ return connect2(url, { ...opts, resetPassword: true }, deps);
37267
+ }
37268
+ return { status: "action_required", provider, name, ...refusal };
37269
+ }
37078
37270
  const b = await billingFor2(await deps.quote(opts.coupon, clusterOf2(url)), name, opts, deps, (price) => progress("billing", `Billing: ${price}`));
37079
37271
  if (b.stop)
37080
37272
  return { status: "action_required", provider, name, ...b.stop };
@@ -37083,10 +37275,10 @@ async function connect2(url, opts, deps) {
37083
37275
  if (opts.resetPassword) {
37084
37276
  let lock;
37085
37277
  try {
37086
- lock = await deps.resetLock(serverOf2(name));
37278
+ lock = await deps.resetLock(cluster);
37087
37279
  } catch (err) {
37088
37280
  if (err instanceof HttpStatusError && err.status === 409) {
37089
- return { status: "action_required", provider, name, ...billing, next: `Another pgai connect --reset-password for ${serverOf2(name)} is running: wait for it to finish, then re-run (a run that stopped frees the server 15 minutes after it started)` };
37281
+ return { status: "action_required", provider, name, ...billing, next: `Another pgai connect --reset-password for ${cluster} is running: wait for it to finish, then re-run (a run that stopped frees the server 15 minutes after it started)` };
37090
37282
  }
37091
37283
  if (err instanceof HttpStatusError && err.status === 404) {
37092
37284
  return { status: "action_required", provider, name, ...billing, next: `This platform cannot lock the server for --reset-password yet: set PGAI_MON_PASSWORD to ${DEFAULT_MONITORING_USER}'s password instead` };
@@ -37103,7 +37295,7 @@ async function connect2(url, opts, deps) {
37103
37295
  try {
37104
37296
  if (lockId) {
37105
37297
  const now = await deps.list();
37106
- const same = now.find((d) => d.name === name && !disconnecting2(d.status));
37298
+ const same = findDatabase(now);
37107
37299
  if (same)
37108
37300
  return { status: "action_required", provider, name, id: same.id, ...billing, next: `${name} is already connected, and its monitoring uses the current password: pgai disconnect ${name} --yes first` };
37109
37301
  const others = othersOnServer(now);
@@ -37112,8 +37304,10 @@ async function connect2(url, opts, deps) {
37112
37304
  }
37113
37305
  progress("preparing", `Preparing ${maskConnectionString(url)}`);
37114
37306
  prepared = await deps.prepare(url, provider, prepOpts);
37115
- if ("next" in prepared)
37116
- return { status: "action_required", provider, name, ...billing, ...prepared };
37307
+ if ("next" in prepared) {
37308
+ const { resettable, ...refusal } = prepared;
37309
+ return { status: "action_required", provider, name, ...billing, ...refusal };
37310
+ }
37117
37311
  if (!("monitoringUrl" in prepared))
37118
37312
  throw new Error("prepare returned no monitoring URL");
37119
37313
  if (prepared.note)
@@ -37174,8 +37368,9 @@ async function connect2(url, opts, deps) {
37174
37368
  }
37175
37369
  let shown;
37176
37370
  for (const deadline = deps.now() + opts.waitMs;; ) {
37177
- if (undoRole && row.billing_error && disconnecting2(row.status)) {
37178
- await undoRole();
37371
+ if (row.billing_error && disconnecting2(row.status)) {
37372
+ if (undoRole)
37373
+ await undoRole();
37179
37374
  return {
37180
37375
  status: "action_required",
37181
37376
  provider,
@@ -37183,7 +37378,7 @@ async function connect2(url, opts, deps) {
37183
37378
  id: row.id,
37184
37379
  ...billing,
37185
37380
  ...checkup ? { checkup } : {},
37186
- next: `The first charge failed (${row.billing_error}): the box was removed and nothing is billed. Update the payment method at ${billingUrl}, then re-run`
37381
+ next: `The first charge failed (${row.billing_error}): the box is being removed and nothing is billed. Update the payment method${billingUrl ? ` at ${billingUrl}` : ""}, then re-run`
37187
37382
  };
37188
37383
  }
37189
37384
  const result = connectStatus2(row, provider, fresh);
@@ -37211,9 +37406,15 @@ async function connect2(url, opts, deps) {
37211
37406
  }
37212
37407
  }
37213
37408
  async function openConnection2(url, opts) {
37409
+ const config = checkUrlParams2(url, opts.agent);
37214
37410
  const conn = resolveAdminConnection({ conn: url });
37411
+ if (config) {
37412
+ const { connectionString, ...explicit } = conn.clientConfig;
37413
+ conn.clientConfig = { ...config, ...explicit };
37414
+ }
37215
37415
  const fallback = !opts.agent && !!conn.sslFallbackEnabled;
37216
37416
  const { client, usedSsl } = await connectWithSslFallback(opts.Client ?? Client2, { ...conn, sslFallbackEnabled: fallback });
37417
+ await client.query("set search_path = pg_catalog, pg_temp");
37217
37418
  return { client, usedSsl, refusedTls: fallback && !usedSsl };
37218
37419
  }
37219
37420
  async function dropMonitoringRole2(client) {
@@ -37314,7 +37515,7 @@ async function prepareDatabase2(url, provider, opts = {}) {
37314
37515
  const reset = !!opts.resetPassword && me.mon_exists;
37315
37516
  if (me.mon_exists && !reset) {
37316
37517
  if (!process.env.PGAI_MON_PASSWORD?.trim())
37317
- return { next: `${exists}. ${setPassword}` };
37518
+ return { next: `${exists}. ${setPassword}`, resettable: true };
37318
37519
  let accepted;
37319
37520
  try {
37320
37521
  accepted = await logsIn(loginUrlFor2(url, me.db, password));
@@ -37443,6 +37644,7 @@ function disconnectBilling(reply) {
37443
37644
  function platformDeps2(p) {
37444
37645
  const rpc = (fn, body = {}) => callRpc({ apiKey: p.apiKey, apiBaseUrl: p.apiBaseUrl, fn, body, operation: fn.replace(/_/g, " "), debug: p.debug, orgScope: p.orgScope });
37445
37646
  return {
37647
+ verifyTls: verifyCollectorTls,
37446
37648
  list: () => rpc("cloud_monitoring_list"),
37447
37649
  create: (body) => rpc("cloud_monitoring_connect", body),
37448
37650
  disconnect: (id) => rpc("cloud_monitoring_disconnect", { instance_id: id }),
@@ -41684,12 +41886,13 @@ async function generateD0042(client, nodeName) {
41684
41886
  let pgssError = null;
41685
41887
  const pgssSampleQueries = [];
41686
41888
  try {
41687
- const extCheck = await client.query("select 1 from pg_extension where extname = 'pg_stat_statements'");
41889
+ const extCheck = await client.query("select n.nspname as schema from pg_catalog.pg_extension e join pg_catalog.pg_namespace n on n.oid = e.extnamespace where e.extname = 'pg_stat_statements'");
41688
41890
  if (extCheck.rows.length > 0) {
41689
41891
  pgssAvailable = true;
41892
+ const pgssView = quoteIdent4(extCheck.rows[0].schema, "pg_stat_statements");
41690
41893
  const statsResult = await client.query(`
41691
41894
  select count(*) as cnt, coalesce(sum(calls), 0) as total_calls
41692
- from pg_stat_statements
41895
+ from ${pgssView}
41693
41896
  `);
41694
41897
  pgssMetricsCount = parseInt(statsResult.rows[0]?.cnt || "0", 10);
41695
41898
  pgssTotalCalls = parseInt(statsResult.rows[0]?.total_calls || "0", 10);
@@ -41699,7 +41902,7 @@ async function generateD0042(client, nodeName) {
41699
41902
  coalesce(usename, 'unknown') as "user",
41700
41903
  coalesce(datname, 'unknown') as database,
41701
41904
  calls
41702
- from pg_stat_statements s
41905
+ from ${pgssView} s
41703
41906
  left join pg_database d on s.dbid = d.oid
41704
41907
  left join pg_user u on s.userid = u.usesysid
41705
41908
  order by calls desc
@@ -41727,16 +41930,17 @@ async function generateD0042(client, nodeName) {
41727
41930
  let kcacheError = null;
41728
41931
  const kcacheSampleQueries = [];
41729
41932
  try {
41730
- const extCheck = await client.query("select 1 from pg_extension where extname = 'pg_stat_kcache'");
41933
+ const extCheck = await client.query("select n.nspname as schema from pg_catalog.pg_extension e join pg_catalog.pg_namespace n on n.oid = e.extnamespace where e.extname = 'pg_stat_kcache'");
41731
41934
  if (extCheck.rows.length > 0) {
41732
41935
  kcacheAvailable = true;
41936
+ const kcacheView = quoteIdent4(extCheck.rows[0].schema, "pg_stat_kcache");
41733
41937
  const statsResult = await client.query(`
41734
41938
  select
41735
41939
  count(*) as cnt,
41736
41940
  coalesce(sum(exec_user_time + exec_system_time), 0) as total_exec_time,
41737
41941
  coalesce(sum(exec_user_time), 0) as total_user_time,
41738
41942
  coalesce(sum(exec_system_time), 0) as total_system_time
41739
- from pg_stat_kcache
41943
+ from ${kcacheView}
41740
41944
  `);
41741
41945
  kcacheMetricsCount = parseInt(statsResult.rows[0]?.cnt || "0", 10);
41742
41946
  kcacheTotalExecTime = parseFloat(statsResult.rows[0]?.total_exec_time || "0");
@@ -41747,7 +41951,7 @@ async function generateD0042(client, nodeName) {
41747
41951
  queryid::text as queryid,
41748
41952
  coalesce(usename, 'unknown') as "user",
41749
41953
  (exec_user_time + exec_system_time) as exec_total_time
41750
- from pg_stat_kcache k
41954
+ from ${kcacheView} k
41751
41955
  left join pg_user u on k.userid = u.usesysid
41752
41956
  order by (exec_user_time + exec_system_time) desc
41753
41957
  limit 5
@@ -43855,14 +44059,14 @@ function writeInstancesFile(file, instances) {
43855
44059
  fs10.closeSync(fd);
43856
44060
  }
43857
44061
  }
43858
- function extractSslmode(connStr) {
44062
+ function extractSslmode2(connStr) {
43859
44063
  try {
43860
44064
  return (new URL(connStr).searchParams.get("sslmode") || "").toLowerCase();
43861
44065
  } catch {
43862
44066
  return "";
43863
44067
  }
43864
44068
  }
43865
- function sslOptionFromSslmode(sslmode) {
44069
+ function sslOptionFromSslmode2(sslmode) {
43866
44070
  switch (sslmode) {
43867
44071
  case "disable":
43868
44072
  return false;
@@ -43886,7 +44090,7 @@ function isLaxSslmode(sslmode) {
43886
44090
  return LAX_SSLMODES2.has(sslmode);
43887
44091
  }
43888
44092
  function warnIfLaxSslmode(connStr) {
43889
- const sslmode = extractSslmode(connStr);
44093
+ const sslmode = extractSslmode2(connStr);
43890
44094
  if (!isLaxSslmode(sslmode))
43891
44095
  return;
43892
44096
  const shown = sslmode || "(unset)";
@@ -43908,23 +44112,23 @@ function warnIfTransactionPoolerPort(connStr) {
43908
44112
  return;
43909
44113
  console.error(`⚠ port ${SUPABASE_POOLER_TRANSACTION_PORT2} is the pooler's TRANSACTION mode: ` + `pooled backends share prepared statements, so collection fails with ` + `42P05 (prepared statement already exists) and metrics stop silently. ` + `Use port ${SUPABASE_POOLER_SESSION_PORT2} on the same host for session mode.`);
43910
44114
  }
43911
- function buildClientConfig(connStr, extra = {}) {
43912
- const sslmode = extractSslmode(connStr);
44115
+ function buildClientConfig2(connStr, extra = {}) {
44116
+ const sslmode = extractSslmode2(connStr);
43913
44117
  const channelBinding = splitChannelBinding2(connStr);
43914
44118
  const enableChannelBinding = requireChannelBinding2(channelBinding.value, sslmode === "disable");
43915
- const parsed = parse(withoutSslmode(channelBinding.uri));
44119
+ const parsed = parse(withoutSslmode2(channelBinding.uri));
43916
44120
  return {
43917
44121
  host: parsed.host || undefined,
43918
44122
  port: parsed.port ? Number(parsed.port) : undefined,
43919
44123
  user: parsed.user,
43920
44124
  password: parsed.password,
43921
- database: databaseName3(withoutSslmode(channelBinding.uri), parsed.database),
43922
- ssl: sslOptionFromSslmode(sslmode),
44125
+ database: databaseName4(withoutSslmode2(channelBinding.uri), parsed.database),
44126
+ ssl: sslOptionFromSslmode2(sslmode),
43923
44127
  ...enableChannelBinding ? { enableChannelBinding: true } : {},
43924
44128
  ...extra
43925
44129
  };
43926
44130
  }
43927
- function databaseName3(connStr, parsed) {
44131
+ function databaseName4(connStr, parsed) {
43928
44132
  if (!parsed)
43929
44133
  return;
43930
44134
  try {
@@ -43934,7 +44138,7 @@ function databaseName3(connStr, parsed) {
43934
44138
  } catch {}
43935
44139
  return parsed;
43936
44140
  }
43937
- function withoutSslmode(connStr) {
44141
+ function withoutSslmode2(connStr) {
43938
44142
  try {
43939
44143
  const u = new URL(connStr);
43940
44144
  u.searchParams.delete("sslmode");
@@ -43956,6 +44160,47 @@ function splitChannelBinding2(uri) {
43956
44160
  const params = uri.slice(start + 1, end).split("&").filter((param) => !new URLSearchParams(param).has("channel_binding"));
43957
44161
  return { uri: uri.slice(0, start) + (params.length ? `?${params.join("&")}` : "") + uri.slice(end), value };
43958
44162
  }
44163
+ async function verifyCollectorTls2(url) {
44164
+ const u = new URL(url);
44165
+ const ca = u.searchParams.get("sslrootcert");
44166
+ for (const key of ["sslrootcert", "sslcert", "sslkey", "uselibpqcompat"])
44167
+ u.searchParams.delete(key);
44168
+ const client = new Client2({
44169
+ ...buildClientConfig2(splitChannelBinding2(u.toString()).uri, { connectionTimeoutMillis: 1e4 }),
44170
+ ssl: { rejectUnauthorized: true, servername: u.hostname, ...ca ? { ca: fs10.readFileSync(ca, "utf8") } : {} }
44171
+ });
44172
+ try {
44173
+ await client.connect();
44174
+ } catch (err) {
44175
+ if (!client.connection.stream.authorized)
44176
+ throw err;
44177
+ } finally {
44178
+ await client.end().catch(() => {});
44179
+ }
44180
+ }
44181
+ async function collectorConnection2(url, verifyTls = verifyCollectorTls2) {
44182
+ const binding = splitChannelBinding2(url);
44183
+ const sslmode = extractSslmode2(url);
44184
+ if (binding.value === "require") {
44185
+ if (sslmode === "disable" || sslmode === "allow") {
44186
+ throw new Error(`channel_binding=require needs TLS, but sslmode=${sslmode} is set`);
44187
+ }
44188
+ let collectorUrl = binding.uri;
44189
+ if (sslmode !== "verify-full") {
44190
+ const u = new URL(collectorUrl);
44191
+ u.searchParams.set("sslmode", "verify-full");
44192
+ collectorUrl = u.toString();
44193
+ try {
44194
+ await verifyTls(collectorUrl);
44195
+ } catch (err) {
44196
+ const why = err instanceof Error && err.message ? ` (${err.message})` : "";
44197
+ throw new Error(`Could not verify the TLS certificate for channel_binding=require${why}. Provide sslrootcert=<CA file> with sslmode=verify-full, or explicitly accept the downgrade by removing channel_binding=require from the URL`);
44198
+ }
44199
+ }
44200
+ return { url: collectorUrl, note: "the collector can't do channel binding; it connects with TLS and full certificate verification" + (sslmode !== "verify-full" ? "; upgraded to sslmode=verify-full" : "") };
44201
+ }
44202
+ return { url: binding.uri, ...binding.value !== null ? { note: "removed channel_binding from the connection string; the collector does not support it (TLS is kept)" } : {} };
44203
+ }
43959
44204
  function requireChannelBinding2(value, sslDisabled) {
43960
44205
  if (value !== "require")
43961
44206
  return false;
@@ -46601,7 +46846,7 @@ async function runConnect(url, opts) {
46601
46846
  const json = jsonOutput(opts.json);
46602
46847
  const name = (() => {
46603
46848
  try {
46604
- return databaseName2(url);
46849
+ return databaseName3(url);
46605
46850
  } catch {
46606
46851
  return "";
46607
46852
  }
@@ -46721,7 +46966,7 @@ withOrgOptions(program2.command("disconnect <name>")).description("stop monitori
46721
46966
  const [row] = (await cloudDatabases(opts, name)).filter((d) => !disconnecting2(d.status));
46722
46967
  if (!row)
46723
46968
  throw new Error(`No database named ${name}. See: pgai databases`);
46724
- if (!opts.yes && !(process.stdin.isTTY && /^y/i.test(await question(`Disconnect ${row.name} and delete its monitoring box? (y/N): `)))) {
46969
+ if (!opts.yes && !(interactive(opts.json) && /^y/i.test(await question(`Disconnect ${row.name} and delete its monitoring box? (y/N): `)))) {
46725
46970
  return emitConnect({ status: "action_required", provider: row.provider, name: row.name, id: row.id, next: `pgai disconnect ${row.name} --yes` }, opts.json);
46726
46971
  }
46727
46972
  const billing = disconnectBilling(await cloudApi(opts.debug).disconnect(row.id));
@@ -46889,7 +47134,7 @@ Use demo mode without API key: postgres-ai mon local-install --demo`);
46889
47134
  try {
46890
47135
  warnIfLaxSslmode(connStr);
46891
47136
  warnIfTransactionPoolerPort(connStr);
46892
- testClient = new Client(buildClientConfig(connStr, { connectionTimeoutMillis: 1e4 }));
47137
+ testClient = new Client(buildClientConfig2(connStr, { connectionTimeoutMillis: 1e4 }));
46893
47138
  await testClient.connect();
46894
47139
  const result = await testClient.query("select version();");
46895
47140
  console.log("\u2713 Connection successful");
@@ -46933,7 +47178,7 @@ You can provide either:`);
46933
47178
  try {
46934
47179
  warnIfLaxSslmode(connStr);
46935
47180
  warnIfTransactionPoolerPort(connStr);
46936
- testClient = new Client(buildClientConfig(connStr, { connectionTimeoutMillis: 1e4 }));
47181
+ testClient = new Client(buildClientConfig2(connStr, { connectionTimeoutMillis: 1e4 }));
46937
47182
  await testClient.connect();
46938
47183
  const result = await testClient.query("select version();");
46939
47184
  console.log("\u2713 Connection successful");
@@ -47776,22 +48021,30 @@ async function syncVmalert(projectDir, apply, addOnly = false) {
47776
48021
  }
47777
48022
  var looksLikeConnStr = (value) => /^postgres(ql)?:\/\//i.test(value);
47778
48023
  var isPlainTargetName = (value) => /^[A-Za-z0-9._=-]+$/.test(value) && !/(password|pwd)\s*=/i.test(value);
47779
- async function addTarget(file, projectDir, connStr, name, env, { apply = true } = {}) {
48024
+ async function addTarget(file, projectDir, connStr, name, env, { apply = true, verifyTls } = {}) {
47780
48025
  if (!connStr) {
47781
48026
  console.error("Connection string required: postgresql://user:pass@host:port/db");
47782
48027
  process.exitCode = 1;
47783
48028
  return false;
47784
48029
  }
47785
48030
  const channelBinding = splitChannelBinding2(connStr);
47786
- connStr = channelBinding.uri;
47787
- const defaultName = defaultTargetName(connStr);
48031
+ const defaultName = defaultTargetName(channelBinding.uri);
47788
48032
  if ("error" in defaultName) {
47789
48033
  console.error(defaultName.error);
47790
48034
  process.exitCode = 1;
47791
48035
  return false;
47792
48036
  }
47793
- if (channelBinding.value !== null) {
47794
- console.error("Note: removed channel_binding from the connection string; the collector does not support it (TLS is kept)");
48037
+ try {
48038
+ const collector = await collectorConnection2(connStr, verifyTls);
48039
+ connStr = collector.url;
48040
+ if (collector.note)
48041
+ console.error(`${channelBinding.value === "require" ? "Warning" : "Note"}: ${collector.note}`);
48042
+ if (channelBinding.value === "require" && caNote2(connStr))
48043
+ console.error(`Note: ${caNote2(connStr)}`);
48044
+ } catch (err) {
48045
+ console.error(err instanceof Error ? err.message : String(err));
48046
+ process.exitCode = 1;
48047
+ return false;
47795
48048
  }
47796
48049
  const instanceName = name && name.trim() ? name.trim() : defaultName.name;
47797
48050
  if (detectProvider(connStr) !== "clickhouse" && extractProjectRefFromUrl(connStr) && supabaseHostMetricsOn(projectDir, env) && !SUPABASE_TARGET_NAME_RE.test(instanceName)) {
@@ -47991,7 +48244,7 @@ targets.command("test <name>").description("test monitoring target database conn
47991
48244
  console.log(`Testing connection to monitoring target '${name}'...`);
47992
48245
  warnIfLaxSslmode(instance.conn_str);
47993
48246
  warnIfTransactionPoolerPort(instance.conn_str);
47994
- const client = new Client(buildClientConfig(instance.conn_str, { connectionTimeoutMillis: 1e4 }));
48247
+ const client = new Client(buildClientConfig2(instance.conn_str, { connectionTimeoutMillis: 1e4 }));
47995
48248
  try {
47996
48249
  try {
47997
48250
  await client.connect();
@@ -3,6 +3,6 @@
3
3
  -- Enable pg_stat_statements for query performance monitoring
4
4
  -- Note: Uses IF NOT EXISTS because extension may already be installed.
5
5
  -- We do NOT drop this extension in unprepare-db since it may have been pre-existing.
6
- create extension if not exists pg_stat_statements;
6
+ create extension if not exists pg_stat_statements with schema public;
7
7
 
8
8
 
@@ -1,6 +1,7 @@
1
1
  -- Optional permissions for RDS Postgres / Aurora (best effort)
2
2
 
3
- create extension if not exists rds_tools;
3
+ create schema if not exists rds_tools;
4
+ create extension if not exists rds_tools with schema rds_tools;
4
5
  grant execute on function rds_tools.pg_ls_multixactdir() to {{ROLE_IDENT}};
5
6
 
6
7
 
@@ -3,6 +3,6 @@
3
3
  -- Enable pg_stat_statements for query performance monitoring
4
4
  -- Note: Uses IF NOT EXISTS because extension may already be installed.
5
5
  -- We do NOT drop this extension in unprepare-db since it may have been pre-existing.
6
- create extension if not exists pg_stat_statements;
6
+ create extension if not exists pg_stat_statements with schema public;
7
7
 
8
8
 
@@ -1,6 +1,7 @@
1
1
  -- Optional permissions for RDS Postgres / Aurora (best effort)
2
2
 
3
- create extension if not exists rds_tools;
3
+ create schema if not exists rds_tools;
4
+ create extension if not exists rds_tools with schema rds_tools;
4
5
  grant execute on function rds_tools.pg_ls_multixactdir() to {{ROLE_IDENT}};
5
6
 
6
7
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.17.0-dev.7",
3
+ "version": "0.17.0-dev.9",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -3,6 +3,6 @@
3
3
  -- Enable pg_stat_statements for query performance monitoring
4
4
  -- Note: Uses IF NOT EXISTS because extension may already be installed.
5
5
  -- We do NOT drop this extension in unprepare-db since it may have been pre-existing.
6
- create extension if not exists pg_stat_statements;
6
+ create extension if not exists pg_stat_statements with schema public;
7
7
 
8
8
 
@@ -1,6 +1,7 @@
1
1
  -- Optional permissions for RDS Postgres / Aurora (best effort)
2
2
 
3
- create extension if not exists rds_tools;
3
+ create schema if not exists rds_tools;
4
+ create extension if not exists rds_tools with schema rds_tools;
4
5
  grant execute on function rds_tools.pg_ls_multixactdir() to {{ROLE_IDENT}};
5
6
 
6
7