postgresai 0.17.0-dev.5 → 0.17.0-dev.6

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.
@@ -13533,7 +13533,7 @@ var {
13533
13533
  // package.json
13534
13534
  var package_default = {
13535
13535
  name: "postgresai",
13536
- version: "0.17.0-dev.5",
13536
+ version: "0.17.0-dev.6",
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.5";
16896
+ var version = "0.17.0-dev.6";
16897
16897
  var package_default2 = {
16898
16898
  name: "postgresai",
16899
16899
  version,
@@ -21642,7 +21642,7 @@ function summarizeG003(nodeData) {
21642
21642
 
21643
21643
  // lib/checkup.ts
21644
21644
  var __dirname = "/builds/postgres-ai/postgresai/cli/lib";
21645
- var CONTRACT_VERSION = "1.1.0";
21645
+ var CONTRACT_VERSION = "1.0.0";
21646
21646
  var SECONDS_PER_DAY = 86400;
21647
21647
  var SECONDS_PER_HOUR = 3600;
21648
21648
  var SECONDS_PER_MINUTE = 60;
@@ -23975,6 +23975,38 @@ function withCheckSummary(report) {
23975
23975
  return report;
23976
23976
  }
23977
23977
 
23978
+ // lib/checkup-upload.ts
23979
+ function responseId(fn, response, ...keys) {
23980
+ const answer = response ?? {};
23981
+ const id = Number(keys.map((key) => answer[key]).find((value) => value != null));
23982
+ if (!Number.isFinite(id) || id <= 0) {
23983
+ throw new Error(typeof answer.message === "string" ? answer.message : `Unexpected ${fn} response: ${JSON.stringify(response)}`);
23984
+ }
23985
+ return id;
23986
+ }
23987
+ async function createCheckupReport(rpc, params) {
23988
+ const fn = "checkup_report_create";
23989
+ const response = await rpc(fn, {
23990
+ access_token: params.apiKey,
23991
+ project: params.project,
23992
+ ...params.status ? { status: params.status } : {}
23993
+ });
23994
+ return { reportId: responseId(fn, response, "report_id") };
23995
+ }
23996
+ async function uploadCheckupReportJson(rpc, params) {
23997
+ const fn = "checkup_report_file_post";
23998
+ const response = await rpc(fn, {
23999
+ access_token: params.apiKey,
24000
+ checkup_report_id: params.reportId,
24001
+ filename: params.filename,
24002
+ check_id: params.checkId,
24003
+ data: params.jsonText,
24004
+ type: "json",
24005
+ generate_issue: true
24006
+ });
24007
+ return { reportChunkId: responseId(fn, response, "report_chunck_id", "report_chunk_id") };
24008
+ }
24009
+
23978
24010
  // lib/host-metrics.ts
23979
24011
  import { createHash } from "node:crypto";
23980
24012
  import { chmodSync as chmodSync3, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
@@ -27936,10 +27968,12 @@ function isNumericProjectRef(ref) {
27936
27968
  // lib/connect.ts
27937
27969
  var money = (cents, currency) => currency.toLowerCase() === "usd" ? `$${(cents / 100).toFixed(2)}` : `${(cents / 100).toFixed(2)} ${currency.toUpperCase()}`;
27938
27970
  function priceText(q) {
27971
+ if (!q.billed && q.same_cluster)
27972
+ return `included: same database cluster as ${q.same_cluster}, no extra charge`;
27939
27973
  if (!q.billed)
27940
27974
  return q.free_slots.total ? `free (${q.free_slots.remaining} of ${q.free_slots.total} free slots)` : "free";
27941
27975
  const { amount, currency, interval } = q.price;
27942
- const base = `${money(amount, currency)}/${interval} per box (${q.plan} plan)${q.subscription ? `, box ${q.quantity + 1} on the subscription` : ""}`;
27976
+ const base = `${money(amount, currency)}/${interval} per database cluster (${q.plan} plan)${q.subscription ? `, cluster ${q.quantity + 1} on the subscription` : ""}`;
27943
27977
  if (q.promo?.valid && q.amount_after_promo !== undefined && q.amount_after_promo !== null) {
27944
27978
  const after = `${money(q.amount_after_promo, currency)}`;
27945
27979
  const how = `with ${q.promo.code} (${q.promo.discount_description})`;
@@ -28007,7 +28041,11 @@ function stateOf(raw) {
28007
28041
  class ClickhouseKeyError extends Error {
28008
28042
  }
28009
28043
  var serverOf = (name) => name.split("/")[0].toLowerCase().replace(/\.(?=$|:)/, "");
28010
- var errorText = (err) => err instanceof Error ? err.message : String(err);
28044
+ function clusterOf(url) {
28045
+ const u = parseUrl(url);
28046
+ return u?.hostname ? `${u.hostname.toLowerCase()}:${u.port || "5432"}` : undefined;
28047
+ }
28048
+ var errorText = (err) => err instanceof AggregateError && !err.message ? err.errors.map(errorText).join("; ") : err instanceof Error ? err.message : String(err);
28011
28049
  var urlPassword = (u) => decodeURIComponent(u.password) || u.searchParams.get("password") || "";
28012
28050
  var URL_PARAMS_KEPT = ["sslmode", "channel_binding", "application_name"];
28013
28051
  var URL_PARAMS_TLS = ["sslrootcert", "sslcert", "sslkey", "uselibpqcompat"];
@@ -28022,6 +28060,22 @@ function checkUrlParams(url, agent) {
28022
28060
  if (moved.length)
28023
28061
  throw new Error(`The URL's query string sets ${moved.join(" and ")}: put the host and the port in the URL itself (postgresql://user:password@host:5432/dbname), so that the server prepared is the server monitored`);
28024
28062
  }
28063
+ function storedPasswordNeeds(monitoringUrl) {
28064
+ const query = new URL(monitoringUrl).search.replace(/^\?/, "");
28065
+ const parts = query ? query.split("&") : [];
28066
+ const keys = parts.map((p) => p.split("=")[0]);
28067
+ const twice = URL_PARAMS_KEPT.filter((k) => keys.indexOf(k) !== keys.lastIndexOf(k));
28068
+ const tls = !twice.includes("sslmode") && parts.some((p) => /^sslmode=(require|verify-ca|verify-full)$/.test(p));
28069
+ const needs = [
28070
+ ...tls ? [] : [`sslmode=require (or verify-full)${twice.includes("sslmode") ? ", once" : ""}`],
28071
+ ...twice.filter((k) => k !== "sslmode").map((k) => `${k} once`),
28072
+ ...parts.some((p) => !p) ? ["no stray &"] : [],
28073
+ ...keys.some((k) => k && !URL_PARAMS_KEPT.includes(k)) ? ["the parameter names without %-escapes"] : []
28074
+ ];
28075
+ return needs.length ? needs.join(" and ") : undefined;
28076
+ }
28077
+ var shellWord = (s) => /^[\w./:@%+=,-]+$/.test(s) ? s : `'${s.replace(/'/g, "'\\''")}'`;
28078
+ var disconnectEach = (names) => names.map((n) => `pgai disconnect ${shellWord(n)} --yes`).join(" and ");
28025
28079
  function roleUrlFor(url, db, password, kept) {
28026
28080
  const u = new URL(url);
28027
28081
  u.username = DEFAULT_MONITORING_USER;
@@ -28030,6 +28084,7 @@ function roleUrlFor(url, db, password, kept) {
28030
28084
  for (const name of [...u.searchParams.keys()])
28031
28085
  if (!kept.includes(name))
28032
28086
  u.searchParams.delete(name);
28087
+ u.search = u.search.replace(/@/g, "%40");
28033
28088
  return u.toString();
28034
28089
  }
28035
28090
  var monitoringUrlFor = (url, db, password) => roleUrlFor(url, db, password, URL_PARAMS_KEPT);
@@ -28082,19 +28137,20 @@ function checkupLines(c) {
28082
28137
  ];
28083
28138
  }
28084
28139
  var boxState = (row) => row.status === "launch_requested" && row.registered_at ? "registered" : row.status ?? "starting";
28085
- async function billingFor(q, name, opts, deps) {
28140
+ async function billingFor(q, name, opts, deps, show) {
28086
28141
  const billing = { price: priceText(q), requires_payment_method: q.requires_payment_method };
28087
28142
  if (opts.coupon) {
28088
28143
  billing.coupon = q.promo?.valid ? { code: q.promo.code, valid: true, description: q.promo.discount_description } : { code: opts.coupon, valid: false, error: q.promo?.error ?? "not valid" };
28089
28144
  }
28090
- const billingPage = { ...billing, requires_payment_method: true, next: `Add a payment method at ${deps.billingUrl(q.org_alias)}, then re-run` };
28145
+ const billingPage = { ...billing, requires_payment_method: true, next: `Add a payment method at ${q.billing_url ?? deps.billingUrl(q.org_alias)}, then re-run` };
28091
28146
  const stop = (next) => ({ billing, billingPage, accepted: false, stop: { ...billing, next } });
28092
28147
  if (billing.coupon && !billing.coupon.valid) {
28093
28148
  return stop(`Promo code ${opts.coupon}: ${billing.coupon.error}. Nothing was changed: re-run with a valid code, or without ${opts.agent ? "coupon" : "--coupon"}`);
28094
28149
  }
28095
28150
  if (q.requires_payment_method)
28096
28151
  return { billing, billingPage, accepted: false, stop: billingPage };
28097
- if (q.billed && !opts.yes && !await deps.confirm(`${name} is billed: ${billing.price}. Provision it? (y/N): `)) {
28152
+ show(billing.price);
28153
+ if (q.billed && !opts.yes && !await deps.confirm(`Provision ${name}? (y/N): `)) {
28098
28154
  return stop(opts.agent ? `Call connect_database again with yes: true to accept ${billing.price}` : `Re-run with --yes to accept ${billing.price}`);
28099
28155
  }
28100
28156
  return { billing, billingPage, accepted: q.billed };
@@ -28117,7 +28173,7 @@ async function connect(url, opts, deps) {
28117
28173
  return { status: "action_required", provider, name, next: "--reset-password works with PostgresAI Cloud only (it checks what else monitors this server): connect without --self-hosted, or set PGAI_MON_PASSWORD" };
28118
28174
  }
28119
28175
  if (opts.selfHosted && deps.localStackRunning()) {
28120
- return { status: "action_required", provider, name, next: "A monitoring stack already runs on this machine: add the database with pgai mon targets add '<postgres_ai_mon URL>'" };
28176
+ 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" };
28121
28177
  }
28122
28178
  const rows = opts.selfHosted ? [] : await deps.list();
28123
28179
  let row = rows.find((d) => d.name === name && !disconnecting(d.status));
@@ -28160,51 +28216,72 @@ async function connect(url, opts, deps) {
28160
28216
  await deps.unprepare(url).catch(() => false);
28161
28217
  };
28162
28218
  let lockId;
28163
- const othersOnServer = (list) => list.filter((d) => d.name !== name && !disconnecting(d.status) && serverOf(d.name) === serverOf(name));
28164
- const cutOff = (others) => ({ status: "action_required", provider, name, next: `A new password for ${DEFAULT_MONITORING_USER} would cut off the monitoring of ${others.map((d) => d.name).join(", ")} on this server: set PGAI_MON_PASSWORD to its password instead` });
28165
- if (opts.resetPassword) {
28166
- if (othersOnServer(rows).length)
28167
- return cutOff(othersOnServer(rows));
28168
- }
28219
+ const unread = (d) => /^Monitoring [0-9a-f]{6}$/.test(d.name);
28220
+ const othersOnServer = (list) => list.filter((d) => d.name !== name && !disconnecting(d.status) && (unread(d) || serverOf(d.name) === serverOf(name)));
28221
+ const storedPassword = rows.some((d) => d.monitoring_password_stored && !/delet/.test(d.status ?? "") && serverOf(d.name) === serverOf(name)) || undefined;
28222
+ const cutOff = (others) => {
28223
+ const on = others.filter((d) => !unread(d)).map((d) => d.name).join(", ");
28224
+ const maybe = others.filter(unread).map((d) => d.name).join(", ");
28225
+ const what = [on && `would cut off the monitoring of ${on} on this server`, maybe && `may cut off the monitoring of ${maybe} (the platform could not read its URL, so its server is not known)`].filter(Boolean).join(", and ");
28226
+ const instead = [
28227
+ ...storedPassword ? ["connect without --reset-password (PostgresAI keeps its password for this server, and sends it only over TLS)"] : [],
28228
+ "set PGAI_MON_PASSWORD to its password instead",
28229
+ `${disconnectEach(others.map((d) => d.name))} first`
28230
+ ];
28231
+ return { status: "action_required", provider, name, next: `A new password for ${DEFAULT_MONITORING_USER} ${what}: ${instead.join(", or ")}` };
28232
+ };
28233
+ const others = othersOnServer(rows).filter((d) => !unread(d)).map((d) => d.name);
28234
+ if (opts.resetPassword && othersOnServer(rows).length)
28235
+ return cutOff(othersOnServer(rows));
28169
28236
  if (opts.coupon !== undefined && !opts.coupon.trim()) {
28170
28237
  return { status: "action_required", provider, name, next: `${opts.agent ? "coupon" : "--coupon"} is empty: pass a promotion code, or leave ${opts.agent ? "coupon" : "--coupon"} out` };
28171
28238
  }
28239
+ const prepOpts = { resetPassword: opts.resetPassword, ...storedPassword ? { storedPassword, others } : {} };
28172
28240
  if (!opts.selfHosted) {
28173
- const b = await billingFor(await deps.quote(opts.coupon), name, opts, deps);
28241
+ const probe = await deps.prepare(url, provider, { ...prepOpts, check: true });
28242
+ if ("next" in probe)
28243
+ return { status: "action_required", provider, name, ...probe };
28244
+ const b = await billingFor(await deps.quote(opts.coupon, clusterOf(url)), name, opts, deps, (price) => progress("billing", `Billing: ${price}`));
28174
28245
  if (b.stop)
28175
28246
  return { status: "action_required", provider, name, ...b.stop };
28176
28247
  ({ billing, billingPage, accepted } = b);
28177
- progress("billing", `Billing: ${billing.price}`);
28178
28248
  }
28179
28249
  if (opts.resetPassword) {
28250
+ let lock;
28180
28251
  try {
28181
- lockId = (await deps.resetLock(serverOf(name))).lock_id;
28252
+ lock = await deps.resetLock(serverOf(name));
28182
28253
  } catch (err) {
28183
28254
  if (err instanceof HttpStatusError && err.status === 409) {
28184
- return { status: "action_required", provider, name, next: `Another pgai connect --reset-password for ${serverOf(name)} is running: wait for it to finish, then re-run` };
28255
+ 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)` };
28185
28256
  }
28186
28257
  if (err instanceof HttpStatusError && err.status === 404) {
28187
- return { status: "action_required", provider, name, next: `This platform cannot lock the server for --reset-password yet: set PGAI_MON_PASSWORD to ${DEFAULT_MONITORING_USER}'s password instead` };
28258
+ 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` };
28188
28259
  }
28189
28260
  throw err;
28190
28261
  }
28262
+ if (typeof lock.lock_id !== "string" || !lock.lock_id)
28263
+ throw new Error("cloud_monitoring_reset_lock returned no lock_id: nothing was changed");
28264
+ lockId = lock.lock_id;
28191
28265
  }
28192
28266
  let prepared;
28193
28267
  let created;
28268
+ let unanswered = false;
28194
28269
  try {
28195
28270
  if (lockId) {
28196
28271
  const now = await deps.list();
28197
28272
  const same = now.find((d) => d.name === name && !disconnecting(d.status));
28198
28273
  if (same)
28199
- return { status: "action_required", provider, name, id: same.id, next: `${name} is already connected, and its monitoring uses the current password: pgai disconnect ${name} --yes first` };
28274
+ 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` };
28200
28275
  const others = othersOnServer(now);
28201
28276
  if (others.length)
28202
- return cutOff(others);
28277
+ return { ...cutOff(others), ...billing };
28203
28278
  }
28204
28279
  progress("preparing", `Preparing ${maskConnectionString(url)}`);
28205
- prepared = await deps.prepare(url, provider, { resetPassword: opts.resetPassword });
28280
+ prepared = await deps.prepare(url, provider, prepOpts);
28206
28281
  if ("next" in prepared)
28207
28282
  return { status: "action_required", provider, name, ...billing, ...prepared };
28283
+ if (!("monitoringUrl" in prepared))
28284
+ throw new Error("prepare returned no monitoring URL");
28208
28285
  if (prepared.note)
28209
28286
  note = `; ${prepared.note}`;
28210
28287
  if (opts.selfHosted) {
@@ -28221,10 +28298,14 @@ async function connect(url, opts, deps) {
28221
28298
  }).catch(async (err) => {
28222
28299
  if (err instanceof HttpStatusError && err.status >= 400 && err.status < 500)
28223
28300
  await undo(prepared);
28301
+ else
28302
+ unanswered = true;
28224
28303
  if (err instanceof HttpStatusError && err.status === 402)
28225
28304
  return /declined/i.test(err.message) ? "declined" : "payment";
28226
28305
  if (err instanceof HttpStatusError && err.status === 412)
28227
28306
  return "price";
28307
+ if (err instanceof HttpStatusError && err.status === 409 && "storedPassword" in prepared && prepared.storedPassword && /none is stored/.test(err.message))
28308
+ return "stored";
28228
28309
  throw err;
28229
28310
  });
28230
28311
  if (created === "payment")
@@ -28234,18 +28315,26 @@ async function connect(url, opts, deps) {
28234
28315
  }
28235
28316
  if (created === "price")
28236
28317
  return { status: "action_required", provider, name, ...billing, next: "The price changed since it was shown: re-run pgai connect to see it" };
28318
+ if (created === "stored") {
28319
+ const gone = `PostgresAI no longer keeps the password of ${DEFAULT_MONITORING_USER} for this server`;
28320
+ return { status: "action_required", provider, name, ...billing, next: opts.agent ? `${gone}: pass its URL as database_url, or run pgai connect <admin-url> --reset-password in a terminal` : `${gone}: re-run with --reset-password (anything else that logs in as ${DEFAULT_MONITORING_USER} then needs the new one), or set PGAI_MON_PASSWORD to its password` };
28321
+ }
28237
28322
  if (created.status === "failed") {
28238
28323
  await undo(prepared);
28239
28324
  return { status: "failed", provider, name, id: created.id, next: `${created.error} Re-run pgai connect later.` };
28240
28325
  }
28241
28326
  } finally {
28242
- if (lockId)
28327
+ if (lockId && !unanswered)
28243
28328
  await deps.resetUnlock(lockId).catch(() => {});
28244
28329
  }
28245
28330
  row = { id: created.id, name: created.name, provider, status: created.status, dashboard_url: null, host_metrics: !!ch };
28246
- checkup = await deps.checkup(withLocalTls(prepared.monitoringUrl, url), created.name).catch((err) => ({ error: errorText(err) }));
28247
- progress("checkup", checkupLines(checkup).join(`
28331
+ if (prepared.storedPassword) {
28332
+ progress("checkup", `Express checkup skipped: it runs only as ${DEFAULT_MONITORING_USER}, whose password PostgresAI keeps for this server. The full checkup follows on the box.`);
28333
+ } else {
28334
+ checkup = await deps.checkup(withLocalTls(prepared.monitoringUrl, url), created.name).catch((err) => ({ error: errorText(err) }));
28335
+ progress("checkup", checkupLines(checkup).join(`
28248
28336
  `), { checkup });
28337
+ }
28249
28338
  }
28250
28339
  let shown;
28251
28340
  for (const deadline = deps.now() + opts.waitMs;; ) {
@@ -28265,9 +28354,11 @@ async function connect(url, opts, deps) {
28265
28354
  row = (await deps.list().catch(() => [])).find((d) => d.id === row.id) ?? row;
28266
28355
  }
28267
28356
  }
28268
- function openConnection(url, opts) {
28357
+ async function openConnection(url, opts) {
28269
28358
  const conn = resolveAdminConnection({ conn: url });
28270
- return connectWithSslFallback(opts.Client ?? Client2, opts.agent ? { ...conn, sslFallbackEnabled: false } : conn);
28359
+ const fallback = !opts.agent && !!conn.sslFallbackEnabled;
28360
+ const { client, usedSsl } = await connectWithSslFallback(opts.Client ?? Client2, { ...conn, sslFallbackEnabled: fallback });
28361
+ return { client, usedSsl, refusedTls: fallback && !usedSsl };
28271
28362
  }
28272
28363
  async function dropMonitoringRole(client) {
28273
28364
  for (const sql of [`drop role ${DEFAULT_MONITORING_USER}`, `drop owned by ${DEFAULT_MONITORING_USER}; drop role ${DEFAULT_MONITORING_USER}`]) {
@@ -28300,7 +28391,7 @@ async function prepareDatabase(url, provider, opts = {}) {
28300
28391
  throw err;
28301
28392
  }
28302
28393
  };
28303
- const { client } = await open(url).catch((err) => {
28394
+ const { client, usedSsl, refusedTls } = await open(url).catch((err) => {
28304
28395
  if (/client password must be a string/.test(String(err?.message)))
28305
28396
  throw new Error("The URL has no password (and PGPASSWORD is not set): pgai connect postgresql://user:password@host:5432/dbname");
28306
28397
  throw err;
@@ -28314,7 +28405,7 @@ async function prepareDatabase(url, provider, opts = {}) {
28314
28405
  and has_database_privilege(current_user, current_database(), 'CREATE')
28315
28406
  and exists (select 1 from pg_extension where extname = 'pg_stat_statements')) as admin,
28316
28407
  exists (select 1 from pg_roles where rolname = '${DEFAULT_MONITORING_USER}') as mon_exists,
28317
- current_setting('scram_iterations', true) as iterations
28408
+ current_setting('scram_iterations', true) as iterations, current_setting('ssl') as ssl
28318
28409
  from pg_roles where rolname = current_user`)).rows[0];
28319
28410
  const notes = [caNote(url)].filter((n) => !!n);
28320
28411
  const noted = () => notes.length ? { note: notes.join("; ") } : {};
@@ -28338,9 +28429,31 @@ async function prepareDatabase(url, provider, opts = {}) {
28338
28429
  }
28339
28430
  } else if (me.admin && pgProvider !== "supabase") {
28340
28431
  const exists = `${DEFAULT_MONITORING_USER} already exists on this server`;
28432
+ const setPassword = `Set PGAI_MON_PASSWORD to the password of ${DEFAULT_MONITORING_USER}, or give it a new one: pgai connect <admin-url> --reset-password (anything else that logs in as ${DEFAULT_MONITORING_USER} then needs the new password)`;
28433
+ const needs = storedPasswordNeeds(monitoringUrlFor(url, me.db, ""));
28434
+ const noTls = refusedTls || !usedSsl && me.ssl === "off";
28435
+ const tlsOnly = `PostgresAI sends the password it keeps for ${DEFAULT_MONITORING_USER} only over TLS`;
28436
+ const others = opts.others?.length ? opts.others : undefined;
28437
+ const disconnectOthers = others ? disconnectEach(others) : "pgai disconnect the server's other databases (pgai databases)";
28438
+ const reconnectOthers = `(and connect ${others ? others.join(", ") : "them"} again with it)`;
28439
+ const noTlsWayOut = opts.agent ? `Pass its URL as database_url, or run pgai connect in a terminal with PGAI_MON_PASSWORD set to its password. If nobody has it, in a terminal: ${disconnectOthers}, then pgai connect <admin-url> --reset-password with PGAI_MON_PASSWORD set to a new one ${reconnectOthers}` : `Set PGAI_MON_PASSWORD to its password, or turn on TLS on the server (ssl = on) and put sslmode=require in the URL. If nobody has the password: ${disconnectOthers}, then re-run with --reset-password and PGAI_MON_PASSWORD set to a new one ${reconnectOthers}`;
28440
+ const reuse = me.mon_exists && !opts.resetPassword && !!opts.storedPassword && !(opts.agent ? "" : process.env.PGAI_MON_PASSWORD?.trim());
28441
+ if (reuse && needs) {
28442
+ if (noTls)
28443
+ return { next: `${exists}, but this server takes no TLS, and ${tlsOnly}. ${noTlsWayOut}` };
28444
+ return { next: `${exists}. ${opts.agent ? "database_url" : "The URL"} needs ${needs}: the password PostgresAI keeps for this server is sent only over TLS, to a URL with each parameter once` };
28445
+ }
28446
+ if (reuse) {
28447
+ if (opts.check)
28448
+ return { checked: true };
28449
+ const { password: unused } = await resolveMonitoringPassword({ monitoringUser: DEFAULT_MONITORING_USER });
28450
+ const plan = await buildInitPlan({ database: me.db, monitoringPassword: unused, iterations: Number(me.iterations), includeOptionalPermissions: true, provider: pgProvider, keepExistingPassword: true });
28451
+ await applyInitPlan({ client, plan });
28452
+ return { monitoringUrl: monitoringUrlFor(url, me.db, ""), storedPassword: true, ...noted() };
28453
+ }
28341
28454
  if (me.mon_exists && opts.agent)
28342
28455
  return { next: `${exists}. Pass its URL as database_url, or run pgai connect in a terminal with PGAI_MON_PASSWORD set to its password` };
28343
- const setPassword = `Set PGAI_MON_PASSWORD to the password of ${DEFAULT_MONITORING_USER}, or give it a new one: pgai connect <admin-url> --reset-password (anything else that logs in as ${DEFAULT_MONITORING_USER} then needs the new password)`;
28456
+ const unset = !opts.storedPassword ? setPassword : noTls ? `This server takes no TLS, and ${tlsOnly}. ${noTlsWayOut}` : !needs ? `Unset PGAI_MON_PASSWORD: PostgresAI keeps the password of ${DEFAULT_MONITORING_USER} for this server` : `Unset PGAI_MON_PASSWORD, and the URL needs ${needs}: PostgresAI keeps the password of ${DEFAULT_MONITORING_USER} for this server, and sends it only over TLS, to a URL with each parameter once`;
28344
28457
  const { password, generated } = await resolveMonitoringPassword({ passwordEnv: opts.agent ? undefined : process.env.PGAI_MON_PASSWORD, monitoringUser: DEFAULT_MONITORING_USER });
28345
28458
  const reset = !!opts.resetPassword && me.mon_exists;
28346
28459
  if (me.mon_exists && !reset) {
@@ -28350,13 +28463,16 @@ async function prepareDatabase(url, provider, opts = {}) {
28350
28463
  try {
28351
28464
  accepted = await logsIn(loginUrlFor(url, me.db, password));
28352
28465
  } catch (err) {
28353
- return { next: `${exists}, and PGAI_MON_PASSWORD could not be checked from this host (${err instanceof Error ? err.message : String(err)}). Run pgai connect from a host that ${DEFAULT_MONITORING_USER} may connect from` };
28466
+ const orUnset = opts.storedPassword && !noTls ? ", or unset PGAI_MON_PASSWORD: PostgresAI keeps its password for this server" : "";
28467
+ return { next: `${exists}, and PGAI_MON_PASSWORD could not be checked from this host (${err instanceof Error ? err.message : String(err)}). Run pgai connect from a host that ${DEFAULT_MONITORING_USER} may connect from${orUnset}` };
28354
28468
  }
28355
28469
  if (!accepted)
28356
- return { next: `${exists} and PGAI_MON_PASSWORD is not its password. ${setPassword}` };
28470
+ return { next: `${exists} and PGAI_MON_PASSWORD is not its password. ${unset}` };
28357
28471
  if (await acceptsAnyPassword())
28358
28472
  notes.push(unchecked("PGAI_MON_PASSWORD"));
28359
28473
  }
28474
+ if (opts.check)
28475
+ return { checked: true };
28360
28476
  const monitoringUrl = monitoringUrlFor(url, me.db, password);
28361
28477
  const loginUrl = loginUrlFor(url, me.db, password);
28362
28478
  const plan = await buildInitPlan({ database: me.db, monitoringPassword: password, iterations: Number(me.iterations), includeOptionalPermissions: true, provider: pgProvider, keepExistingPassword: !reset });
@@ -28404,26 +28520,17 @@ async function expressCheckup(url, opts = {}) {
28404
28520
  }
28405
28521
  }
28406
28522
  async function saveCheckupReport(rpc, accessToken, project, reports) {
28407
- const idOf = (fn, answer, ...keys) => {
28408
- const a = answer ?? {};
28409
- const id = Number(keys.map((k) => a[k]).find((v) => v !== undefined));
28410
- if (!(id > 0))
28411
- throw new Error(typeof a.message === "string" ? a.message : `Unexpected ${fn} answer: ${JSON.stringify(answer)}`);
28412
- return id;
28413
- };
28414
- const reportId = idOf("checkup_report_create", await rpc("checkup_report_create", { access_token: accessToken, project }), "report_id");
28523
+ const { reportId } = await createCheckupReport(rpc, { apiKey: accessToken, project });
28415
28524
  const setStatus = (status) => rpc("checkup_report_status_update", { access_token: accessToken, report_id: reportId, status });
28416
28525
  try {
28417
28526
  for (const [checkId, report] of Object.entries(reports)) {
28418
- idOf("checkup_report_file_post", await rpc("checkup_report_file_post", {
28419
- access_token: accessToken,
28420
- checkup_report_id: reportId,
28527
+ await uploadCheckupReportJson(rpc, {
28528
+ apiKey: accessToken,
28529
+ reportId,
28421
28530
  filename: `${checkId}.json`,
28422
- check_id: checkId,
28423
- data: JSON.stringify(report, null, 2),
28424
- type: "json",
28425
- generate_issue: true
28426
- }), "report_chunck_id", "report_chunk_id");
28531
+ checkId,
28532
+ jsonText: JSON.stringify(report, null, 2)
28533
+ });
28427
28534
  }
28428
28535
  } catch (err) {
28429
28536
  await setStatus("failed").catch(() => {});
@@ -28473,7 +28580,7 @@ function platformDeps(p) {
28473
28580
  unprepare: (url) => unprepareDatabase(url, { agent: p.agent }),
28474
28581
  clickhouseOrg: clickhouseOrgFor,
28475
28582
  checkup: (url, project) => expressCheckup(url, { agent: p.agent, save: (reports) => saveCheckupReport(rpc, p.apiKey, project, reports) }),
28476
- quote: (coupon) => rpc("cloud_monitoring_quote", coupon ? { promo_code: coupon } : {}),
28583
+ quote: (coupon, cluster) => rpc("cloud_monitoring_quote", { ...coupon ? { promo_code: coupon } : {}, ...cluster ? { db_server: cluster } : {} }),
28477
28584
  billingUrl: (orgAlias) => `${p.uiBaseUrl}/${orgAlias}/billing`,
28478
28585
  handoffUrl: async (provider) => {
28479
28586
  const orgs = await listOrgs({ apiKey: p.apiKey, apiBaseUrl: p.apiBaseUrl });
@@ -35744,7 +35851,7 @@ async function startMcpServer(rootOpts, extra) {
35744
35851
  },
35745
35852
  {
35746
35853
  name: "connect_database",
35747
- description: "Put a Postgres database under PostgresAI Cloud monitoring (same as `pgai connect`): prepares the monitoring role (an admin URL creates it; otherwise returns the SQL), provisions the monitoring box, and returns JSON with status (connected | provisioning | disconnecting | action_required | failed), dashboard_url and next (the exact next action). A billed box is provisioned only with yes: true: without it the result is action_required with the price (and requires_payment_method), so show the price to the user first. It does not wait for the box: call it again to see the status. Safe to call again. ClickHouse Managed Postgres, RDS and Supabase are detected from the host. The URL must carry its password, and only these query parameters: sslmode, channel_binding, application_name. PGAI_MON_PASSWORD, PGPASSWORD and CLICKHOUSE_KEY_ID + CLICKHOUSE_KEY_SECRET of the server process are not used (pass clickhouse_key), and a TLS failure is not retried in plaintext (say sslmode=disable for a server without TLS).",
35854
+ description: "Put a Postgres database under PostgresAI Cloud monitoring (same as `pgai connect`): prepares the monitoring role (an admin URL creates it; otherwise returns the SQL), provisions the monitoring box, and returns JSON with status (connected | provisioning | disconnecting | action_required | failed), dashboard_url and next (the exact next action). A billed box is provisioned only with yes: true: without it the result is action_required with the price (and requires_payment_method), so show the price to the user first. It does not wait for the box: call it again to see the status. Safe to call again. ClickHouse Managed Postgres, RDS and Supabase are detected from the host. The URL must carry its password, and only these query parameters: sslmode, channel_binding, application_name. PGAI_MON_PASSWORD, PGPASSWORD and CLICKHOUSE_KEY_ID + CLICKHOUSE_KEY_SECRET of the server process are not used (pass clickhouse_key), and a TLS failure is not retried in plaintext (say sslmode=disable for a server without TLS). For another database on a server PostgresAI already monitors for the org, an admin URL with sslmode=require (or verify-*), each query parameter once, is enough: PostgresAI fills in the password of postgres_ai_mon it keeps, over TLS only (on a server without TLS, pass the postgres_ai_mon URL; if nobody has its password, the user runs in a terminal: pgai disconnect the server's other databases, then pgai connect <admin-url> --reset-password with PGAI_MON_PASSWORD set to a new one, then connects them again with it).",
35748
35855
  inputSchema: {
35749
35856
  type: "object",
35750
35857
  properties: {
@@ -36658,10 +36765,12 @@ function renderMarkdownForTerminal(md) {
36658
36765
  // lib/connect.ts
36659
36766
  var money2 = (cents, currency) => currency.toLowerCase() === "usd" ? `$${(cents / 100).toFixed(2)}` : `${(cents / 100).toFixed(2)} ${currency.toUpperCase()}`;
36660
36767
  function priceText2(q) {
36768
+ if (!q.billed && q.same_cluster)
36769
+ return `included: same database cluster as ${q.same_cluster}, no extra charge`;
36661
36770
  if (!q.billed)
36662
36771
  return q.free_slots.total ? `free (${q.free_slots.remaining} of ${q.free_slots.total} free slots)` : "free";
36663
36772
  const { amount, currency, interval } = q.price;
36664
- const base = `${money2(amount, currency)}/${interval} per box (${q.plan} plan)${q.subscription ? `, box ${q.quantity + 1} on the subscription` : ""}`;
36773
+ const base = `${money2(amount, currency)}/${interval} per database cluster (${q.plan} plan)${q.subscription ? `, cluster ${q.quantity + 1} on the subscription` : ""}`;
36665
36774
  if (q.promo?.valid && q.amount_after_promo !== undefined && q.amount_after_promo !== null) {
36666
36775
  const after = `${money2(q.amount_after_promo, currency)}`;
36667
36776
  const how = `with ${q.promo.code} (${q.promo.discount_description})`;
@@ -36729,7 +36838,11 @@ function stateOf2(raw) {
36729
36838
  class ClickhouseKeyError2 extends Error {
36730
36839
  }
36731
36840
  var serverOf2 = (name) => name.split("/")[0].toLowerCase().replace(/\.(?=$|:)/, "");
36732
- var errorText2 = (err) => err instanceof Error ? err.message : String(err);
36841
+ function clusterOf2(url) {
36842
+ const u = parseUrl2(url);
36843
+ return u?.hostname ? `${u.hostname.toLowerCase()}:${u.port || "5432"}` : undefined;
36844
+ }
36845
+ var errorText2 = (err) => err instanceof AggregateError && !err.message ? err.errors.map(errorText2).join("; ") : err instanceof Error ? err.message : String(err);
36733
36846
  var urlPassword2 = (u) => decodeURIComponent(u.password) || u.searchParams.get("password") || "";
36734
36847
  var URL_PARAMS_KEPT2 = ["sslmode", "channel_binding", "application_name"];
36735
36848
  var URL_PARAMS_TLS2 = ["sslrootcert", "sslcert", "sslkey", "uselibpqcompat"];
@@ -36744,6 +36857,22 @@ function checkUrlParams2(url, agent) {
36744
36857
  if (moved.length)
36745
36858
  throw new Error(`The URL's query string sets ${moved.join(" and ")}: put the host and the port in the URL itself (postgresql://user:password@host:5432/dbname), so that the server prepared is the server monitored`);
36746
36859
  }
36860
+ function storedPasswordNeeds2(monitoringUrl) {
36861
+ const query = new URL(monitoringUrl).search.replace(/^\?/, "");
36862
+ const parts = query ? query.split("&") : [];
36863
+ const keys = parts.map((p) => p.split("=")[0]);
36864
+ const twice = URL_PARAMS_KEPT2.filter((k) => keys.indexOf(k) !== keys.lastIndexOf(k));
36865
+ const tls = !twice.includes("sslmode") && parts.some((p) => /^sslmode=(require|verify-ca|verify-full)$/.test(p));
36866
+ const needs = [
36867
+ ...tls ? [] : [`sslmode=require (or verify-full)${twice.includes("sslmode") ? ", once" : ""}`],
36868
+ ...twice.filter((k) => k !== "sslmode").map((k) => `${k} once`),
36869
+ ...parts.some((p) => !p) ? ["no stray &"] : [],
36870
+ ...keys.some((k) => k && !URL_PARAMS_KEPT2.includes(k)) ? ["the parameter names without %-escapes"] : []
36871
+ ];
36872
+ return needs.length ? needs.join(" and ") : undefined;
36873
+ }
36874
+ var shellWord2 = (s) => /^[\w./:@%+=,-]+$/.test(s) ? s : `'${s.replace(/'/g, "'\\''")}'`;
36875
+ var disconnectEach2 = (names) => names.map((n) => `pgai disconnect ${shellWord2(n)} --yes`).join(" and ");
36747
36876
  function roleUrlFor2(url, db, password, kept) {
36748
36877
  const u = new URL(url);
36749
36878
  u.username = DEFAULT_MONITORING_USER;
@@ -36752,6 +36881,7 @@ function roleUrlFor2(url, db, password, kept) {
36752
36881
  for (const name of [...u.searchParams.keys()])
36753
36882
  if (!kept.includes(name))
36754
36883
  u.searchParams.delete(name);
36884
+ u.search = u.search.replace(/@/g, "%40");
36755
36885
  return u.toString();
36756
36886
  }
36757
36887
  var monitoringUrlFor2 = (url, db, password) => roleUrlFor2(url, db, password, URL_PARAMS_KEPT2);
@@ -36813,19 +36943,20 @@ function progressText(e) {
36813
36943
  `);
36814
36944
  }
36815
36945
  var boxState2 = (row) => row.status === "launch_requested" && row.registered_at ? "registered" : row.status ?? "starting";
36816
- async function billingFor2(q, name, opts, deps) {
36946
+ async function billingFor2(q, name, opts, deps, show) {
36817
36947
  const billing = { price: priceText2(q), requires_payment_method: q.requires_payment_method };
36818
36948
  if (opts.coupon) {
36819
36949
  billing.coupon = q.promo?.valid ? { code: q.promo.code, valid: true, description: q.promo.discount_description } : { code: opts.coupon, valid: false, error: q.promo?.error ?? "not valid" };
36820
36950
  }
36821
- const billingPage = { ...billing, requires_payment_method: true, next: `Add a payment method at ${deps.billingUrl(q.org_alias)}, then re-run` };
36951
+ const billingPage = { ...billing, requires_payment_method: true, next: `Add a payment method at ${q.billing_url ?? deps.billingUrl(q.org_alias)}, then re-run` };
36822
36952
  const stop = (next) => ({ billing, billingPage, accepted: false, stop: { ...billing, next } });
36823
36953
  if (billing.coupon && !billing.coupon.valid) {
36824
36954
  return stop(`Promo code ${opts.coupon}: ${billing.coupon.error}. Nothing was changed: re-run with a valid code, or without ${opts.agent ? "coupon" : "--coupon"}`);
36825
36955
  }
36826
36956
  if (q.requires_payment_method)
36827
36957
  return { billing, billingPage, accepted: false, stop: billingPage };
36828
- if (q.billed && !opts.yes && !await deps.confirm(`${name} is billed: ${billing.price}. Provision it? (y/N): `)) {
36958
+ show(billing.price);
36959
+ if (q.billed && !opts.yes && !await deps.confirm(`Provision ${name}? (y/N): `)) {
36829
36960
  return stop(opts.agent ? `Call connect_database again with yes: true to accept ${billing.price}` : `Re-run with --yes to accept ${billing.price}`);
36830
36961
  }
36831
36962
  return { billing, billingPage, accepted: q.billed };
@@ -36848,7 +36979,7 @@ async function connect2(url, opts, deps) {
36848
36979
  return { status: "action_required", provider, name, next: "--reset-password works with PostgresAI Cloud only (it checks what else monitors this server): connect without --self-hosted, or set PGAI_MON_PASSWORD" };
36849
36980
  }
36850
36981
  if (opts.selfHosted && deps.localStackRunning()) {
36851
- return { status: "action_required", provider, name, next: "A monitoring stack already runs on this machine: add the database with pgai mon targets add '<postgres_ai_mon URL>'" };
36982
+ 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" };
36852
36983
  }
36853
36984
  const rows = opts.selfHosted ? [] : await deps.list();
36854
36985
  let row = rows.find((d) => d.name === name && !disconnecting2(d.status));
@@ -36891,51 +37022,72 @@ async function connect2(url, opts, deps) {
36891
37022
  await deps.unprepare(url).catch(() => false);
36892
37023
  };
36893
37024
  let lockId;
36894
- const othersOnServer = (list) => list.filter((d) => d.name !== name && !disconnecting2(d.status) && serverOf2(d.name) === serverOf2(name));
36895
- const cutOff = (others) => ({ status: "action_required", provider, name, next: `A new password for ${DEFAULT_MONITORING_USER} would cut off the monitoring of ${others.map((d) => d.name).join(", ")} on this server: set PGAI_MON_PASSWORD to its password instead` });
36896
- if (opts.resetPassword) {
36897
- if (othersOnServer(rows).length)
36898
- return cutOff(othersOnServer(rows));
36899
- }
37025
+ const unread = (d) => /^Monitoring [0-9a-f]{6}$/.test(d.name);
37026
+ const othersOnServer = (list) => list.filter((d) => d.name !== name && !disconnecting2(d.status) && (unread(d) || serverOf2(d.name) === serverOf2(name)));
37027
+ const storedPassword = rows.some((d) => d.monitoring_password_stored && !/delet/.test(d.status ?? "") && serverOf2(d.name) === serverOf2(name)) || undefined;
37028
+ const cutOff = (others) => {
37029
+ const on = others.filter((d) => !unread(d)).map((d) => d.name).join(", ");
37030
+ const maybe = others.filter(unread).map((d) => d.name).join(", ");
37031
+ const what = [on && `would cut off the monitoring of ${on} on this server`, maybe && `may cut off the monitoring of ${maybe} (the platform could not read its URL, so its server is not known)`].filter(Boolean).join(", and ");
37032
+ const instead = [
37033
+ ...storedPassword ? ["connect without --reset-password (PostgresAI keeps its password for this server, and sends it only over TLS)"] : [],
37034
+ "set PGAI_MON_PASSWORD to its password instead",
37035
+ `${disconnectEach2(others.map((d) => d.name))} first`
37036
+ ];
37037
+ return { status: "action_required", provider, name, next: `A new password for ${DEFAULT_MONITORING_USER} ${what}: ${instead.join(", or ")}` };
37038
+ };
37039
+ const others = othersOnServer(rows).filter((d) => !unread(d)).map((d) => d.name);
37040
+ if (opts.resetPassword && othersOnServer(rows).length)
37041
+ return cutOff(othersOnServer(rows));
36900
37042
  if (opts.coupon !== undefined && !opts.coupon.trim()) {
36901
37043
  return { status: "action_required", provider, name, next: `${opts.agent ? "coupon" : "--coupon"} is empty: pass a promotion code, or leave ${opts.agent ? "coupon" : "--coupon"} out` };
36902
37044
  }
37045
+ const prepOpts = { resetPassword: opts.resetPassword, ...storedPassword ? { storedPassword, others } : {} };
36903
37046
  if (!opts.selfHosted) {
36904
- const b = await billingFor2(await deps.quote(opts.coupon), name, opts, deps);
37047
+ const probe = await deps.prepare(url, provider, { ...prepOpts, check: true });
37048
+ if ("next" in probe)
37049
+ return { status: "action_required", provider, name, ...probe };
37050
+ const b = await billingFor2(await deps.quote(opts.coupon, clusterOf2(url)), name, opts, deps, (price) => progress("billing", `Billing: ${price}`));
36905
37051
  if (b.stop)
36906
37052
  return { status: "action_required", provider, name, ...b.stop };
36907
37053
  ({ billing, billingPage, accepted } = b);
36908
- progress("billing", `Billing: ${billing.price}`);
36909
37054
  }
36910
37055
  if (opts.resetPassword) {
37056
+ let lock;
36911
37057
  try {
36912
- lockId = (await deps.resetLock(serverOf2(name))).lock_id;
37058
+ lock = await deps.resetLock(serverOf2(name));
36913
37059
  } catch (err) {
36914
37060
  if (err instanceof HttpStatusError && err.status === 409) {
36915
- return { status: "action_required", provider, name, next: `Another pgai connect --reset-password for ${serverOf2(name)} is running: wait for it to finish, then re-run` };
37061
+ 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)` };
36916
37062
  }
36917
37063
  if (err instanceof HttpStatusError && err.status === 404) {
36918
- return { status: "action_required", provider, name, next: `This platform cannot lock the server for --reset-password yet: set PGAI_MON_PASSWORD to ${DEFAULT_MONITORING_USER}'s password instead` };
37064
+ 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` };
36919
37065
  }
36920
37066
  throw err;
36921
37067
  }
37068
+ if (typeof lock.lock_id !== "string" || !lock.lock_id)
37069
+ throw new Error("cloud_monitoring_reset_lock returned no lock_id: nothing was changed");
37070
+ lockId = lock.lock_id;
36922
37071
  }
36923
37072
  let prepared;
36924
37073
  let created;
37074
+ let unanswered = false;
36925
37075
  try {
36926
37076
  if (lockId) {
36927
37077
  const now = await deps.list();
36928
37078
  const same = now.find((d) => d.name === name && !disconnecting2(d.status));
36929
37079
  if (same)
36930
- return { status: "action_required", provider, name, id: same.id, next: `${name} is already connected, and its monitoring uses the current password: pgai disconnect ${name} --yes first` };
37080
+ 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` };
36931
37081
  const others = othersOnServer(now);
36932
37082
  if (others.length)
36933
- return cutOff(others);
37083
+ return { ...cutOff(others), ...billing };
36934
37084
  }
36935
37085
  progress("preparing", `Preparing ${maskConnectionString(url)}`);
36936
- prepared = await deps.prepare(url, provider, { resetPassword: opts.resetPassword });
37086
+ prepared = await deps.prepare(url, provider, prepOpts);
36937
37087
  if ("next" in prepared)
36938
37088
  return { status: "action_required", provider, name, ...billing, ...prepared };
37089
+ if (!("monitoringUrl" in prepared))
37090
+ throw new Error("prepare returned no monitoring URL");
36939
37091
  if (prepared.note)
36940
37092
  note = `; ${prepared.note}`;
36941
37093
  if (opts.selfHosted) {
@@ -36952,10 +37104,14 @@ async function connect2(url, opts, deps) {
36952
37104
  }).catch(async (err) => {
36953
37105
  if (err instanceof HttpStatusError && err.status >= 400 && err.status < 500)
36954
37106
  await undo(prepared);
37107
+ else
37108
+ unanswered = true;
36955
37109
  if (err instanceof HttpStatusError && err.status === 402)
36956
37110
  return /declined/i.test(err.message) ? "declined" : "payment";
36957
37111
  if (err instanceof HttpStatusError && err.status === 412)
36958
37112
  return "price";
37113
+ if (err instanceof HttpStatusError && err.status === 409 && "storedPassword" in prepared && prepared.storedPassword && /none is stored/.test(err.message))
37114
+ return "stored";
36959
37115
  throw err;
36960
37116
  });
36961
37117
  if (created === "payment")
@@ -36965,18 +37121,26 @@ async function connect2(url, opts, deps) {
36965
37121
  }
36966
37122
  if (created === "price")
36967
37123
  return { status: "action_required", provider, name, ...billing, next: "The price changed since it was shown: re-run pgai connect to see it" };
37124
+ if (created === "stored") {
37125
+ const gone = `PostgresAI no longer keeps the password of ${DEFAULT_MONITORING_USER} for this server`;
37126
+ return { status: "action_required", provider, name, ...billing, next: opts.agent ? `${gone}: pass its URL as database_url, or run pgai connect <admin-url> --reset-password in a terminal` : `${gone}: re-run with --reset-password (anything else that logs in as ${DEFAULT_MONITORING_USER} then needs the new one), or set PGAI_MON_PASSWORD to its password` };
37127
+ }
36968
37128
  if (created.status === "failed") {
36969
37129
  await undo(prepared);
36970
37130
  return { status: "failed", provider, name, id: created.id, next: `${created.error} Re-run pgai connect later.` };
36971
37131
  }
36972
37132
  } finally {
36973
- if (lockId)
37133
+ if (lockId && !unanswered)
36974
37134
  await deps.resetUnlock(lockId).catch(() => {});
36975
37135
  }
36976
37136
  row = { id: created.id, name: created.name, provider, status: created.status, dashboard_url: null, host_metrics: !!ch };
36977
- checkup = await deps.checkup(withLocalTls2(prepared.monitoringUrl, url), created.name).catch((err) => ({ error: errorText2(err) }));
36978
- progress("checkup", checkupLines2(checkup).join(`
37137
+ if (prepared.storedPassword) {
37138
+ progress("checkup", `Express checkup skipped: it runs only as ${DEFAULT_MONITORING_USER}, whose password PostgresAI keeps for this server. The full checkup follows on the box.`);
37139
+ } else {
37140
+ checkup = await deps.checkup(withLocalTls2(prepared.monitoringUrl, url), created.name).catch((err) => ({ error: errorText2(err) }));
37141
+ progress("checkup", checkupLines2(checkup).join(`
36979
37142
  `), { checkup });
37143
+ }
36980
37144
  }
36981
37145
  let shown;
36982
37146
  for (const deadline = deps.now() + opts.waitMs;; ) {
@@ -36996,9 +37160,11 @@ async function connect2(url, opts, deps) {
36996
37160
  row = (await deps.list().catch(() => [])).find((d) => d.id === row.id) ?? row;
36997
37161
  }
36998
37162
  }
36999
- function openConnection2(url, opts) {
37163
+ async function openConnection2(url, opts) {
37000
37164
  const conn = resolveAdminConnection({ conn: url });
37001
- return connectWithSslFallback(opts.Client ?? Client2, opts.agent ? { ...conn, sslFallbackEnabled: false } : conn);
37165
+ const fallback = !opts.agent && !!conn.sslFallbackEnabled;
37166
+ const { client, usedSsl } = await connectWithSslFallback(opts.Client ?? Client2, { ...conn, sslFallbackEnabled: fallback });
37167
+ return { client, usedSsl, refusedTls: fallback && !usedSsl };
37002
37168
  }
37003
37169
  async function dropMonitoringRole2(client) {
37004
37170
  for (const sql of [`drop role ${DEFAULT_MONITORING_USER}`, `drop owned by ${DEFAULT_MONITORING_USER}; drop role ${DEFAULT_MONITORING_USER}`]) {
@@ -37031,7 +37197,7 @@ async function prepareDatabase2(url, provider, opts = {}) {
37031
37197
  throw err;
37032
37198
  }
37033
37199
  };
37034
- const { client } = await open(url).catch((err) => {
37200
+ const { client, usedSsl, refusedTls } = await open(url).catch((err) => {
37035
37201
  if (/client password must be a string/.test(String(err?.message)))
37036
37202
  throw new Error("The URL has no password (and PGPASSWORD is not set): pgai connect postgresql://user:password@host:5432/dbname");
37037
37203
  throw err;
@@ -37045,7 +37211,7 @@ async function prepareDatabase2(url, provider, opts = {}) {
37045
37211
  and has_database_privilege(current_user, current_database(), 'CREATE')
37046
37212
  and exists (select 1 from pg_extension where extname = 'pg_stat_statements')) as admin,
37047
37213
  exists (select 1 from pg_roles where rolname = '${DEFAULT_MONITORING_USER}') as mon_exists,
37048
- current_setting('scram_iterations', true) as iterations
37214
+ current_setting('scram_iterations', true) as iterations, current_setting('ssl') as ssl
37049
37215
  from pg_roles where rolname = current_user`)).rows[0];
37050
37216
  const notes = [caNote2(url)].filter((n) => !!n);
37051
37217
  const noted = () => notes.length ? { note: notes.join("; ") } : {};
@@ -37069,9 +37235,31 @@ async function prepareDatabase2(url, provider, opts = {}) {
37069
37235
  }
37070
37236
  } else if (me.admin && pgProvider !== "supabase") {
37071
37237
  const exists = `${DEFAULT_MONITORING_USER} already exists on this server`;
37238
+ const setPassword = `Set PGAI_MON_PASSWORD to the password of ${DEFAULT_MONITORING_USER}, or give it a new one: pgai connect <admin-url> --reset-password (anything else that logs in as ${DEFAULT_MONITORING_USER} then needs the new password)`;
37239
+ const needs = storedPasswordNeeds2(monitoringUrlFor2(url, me.db, ""));
37240
+ const noTls = refusedTls || !usedSsl && me.ssl === "off";
37241
+ const tlsOnly = `PostgresAI sends the password it keeps for ${DEFAULT_MONITORING_USER} only over TLS`;
37242
+ const others = opts.others?.length ? opts.others : undefined;
37243
+ const disconnectOthers = others ? disconnectEach2(others) : "pgai disconnect the server's other databases (pgai databases)";
37244
+ const reconnectOthers = `(and connect ${others ? others.join(", ") : "them"} again with it)`;
37245
+ const noTlsWayOut = opts.agent ? `Pass its URL as database_url, or run pgai connect in a terminal with PGAI_MON_PASSWORD set to its password. If nobody has it, in a terminal: ${disconnectOthers}, then pgai connect <admin-url> --reset-password with PGAI_MON_PASSWORD set to a new one ${reconnectOthers}` : `Set PGAI_MON_PASSWORD to its password, or turn on TLS on the server (ssl = on) and put sslmode=require in the URL. If nobody has the password: ${disconnectOthers}, then re-run with --reset-password and PGAI_MON_PASSWORD set to a new one ${reconnectOthers}`;
37246
+ const reuse = me.mon_exists && !opts.resetPassword && !!opts.storedPassword && !(opts.agent ? "" : process.env.PGAI_MON_PASSWORD?.trim());
37247
+ if (reuse && needs) {
37248
+ if (noTls)
37249
+ return { next: `${exists}, but this server takes no TLS, and ${tlsOnly}. ${noTlsWayOut}` };
37250
+ return { next: `${exists}. ${opts.agent ? "database_url" : "The URL"} needs ${needs}: the password PostgresAI keeps for this server is sent only over TLS, to a URL with each parameter once` };
37251
+ }
37252
+ if (reuse) {
37253
+ if (opts.check)
37254
+ return { checked: true };
37255
+ const { password: unused } = await resolveMonitoringPassword({ monitoringUser: DEFAULT_MONITORING_USER });
37256
+ const plan = await buildInitPlan({ database: me.db, monitoringPassword: unused, iterations: Number(me.iterations), includeOptionalPermissions: true, provider: pgProvider, keepExistingPassword: true });
37257
+ await applyInitPlan({ client, plan });
37258
+ return { monitoringUrl: monitoringUrlFor2(url, me.db, ""), storedPassword: true, ...noted() };
37259
+ }
37072
37260
  if (me.mon_exists && opts.agent)
37073
37261
  return { next: `${exists}. Pass its URL as database_url, or run pgai connect in a terminal with PGAI_MON_PASSWORD set to its password` };
37074
- const setPassword = `Set PGAI_MON_PASSWORD to the password of ${DEFAULT_MONITORING_USER}, or give it a new one: pgai connect <admin-url> --reset-password (anything else that logs in as ${DEFAULT_MONITORING_USER} then needs the new password)`;
37262
+ const unset = !opts.storedPassword ? setPassword : noTls ? `This server takes no TLS, and ${tlsOnly}. ${noTlsWayOut}` : !needs ? `Unset PGAI_MON_PASSWORD: PostgresAI keeps the password of ${DEFAULT_MONITORING_USER} for this server` : `Unset PGAI_MON_PASSWORD, and the URL needs ${needs}: PostgresAI keeps the password of ${DEFAULT_MONITORING_USER} for this server, and sends it only over TLS, to a URL with each parameter once`;
37075
37263
  const { password, generated } = await resolveMonitoringPassword({ passwordEnv: opts.agent ? undefined : process.env.PGAI_MON_PASSWORD, monitoringUser: DEFAULT_MONITORING_USER });
37076
37264
  const reset = !!opts.resetPassword && me.mon_exists;
37077
37265
  if (me.mon_exists && !reset) {
@@ -37081,13 +37269,16 @@ async function prepareDatabase2(url, provider, opts = {}) {
37081
37269
  try {
37082
37270
  accepted = await logsIn(loginUrlFor2(url, me.db, password));
37083
37271
  } catch (err) {
37084
- return { next: `${exists}, and PGAI_MON_PASSWORD could not be checked from this host (${err instanceof Error ? err.message : String(err)}). Run pgai connect from a host that ${DEFAULT_MONITORING_USER} may connect from` };
37272
+ const orUnset = opts.storedPassword && !noTls ? ", or unset PGAI_MON_PASSWORD: PostgresAI keeps its password for this server" : "";
37273
+ return { next: `${exists}, and PGAI_MON_PASSWORD could not be checked from this host (${err instanceof Error ? err.message : String(err)}). Run pgai connect from a host that ${DEFAULT_MONITORING_USER} may connect from${orUnset}` };
37085
37274
  }
37086
37275
  if (!accepted)
37087
- return { next: `${exists} and PGAI_MON_PASSWORD is not its password. ${setPassword}` };
37276
+ return { next: `${exists} and PGAI_MON_PASSWORD is not its password. ${unset}` };
37088
37277
  if (await acceptsAnyPassword())
37089
37278
  notes.push(unchecked("PGAI_MON_PASSWORD"));
37090
37279
  }
37280
+ if (opts.check)
37281
+ return { checked: true };
37091
37282
  const monitoringUrl = monitoringUrlFor2(url, me.db, password);
37092
37283
  const loginUrl = loginUrlFor2(url, me.db, password);
37093
37284
  const plan = await buildInitPlan({ database: me.db, monitoringPassword: password, iterations: Number(me.iterations), includeOptionalPermissions: true, provider: pgProvider, keepExistingPassword: !reset });
@@ -37135,26 +37326,17 @@ async function expressCheckup2(url, opts = {}) {
37135
37326
  }
37136
37327
  }
37137
37328
  async function saveCheckupReport2(rpc, accessToken, project, reports) {
37138
- const idOf = (fn, answer, ...keys) => {
37139
- const a = answer ?? {};
37140
- const id = Number(keys.map((k) => a[k]).find((v) => v !== undefined));
37141
- if (!(id > 0))
37142
- throw new Error(typeof a.message === "string" ? a.message : `Unexpected ${fn} answer: ${JSON.stringify(answer)}`);
37143
- return id;
37144
- };
37145
- const reportId = idOf("checkup_report_create", await rpc("checkup_report_create", { access_token: accessToken, project }), "report_id");
37329
+ const { reportId } = await createCheckupReport(rpc, { apiKey: accessToken, project });
37146
37330
  const setStatus = (status) => rpc("checkup_report_status_update", { access_token: accessToken, report_id: reportId, status });
37147
37331
  try {
37148
37332
  for (const [checkId, report] of Object.entries(reports)) {
37149
- idOf("checkup_report_file_post", await rpc("checkup_report_file_post", {
37150
- access_token: accessToken,
37151
- checkup_report_id: reportId,
37333
+ await uploadCheckupReportJson(rpc, {
37334
+ apiKey: accessToken,
37335
+ reportId,
37152
37336
  filename: `${checkId}.json`,
37153
- check_id: checkId,
37154
- data: JSON.stringify(report, null, 2),
37155
- type: "json",
37156
- generate_issue: true
37157
- }), "report_chunck_id", "report_chunk_id");
37337
+ checkId,
37338
+ jsonText: JSON.stringify(report, null, 2)
37339
+ });
37158
37340
  }
37159
37341
  } catch (err) {
37160
37342
  await setStatus("failed").catch(() => {});
@@ -37192,6 +37374,17 @@ async function clickhouseOrgFor2(host, keyId, keySecret) {
37192
37374
  }
37193
37375
  throw failed ?? notFound;
37194
37376
  }
37377
+ function disconnectBilling(reply) {
37378
+ const r = reply ?? {};
37379
+ if (r.billing_warning)
37380
+ return `not released (${r.billing_warning}): contact support`;
37381
+ if (r.billing?.subscription === "canceled")
37382
+ return "subscription canceled: no further charges; the unused part of this period is credited (prorated)";
37383
+ if (r.billing?.subscription === "active" && typeof r.billing.quantity === "number") {
37384
+ return `${r.billing.quantity} ${r.billing.quantity === 1 ? "database cluster" : "database clusters"} left on the subscription`;
37385
+ }
37386
+ return;
37387
+ }
37195
37388
  function platformDeps2(p) {
37196
37389
  const rpc = (fn, body = {}) => callRpc({ apiKey: p.apiKey, apiBaseUrl: p.apiBaseUrl, fn, body, operation: fn.replace(/_/g, " "), debug: p.debug, orgScope: p.orgScope });
37197
37390
  return {
@@ -37204,7 +37397,7 @@ function platformDeps2(p) {
37204
37397
  unprepare: (url) => unprepareDatabase2(url, { agent: p.agent }),
37205
37398
  clickhouseOrg: clickhouseOrgFor2,
37206
37399
  checkup: (url, project) => expressCheckup2(url, { agent: p.agent, save: (reports) => saveCheckupReport2(rpc, p.apiKey, project, reports) }),
37207
- quote: (coupon) => rpc("cloud_monitoring_quote", coupon ? { promo_code: coupon } : {}),
37400
+ quote: (coupon, cluster) => rpc("cloud_monitoring_quote", { ...coupon ? { promo_code: coupon } : {}, ...cluster ? { db_server: cluster } : {} }),
37208
37401
  billingUrl: (orgAlias) => `${p.uiBaseUrl}/${orgAlias}/billing`,
37209
37402
  handoffUrl: async (provider) => {
37210
37403
  const orgs = await listOrgs({ apiKey: p.apiKey, apiBaseUrl: p.apiBaseUrl });
@@ -39329,10 +39522,6 @@ async function applyUninitPlan(params) {
39329
39522
  }
39330
39523
  return { applied, errors };
39331
39524
  }
39332
- async function legacyHelperWarnings(client) {
39333
- const r = await client.query("select to_regprocedure('postgres_ai.explain_generic(text,text,text)') is not null as present");
39334
- return r.rows?.[0]?.present ? ["legacy SECURITY DEFINER helper postgres_ai.explain_generic(text, text, text) is still installed; its owner or a superuser must drop it"] : [];
39335
- }
39336
39525
  async function verifyInitSetup2(params) {
39337
39526
  await params.client.query("begin isolation level repeatable read;");
39338
39527
  try {
@@ -40497,7 +40686,7 @@ function feedbackMessage() {
40497
40686
  }
40498
40687
 
40499
40688
  // bin/postgres-ai.ts
40500
- import { createInterface } from "readline";
40689
+ import { createInterface as createInterface2 } from "readline";
40501
40690
  import { Writable } from "stream";
40502
40691
  import * as childProcess from "child_process";
40503
40692
 
@@ -40505,7 +40694,7 @@ import * as childProcess from "child_process";
40505
40694
  import * as fs9 from "fs";
40506
40695
  import * as path8 from "path";
40507
40696
  var __dirname = "/builds/postgres-ai/postgresai/cli/lib";
40508
- var CONTRACT_VERSION2 = "1.1.0";
40697
+ var CONTRACT_VERSION2 = "1.0.0";
40509
40698
  var SECONDS_PER_DAY2 = 86400;
40510
40699
  var SECONDS_PER_HOUR2 = 3600;
40511
40700
  var SECONDS_PER_MINUTE2 = 60;
@@ -43090,48 +43279,11 @@ async function verifyApiKey(params) {
43090
43279
  clearTimeout(timer);
43091
43280
  }
43092
43281
  }
43093
- async function createCheckupReport(params) {
43094
- const { apiKey, apiBaseUrl, project, status } = params;
43095
- const bodyObj = {
43096
- access_token: apiKey,
43097
- project
43098
- };
43099
- if (status)
43100
- bodyObj.status = status;
43101
- const resp = await postRpc({
43102
- apiKey,
43103
- apiBaseUrl,
43104
- rpcName: "checkup_report_create",
43105
- bodyObj
43106
- });
43107
- const reportId = Number(resp?.report_id);
43108
- if (!Number.isFinite(reportId) || reportId <= 0) {
43109
- throw new Error(`Unexpected checkup_report_create response: ${JSON.stringify(resp)}`);
43110
- }
43111
- return { reportId };
43282
+ async function createCheckupReport2(params) {
43283
+ return createCheckupReport((rpcName, bodyObj) => postRpc({ ...params, rpcName, bodyObj }), params);
43112
43284
  }
43113
- async function uploadCheckupReportJson(params) {
43114
- const { apiKey, apiBaseUrl, reportId, filename, checkId, jsonText } = params;
43115
- const bodyObj = {
43116
- access_token: apiKey,
43117
- checkup_report_id: reportId,
43118
- filename,
43119
- check_id: checkId,
43120
- data: jsonText,
43121
- type: "json",
43122
- generate_issue: true
43123
- };
43124
- const resp = await postRpc({
43125
- apiKey,
43126
- apiBaseUrl,
43127
- rpcName: "checkup_report_file_post",
43128
- bodyObj
43129
- });
43130
- const chunkId = Number(resp?.report_chunck_id ?? resp?.report_chunk_id);
43131
- if (!Number.isFinite(chunkId) || chunkId <= 0) {
43132
- throw new Error(`Unexpected checkup_report_file_post response: ${JSON.stringify(resp)}`);
43133
- }
43134
- return { reportChunkId: chunkId };
43285
+ async function uploadCheckupReportJson2(params) {
43286
+ return uploadCheckupReportJson((rpcName, bodyObj) => postRpc({ ...params, rpcName, bodyObj }), params);
43135
43287
  }
43136
43288
  async function convertCheckupReportJsonToMarkdown(params) {
43137
43289
  const { apiKey, apiBaseUrl, checkId, jsonPayload, reportType } = params;
@@ -43525,21 +43677,45 @@ function summarizeG0032(nodeData) {
43525
43677
  }
43526
43678
 
43527
43679
  // lib/json-stderr.ts
43680
+ import { spawn, spawnSync } from "node:child_process";
43681
+ import { createInterface } from "node:readline";
43528
43682
  import { format } from "node:util";
43529
43683
  function writeEvent(event) {
43530
43684
  process.stderr.write(`${JSON.stringify(event)}
43531
43685
  `);
43532
43686
  }
43533
- async function withJsonStderr(fn) {
43687
+ function jsonConsole() {
43534
43688
  const { error, warn } = console;
43535
- console.error = (...args) => writeEvent({ event: "log", level: "error", message: format(...args) });
43536
- console.warn = (...args) => writeEvent({ event: "log", level: "warn", message: format(...args) });
43537
- try {
43538
- return await fn();
43539
- } finally {
43689
+ const log = (level) => (...args) => {
43690
+ const message = format(...args);
43691
+ writeEvent({ event: "log", level: /^\s*Debug:/.test(message) ? "debug" : /^\s*(?:\[[^\]]+\]\s*)?Warning:/.test(message) ? "warn" : level, message });
43692
+ };
43693
+ console.error = log("error");
43694
+ console.warn = log("warn");
43695
+ return () => {
43540
43696
  console.error = error;
43541
43697
  console.warn = warn;
43542
- }
43698
+ };
43699
+ }
43700
+ var LOGIN = /^(\s*(?:Login|VictoriaMetrics Auth): ).+$/;
43701
+ function runChild(command, args, env, json, source) {
43702
+ if (!json)
43703
+ return Promise.resolve(spawnSync(command, args, { stdio: ["ignore", 2, 2], env }).status);
43704
+ return new Promise((resolve) => {
43705
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], env });
43706
+ for (const [stream, level] of [[child.stdout, "info"], [child.stderr, "error"]]) {
43707
+ createInterface({ input: stream }).on("line", (line) => {
43708
+ const message = line.replace(LOGIN, "$1***** (pgai mon show-grafana-credentials)");
43709
+ if (line.trim() !== "")
43710
+ writeEvent({ event: "log", level, source, message });
43711
+ });
43712
+ }
43713
+ child.on("error", (err) => {
43714
+ writeEvent({ event: "log", level: "error", source, message: err.message });
43715
+ resolve(null);
43716
+ });
43717
+ child.on("close", (code) => resolve(code));
43718
+ });
43543
43719
  }
43544
43720
 
43545
43721
  // lib/instances.ts
@@ -43687,12 +43863,22 @@ function buildClientConfig(connStr, extra = {}) {
43687
43863
  port: parsed.port ? Number(parsed.port) : undefined,
43688
43864
  user: parsed.user,
43689
43865
  password: parsed.password,
43690
- database: parsed.database || undefined,
43866
+ database: databaseName3(withoutSslmode(channelBinding.uri), parsed.database),
43691
43867
  ssl: sslOptionFromSslmode(sslmode),
43692
43868
  ...enableChannelBinding ? { enableChannelBinding: true } : {},
43693
43869
  ...extra
43694
43870
  };
43695
43871
  }
43872
+ function databaseName3(connStr, parsed) {
43873
+ if (!parsed)
43874
+ return;
43875
+ try {
43876
+ const u = new URL(connStr);
43877
+ if (/^postgres(ql)?:$/.test(u.protocol))
43878
+ return decodeURIComponent(u.pathname.slice(1));
43879
+ } catch {}
43880
+ return parsed;
43881
+ }
43696
43882
  function withoutSslmode(connStr) {
43697
43883
  try {
43698
43884
  const u = new URL(connStr);
@@ -43736,7 +43922,7 @@ Download: https://nodejs.org/`);
43736
43922
  var rl = null;
43737
43923
  function getReadline() {
43738
43924
  if (!rl) {
43739
- rl = createInterface({ input: process.stdin, output: process.stdout });
43925
+ rl = createInterface2({ input: process.stdin, output: process.stdout });
43740
43926
  }
43741
43927
  return rl;
43742
43928
  }
@@ -43961,7 +44147,7 @@ async function execFilePromise(file, args, options = {}) {
43961
44147
  });
43962
44148
  });
43963
44149
  }
43964
- function spawnSync2(cmd, args, options) {
44150
+ function spawnSync3(cmd, args, options) {
43965
44151
  const result = childProcess.spawnSync(cmd, args, {
43966
44152
  stdio: options?.stdio === "inherit" ? "inherit" : "pipe",
43967
44153
  env: options?.env,
@@ -43974,7 +44160,7 @@ function spawnSync2(cmd, args, options) {
43974
44160
  stderr: typeof result.stderr === "string" ? result.stderr : ""
43975
44161
  };
43976
44162
  }
43977
- function spawn2(cmd, args, options) {
44163
+ function spawn3(cmd, args, options) {
43978
44164
  const proc = childProcess.spawn(cmd, args, {
43979
44165
  stdio: options?.stdio ?? "pipe",
43980
44166
  env: options?.env,
@@ -44005,7 +44191,7 @@ async function question(prompt) {
44005
44191
  }
44006
44192
  async function questionHidden(prompt) {
44007
44193
  closeReadline();
44008
- const hidden = createInterface({ input: process.stdin, output: new Writable({ write: (_chunk, _encoding, done) => done() }), terminal: true });
44194
+ const hidden = createInterface2({ input: process.stdin, output: new Writable({ write: (_chunk, _encoding, done) => done() }), terminal: true });
44009
44195
  process.stdout.write(prompt);
44010
44196
  return new Promise((resolve) => {
44011
44197
  hidden.question("", (answer) => {
@@ -44136,7 +44322,7 @@ function prepareUploadConfig(opts, rootOpts, shouldUpload, uploadExplicitlyReque
44136
44322
  }
44137
44323
  async function uploadCheckupReports(uploadCfg, reports, spinner, logUpload) {
44138
44324
  spinner.update("Creating remote checkup report");
44139
- const created = await withRetry(() => createCheckupReport({
44325
+ const created = await withRetry(() => createCheckupReport2({
44140
44326
  apiKey: uploadCfg.apiKey,
44141
44327
  apiBaseUrl: uploadCfg.apiBaseUrl,
44142
44328
  project: uploadCfg.project
@@ -44149,7 +44335,7 @@ async function uploadCheckupReports(uploadCfg, reports, spinner, logUpload) {
44149
44335
  for (const [checkId, report] of Object.entries(reports)) {
44150
44336
  spinner.update(`Uploading ${checkId}.json`);
44151
44337
  const jsonText = JSON.stringify(report, null, 2);
44152
- const r = await withRetry(() => uploadCheckupReportJson({
44338
+ const r = await withRetry(() => uploadCheckupReportJson2({
44153
44339
  apiKey: uploadCfg.apiKey,
44154
44340
  apiBaseUrl: uploadCfg.apiBaseUrl,
44155
44341
  reportId,
@@ -44290,47 +44476,12 @@ function sanitizeTagForBackup(tag) {
44290
44476
  const stripped = stripMatchingQuotes(tag);
44291
44477
  return /^[A-Za-z0-9._-]{1,64}$/.test(stripped) ? stripped : null;
44292
44478
  }
44293
- function planUpdateTag(current, cliVersion) {
44294
- const semver = (v) => {
44295
- const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(v);
44296
- return m ? { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] } : null;
44297
- };
44298
- const cli = semver(cliVersion);
44299
- if (!cli || cli.pre) {
44300
- return { tag: null, note: `PGAI_TAG stays ${current ?? "unset"}: this CLI (${cliVersion}) is not a release. To pick a stack version, set PGAI_TAG=<version> in .env and re-run 'postgresai mon update'` };
44301
- }
44302
- if (current === cliVersion)
44303
- return { tag: null, note: `PGAI_TAG is ${cliVersion}, matching this CLI` };
44304
- const cur = current ? semver(current) : null;
44305
- if (cur) {
44306
- const i = cur.core.findIndex((part, j) => part !== cli.core[j]);
44307
- if (i >= 0 && cur.core[i] > cli.core[i]) {
44308
- return { tag: null, note: `PGAI_TAG stays ${current}: it is newer than this CLI (${cliVersion}). Upgrade the CLI to move the stack: npm install -g postgresai@latest` };
44309
- }
44310
- }
44311
- return { tag: cliVersion, note: `PGAI_TAG: ${current ?? "unset"} -> ${cliVersion}` };
44312
- }
44313
- var ENV_TAG_LINE = /^([ \t]*(?:export[ \t]+)?PGAI_TAG[ \t]*=[ \t]*)(["']?)([A-Za-z0-9._-]*)\2((?:[ \t]+#[^\r\n]*)?[ \t]*\r?)$/gm;
44314
- var ENV_TAG_ANY = /^[ \t]*(?:export[ \t]+)?PGAI_TAG[ \t]*[=:]/gm;
44315
- function readEnvTag(projectDir) {
44479
+ function readDeployedTag(projectDir) {
44316
44480
  const envFile = path9.resolve(projectDir, ".env");
44317
44481
  if (!fs11.existsSync(envFile))
44318
- return { tag: null, plain: true };
44319
- const content = fs11.readFileSync(envFile, "utf8");
44320
- const plainLines = [...content.matchAll(ENV_TAG_LINE)];
44321
- if (plainLines.length !== [...content.matchAll(ENV_TAG_ANY)].length)
44322
- return { tag: null, plain: false };
44323
- const last = plainLines.at(-1);
44324
- return { tag: last && last[3] ? last[3] : null, plain: true };
44325
- }
44326
- function writeEnvTag(projectDir, tag) {
44327
- const envFile = path9.resolve(projectDir, ".env");
44328
- const existing = fs11.existsSync(envFile) ? fs11.readFileSync(envFile, "utf8") : "";
44329
- const content = new RegExp(ENV_TAG_LINE.source, "m").test(existing) ? existing.replace(ENV_TAG_LINE, (_match, prefix, quote, _value, rest) => `${prefix}${quote}${tag}${quote}${rest}`) : `${existing}${existing && !existing.endsWith(`
44330
- `) ? `
44331
- ` : ""}PGAI_TAG=${tag}
44332
- `;
44333
- writeEnvFile(envFile, content);
44482
+ return null;
44483
+ const m = fs11.readFileSync(envFile, "utf8").match(/^PGAI_TAG=(.+)$/m);
44484
+ return m ? sanitizeTagForBackup(m[1]) : null;
44334
44485
  }
44335
44486
  function isValidComposeYaml(text) {
44336
44487
  const trimmed = text.trim();
@@ -44381,7 +44532,7 @@ async function refreshBundledComposeIfStale(projectDir, oldTag) {
44381
44532
  const existing = fs11.readFileSync(composeFile, "utf8");
44382
44533
  if (composeContentEqual(existing, fetched))
44383
44534
  return false;
44384
- const deployedTag = sanitizeTagForBackup(oldTag ?? readEnvTag(projectDir).tag);
44535
+ const deployedTag = sanitizeTagForBackup(oldTag ?? readDeployedTag(projectDir));
44385
44536
  const tagPart = deployedTag ?? new Date().toISOString().replace(/[:.]/g, "-");
44386
44537
  const oldHash = crypto2.createHash("sha256").update(existing).digest("hex").slice(0, 8);
44387
44538
  const backup = path9.resolve(projectDir, `docker-compose.yml.bak-${tagPart}-${oldHash}`);
@@ -44431,6 +44582,56 @@ function printResult(result, json) {
44431
44582
  var program2 = new Command2;
44432
44583
  program2.name("postgres-ai").description("PostgresAI CLI").version(package_default.version).option("--api-key <key>", "API key (overrides PGAI_API_KEY)").option("--api-base-url <url>", "API base URL for backend RPC (overrides PGAI_API_BASE_URL)").option("--ui-base-url <url>", "UI base URL for browser routes (overrides PGAI_UI_BASE_URL)").option("--storage-base-url <url>", "Storage base URL for file uploads (overrides PGAI_STORAGE_BASE_URL)");
44433
44584
  program2.showHelpAfterError();
44585
+ program2.configureOutput({
44586
+ outputError: (text, write) => jsonConnectArgv() ? writeEvent({ event: "log", level: "error", message: text.trim().replace(/^(error: unknown option ')(--[^=]*|-[^-])[\s\S]*'(?=\n\(Did you mean [^\n]*\)$|$)/, "$1$2'") }) : write(text),
44587
+ writeErr: (text) => void (jsonConnectArgv() || process.stderr.write(text))
44588
+ });
44589
+ function splitArgv(options, argv) {
44590
+ const operands = [];
44591
+ const unknown = [];
44592
+ let dest = operands;
44593
+ let json = false;
44594
+ for (let i = 0;i < argv.length; i++) {
44595
+ const arg = argv[i];
44596
+ if (arg === "--") {
44597
+ dest.push(...dest === unknown ? argv.slice(i) : argv.slice(i + 1));
44598
+ break;
44599
+ }
44600
+ const option = options.find((o) => o.long === arg || o.short === arg);
44601
+ if (option) {
44602
+ if (option.required)
44603
+ i++;
44604
+ else if (option.long === "--json")
44605
+ json = true;
44606
+ continue;
44607
+ }
44608
+ if (options.some((o) => o.required && arg.startsWith(`${o.long}=`)))
44609
+ continue;
44610
+ if (dest === operands && arg.length > 1 && arg.startsWith("-"))
44611
+ dest = unknown;
44612
+ dest.push(arg);
44613
+ }
44614
+ return { operands, unknown, json };
44615
+ }
44616
+ function argvRun(argv = process.argv.slice(2)) {
44617
+ const root = splitArgv(program2.options, argv);
44618
+ let command = program2.commands.find((c) => c.name() === root.operands[0]);
44619
+ let rest = root.unknown;
44620
+ if (root.operands.length === 0) {
44621
+ const end = rest.indexOf("--");
44622
+ const at = rest.slice(0, end < 0 ? undefined : end).indexOf("connect");
44623
+ if (at >= 0)
44624
+ [command, rest] = [program2.commands.find((c) => c.name() === "connect"), rest.slice(at + 1)];
44625
+ }
44626
+ return { command, json: !!command && splitArgv(command.options, rest).json };
44627
+ }
44628
+ function jsonConnect(command, json) {
44629
+ return command?.parent === program2 && command.name() === "connect" && jsonOutput(json);
44630
+ }
44631
+ function jsonConnectArgv() {
44632
+ const run = argvRun();
44633
+ return jsonConnect(run.command, run.json);
44634
+ }
44434
44635
  program2.addHelpText("after", () => `
44435
44636
  \uD83D\uDCA1 Ideas / feedback: ${FEEDBACK_URL}
44436
44637
  `);
@@ -44452,9 +44653,15 @@ function withOrgOptions(command) {
44452
44653
  ORG_SCOPED_COMMANDS.add(command);
44453
44654
  return command.option("--org <alias>", `organization alias (or ${ORG_ENV}); required with a global token`).option("--org-id <id>", `organization id (or ${ORG_ID_ENV}); alternative to --org`);
44454
44655
  }
44455
- function jsonConnect(command) {
44456
- return command.parent === program2 && command.name() === "connect" && (!!command.opts().json || !process.stdout.isTTY);
44457
- }
44656
+ var restoreConsole;
44657
+ program2.hook("preAction", (_thisCommand, actionCommand) => {
44658
+ if (jsonConnect(actionCommand, actionCommand.opts().json))
44659
+ restoreConsole = jsonConsole();
44660
+ });
44661
+ program2.hook("postAction", () => {
44662
+ restoreConsole?.();
44663
+ restoreConsole = undefined;
44664
+ });
44458
44665
  program2.hook("preAction", (_thisCommand, actionCommand) => {
44459
44666
  const initPointsToConnect = actionCommand.parent === program2 && actionCommand.name() === "init" && !interactive(actionCommand.opts().json);
44460
44667
  if (!ORG_SCOPED_COMMANDS.has(actionCommand) || initPointsToConnect) {
@@ -44467,10 +44674,7 @@ program2.hook("preAction", (_thisCommand, actionCommand) => {
44467
44674
  setActiveOrgScope(requireOrgScope(opts, apiKey));
44468
44675
  } catch (err) {
44469
44676
  if (err instanceof OrgScopeError2) {
44470
- if (jsonConnect(actionCommand))
44471
- writeEvent({ event: "log", level: "error", message: err.message });
44472
- else
44473
- console.error(err.message);
44677
+ console.error(err.message);
44474
44678
  process.exit(1);
44475
44679
  }
44476
44680
  throw err;
@@ -44485,7 +44689,7 @@ program2.command("feedback").description("share ideas or feedback about the CLI"
44485
44689
  if (opts.open) {
44486
44690
  const openCommand = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
44487
44691
  try {
44488
- spawn2(openCommand, [FEEDBACK_URL], { detached: true, stdio: "ignore" }).unref();
44692
+ spawn3(openCommand, [FEEDBACK_URL], { detached: true, stdio: "ignore" }).unref();
44489
44693
  } catch {}
44490
44694
  }
44491
44695
  });
@@ -44692,7 +44896,6 @@ program2.command("prepare-db [conn]").description("prepare database for monitori
44692
44896
  monitoringUser: opts.monitoringUser,
44693
44897
  includeOptionalPermissions
44694
44898
  });
44695
- v.missingOptional.push(...await legacyHelperWarnings(supabaseClient));
44696
44899
  if (v.ok) {
44697
44900
  if (jsonOutput) {
44698
44901
  const result = {
@@ -44815,7 +45018,6 @@ program2.command("prepare-db [conn]").description("prepare database for monitori
44815
45018
  client: supabaseClient,
44816
45019
  plan: effectivePlan
44817
45020
  });
44818
- const legacyWarnings = await legacyHelperWarnings(supabaseClient);
44819
45021
  if (jsonOutput) {
44820
45022
  const result = {
44821
45023
  success: true,
@@ -44825,10 +45027,7 @@ program2.command("prepare-db [conn]").description("prepare database for monitori
44825
45027
  monitoringUser: opts.monitoringUser,
44826
45028
  applied,
44827
45029
  skippedOptional,
44828
- warnings: [
44829
- ...skippedOptional.length > 0 ? ["Some optional steps were skipped (not supported or insufficient privileges)"] : [],
44830
- ...legacyWarnings
44831
- ]
45030
+ warnings: skippedOptional.length > 0 ? ["Some optional steps were skipped (not supported or insufficient privileges)"] : []
44832
45031
  };
44833
45032
  if (passwordGenerated) {
44834
45033
  result.generatedPassword = monPassword;
@@ -44844,8 +45043,6 @@ program2.command("prepare-db [conn]").description("prepare database for monitori
44844
45043
  for (const s of skippedOptional)
44845
45044
  console.error(`- ${s}`);
44846
45045
  }
44847
- for (const w of legacyWarnings)
44848
- console.error(`\u26A0 ${w}`);
44849
45046
  if (process.stdout.isTTY) {
44850
45047
  console.log(`Applied ${applied.length} steps`);
44851
45048
  }
@@ -44987,7 +45184,6 @@ program2.command("prepare-db [conn]").description("prepare database for monitori
44987
45184
  includeOptionalPermissions,
44988
45185
  provider
44989
45186
  });
44990
- v.missingOptional.push(...await legacyHelperWarnings(client));
44991
45187
  if (v.ok) {
44992
45188
  if (jsonOutput) {
44993
45189
  outputJson({
@@ -45106,7 +45302,6 @@ program2.command("prepare-db [conn]").description("prepare database for monitori
45106
45302
  return;
45107
45303
  }
45108
45304
  const { applied, skippedOptional } = await applyInitPlan2({ client, plan: effectivePlan });
45109
- const legacyWarnings = await legacyHelperWarnings(client);
45110
45305
  if (jsonOutput) {
45111
45306
  const result = {
45112
45307
  success: true,
@@ -45116,10 +45311,7 @@ program2.command("prepare-db [conn]").description("prepare database for monitori
45116
45311
  monitoringUser: opts.monitoringUser,
45117
45312
  applied,
45118
45313
  skippedOptional,
45119
- warnings: [
45120
- ...skippedOptional.length > 0 ? ["Some optional steps were skipped (not supported or insufficient privileges)"] : [],
45121
- ...legacyWarnings
45122
- ]
45314
+ warnings: skippedOptional.length > 0 ? ["Some optional steps were skipped (not supported or insufficient privileges)"] : []
45123
45315
  };
45124
45316
  if (passwordGenerated) {
45125
45317
  result.generatedPassword = monPassword;
@@ -45132,8 +45324,6 @@ program2.command("prepare-db [conn]").description("prepare database for monitori
45132
45324
  for (const s of skippedOptional)
45133
45325
  console.error(`- ${s}`);
45134
45326
  }
45135
- for (const w of legacyWarnings)
45136
- console.error(`\u26A0 ${w}`);
45137
45327
  if (process.stdout.isTTY) {
45138
45328
  console.log(`Applied ${applied.length} steps`);
45139
45329
  }
@@ -45784,7 +45974,7 @@ function ensureHostMetricsDir(projectDir) {
45784
45974
  }
45785
45975
  function isDockerRunning() {
45786
45976
  try {
45787
- const result = spawnSync2("docker", ["info"], { stdio: "pipe", timeout: 5000 });
45977
+ const result = spawnSync3("docker", ["info"], { stdio: "pipe", timeout: 5000 });
45788
45978
  return result.status === 0;
45789
45979
  } catch {
45790
45980
  return false;
@@ -45797,7 +45987,7 @@ function shouldScaleOutNodeExporter(args) {
45797
45987
  return !args.slice(upIndex + 1).some((a) => !a.startsWith("-"));
45798
45988
  }
45799
45989
  function getComposeCmd() {
45800
- const tryCmd = (cmd, args) => spawnSync2(cmd, args, { stdio: "ignore", timeout: 5000 }).status === 0;
45990
+ const tryCmd = (cmd, args) => spawnSync3(cmd, args, { stdio: "ignore", timeout: 5000 }).status === 0;
45801
45991
  if (tryCmd("docker", ["compose", "version"]))
45802
45992
  return ["docker", "compose"];
45803
45993
  if (tryCmd("docker-compose", ["version"]))
@@ -45806,7 +45996,7 @@ function getComposeCmd() {
45806
45996
  }
45807
45997
  function checkRunningContainers() {
45808
45998
  try {
45809
- const result = spawnSync2("docker", ["ps", "--filter", "name=grafana-with-datasources", "--filter", "name=pgwatch", "--format", "{{.Names}}"], { stdio: "pipe", encoding: "utf8", timeout: 5000 });
45999
+ const result = spawnSync3("docker", ["ps", "--filter", "name=grafana-with-datasources", "--filter", "name=pgwatch", "--format", "{{.Names}}"], { stdio: "pipe", encoding: "utf8", timeout: 5000 });
45810
46000
  if (result.status === 0 && result.stdout) {
45811
46001
  const containers = result.stdout.trim().split(`
45812
46002
  `).filter(Boolean);
@@ -46151,7 +46341,7 @@ async function runCompose(args, grafanaPassword, extraEnv) {
46151
46341
  finalArgs.push("--scale", "self-node-exporter=0");
46152
46342
  }
46153
46343
  return new Promise((resolve) => {
46154
- const child = spawn2(cmd[0], [...cmd.slice(1), "-f", composeFile, ...finalArgs], {
46344
+ const child = spawn3(cmd[0], [...cmd.slice(1), "-f", composeFile, ...finalArgs], {
46155
46345
  stdio: "inherit",
46156
46346
  env,
46157
46347
  cwd: projectDir
@@ -46341,7 +46531,7 @@ function emitConnect(result, json) {
46341
46531
  process.exitCode = CONNECT_EXIT[result.status];
46342
46532
  }
46343
46533
  function failCloud(err, json) {
46344
- printResult({ status: "failed", next: err instanceof Error ? err.message : String(err) }, json);
46534
+ printResult({ status: "failed", next: errorText2(err) }, json);
46345
46535
  process.exitCode = 1;
46346
46536
  }
46347
46537
  function signedIn(opts) {
@@ -46352,22 +46542,8 @@ function signedIn(opts) {
46352
46542
  }
46353
46543
  return !!cloudApi().apiKey;
46354
46544
  }
46355
- function runConnect(url, opts) {
46356
- return opts.json || !process.stdout.isTTY ? withJsonStderr(() => connectCommand(url, opts, true)) : connectCommand(url, opts, false);
46357
- }
46358
- function runLocalInstall(args, env, json) {
46359
- if (!json)
46360
- return Promise.resolve(childProcess.spawnSync(process.execPath, args, { stdio: ["ignore", 2, 2], env }).status);
46361
- const child = childProcess.spawn(process.execPath, args, { stdio: ["ignore", "pipe", "pipe"], env });
46362
- for (const stream of [child.stdout, child.stderr]) {
46363
- createInterface({ input: stream }).on("line", (line) => writeEvent({ event: "log", level: "info", source: "mon local-install", message: line }));
46364
- }
46365
- return new Promise((resolve) => {
46366
- child.on("error", () => resolve(null));
46367
- child.on("close", (code) => resolve(code));
46368
- });
46369
- }
46370
- async function connectCommand(url, opts, json) {
46545
+ async function runConnect(url, opts) {
46546
+ const json = jsonOutput(opts.json);
46371
46547
  const name = (() => {
46372
46548
  try {
46373
46549
  return databaseName2(url);
@@ -46394,7 +46570,7 @@ async function connectCommand(url, opts, json) {
46394
46570
  const scope = getActiveOrgScope2();
46395
46571
  const org = scope?.alias ? ["--org", scope.alias] : scope?.id ? ["--org-id", String(scope.id)] : [];
46396
46572
  const project = name.replace(/[^A-Za-z0-9._-]+/g, "-");
46397
- const status = await runLocalInstall([process.argv[1], "mon", "local-install", "-y", ...org, "--project", project], { ...process.env, ...env, PGAI_DB_URL: monitoringUrl, PGAI_API_BASE_URL: api.apiBaseUrl, ...api.apiKey ? { PGAI_API_KEY: api.apiKey } : {} }, json);
46573
+ const status = await runChild(process.execPath, [process.argv[1], "mon", "local-install", "-y", ...org, "--project", project], { ...process.env, ...env, PGAI_DB_URL: monitoringUrl, PGAI_API_BASE_URL: api.apiBaseUrl, ...api.apiKey ? { PGAI_API_KEY: api.apiKey } : {} }, json, "mon local-install");
46398
46574
  if (status !== 0)
46399
46575
  throw new Error("mon local-install failed (see above)");
46400
46576
  },
@@ -46412,31 +46588,32 @@ async function connectCommand(url, opts, json) {
46412
46588
  });
46413
46589
  emitConnect(result, opts.json);
46414
46590
  } catch (err) {
46415
- emitConnect({ status: "failed", provider, name, next: err instanceof Error ? err.message : String(err) }, opts.json);
46591
+ emitConnect({ status: "failed", provider, name, next: errorText2(err) }, opts.json);
46416
46592
  }
46417
46593
  }
46418
- withOrgOptions(program2.command("connect <database-url>")).configureOutput({
46419
- writeErr: (text) => {
46420
- if (!(process.argv.includes("--json") || !process.stdout.isTTY))
46421
- return void process.stderr.write(text);
46422
- for (const line of text.split(`
46423
- `).filter((l) => l.trim() !== ""))
46424
- writeEvent({ event: "log", level: "error", message: line });
46425
- }
46426
- }).description("put a database under PostgresAI's care: prepare it, provision monitoring, print the dashboard").option("--provider <provider>", "clickhouse | rds | supabase | self-managed (default: detected from the host)").option("--clickhouse-key <id:secret>", "ClickHouse Cloud API key (Basic Service API Reader) for CPU, memory and disk; or CLICKHOUSE_KEY_ID + CLICKHOUSE_KEY_SECRET").option("--self-hosted", "run the monitoring stack on this machine (mon local-install) instead of PostgresAI Cloud").option("--reset-password", "postgres_ai_mon exists and its password is lost: set a new one (admin URL; refused while another database on the server is monitored)").option("--wait <minutes>", "how long to wait for the monitoring box (0 = do not wait)", "20").option("--coupon <code>", "promotion code for the organization's monitoring subscription (applies when it is first created)").option("-y, --yes", "never prompt: accept the price of a billed box").option("--json", "JSON output (default when stdout is not a TTY)").option("--debug", "print HTTP requests (secrets masked)").addHelpText("after", [
46594
+ withOrgOptions(program2.command("connect <database-url>")).description("put a database under PostgresAI's care: prepare it, provision monitoring, print the dashboard").option("--provider <provider>", "clickhouse | rds | supabase | self-managed (default: detected from the host)").option("--clickhouse-key <id:secret>", "ClickHouse Cloud API key (Basic Service API Reader) for CPU, memory and disk; or CLICKHOUSE_KEY_ID + CLICKHOUSE_KEY_SECRET").option("--self-hosted", "run the monitoring stack on this machine (mon local-install) instead of PostgresAI Cloud").option("--reset-password", "postgres_ai_mon exists and its password is lost: set a new one (admin URL; refused while another database on the server is monitored)").option("--wait <minutes>", "how long to wait for the monitoring box (0 = do not wait)", "20").option("--coupon <code>", "promotion code for the organization's monitoring subscription (applies when it is first created)").option("-y, --yes", "never prompt: accept the price of a billed box").option("--json", "JSON output (default when stdout is not a TTY)").option("--debug", "print HTTP requests (secrets masked)").addHelpText("after", [
46427
46595
  "",
46428
46596
  "Steps (each skipped when already done; safe to re-run): sign in, prepare the database",
46429
46597
  "(an admin URL creates the postgres_ai_mon role; otherwise the SQL is printed), provision",
46430
46598
  "the monitoring box, run the express checkup while it starts, wait, print the dashboard URL.",
46431
46599
  "",
46432
- 'Billing: before a new box, connect shows its price (or "free (N of M free slots)"). A billed',
46600
+ "Billing is per Postgres cluster observed: another database in a cluster the organization already",
46601
+ 'monitors is included. Once the URL is checked, connect shows the price (or "free (N of M free slots)"). A billed',
46433
46602
  "box is provisioned only when accepted: at the prompt, or with --yes. With no payment method",
46434
46603
  "it stops (exit 3) and names the console page to add one. --coupon applies a promotion code.",
46435
46604
  "",
46436
46605
  'Exit codes: 0 connected or provisioning, 1 failed, 3 action required (see "next").',
46437
46606
  "",
46607
+ "JSON output (--json, or stdout not a TTY): stdout is the result; stderr is one JSON event a",
46608
+ 'line: the steps, and {"event":"log","level":"error"|"warn"|"info"|"debug","message":...}',
46609
+ 'for any other text (an error, a warning, --debug); "source":"mon local-install" for that',
46610
+ "child's lines (the logins it prints are masked: pgai mon show-grafana-credentials shows them).",
46611
+ "",
46438
46612
  "Environment: PGAI_API_KEY (instead of signing in), PGAI_MON_PASSWORD (the password of",
46439
- "postgres_ai_mon when the role already exists; it is checked, never changed),",
46613
+ "postgres_ai_mon when the role already exists; it is checked, never changed; not needed",
46614
+ "for another database on a server PostgresAI already monitors for the org, with",
46615
+ "sslmode=require or verify-* in the URL: PostgresAI fills in the password it keeps (over TLS only), and",
46616
+ "the express checkup is skipped, as it runs only as postgres_ai_mon),",
46440
46617
  "PGPASSWORD (the password for a URL without one),",
46441
46618
  "CLICKHOUSE_KEY_ID + CLICKHOUSE_KEY_SECRET (instead of --clickhouse-key).",
46442
46619
  "",
@@ -46447,6 +46624,9 @@ withOrgOptions(program2.command("connect <database-url>")).configureOutput({
46447
46624
  ].join(`
46448
46625
  `)).action(runConnect);
46449
46626
  var interactive = (json) => !!process.stdin.isTTY && !!process.stdout.isTTY && !json;
46627
+ function jsonOutput(json) {
46628
+ return !!json || !process.stdout.isTTY;
46629
+ }
46450
46630
  withOrgOptions(program2.command("init")).description("first run for a person at a terminal: sign in, ask for the database URL, then pgai connect").option("--json", "JSON output (init is interactive: prints the pgai connect command to use instead)").action(async (opts) => {
46451
46631
  if (!interactive(opts.json)) {
46452
46632
  return emitConnect({ status: "action_required", provider: "self-managed", name: "", next: "pgai init is for a person at a terminal; agents and scripts: pgai connect <database-url>" }, opts.json);
@@ -46489,8 +46669,8 @@ withOrgOptions(program2.command("disconnect <name>")).description("stop monitori
46489
46669
  if (!opts.yes && !(process.stdin.isTTY && /^y/i.test(await question(`Disconnect ${row.name} and delete its monitoring box? (y/N): `)))) {
46490
46670
  return emitConnect({ status: "action_required", provider: row.provider, name: row.name, id: row.id, next: `pgai disconnect ${row.name} --yes` }, opts.json);
46491
46671
  }
46492
- await cloudApi(opts.debug).disconnect(row.id);
46493
- printResult({ status: "disconnected", name: row.name, id: row.id, next: row.host_metrics ? "Delete the ClickHouse Cloud API key you gave us" : "none" }, opts.json);
46672
+ const billing = disconnectBilling(await cloudApi(opts.debug).disconnect(row.id));
46673
+ printResult({ status: "disconnected", name: row.name, id: row.id, ...billing ? { billing } : {}, next: row.host_metrics ? "Delete the ClickHouse Cloud API key you gave us" : "none" }, opts.json);
46494
46674
  } catch (err) {
46495
46675
  failCloud(err, opts.json);
46496
46676
  }
@@ -47076,9 +47256,9 @@ mon.command("health").description("health check for monitoring services").option
47076
47256
  allHealthy = true;
47077
47257
  for (const service of services) {
47078
47258
  try {
47079
- const result = spawnSync2("docker", ["inspect", "-f", "{{.State.Status}}", service.container], { stdio: "pipe" });
47259
+ const result = spawnSync3("docker", ["inspect", "-f", "{{.State.Status}}", service.container], { stdio: "pipe" });
47080
47260
  const present = result.status === 0;
47081
- const health = present && service.readHealth ? (spawnSync2("docker", ["inspect", "-f", "{{if .State.Health}}{{.State.Health.Status}}{{end}}", service.container], { stdio: "pipe" }).stdout || "").trim() : "";
47261
+ const health = present && service.readHealth ? (spawnSync3("docker", ["inspect", "-f", "{{if .State.Health}}{{.State.Health.Status}}{{end}}", service.container], { stdio: "pipe" }).stdout || "").trim() : "";
47082
47262
  const verdict = judgeServiceHealth(service, present, (result.stdout || "").trim(), health);
47083
47263
  console.log(`${verdict.mark} ${service.name}: ${verdict.label}`);
47084
47264
  if (!verdict.ok)
@@ -47172,18 +47352,6 @@ mon.command("update").description("update monitoring stack (migrate .env, pull i
47172
47352
  } else {
47173
47353
  console.log("\u2713 .env is up to date");
47174
47354
  }
47175
- const envTag = readEnvTag(projectDir);
47176
- const deployedTag = envTag.tag;
47177
- const tagPlan = envTag.plain ? planUpdateTag(deployedTag, package_default.version) : { tag: null, note: `PGAI_TAG in .env is not a plain value, so it is left as is. To move the stack to ${package_default.version}, set PGAI_TAG=${package_default.version} in .env and re-run 'postgresai mon update'` };
47178
- if (process.env.PGAI_TAG && process.env.PGAI_TAG !== (tagPlan.tag ?? deployedTag)) {
47179
- console.error(`\u26A0 PGAI_TAG=${process.env.PGAI_TAG} is set in the environment, and docker compose prefers it over .env: unset it before 'postgresai mon stop && postgresai mon start'`);
47180
- }
47181
- if (tagPlan.tag) {
47182
- writeEnvTag(projectDir, tagPlan.tag);
47183
- if (process.env.PGAI_TAG === deployedTag)
47184
- process.env.PGAI_TAG = tagPlan.tag;
47185
- }
47186
- console.log(`${tagPlan.tag ? "\u2713 " : ""}${tagPlan.note}`);
47187
47355
  console.log();
47188
47356
  const gitDir = path9.resolve(projectDir, ".git");
47189
47357
  if (fs11.existsSync(gitDir)) {
@@ -47197,7 +47365,7 @@ mon.command("update").description("update monitoring stack (migrate .env, pull i
47197
47365
  console.log(pullOut);
47198
47366
  } else {
47199
47367
  console.log("(not a git checkout \u2014 checking bundled docker-compose.yml)");
47200
- await refreshBundledComposeIfStale(projectDir, deployedTag);
47368
+ await refreshBundledComposeIfStale(projectDir);
47201
47369
  }
47202
47370
  console.log(`
47203
47371
  Updating Docker images...`);
@@ -47217,7 +47385,7 @@ Updating Docker images...`);
47217
47385
  \u2713 Update completed successfully`);
47218
47386
  console.log(`
47219
47387
  To apply updates, restart monitoring services:`);
47220
- console.log(" postgresai mon stop && postgresai mon start");
47388
+ console.log(" postgres-ai mon restart");
47221
47389
  } else {
47222
47390
  console.error(`
47223
47391
  \u2717 Docker image update failed`);
@@ -47358,7 +47526,7 @@ async function sinkPrometheusMaybeUp() {
47358
47526
  } catch {
47359
47527
  return true;
47360
47528
  }
47361
- const ps = spawnSync2(cmd[0], [...cmd.slice(1), "-f", composeFile, "ps", "-a", "-q", "--status", "running", "--status", "paused", "sink-prometheus"]);
47529
+ const ps = spawnSync3(cmd[0], [...cmd.slice(1), "-f", composeFile, "ps", "-a", "-q", "--status", "running", "--status", "paused", "sink-prometheus"]);
47362
47530
  return ps.status !== 0 || ps.stdout.trim() !== "";
47363
47531
  }
47364
47532
  async function reloadHostMetrics(projectDir, job, file) {
@@ -47393,14 +47561,36 @@ async function reloadHostMetrics(projectDir, job, file) {
47393
47561
  }
47394
47562
  return true;
47395
47563
  }
47564
+ var CONN_STR_FORMAT = "Invalid connection string format: use postgresql://user:password@host[:port]/database";
47396
47565
  function defaultTargetName(connStr) {
47566
+ if (!/^postgres(ql)?:\/\//.test(connStr))
47567
+ return { error: CONN_STR_FORMAT };
47568
+ if (/[\x00-\x1f\x7f]/.test(connStr))
47569
+ return { error: "Invalid connection string format: remove the control character (such as a CR from a CRLF file), or percent-encode it" };
47570
+ const at = connStr.lastIndexOf("@");
47571
+ if (at < 0)
47572
+ return { error: CONN_STR_FORMAT };
47573
+ if (!/^(?:[A-Za-z0-9\-._~!$&'()*+,;=:@]|%[0-9A-Fa-f]{2})*$/.test(connStr.slice(connStr.indexOf("//") + 2, at))) {
47574
+ return { error: "Invalid connection string format: percent-encode the user name and the password (all but ASCII letters, digits, '-', '.', '_' and '~'), and an '@' in the database name or the query" };
47575
+ }
47576
+ let url;
47397
47577
  try {
47398
- new URL(connStr);
47578
+ url = new URL(connStr);
47399
47579
  } catch {
47400
- return null;
47401
- }
47402
- const m = connStr.match(/^postgres(?:ql)?:\/\/[^:]+:[^@]+@([^:\/]+)(?::\d+)?\/([^?]+)/);
47403
- return m ? `${m[1]}-${m[2]}`.replace(/[^a-zA-Z0-9-]/g, "-") : null;
47580
+ return { error: CONN_STR_FORMAT };
47581
+ }
47582
+ if (!url.username)
47583
+ return { error: CONN_STR_FORMAT };
47584
+ if (/%3a/i.test(url.username))
47585
+ return { error: "Invalid connection string format: percent-encode the user name and the password one at a time, with a raw ':' between them" };
47586
+ if (!url.password)
47587
+ return { error: "Invalid connection string format: put the password in the URL: postgresql://user:password@host[:port]/database" };
47588
+ if (url.hostname.includes(":"))
47589
+ return { error: "Invalid connection string format: an IPv6 address is not supported as the host; use a host name" };
47590
+ const db = url.pathname.slice(1);
47591
+ if (!db)
47592
+ return { error: CONN_STR_FORMAT };
47593
+ return { name: `${url.hostname}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-") };
47404
47594
  }
47405
47595
  function parseEnvValue(content, key) {
47406
47596
  const last = [...content.matchAll(new RegExp(`^[ \\t]*(?:export[ \\t]+)?${key}=(.*)$`, "gm"))].at(-1);
@@ -47445,7 +47635,7 @@ async function setRdsHostStats(projectDir, values) {
47445
47635
  if (!cmd)
47446
47636
  return true;
47447
47637
  const { composeFile } = await resolveOrInitPaths();
47448
- const ps = spawnSync2(cmd[0], [...cmd.slice(1), "-f", composeFile, "--profile", "rds", "ps", "-q", "--status", "running", "rds-host-stats"]);
47638
+ const ps = spawnSync3(cmd[0], [...cmd.slice(1), "-f", composeFile, "--profile", "rds", "ps", "-q", "--status", "running", "rds-host-stats"]);
47449
47639
  if (ps.status !== 0) {
47450
47640
  console.error("If rds-host-stats is running, recreate it: docker compose --profile rds up -d rds-host-stats");
47451
47641
  return true;
@@ -47530,6 +47720,7 @@ async function syncVmalert(projectDir, apply, addOnly = false) {
47530
47720
  return true;
47531
47721
  }
47532
47722
  var looksLikeConnStr = (value) => /^postgres(ql)?:\/\//i.test(value);
47723
+ var isPlainTargetName = (value) => /^[A-Za-z0-9._=-]+$/.test(value) && !/(password|pwd)\s*=/i.test(value);
47533
47724
  async function addTarget(file, projectDir, connStr, name, env, { apply = true } = {}) {
47534
47725
  if (!connStr) {
47535
47726
  console.error("Connection string required: postgresql://user:pass@host:port/db");
@@ -47539,15 +47730,15 @@ async function addTarget(file, projectDir, connStr, name, env, { apply = true }
47539
47730
  const channelBinding = splitChannelBinding2(connStr);
47540
47731
  connStr = channelBinding.uri;
47541
47732
  const defaultName = defaultTargetName(connStr);
47542
- if (!defaultName) {
47543
- console.error("Invalid connection string format");
47733
+ if ("error" in defaultName) {
47734
+ console.error(defaultName.error);
47544
47735
  process.exitCode = 1;
47545
47736
  return false;
47546
47737
  }
47547
47738
  if (channelBinding.value !== null) {
47548
47739
  console.error("Note: removed channel_binding from the connection string; the collector does not support it (TLS is kept)");
47549
47740
  }
47550
- const instanceName = name && name.trim() ? name.trim() : defaultName;
47741
+ const instanceName = name && name.trim() ? name.trim() : defaultName.name;
47551
47742
  if (detectProvider(connStr) !== "clickhouse" && extractProjectRefFromUrl(connStr) && supabaseHostMetricsOn(projectDir, env) && !SUPABASE_TARGET_NAME_RE.test(instanceName)) {
47552
47743
  console.error(SUPABASE_TARGET_NAME_ERROR);
47553
47744
  process.exitCode = 1;
@@ -47630,14 +47821,40 @@ Supabase: with PGAI_SUPABASE_HOST_METRICS=true (environment or .env), writes hos
47630
47821
  RDS instance endpoint: writes RDS_DB_INSTANCE_IDENTIFIER, AWS_REGION, PGAI_CLUSTER and PGAI_NODE_NAME to .env for rds-host-stats.
47631
47822
 
47632
47823
  Environment:
47633
- PGAI_DB_URL the connection string, when none is given (keeps the password out of argv);
47634
- the only argument is then the name: PGAI_DB_URL=... postgres-ai mon targets add my-db
47824
+ PGAI_DB_URL the connection string, unless a postgres:// or postgresql:// URL is given
47825
+ (keeps the password out of argv). The only argument is then the name, of
47826
+ ASCII letters, digits, '.', '_', '=' and '-', with no password= or pwd=;
47827
+ anything else is refused. Set it for this command only:
47828
+ PGAI_DB_URL='postgresql://user:pass@host:5432/db' postgres-ai mon targets add my-db
47829
+ Under sudo, pass it on stdin: sudo logs a variable kept with --preserve-env.
47830
+ printf '%s\\n' "$URL" | sudo sh -c \\
47831
+ 'IFS= read -r PGAI_DB_URL; export PGAI_DB_URL; exec postgres-ai mon targets add my-db'
47832
+ sudo I/O logging (log_input in sudoers) records stdin: then read it from a
47833
+ file of mode 0600 in the root shell.
47834
+ sudo sh -c \\
47835
+ 'IFS= read -r PGAI_DB_URL < /path/to/db-url; export PGAI_DB_URL; exec postgres-ai mon targets add my-db'
47635
47836
  `).action(async (connStr, name) => {
47636
47837
  const { instancesFile: file, projectDir } = await resolveOrInitPaths();
47637
47838
  const envUrl = process.env.PGAI_DB_URL || undefined;
47839
+ delete process.env.PGAI_DB_URL;
47638
47840
  if (envUrl && name === undefined && (connStr === undefined || !looksLikeConnStr(connStr))) {
47639
- name = connStr;
47841
+ const arg = connStr?.trim();
47842
+ if (arg && !isPlainTargetName(arg)) {
47843
+ console.error("PGAI_DB_URL is set: pass only the target name (ASCII letters, digits, '.', '_', '=', '-'; no password= or pwd=)");
47844
+ process.exitCode = 1;
47845
+ return;
47846
+ }
47847
+ name = arg;
47640
47848
  connStr = envUrl;
47849
+ let user = "";
47850
+ if (!("error" in defaultTargetName(splitChannelBinding2(envUrl).uri))) {
47851
+ try {
47852
+ user = decodeURIComponent(new URL(envUrl).username);
47853
+ } catch {
47854
+ user = new URL(envUrl).username;
47855
+ }
47856
+ }
47857
+ console.error(`Using PGAI_DB_URL${user ? ` (user ${user})` : ""}`);
47641
47858
  }
47642
47859
  await addTarget(file, projectDir, connStr, name, process.env);
47643
47860
  });
@@ -47838,7 +48055,7 @@ Opening browser for authentication...`);
47838
48055
  ${authUrl}
47839
48056
  `);
47840
48057
  const openCommand = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
47841
- const browserProcess = spawn2(openCommand, [authUrl], { detached: true, stdio: "ignore" });
48058
+ const browserProcess = spawn3(openCommand, [authUrl], { detached: true, stdio: "ignore" });
47842
48059
  browserProcess.on("error", () => {});
47843
48060
  browserProcess.unref();
47844
48061
  console.log("Waiting for authorization...");
@@ -49547,10 +49764,9 @@ export {
49547
49764
  monitoringHealthServices,
49548
49765
  persistInstanceId,
49549
49766
  planMonitoringRegistration,
49550
- planUpdateTag,
49551
49767
  program2 as program,
49552
49768
  readComposeProfiles,
49553
- readEnvTag,
49769
+ readDeployedTag,
49554
49770
  refreshBundledComposeIfStale,
49555
49771
  registerMonitoringInstance,
49556
49772
  resolveAdoptedProject,
@@ -49558,6 +49774,5 @@ export {
49558
49774
  selfRegisterAndPersist,
49559
49775
  shouldScaleOutNodeExporter,
49560
49776
  statOwner,
49561
- updatePgwatchConfig,
49562
- writeEnvTag
49777
+ updatePgwatchConfig
49563
49778
  };