postgresai 0.17.0-dev.4 → 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.4",
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.4";
16896
+ var version = "0.17.0-dev.6";
16897
16897
  var package_default2 = {
16898
16898
  name: "postgresai",
16899
16899
  version,
@@ -17169,6 +17169,22 @@ class HttpStatusError extends Error {
17169
17169
  function isRetryableHttpStatus(status) {
17170
17170
  return status >= 500 || status === 429;
17171
17171
  }
17172
+ function isUnknownRpcSignature(status, text, rpc) {
17173
+ if (status !== 404)
17174
+ return false;
17175
+ try {
17176
+ const parsed = JSON.parse(text);
17177
+ return typeof parsed.message === "string" && /Could not find the .* function/.test(parsed.message) && parsed.message.includes(`${rpc}(`);
17178
+ } catch {
17179
+ return false;
17180
+ }
17181
+ }
17182
+ function positiveSeconds(v) {
17183
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null;
17184
+ }
17185
+ function asyncPollDelayMs(attempt) {
17186
+ return Math.min(1000 * 2 ** attempt, 15000);
17187
+ }
17172
17188
  var DEFAULT_HTTP_REQUEST_TIMEOUT_MS = 25000;
17173
17189
 
17174
17190
  class HttpRequestTimeoutError extends Error {
@@ -21466,7 +21482,8 @@ function summarizeF001(nodeData) {
21466
21482
  return `Autovacuum config: ${parts.join(", ")} finding${fired.length > 1 ? "s" : ""}`;
21467
21483
  };
21468
21484
  if (severity === "CRITICAL" || severity === "WARNING") {
21469
- return { status: "warning", message: summarize() };
21485
+ const top = fired.find((r) => r.severity === "CRITICAL") ?? fired.find((r) => r.severity === "WARNING");
21486
+ return { status: "warning", message: top?.conclusion ? `${summarize()}. ${top.conclusion}` : summarize() };
21470
21487
  }
21471
21488
  return { status: "info", message: summarize() };
21472
21489
  }
@@ -23958,6 +23975,38 @@ function withCheckSummary(report) {
23958
23975
  return report;
23959
23976
  }
23960
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
+
23961
24010
  // lib/host-metrics.ts
23962
24011
  import { createHash } from "node:crypto";
23963
24012
  import { chmodSync as chmodSync3, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
@@ -27817,46 +27866,58 @@ async function verifyInitSetup(params) {
27817
27866
  }
27818
27867
 
27819
27868
  // lib/joe.ts
27869
+ var JOE_ASYNC_FALLBACK_WAIT_MS = 10 * 60 * 1000;
27870
+ var JOE_ASYNC_MAX_WAIT_MS = 60 * 60 * 1000;
27820
27871
  async function callRpc(params) {
27872
+ return (await callRpcRaw(params)).data;
27873
+ }
27874
+ async function callRpcRaw(params) {
27821
27875
  const { apiKey, apiBaseUrl, fn, body, operation, debug } = params;
27822
27876
  if (!apiKey) {
27823
27877
  throw new Error("API key is required");
27824
27878
  }
27825
27879
  const base = normalizeBaseUrl(apiBaseUrl);
27826
27880
  const url = new URL(`${base}/rpc/${fn}`);
27827
- const payload = JSON.stringify(body);
27828
27881
  const headers = buildAuthHeaders(apiKey, params.orgScope);
27829
- if (debug) {
27830
- const debugHeaders = { ...headers, "access-token": maskSecret(apiKey) };
27831
- console.error(`Debug: POST URL: ${url.toString()}`);
27832
- console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
27833
- console.error(`Debug: Request body: ${redactSecretsForLog(payload)}`);
27834
- }
27835
- let response;
27836
- const requestTimeout = requestTimeoutSignal(params.timeoutMs);
27837
- try {
27838
- response = await fetch(url.toString(), {
27839
- method: "POST",
27840
- headers,
27841
- body: payload,
27842
- signal: requestTimeout.signal
27843
- });
27844
- } catch (err) {
27845
- if (isFetchTimeout(err)) {
27846
- throw new HttpRequestTimeoutError(operation, requestTimeout.timeoutMs);
27882
+ const send = async (bodyObj) => {
27883
+ const payload = JSON.stringify(bodyObj);
27884
+ if (debug) {
27885
+ const debugHeaders = { ...headers, "access-token": maskSecret(apiKey) };
27886
+ console.error(`Debug: POST URL: ${url.toString()}`);
27887
+ console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
27888
+ console.error(`Debug: Request body: ${redactSecretsForLog(payload)}`);
27889
+ }
27890
+ let response;
27891
+ const requestTimeout = requestTimeoutSignal(params.timeoutMs);
27892
+ try {
27893
+ response = await fetch(url.toString(), {
27894
+ method: "POST",
27895
+ headers,
27896
+ body: payload,
27897
+ signal: requestTimeout.signal
27898
+ });
27899
+ } catch (err) {
27900
+ if (isFetchTimeout(err)) {
27901
+ throw new HttpRequestTimeoutError(operation, requestTimeout.timeoutMs);
27902
+ }
27903
+ throw new Error(describeFetchError(operation, base, err));
27847
27904
  }
27848
- throw new Error(describeFetchError(operation, base, err));
27849
- }
27850
- const text = await response.text();
27851
- if (debug) {
27852
- console.error(`Debug: Response status: ${response.status}`);
27853
- console.error(`Debug: Response body: ${redactSecretsForLog(text)}`);
27905
+ const text = await response.text();
27906
+ if (debug) {
27907
+ console.error(`Debug: Response status: ${response.status}`);
27908
+ console.error(`Debug: Response body: ${redactSecretsForLog(text)}`);
27909
+ }
27910
+ return { response, text };
27911
+ };
27912
+ let { response, text } = await send(body);
27913
+ if (params.fallbackBody && isUnknownRpcSignature(response.status, text, fn)) {
27914
+ ({ response, text } = await send(params.fallbackBody));
27854
27915
  }
27855
27916
  if (!response.ok) {
27856
27917
  throw new HttpStatusError(formatHttpError(operation, response.status, text, response.statusText), response.status);
27857
27918
  }
27858
27919
  try {
27859
- return JSON.parse(text);
27920
+ return { data: JSON.parse(text), status: response.status };
27860
27921
  } catch {
27861
27922
  throw new Error(`${operation}: failed to parse response: ${redactSecretsForLog(text)}`);
27862
27923
  }
@@ -27905,6 +27966,25 @@ function isNumericProjectRef(ref) {
27905
27966
  }
27906
27967
 
27907
27968
  // lib/connect.ts
27969
+ var money = (cents, currency) => currency.toLowerCase() === "usd" ? `$${(cents / 100).toFixed(2)}` : `${(cents / 100).toFixed(2)} ${currency.toUpperCase()}`;
27970
+ function priceText(q) {
27971
+ if (!q.billed && q.same_cluster)
27972
+ return `included: same database cluster as ${q.same_cluster}, no extra charge`;
27973
+ if (!q.billed)
27974
+ return q.free_slots.total ? `free (${q.free_slots.remaining} of ${q.free_slots.total} free slots)` : "free";
27975
+ const { amount, currency, interval } = q.price;
27976
+ const base = `${money(amount, currency)}/${interval} per database cluster (${q.plan} plan)${q.subscription ? `, cluster ${q.quantity + 1} on the subscription` : ""}`;
27977
+ if (q.promo?.valid && q.amount_after_promo !== undefined && q.amount_after_promo !== null) {
27978
+ const after = `${money(q.amount_after_promo, currency)}`;
27979
+ const how = `with ${q.promo.code} (${q.promo.discount_description})`;
27980
+ if (q.promo.duration === "forever")
27981
+ return `${after}/${interval} ${how}, instead of ${base}`;
27982
+ if (q.promo.duration === "repeating")
27983
+ return `${after}/${interval} for ${q.promo.duration_in_months} months ${how}, then ${base}`;
27984
+ return `${after} the first ${interval} ${how}, then ${base}`;
27985
+ }
27986
+ return base;
27987
+ }
27908
27988
  var FIRST_CHECKUP_DELAY_MS = 30 * 60000;
27909
27989
  var POLL_MS = 15000;
27910
27990
  var PROVIDERS = ["clickhouse", "rds", "supabase", "self-managed"];
@@ -27960,8 +28040,12 @@ function stateOf(raw) {
27960
28040
 
27961
28041
  class ClickhouseKeyError extends Error {
27962
28042
  }
27963
- var serverOf = (name) => name.split("/")[0];
27964
- var errorText = (err) => err instanceof Error ? err.message : String(err);
28043
+ var serverOf = (name) => name.split("/")[0].toLowerCase().replace(/\.(?=$|:)/, "");
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);
27965
28049
  var urlPassword = (u) => decodeURIComponent(u.password) || u.searchParams.get("password") || "";
27966
28050
  var URL_PARAMS_KEPT = ["sslmode", "channel_binding", "application_name"];
27967
28051
  var URL_PARAMS_TLS = ["sslrootcert", "sslcert", "sslkey", "uselibpqcompat"];
@@ -27976,6 +28060,22 @@ function checkUrlParams(url, agent) {
27976
28060
  if (moved.length)
27977
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`);
27978
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 ");
27979
28079
  function roleUrlFor(url, db, password, kept) {
27980
28080
  const u = new URL(url);
27981
28081
  u.username = DEFAULT_MONITORING_USER;
@@ -27984,6 +28084,7 @@ function roleUrlFor(url, db, password, kept) {
27984
28084
  for (const name of [...u.searchParams.keys()])
27985
28085
  if (!kept.includes(name))
27986
28086
  u.searchParams.delete(name);
28087
+ u.search = u.search.replace(/@/g, "%40");
27987
28088
  return u.toString();
27988
28089
  }
27989
28090
  var monitoringUrlFor = (url, db, password) => roleUrlFor(url, db, password, URL_PARAMS_KEPT);
@@ -28023,15 +28124,40 @@ function checkupLines(c) {
28023
28124
  return [`Express checkup could not run: ${c.error}`];
28024
28125
  const warnings = c.findings.filter((f) => f.status === "warning");
28025
28126
  const ok = c.findings.filter((f) => f.status === "ok");
28127
+ const failed = c.failed ?? [];
28128
+ const counts = [`${warnings.length} warning${warnings.length === 1 ? "" : "s"}`, `${ok.length} ok`, `${c.info.length} info`, ...failed.length ? [`${failed.length} could not run`] : []];
28026
28129
  return [
28027
- `Express checkup while the box starts (${c.checks} checks: ${warnings.length} warning${warnings.length === 1 ? "" : "s"}, ${ok.length} ok):`,
28130
+ `Express checkup while the box starts (${c.checks} checks: ${counts.join(", ")}):`,
28028
28131
  ...warnings.map((f) => ` ${f.check_id} ${f.title}: ${f.message}`),
28029
28132
  ...ok.length ? [` ok: ${ok.map((f) => f.check_id).join(" ")}`] : [],
28030
- ...c.failed?.length ? [` could not run: ${c.failed.join(" ")}`] : [],
28133
+ ...c.info.length ? [` info: ${c.info.join(" ")}`] : [],
28134
+ ...failed.length ? [` could not run: ${failed.join(" ")}`] : [],
28135
+ c.report_id ? `Saved as report ${c.report_id}: pgai reports files ${c.report_id}` : `Not saved to PostgresAI: ${c.upload_error}`,
28031
28136
  "The full checkup (query analysis and trends) follows on the box."
28032
28137
  ];
28033
28138
  }
28139
+ var boxState = (row) => row.status === "launch_requested" && row.registered_at ? "registered" : row.status ?? "starting";
28140
+ async function billingFor(q, name, opts, deps, show) {
28141
+ const billing = { price: priceText(q), requires_payment_method: q.requires_payment_method };
28142
+ if (opts.coupon) {
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" };
28144
+ }
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` };
28146
+ const stop = (next) => ({ billing, billingPage, accepted: false, stop: { ...billing, next } });
28147
+ if (billing.coupon && !billing.coupon.valid) {
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"}`);
28149
+ }
28150
+ if (q.requires_payment_method)
28151
+ return { billing, billingPage, accepted: false, stop: billingPage };
28152
+ show(billing.price);
28153
+ if (q.billed && !opts.yes && !await deps.confirm(`Provision ${name}? (y/N): `)) {
28154
+ return stop(opts.agent ? `Call connect_database again with yes: true to accept ${billing.price}` : `Re-run with --yes to accept ${billing.price}`);
28155
+ }
28156
+ return { billing, billingPage, accepted: q.billed };
28157
+ }
28034
28158
  async function connect(url, opts, deps) {
28159
+ const started = deps.now();
28160
+ const progress = (event, message, more = {}) => deps.progress({ event, elapsed_s: Math.round((deps.now() - started) / 1000), message, ...more });
28035
28161
  const provider = opts.provider ?? detectCloudProvider(url);
28036
28162
  if (!PROVIDERS.includes(provider))
28037
28163
  throw new Error(`--provider must be one of: ${PROVIDERS.join(", ")}`);
@@ -28047,13 +28173,16 @@ async function connect(url, opts, deps) {
28047
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" };
28048
28174
  }
28049
28175
  if (opts.selfHosted && deps.localStackRunning()) {
28050
- 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" };
28051
28177
  }
28052
28178
  const rows = opts.selfHosted ? [] : await deps.list();
28053
28179
  let row = rows.find((d) => d.name === name && !disconnecting(d.status));
28054
28180
  const fresh = !row;
28055
28181
  let note = "";
28056
28182
  let checkup;
28183
+ let billing = {};
28184
+ let billingPage;
28185
+ let accepted = false;
28057
28186
  const findOrg = async () => {
28058
28187
  try {
28059
28188
  return await deps.clickhouseOrg(new URL(url).hostname, key.keyId, key.keySecret);
@@ -28082,62 +28211,154 @@ async function connect(url, opts, deps) {
28082
28211
  }
28083
28212
  ch = found;
28084
28213
  }
28085
- if (opts.resetPassword) {
28086
- const others = rows.filter((d) => d.name !== name && !disconnecting(d.status) && serverOf(d.name) === serverOf(name));
28087
- if (others.length) {
28088
- return { 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` };
28089
- }
28090
- }
28091
- deps.progress(`Preparing ${maskConnectionString(url)}`);
28092
- const prepared = await deps.prepare(url, provider, { resetPassword: opts.resetPassword });
28093
- if ("next" in prepared)
28094
- return { status: "action_required", provider, name, ...prepared };
28095
- if (prepared.note)
28096
- note = `; ${prepared.note}`;
28097
- if (opts.selfHosted) {
28098
- await deps.selfHosted(prepared.monitoringUrl, key && ch ? { CLICKHOUSE_ORG_ID: ch.orgId, CLICKHOUSE_KEY_ID: key.keyId, CLICKHOUSE_KEY_SECRET: key.keySecret } : {});
28099
- return { status: "connected", provider, name, dashboard_url: "http://localhost:3000", host_metrics: !!ch, next: `pgai mon health${note}` };
28100
- }
28101
- deps.progress(`Provisioning monitoring for ${name}`);
28102
- const undo = async () => {
28103
- if (prepared.generated)
28214
+ const undo = async (p) => {
28215
+ if ("generated" in p && p.generated)
28104
28216
  await deps.unprepare(url).catch(() => false);
28105
28217
  };
28106
- const created = await deps.create({
28107
- db_url: prepared.monitoringUrl,
28108
- ...provider === "clickhouse" ? { provider } : {},
28109
- ...key && ch ? { clickhouse_org_id: ch.orgId, clickhouse_key_id: key.keyId, clickhouse_key_secret: key.keySecret } : {}
28110
- }).catch(async (err) => {
28111
- if (err instanceof HttpStatusError && err.status >= 400 && err.status < 500)
28112
- await undo();
28113
- throw err;
28114
- });
28115
- if (created.status === "failed") {
28116
- await undo();
28117
- return { status: "failed", provider, name, id: created.id, next: `${created.error} Re-run pgai connect later.` };
28218
+ let lockId;
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));
28236
+ if (opts.coupon !== undefined && !opts.coupon.trim()) {
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` };
28238
+ }
28239
+ const prepOpts = { resetPassword: opts.resetPassword, ...storedPassword ? { storedPassword, others } : {} };
28240
+ if (!opts.selfHosted) {
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}`));
28245
+ if (b.stop)
28246
+ return { status: "action_required", provider, name, ...b.stop };
28247
+ ({ billing, billingPage, accepted } = b);
28248
+ }
28249
+ if (opts.resetPassword) {
28250
+ let lock;
28251
+ try {
28252
+ lock = await deps.resetLock(serverOf(name));
28253
+ } catch (err) {
28254
+ if (err instanceof HttpStatusError && err.status === 409) {
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)` };
28256
+ }
28257
+ if (err instanceof HttpStatusError && err.status === 404) {
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` };
28259
+ }
28260
+ throw err;
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;
28265
+ }
28266
+ let prepared;
28267
+ let created;
28268
+ let unanswered = false;
28269
+ try {
28270
+ if (lockId) {
28271
+ const now = await deps.list();
28272
+ const same = now.find((d) => d.name === name && !disconnecting(d.status));
28273
+ if (same)
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` };
28275
+ const others = othersOnServer(now);
28276
+ if (others.length)
28277
+ return { ...cutOff(others), ...billing };
28278
+ }
28279
+ progress("preparing", `Preparing ${maskConnectionString(url)}`);
28280
+ prepared = await deps.prepare(url, provider, prepOpts);
28281
+ if ("next" in prepared)
28282
+ return { status: "action_required", provider, name, ...billing, ...prepared };
28283
+ if (!("monitoringUrl" in prepared))
28284
+ throw new Error("prepare returned no monitoring URL");
28285
+ if (prepared.note)
28286
+ note = `; ${prepared.note}`;
28287
+ if (opts.selfHosted) {
28288
+ await deps.selfHosted(prepared.monitoringUrl, key && ch ? { CLICKHOUSE_ORG_ID: ch.orgId, CLICKHOUSE_KEY_ID: key.keyId, CLICKHOUSE_KEY_SECRET: key.keySecret } : {});
28289
+ return { status: "connected", provider, name, dashboard_url: "http://localhost:3000", host_metrics: !!ch, next: `pgai mon health${note}` };
28290
+ }
28291
+ progress("provisioning", `Provisioning monitoring for ${name}`);
28292
+ created = await deps.create({
28293
+ db_url: prepared.monitoringUrl,
28294
+ ...provider === "clickhouse" ? { provider } : {},
28295
+ ...key && ch ? { clickhouse_org_id: ch.orgId, clickhouse_key_id: key.keyId, clickhouse_key_secret: key.keySecret } : {},
28296
+ ...opts.coupon ? { promo_code: opts.coupon } : {},
28297
+ ...accepted ? { accept_price: true } : {}
28298
+ }).catch(async (err) => {
28299
+ if (err instanceof HttpStatusError && err.status >= 400 && err.status < 500)
28300
+ await undo(prepared);
28301
+ else
28302
+ unanswered = true;
28303
+ if (err instanceof HttpStatusError && err.status === 402)
28304
+ return /declined/i.test(err.message) ? "declined" : "payment";
28305
+ if (err instanceof HttpStatusError && err.status === 412)
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";
28309
+ throw err;
28310
+ });
28311
+ if (created === "payment")
28312
+ return { status: "action_required", provider, name, ...billingPage };
28313
+ if (created === "declined") {
28314
+ return { status: "action_required", provider, name, ...billingPage, next: billingPage.next.replace(/^Add a payment method at /, "The payment method on file was declined: update it at ") };
28315
+ }
28316
+ if (created === "price")
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
+ }
28322
+ if (created.status === "failed") {
28323
+ await undo(prepared);
28324
+ return { status: "failed", provider, name, id: created.id, next: `${created.error} Re-run pgai connect later.` };
28325
+ }
28326
+ } finally {
28327
+ if (lockId && !unanswered)
28328
+ await deps.resetUnlock(lockId).catch(() => {});
28118
28329
  }
28119
28330
  row = { id: created.id, name: created.name, provider, status: created.status, dashboard_url: null, host_metrics: !!ch };
28120
- checkup = await deps.checkup(withLocalTls(prepared.monitoringUrl, url)).catch((err) => ({ error: errorText(err) }));
28121
- for (const line of checkupLines(checkup))
28122
- deps.progress(line);
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(`
28336
+ `), { checkup });
28337
+ }
28123
28338
  }
28124
- for (const deadline = Date.now() + opts.waitMs;; ) {
28339
+ let shown;
28340
+ for (const deadline = deps.now() + opts.waitMs;; ) {
28125
28341
  const result = connectStatus(row, provider, fresh);
28126
- if (result.status !== "provisioning" || Date.now() >= deadline) {
28342
+ const state = boxState(row);
28343
+ if (state !== shown && (shown !== undefined || result.status === "provisioning"))
28344
+ progress("box", `Monitoring box: ${state}`, { state });
28345
+ shown = state;
28346
+ if (result.status !== "provisioning" || deps.now() >= deadline) {
28127
28347
  if (result.status === "connected" && provider === "clickhouse" && !row.host_metrics) {
28128
28348
  result.next += "; for CPU, memory and disk, disconnect and reconnect with --clickhouse-key <key-id>:<key-secret>";
28129
28349
  }
28130
28350
  const { next, ...rest } = result;
28131
- return { ...rest, ...checkup ? { checkup } : {}, next: next + note };
28351
+ return { ...rest, ...billing, ...checkup ? { checkup } : {}, next: next + note };
28132
28352
  }
28133
- deps.progress(`Waiting for the monitoring box (${row.status ?? "starting"})`);
28134
28353
  await deps.sleep(POLL_MS);
28135
28354
  row = (await deps.list().catch(() => [])).find((d) => d.id === row.id) ?? row;
28136
28355
  }
28137
28356
  }
28138
- function openConnection(url, opts) {
28357
+ async function openConnection(url, opts) {
28139
28358
  const conn = resolveAdminConnection({ conn: url });
28140
- 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 };
28141
28362
  }
28142
28363
  async function dropMonitoringRole(client) {
28143
28364
  for (const sql of [`drop role ${DEFAULT_MONITORING_USER}`, `drop owned by ${DEFAULT_MONITORING_USER}; drop role ${DEFAULT_MONITORING_USER}`]) {
@@ -28170,7 +28391,7 @@ async function prepareDatabase(url, provider, opts = {}) {
28170
28391
  throw err;
28171
28392
  }
28172
28393
  };
28173
- const { client } = await open(url).catch((err) => {
28394
+ const { client, usedSsl, refusedTls } = await open(url).catch((err) => {
28174
28395
  if (/client password must be a string/.test(String(err?.message)))
28175
28396
  throw new Error("The URL has no password (and PGPASSWORD is not set): pgai connect postgresql://user:password@host:5432/dbname");
28176
28397
  throw err;
@@ -28184,7 +28405,7 @@ async function prepareDatabase(url, provider, opts = {}) {
28184
28405
  and has_database_privilege(current_user, current_database(), 'CREATE')
28185
28406
  and exists (select 1 from pg_extension where extname = 'pg_stat_statements')) as admin,
28186
28407
  exists (select 1 from pg_roles where rolname = '${DEFAULT_MONITORING_USER}') as mon_exists,
28187
- current_setting('scram_iterations', true) as iterations
28408
+ current_setting('scram_iterations', true) as iterations, current_setting('ssl') as ssl
28188
28409
  from pg_roles where rolname = current_user`)).rows[0];
28189
28410
  const notes = [caNote(url)].filter((n) => !!n);
28190
28411
  const noted = () => notes.length ? { note: notes.join("; ") } : {};
@@ -28208,9 +28429,31 @@ async function prepareDatabase(url, provider, opts = {}) {
28208
28429
  }
28209
28430
  } else if (me.admin && pgProvider !== "supabase") {
28210
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
+ }
28211
28454
  if (me.mon_exists && opts.agent)
28212
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` };
28213
- 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`;
28214
28457
  const { password, generated } = await resolveMonitoringPassword({ passwordEnv: opts.agent ? undefined : process.env.PGAI_MON_PASSWORD, monitoringUser: DEFAULT_MONITORING_USER });
28215
28458
  const reset = !!opts.resetPassword && me.mon_exists;
28216
28459
  if (me.mon_exists && !reset) {
@@ -28220,13 +28463,16 @@ async function prepareDatabase(url, provider, opts = {}) {
28220
28463
  try {
28221
28464
  accepted = await logsIn(loginUrlFor(url, me.db, password));
28222
28465
  } catch (err) {
28223
- 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}` };
28224
28468
  }
28225
28469
  if (!accepted)
28226
- 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}` };
28227
28471
  if (await acceptsAnyPassword())
28228
28472
  notes.push(unchecked("PGAI_MON_PASSWORD"));
28229
28473
  }
28474
+ if (opts.check)
28475
+ return { checked: true };
28230
28476
  const monitoringUrl = monitoringUrlFor(url, me.db, password);
28231
28477
  const loginUrl = loginUrlFor(url, me.db, password);
28232
28478
  const plan = await buildInitPlan({ database: me.db, monitoringPassword: password, iterations: Number(me.iterations), includeOptionalPermissions: true, provider: pgProvider, keepExistingPassword: !reset });
@@ -28255,14 +28501,43 @@ ${redactPasswordsInSql(s.sql)}`).join(`
28255
28501
  }
28256
28502
  async function expressCheckup(url, opts = {}) {
28257
28503
  const { client } = await openConnection(url, opts);
28504
+ let reports;
28505
+ const failed = [];
28258
28506
  try {
28259
- const failed = [];
28260
- const reports = await generateAllReports(client, "node-01", undefined, (f) => failed.push(f.checkId));
28261
- const findings = Object.values(reports).map((r) => ({ check_id: r.checkId, title: r.checkTitle, status: r.summary.status, message: r.summary.message })).filter((f) => f.status !== "info").sort((a, b) => Number(b.status === "warning") - Number(a.status === "warning"));
28262
- return { checks: Object.keys(reports).length + failed.length, findings, ...failed.length ? { failed } : {} };
28507
+ reports = await generateAllReports(client, "node-01", undefined, (f) => failed.push(f.checkId));
28263
28508
  } finally {
28264
28509
  await client.end();
28265
28510
  }
28511
+ const all = Object.values(reports).map((r) => ({ check_id: r.checkId, title: r.checkTitle, status: r.summary.status, message: r.summary.message }));
28512
+ const findings = all.filter((f) => f.status !== "info").sort((a, b) => Number(b.status === "warning") - Number(a.status === "warning"));
28513
+ const result = { checks: all.length + failed.length, findings, info: all.filter((f) => f.status === "info").map((f) => f.check_id), ...failed.length ? { failed } : {} };
28514
+ if (!opts.save)
28515
+ return result;
28516
+ try {
28517
+ return { ...result, report_id: await opts.save(reports) };
28518
+ } catch (err) {
28519
+ return { ...result, upload_error: errorText(err) };
28520
+ }
28521
+ }
28522
+ async function saveCheckupReport(rpc, accessToken, project, reports) {
28523
+ const { reportId } = await createCheckupReport(rpc, { apiKey: accessToken, project });
28524
+ const setStatus = (status) => rpc("checkup_report_status_update", { access_token: accessToken, report_id: reportId, status });
28525
+ try {
28526
+ for (const [checkId, report] of Object.entries(reports)) {
28527
+ await uploadCheckupReportJson(rpc, {
28528
+ apiKey: accessToken,
28529
+ reportId,
28530
+ filename: `${checkId}.json`,
28531
+ checkId,
28532
+ jsonText: JSON.stringify(report, null, 2)
28533
+ });
28534
+ }
28535
+ } catch (err) {
28536
+ await setStatus("failed").catch(() => {});
28537
+ throw err;
28538
+ }
28539
+ await setStatus("completed");
28540
+ return reportId;
28266
28541
  }
28267
28542
  async function clickhouseOrgFor(host, keyId, keySecret) {
28268
28543
  const apiUrl = process.env.CLICKHOUSE_API_URL || "https://api.clickhouse.cloud";
@@ -28299,16 +28574,21 @@ function platformDeps(p) {
28299
28574
  list: () => rpc("cloud_monitoring_list"),
28300
28575
  create: (body) => rpc("cloud_monitoring_connect", body),
28301
28576
  disconnect: (id) => rpc("cloud_monitoring_disconnect", { instance_id: id }),
28577
+ resetLock: (server) => rpc("cloud_monitoring_reset_lock", { server }),
28578
+ resetUnlock: (lockId) => rpc("cloud_monitoring_reset_unlock", { lock_id: lockId }),
28302
28579
  prepare: (url, provider, o = {}) => prepareDatabase(url, provider, { ...o, agent: p.agent }),
28303
28580
  unprepare: (url) => unprepareDatabase(url, { agent: p.agent }),
28304
28581
  clickhouseOrg: clickhouseOrgFor,
28305
- checkup: (url) => expressCheckup(url, { agent: p.agent }),
28582
+ checkup: (url, project) => expressCheckup(url, { agent: p.agent, save: (reports) => saveCheckupReport(rpc, p.apiKey, project, reports) }),
28583
+ quote: (coupon, cluster) => rpc("cloud_monitoring_quote", { ...coupon ? { promo_code: coupon } : {}, ...cluster ? { db_server: cluster } : {} }),
28584
+ billingUrl: (orgAlias) => `${p.uiBaseUrl}/${orgAlias}/billing`,
28306
28585
  handoffUrl: async (provider) => {
28307
28586
  const orgs = await listOrgs({ apiKey: p.apiKey, apiBaseUrl: p.apiBaseUrl });
28308
28587
  const org = orgs.length === 1 ? orgs[0] : orgs.find((o) => o.alias === p.orgScope?.alias || o.org_id === p.orgScope?.id);
28309
28588
  return `${p.uiBaseUrl}/${org?.alias ?? "<org>"}/monitoring/scale/create/${provider}`;
28310
28589
  },
28311
- sleep: (ms) => new Promise((r) => setTimeout(r, ms))
28590
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
28591
+ now: () => Date.now()
28312
28592
  };
28313
28593
  }
28314
28594
 
@@ -34995,6 +35275,8 @@ async function handleToolCall(req, rootOpts, extra) {
34995
35275
  const result = await connect(url, {
34996
35276
  provider: args.provider ? String(args.provider) : undefined,
34997
35277
  clickhouseKey: args.clickhouse_key ? String(args.clickhouse_key) : undefined,
35278
+ coupon: args.coupon === undefined || args.coupon === null ? undefined : String(args.coupon),
35279
+ yes: args.yes === true,
34998
35280
  waitMs: 0,
34999
35281
  agent: true
35000
35282
  }, {
@@ -35003,6 +35285,7 @@ async function handleToolCall(req, rootOpts, extra) {
35003
35285
  throw new Error("A self-hosted stack is set up from the CLI: pgai connect <url> --self-hosted");
35004
35286
  },
35005
35287
  localStackRunning: () => false,
35288
+ confirm: async () => false,
35006
35289
  progress: () => {}
35007
35290
  });
35008
35291
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], isError: result.status === "failed" };
@@ -35568,7 +35851,7 @@ async function startMcpServer(rootOpts, extra) {
35568
35851
  },
35569
35852
  {
35570
35853
  name: "connect_database",
35571
- 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). 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).",
35572
35855
  inputSchema: {
35573
35856
  type: "object",
35574
35857
  properties: {
@@ -35576,6 +35859,8 @@ async function startMcpServer(rootOpts, extra) {
35576
35859
  database_url: { type: "string", description: "postgresql:// URL with the password in it (admin, or the postgres_ai_mon role)" },
35577
35860
  provider: { type: "string", description: "clickhouse | rds | supabase | self-managed (default: detected from the host)" },
35578
35861
  clickhouse_key: { type: "string", description: "ClickHouse Cloud API key as <key-id>:<key-secret> (Basic Service API Reader), for CPU, memory and disk" },
35862
+ yes: { type: "boolean", description: "Accept the price of a billed box. Without it, a billed box is not provisioned: the result has status action_required and the price; ask the user, then call again with yes: true" },
35863
+ coupon: { type: "string", description: "Stripe promotion code for the organization's monitoring subscription (applies when it is first created)" },
35579
35864
  debug: { type: "boolean", description: "Enable verbose debug logs" }
35580
35865
  },
35581
35866
  required: ["database_url"],
@@ -36478,6 +36763,25 @@ function renderMarkdownForTerminal(md) {
36478
36763
  }
36479
36764
 
36480
36765
  // lib/connect.ts
36766
+ var money2 = (cents, currency) => currency.toLowerCase() === "usd" ? `$${(cents / 100).toFixed(2)}` : `${(cents / 100).toFixed(2)} ${currency.toUpperCase()}`;
36767
+ function priceText2(q) {
36768
+ if (!q.billed && q.same_cluster)
36769
+ return `included: same database cluster as ${q.same_cluster}, no extra charge`;
36770
+ if (!q.billed)
36771
+ return q.free_slots.total ? `free (${q.free_slots.remaining} of ${q.free_slots.total} free slots)` : "free";
36772
+ const { amount, currency, interval } = q.price;
36773
+ const base = `${money2(amount, currency)}/${interval} per database cluster (${q.plan} plan)${q.subscription ? `, cluster ${q.quantity + 1} on the subscription` : ""}`;
36774
+ if (q.promo?.valid && q.amount_after_promo !== undefined && q.amount_after_promo !== null) {
36775
+ const after = `${money2(q.amount_after_promo, currency)}`;
36776
+ const how = `with ${q.promo.code} (${q.promo.discount_description})`;
36777
+ if (q.promo.duration === "forever")
36778
+ return `${after}/${interval} ${how}, instead of ${base}`;
36779
+ if (q.promo.duration === "repeating")
36780
+ return `${after}/${interval} for ${q.promo.duration_in_months} months ${how}, then ${base}`;
36781
+ return `${after} the first ${interval} ${how}, then ${base}`;
36782
+ }
36783
+ return base;
36784
+ }
36481
36785
  var FIRST_CHECKUP_DELAY_MS2 = 30 * 60000;
36482
36786
  var POLL_MS2 = 15000;
36483
36787
  var PROVIDERS2 = ["clickhouse", "rds", "supabase", "self-managed"];
@@ -36533,8 +36837,12 @@ function stateOf2(raw) {
36533
36837
 
36534
36838
  class ClickhouseKeyError2 extends Error {
36535
36839
  }
36536
- var serverOf2 = (name) => name.split("/")[0];
36537
- var errorText2 = (err) => err instanceof Error ? err.message : String(err);
36840
+ var serverOf2 = (name) => name.split("/")[0].toLowerCase().replace(/\.(?=$|:)/, "");
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);
36538
36846
  var urlPassword2 = (u) => decodeURIComponent(u.password) || u.searchParams.get("password") || "";
36539
36847
  var URL_PARAMS_KEPT2 = ["sslmode", "channel_binding", "application_name"];
36540
36848
  var URL_PARAMS_TLS2 = ["sslrootcert", "sslcert", "sslkey", "uselibpqcompat"];
@@ -36549,6 +36857,22 @@ function checkUrlParams2(url, agent) {
36549
36857
  if (moved.length)
36550
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`);
36551
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 ");
36552
36876
  function roleUrlFor2(url, db, password, kept) {
36553
36877
  const u = new URL(url);
36554
36878
  u.username = DEFAULT_MONITORING_USER;
@@ -36557,6 +36881,7 @@ function roleUrlFor2(url, db, password, kept) {
36557
36881
  for (const name of [...u.searchParams.keys()])
36558
36882
  if (!kept.includes(name))
36559
36883
  u.searchParams.delete(name);
36884
+ u.search = u.search.replace(/@/g, "%40");
36560
36885
  return u.toString();
36561
36886
  }
36562
36887
  var monitoringUrlFor2 = (url, db, password) => roleUrlFor2(url, db, password, URL_PARAMS_KEPT2);
@@ -36596,15 +36921,49 @@ function checkupLines2(c) {
36596
36921
  return [`Express checkup could not run: ${c.error}`];
36597
36922
  const warnings = c.findings.filter((f) => f.status === "warning");
36598
36923
  const ok = c.findings.filter((f) => f.status === "ok");
36924
+ const failed = c.failed ?? [];
36925
+ const counts = [`${warnings.length} warning${warnings.length === 1 ? "" : "s"}`, `${ok.length} ok`, `${c.info.length} info`, ...failed.length ? [`${failed.length} could not run`] : []];
36599
36926
  return [
36600
- `Express checkup while the box starts (${c.checks} checks: ${warnings.length} warning${warnings.length === 1 ? "" : "s"}, ${ok.length} ok):`,
36927
+ `Express checkup while the box starts (${c.checks} checks: ${counts.join(", ")}):`,
36601
36928
  ...warnings.map((f) => ` ${f.check_id} ${f.title}: ${f.message}`),
36602
36929
  ...ok.length ? [` ok: ${ok.map((f) => f.check_id).join(" ")}`] : [],
36603
- ...c.failed?.length ? [` could not run: ${c.failed.join(" ")}`] : [],
36930
+ ...c.info.length ? [` info: ${c.info.join(" ")}`] : [],
36931
+ ...failed.length ? [` could not run: ${failed.join(" ")}`] : [],
36932
+ c.report_id ? `Saved as report ${c.report_id}: pgai reports files ${c.report_id}` : `Not saved to PostgresAI: ${c.upload_error}`,
36604
36933
  "The full checkup (query analysis and trends) follows on the box."
36605
36934
  ];
36606
36935
  }
36936
+ function progressText(e) {
36937
+ const s = Math.round(e.elapsed_s);
36938
+ const elapsed = s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
36939
+ const [first, ...rest] = e.message.split(`
36940
+ `);
36941
+ const colon = first.endsWith(":") ? ":" : "";
36942
+ return [`${first.slice(0, first.length - colon.length)} (+${elapsed})${colon}`, ...rest].join(`
36943
+ `);
36944
+ }
36945
+ var boxState2 = (row) => row.status === "launch_requested" && row.registered_at ? "registered" : row.status ?? "starting";
36946
+ async function billingFor2(q, name, opts, deps, show) {
36947
+ const billing = { price: priceText2(q), requires_payment_method: q.requires_payment_method };
36948
+ if (opts.coupon) {
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" };
36950
+ }
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` };
36952
+ const stop = (next) => ({ billing, billingPage, accepted: false, stop: { ...billing, next } });
36953
+ if (billing.coupon && !billing.coupon.valid) {
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"}`);
36955
+ }
36956
+ if (q.requires_payment_method)
36957
+ return { billing, billingPage, accepted: false, stop: billingPage };
36958
+ show(billing.price);
36959
+ if (q.billed && !opts.yes && !await deps.confirm(`Provision ${name}? (y/N): `)) {
36960
+ return stop(opts.agent ? `Call connect_database again with yes: true to accept ${billing.price}` : `Re-run with --yes to accept ${billing.price}`);
36961
+ }
36962
+ return { billing, billingPage, accepted: q.billed };
36963
+ }
36607
36964
  async function connect2(url, opts, deps) {
36965
+ const started = deps.now();
36966
+ const progress = (event, message, more = {}) => deps.progress({ event, elapsed_s: Math.round((deps.now() - started) / 1000), message, ...more });
36608
36967
  const provider = opts.provider ?? detectCloudProvider2(url);
36609
36968
  if (!PROVIDERS2.includes(provider))
36610
36969
  throw new Error(`--provider must be one of: ${PROVIDERS2.join(", ")}`);
@@ -36620,13 +36979,16 @@ async function connect2(url, opts, deps) {
36620
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" };
36621
36980
  }
36622
36981
  if (opts.selfHosted && deps.localStackRunning()) {
36623
- 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" };
36624
36983
  }
36625
36984
  const rows = opts.selfHosted ? [] : await deps.list();
36626
36985
  let row = rows.find((d) => d.name === name && !disconnecting2(d.status));
36627
36986
  const fresh = !row;
36628
36987
  let note = "";
36629
36988
  let checkup;
36989
+ let billing = {};
36990
+ let billingPage;
36991
+ let accepted = false;
36630
36992
  const findOrg = async () => {
36631
36993
  try {
36632
36994
  return await deps.clickhouseOrg(new URL(url).hostname, key.keyId, key.keySecret);
@@ -36655,62 +37017,154 @@ async function connect2(url, opts, deps) {
36655
37017
  }
36656
37018
  ch = found;
36657
37019
  }
36658
- if (opts.resetPassword) {
36659
- const others = rows.filter((d) => d.name !== name && !disconnecting2(d.status) && serverOf2(d.name) === serverOf2(name));
36660
- if (others.length) {
36661
- return { 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` };
36662
- }
36663
- }
36664
- deps.progress(`Preparing ${maskConnectionString(url)}`);
36665
- const prepared = await deps.prepare(url, provider, { resetPassword: opts.resetPassword });
36666
- if ("next" in prepared)
36667
- return { status: "action_required", provider, name, ...prepared };
36668
- if (prepared.note)
36669
- note = `; ${prepared.note}`;
36670
- if (opts.selfHosted) {
36671
- await deps.selfHosted(prepared.monitoringUrl, key && ch ? { CLICKHOUSE_ORG_ID: ch.orgId, CLICKHOUSE_KEY_ID: key.keyId, CLICKHOUSE_KEY_SECRET: key.keySecret } : {});
36672
- return { status: "connected", provider, name, dashboard_url: "http://localhost:3000", host_metrics: !!ch, next: `pgai mon health${note}` };
36673
- }
36674
- deps.progress(`Provisioning monitoring for ${name}`);
36675
- const undo = async () => {
36676
- if (prepared.generated)
37020
+ const undo = async (p) => {
37021
+ if ("generated" in p && p.generated)
36677
37022
  await deps.unprepare(url).catch(() => false);
36678
37023
  };
36679
- const created = await deps.create({
36680
- db_url: prepared.monitoringUrl,
36681
- ...provider === "clickhouse" ? { provider } : {},
36682
- ...key && ch ? { clickhouse_org_id: ch.orgId, clickhouse_key_id: key.keyId, clickhouse_key_secret: key.keySecret } : {}
36683
- }).catch(async (err) => {
36684
- if (err instanceof HttpStatusError && err.status >= 400 && err.status < 500)
36685
- await undo();
36686
- throw err;
36687
- });
36688
- if (created.status === "failed") {
36689
- await undo();
36690
- return { status: "failed", provider, name, id: created.id, next: `${created.error} Re-run pgai connect later.` };
37024
+ let lockId;
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));
37042
+ if (opts.coupon !== undefined && !opts.coupon.trim()) {
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` };
37044
+ }
37045
+ const prepOpts = { resetPassword: opts.resetPassword, ...storedPassword ? { storedPassword, others } : {} };
37046
+ if (!opts.selfHosted) {
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}`));
37051
+ if (b.stop)
37052
+ return { status: "action_required", provider, name, ...b.stop };
37053
+ ({ billing, billingPage, accepted } = b);
37054
+ }
37055
+ if (opts.resetPassword) {
37056
+ let lock;
37057
+ try {
37058
+ lock = await deps.resetLock(serverOf2(name));
37059
+ } catch (err) {
37060
+ if (err instanceof HttpStatusError && err.status === 409) {
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)` };
37062
+ }
37063
+ if (err instanceof HttpStatusError && err.status === 404) {
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` };
37065
+ }
37066
+ throw err;
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;
37071
+ }
37072
+ let prepared;
37073
+ let created;
37074
+ let unanswered = false;
37075
+ try {
37076
+ if (lockId) {
37077
+ const now = await deps.list();
37078
+ const same = now.find((d) => d.name === name && !disconnecting2(d.status));
37079
+ if (same)
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` };
37081
+ const others = othersOnServer(now);
37082
+ if (others.length)
37083
+ return { ...cutOff(others), ...billing };
37084
+ }
37085
+ progress("preparing", `Preparing ${maskConnectionString(url)}`);
37086
+ prepared = await deps.prepare(url, provider, prepOpts);
37087
+ if ("next" in prepared)
37088
+ return { status: "action_required", provider, name, ...billing, ...prepared };
37089
+ if (!("monitoringUrl" in prepared))
37090
+ throw new Error("prepare returned no monitoring URL");
37091
+ if (prepared.note)
37092
+ note = `; ${prepared.note}`;
37093
+ if (opts.selfHosted) {
37094
+ await deps.selfHosted(prepared.monitoringUrl, key && ch ? { CLICKHOUSE_ORG_ID: ch.orgId, CLICKHOUSE_KEY_ID: key.keyId, CLICKHOUSE_KEY_SECRET: key.keySecret } : {});
37095
+ return { status: "connected", provider, name, dashboard_url: "http://localhost:3000", host_metrics: !!ch, next: `pgai mon health${note}` };
37096
+ }
37097
+ progress("provisioning", `Provisioning monitoring for ${name}`);
37098
+ created = await deps.create({
37099
+ db_url: prepared.monitoringUrl,
37100
+ ...provider === "clickhouse" ? { provider } : {},
37101
+ ...key && ch ? { clickhouse_org_id: ch.orgId, clickhouse_key_id: key.keyId, clickhouse_key_secret: key.keySecret } : {},
37102
+ ...opts.coupon ? { promo_code: opts.coupon } : {},
37103
+ ...accepted ? { accept_price: true } : {}
37104
+ }).catch(async (err) => {
37105
+ if (err instanceof HttpStatusError && err.status >= 400 && err.status < 500)
37106
+ await undo(prepared);
37107
+ else
37108
+ unanswered = true;
37109
+ if (err instanceof HttpStatusError && err.status === 402)
37110
+ return /declined/i.test(err.message) ? "declined" : "payment";
37111
+ if (err instanceof HttpStatusError && err.status === 412)
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";
37115
+ throw err;
37116
+ });
37117
+ if (created === "payment")
37118
+ return { status: "action_required", provider, name, ...billingPage };
37119
+ if (created === "declined") {
37120
+ return { status: "action_required", provider, name, ...billingPage, next: billingPage.next.replace(/^Add a payment method at /, "The payment method on file was declined: update it at ") };
37121
+ }
37122
+ if (created === "price")
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
+ }
37128
+ if (created.status === "failed") {
37129
+ await undo(prepared);
37130
+ return { status: "failed", provider, name, id: created.id, next: `${created.error} Re-run pgai connect later.` };
37131
+ }
37132
+ } finally {
37133
+ if (lockId && !unanswered)
37134
+ await deps.resetUnlock(lockId).catch(() => {});
36691
37135
  }
36692
37136
  row = { id: created.id, name: created.name, provider, status: created.status, dashboard_url: null, host_metrics: !!ch };
36693
- checkup = await deps.checkup(withLocalTls2(prepared.monitoringUrl, url)).catch((err) => ({ error: errorText2(err) }));
36694
- for (const line of checkupLines2(checkup))
36695
- deps.progress(line);
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(`
37142
+ `), { checkup });
37143
+ }
36696
37144
  }
36697
- for (const deadline = Date.now() + opts.waitMs;; ) {
37145
+ let shown;
37146
+ for (const deadline = deps.now() + opts.waitMs;; ) {
36698
37147
  const result = connectStatus2(row, provider, fresh);
36699
- if (result.status !== "provisioning" || Date.now() >= deadline) {
37148
+ const state = boxState2(row);
37149
+ if (state !== shown && (shown !== undefined || result.status === "provisioning"))
37150
+ progress("box", `Monitoring box: ${state}`, { state });
37151
+ shown = state;
37152
+ if (result.status !== "provisioning" || deps.now() >= deadline) {
36700
37153
  if (result.status === "connected" && provider === "clickhouse" && !row.host_metrics) {
36701
37154
  result.next += "; for CPU, memory and disk, disconnect and reconnect with --clickhouse-key <key-id>:<key-secret>";
36702
37155
  }
36703
37156
  const { next, ...rest } = result;
36704
- return { ...rest, ...checkup ? { checkup } : {}, next: next + note };
37157
+ return { ...rest, ...billing, ...checkup ? { checkup } : {}, next: next + note };
36705
37158
  }
36706
- deps.progress(`Waiting for the monitoring box (${row.status ?? "starting"})`);
36707
37159
  await deps.sleep(POLL_MS2);
36708
37160
  row = (await deps.list().catch(() => [])).find((d) => d.id === row.id) ?? row;
36709
37161
  }
36710
37162
  }
36711
- function openConnection2(url, opts) {
37163
+ async function openConnection2(url, opts) {
36712
37164
  const conn = resolveAdminConnection({ conn: url });
36713
- 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 };
36714
37168
  }
36715
37169
  async function dropMonitoringRole2(client) {
36716
37170
  for (const sql of [`drop role ${DEFAULT_MONITORING_USER}`, `drop owned by ${DEFAULT_MONITORING_USER}; drop role ${DEFAULT_MONITORING_USER}`]) {
@@ -36743,7 +37197,7 @@ async function prepareDatabase2(url, provider, opts = {}) {
36743
37197
  throw err;
36744
37198
  }
36745
37199
  };
36746
- const { client } = await open(url).catch((err) => {
37200
+ const { client, usedSsl, refusedTls } = await open(url).catch((err) => {
36747
37201
  if (/client password must be a string/.test(String(err?.message)))
36748
37202
  throw new Error("The URL has no password (and PGPASSWORD is not set): pgai connect postgresql://user:password@host:5432/dbname");
36749
37203
  throw err;
@@ -36757,7 +37211,7 @@ async function prepareDatabase2(url, provider, opts = {}) {
36757
37211
  and has_database_privilege(current_user, current_database(), 'CREATE')
36758
37212
  and exists (select 1 from pg_extension where extname = 'pg_stat_statements')) as admin,
36759
37213
  exists (select 1 from pg_roles where rolname = '${DEFAULT_MONITORING_USER}') as mon_exists,
36760
- current_setting('scram_iterations', true) as iterations
37214
+ current_setting('scram_iterations', true) as iterations, current_setting('ssl') as ssl
36761
37215
  from pg_roles where rolname = current_user`)).rows[0];
36762
37216
  const notes = [caNote2(url)].filter((n) => !!n);
36763
37217
  const noted = () => notes.length ? { note: notes.join("; ") } : {};
@@ -36781,9 +37235,31 @@ async function prepareDatabase2(url, provider, opts = {}) {
36781
37235
  }
36782
37236
  } else if (me.admin && pgProvider !== "supabase") {
36783
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
+ }
36784
37260
  if (me.mon_exists && opts.agent)
36785
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` };
36786
- 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`;
36787
37263
  const { password, generated } = await resolveMonitoringPassword({ passwordEnv: opts.agent ? undefined : process.env.PGAI_MON_PASSWORD, monitoringUser: DEFAULT_MONITORING_USER });
36788
37264
  const reset = !!opts.resetPassword && me.mon_exists;
36789
37265
  if (me.mon_exists && !reset) {
@@ -36793,13 +37269,16 @@ async function prepareDatabase2(url, provider, opts = {}) {
36793
37269
  try {
36794
37270
  accepted = await logsIn(loginUrlFor2(url, me.db, password));
36795
37271
  } catch (err) {
36796
- 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}` };
36797
37274
  }
36798
37275
  if (!accepted)
36799
- 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}` };
36800
37277
  if (await acceptsAnyPassword())
36801
37278
  notes.push(unchecked("PGAI_MON_PASSWORD"));
36802
37279
  }
37280
+ if (opts.check)
37281
+ return { checked: true };
36803
37282
  const monitoringUrl = monitoringUrlFor2(url, me.db, password);
36804
37283
  const loginUrl = loginUrlFor2(url, me.db, password);
36805
37284
  const plan = await buildInitPlan({ database: me.db, monitoringPassword: password, iterations: Number(me.iterations), includeOptionalPermissions: true, provider: pgProvider, keepExistingPassword: !reset });
@@ -36828,14 +37307,43 @@ ${redactPasswordsInSql(s.sql)}`).join(`
36828
37307
  }
36829
37308
  async function expressCheckup2(url, opts = {}) {
36830
37309
  const { client } = await openConnection2(url, opts);
37310
+ let reports;
37311
+ const failed = [];
36831
37312
  try {
36832
- const failed = [];
36833
- const reports = await generateAllReports(client, "node-01", undefined, (f) => failed.push(f.checkId));
36834
- const findings = Object.values(reports).map((r) => ({ check_id: r.checkId, title: r.checkTitle, status: r.summary.status, message: r.summary.message })).filter((f) => f.status !== "info").sort((a, b) => Number(b.status === "warning") - Number(a.status === "warning"));
36835
- return { checks: Object.keys(reports).length + failed.length, findings, ...failed.length ? { failed } : {} };
37313
+ reports = await generateAllReports(client, "node-01", undefined, (f) => failed.push(f.checkId));
36836
37314
  } finally {
36837
37315
  await client.end();
36838
37316
  }
37317
+ const all = Object.values(reports).map((r) => ({ check_id: r.checkId, title: r.checkTitle, status: r.summary.status, message: r.summary.message }));
37318
+ const findings = all.filter((f) => f.status !== "info").sort((a, b) => Number(b.status === "warning") - Number(a.status === "warning"));
37319
+ const result = { checks: all.length + failed.length, findings, info: all.filter((f) => f.status === "info").map((f) => f.check_id), ...failed.length ? { failed } : {} };
37320
+ if (!opts.save)
37321
+ return result;
37322
+ try {
37323
+ return { ...result, report_id: await opts.save(reports) };
37324
+ } catch (err) {
37325
+ return { ...result, upload_error: errorText2(err) };
37326
+ }
37327
+ }
37328
+ async function saveCheckupReport2(rpc, accessToken, project, reports) {
37329
+ const { reportId } = await createCheckupReport(rpc, { apiKey: accessToken, project });
37330
+ const setStatus = (status) => rpc("checkup_report_status_update", { access_token: accessToken, report_id: reportId, status });
37331
+ try {
37332
+ for (const [checkId, report] of Object.entries(reports)) {
37333
+ await uploadCheckupReportJson(rpc, {
37334
+ apiKey: accessToken,
37335
+ reportId,
37336
+ filename: `${checkId}.json`,
37337
+ checkId,
37338
+ jsonText: JSON.stringify(report, null, 2)
37339
+ });
37340
+ }
37341
+ } catch (err) {
37342
+ await setStatus("failed").catch(() => {});
37343
+ throw err;
37344
+ }
37345
+ await setStatus("completed");
37346
+ return reportId;
36839
37347
  }
36840
37348
  async function clickhouseOrgFor2(host, keyId, keySecret) {
36841
37349
  const apiUrl = process.env.CLICKHOUSE_API_URL || "https://api.clickhouse.cloud";
@@ -36866,90 +37374,141 @@ async function clickhouseOrgFor2(host, keyId, keySecret) {
36866
37374
  }
36867
37375
  throw failed ?? notFound;
36868
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
+ }
36869
37388
  function platformDeps2(p) {
36870
37389
  const rpc = (fn, body = {}) => callRpc({ apiKey: p.apiKey, apiBaseUrl: p.apiBaseUrl, fn, body, operation: fn.replace(/_/g, " "), debug: p.debug, orgScope: p.orgScope });
36871
37390
  return {
36872
37391
  list: () => rpc("cloud_monitoring_list"),
36873
37392
  create: (body) => rpc("cloud_monitoring_connect", body),
36874
37393
  disconnect: (id) => rpc("cloud_monitoring_disconnect", { instance_id: id }),
37394
+ resetLock: (server) => rpc("cloud_monitoring_reset_lock", { server }),
37395
+ resetUnlock: (lockId) => rpc("cloud_monitoring_reset_unlock", { lock_id: lockId }),
36875
37396
  prepare: (url, provider, o = {}) => prepareDatabase2(url, provider, { ...o, agent: p.agent }),
36876
37397
  unprepare: (url) => unprepareDatabase2(url, { agent: p.agent }),
36877
37398
  clickhouseOrg: clickhouseOrgFor2,
36878
- checkup: (url) => expressCheckup2(url, { agent: p.agent }),
37399
+ checkup: (url, project) => expressCheckup2(url, { agent: p.agent, save: (reports) => saveCheckupReport2(rpc, p.apiKey, project, reports) }),
37400
+ quote: (coupon, cluster) => rpc("cloud_monitoring_quote", { ...coupon ? { promo_code: coupon } : {}, ...cluster ? { db_server: cluster } : {} }),
37401
+ billingUrl: (orgAlias) => `${p.uiBaseUrl}/${orgAlias}/billing`,
36879
37402
  handoffUrl: async (provider) => {
36880
37403
  const orgs = await listOrgs({ apiKey: p.apiKey, apiBaseUrl: p.apiBaseUrl });
36881
37404
  const org = orgs.length === 1 ? orgs[0] : orgs.find((o) => o.alias === p.orgScope?.alias || o.org_id === p.orgScope?.id);
36882
37405
  return `${p.uiBaseUrl}/${org?.alias ?? "<org>"}/monitoring/scale/create/${provider}`;
36883
37406
  },
36884
- sleep: (ms) => new Promise((r) => setTimeout(r, ms))
37407
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
37408
+ now: () => Date.now()
36885
37409
  };
36886
37410
  }
36887
37411
 
36888
37412
  // lib/joe.ts
36889
37413
  var DEFAULT_BUDGET_MS = 25000;
36890
37414
  var DEFAULT_POLL_INTERVAL_MS = 800;
37415
+ var JOE_ASYNC_FALLBACK_WAIT_MS2 = 10 * 60 * 1000;
37416
+ var JOE_ASYNC_MAX_WAIT_MS2 = 60 * 60 * 1000;
37417
+ var JOE_ASYNC_MAX_POLL_FAILURES = 5;
36891
37418
  async function callRpc2(params) {
37419
+ return (await callRpcRaw2(params)).data;
37420
+ }
37421
+ async function callRpcRaw2(params) {
36892
37422
  const { apiKey, apiBaseUrl, fn, body, operation, debug } = params;
36893
37423
  if (!apiKey) {
36894
37424
  throw new Error("API key is required");
36895
37425
  }
36896
37426
  const base = normalizeBaseUrl(apiBaseUrl);
36897
37427
  const url = new URL(`${base}/rpc/${fn}`);
36898
- const payload = JSON.stringify(body);
36899
37428
  const headers = buildAuthHeaders(apiKey, params.orgScope);
36900
- if (debug) {
36901
- const debugHeaders = { ...headers, "access-token": maskSecret(apiKey) };
36902
- console.error(`Debug: POST URL: ${url.toString()}`);
36903
- console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
36904
- console.error(`Debug: Request body: ${redactSecretsForLog(payload)}`);
36905
- }
36906
- let response;
36907
- const requestTimeout = requestTimeoutSignal(params.timeoutMs);
36908
- try {
36909
- response = await fetch(url.toString(), {
36910
- method: "POST",
36911
- headers,
36912
- body: payload,
36913
- signal: requestTimeout.signal
36914
- });
36915
- } catch (err) {
36916
- if (isFetchTimeout(err)) {
36917
- throw new HttpRequestTimeoutError(operation, requestTimeout.timeoutMs);
37429
+ const send = async (bodyObj) => {
37430
+ const payload = JSON.stringify(bodyObj);
37431
+ if (debug) {
37432
+ const debugHeaders = { ...headers, "access-token": maskSecret(apiKey) };
37433
+ console.error(`Debug: POST URL: ${url.toString()}`);
37434
+ console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
37435
+ console.error(`Debug: Request body: ${redactSecretsForLog(payload)}`);
37436
+ }
37437
+ let response;
37438
+ const requestTimeout = requestTimeoutSignal(params.timeoutMs);
37439
+ try {
37440
+ response = await fetch(url.toString(), {
37441
+ method: "POST",
37442
+ headers,
37443
+ body: payload,
37444
+ signal: requestTimeout.signal
37445
+ });
37446
+ } catch (err) {
37447
+ if (isFetchTimeout(err)) {
37448
+ throw new HttpRequestTimeoutError(operation, requestTimeout.timeoutMs);
37449
+ }
37450
+ throw new Error(describeFetchError(operation, base, err));
36918
37451
  }
36919
- throw new Error(describeFetchError(operation, base, err));
36920
- }
36921
- const text = await response.text();
36922
- if (debug) {
36923
- console.error(`Debug: Response status: ${response.status}`);
36924
- console.error(`Debug: Response body: ${redactSecretsForLog(text)}`);
37452
+ const text = await response.text();
37453
+ if (debug) {
37454
+ console.error(`Debug: Response status: ${response.status}`);
37455
+ console.error(`Debug: Response body: ${redactSecretsForLog(text)}`);
37456
+ }
37457
+ return { response, text };
37458
+ };
37459
+ let { response, text } = await send(body);
37460
+ if (params.fallbackBody && isUnknownRpcSignature(response.status, text, fn)) {
37461
+ ({ response, text } = await send(params.fallbackBody));
36925
37462
  }
36926
37463
  if (!response.ok) {
36927
37464
  throw new HttpStatusError(formatHttpError(operation, response.status, text, response.statusText), response.status);
36928
37465
  }
36929
37466
  try {
36930
- return JSON.parse(text);
37467
+ return { data: JSON.parse(text), status: response.status };
36931
37468
  } catch {
36932
37469
  throw new Error(`${operation}: failed to parse response: ${redactSecretsForLog(text)}`);
36933
37470
  }
36934
37471
  }
36935
- async function startCommand(params) {
37472
+ function joeAsyncWaitMs(handle) {
37473
+ const expires = positiveSeconds(handle.expires_in_s);
37474
+ return Math.min(expires ? expires * 1000 : JOE_ASYNC_FALLBACK_WAIT_MS2, JOE_ASYNC_MAX_WAIT_MS2);
37475
+ }
37476
+ function asyncHandleCommandId(reply, operation) {
37477
+ const commandId = reply?.command_id;
37478
+ if (typeof commandId !== "string" || !/^[0-9]+$/.test(commandId)) {
37479
+ throw new Error(`${operation}: the platform accepted the command over the job channel but ` + `returned no command id: ${redactSecretsForLog(JSON.stringify(reply))}`);
37480
+ }
37481
+ return commandId;
37482
+ }
37483
+ async function startCommandDetailed(params) {
36936
37484
  const { apiKey, apiBaseUrl, instanceId, command, debug, timeoutMs } = params;
36937
37485
  if (!String(command ?? "").trim()) {
36938
37486
  throw new Error("command text is required");
36939
37487
  }
36940
- const commandId = await callRpc2({
37488
+ const operation = "Failed to run Joe command";
37489
+ const body = { instance_id: instanceId, command };
37490
+ const { data, status } = await callRpcRaw2({
36941
37491
  apiKey,
36942
37492
  apiBaseUrl,
36943
37493
  fn: "joe_command_run",
36944
- body: { instance_id: instanceId, command },
36945
- operation: "Failed to run Joe command",
37494
+ body: { ...body, accept_async: true },
37495
+ fallbackBody: body,
37496
+ operation,
36946
37497
  debug,
36947
37498
  timeoutMs
36948
37499
  });
36949
- if (typeof commandId !== "string" || !/^[0-9]+$/.test(commandId)) {
36950
- throw new Error(`Failed to run Joe command: expected a command id string, got: ${redactSecretsForLog(JSON.stringify(commandId))}`);
37500
+ if (status === 202) {
37501
+ const handle = data ?? {};
37502
+ return {
37503
+ commandId: asyncHandleCommandId(data, operation),
37504
+ asyncWaitMs: joeAsyncWaitMs(handle),
37505
+ firstAnswerEstimateS: positiveSeconds(handle.first_answer_estimate_s)
37506
+ };
36951
37507
  }
36952
- return commandId;
37508
+ if (typeof data !== "string" || !/^[0-9]+$/.test(data)) {
37509
+ throw new Error(`${operation}: expected a command id string, got: ${redactSecretsForLog(JSON.stringify(data))}`);
37510
+ }
37511
+ return { commandId: data, asyncWaitMs: null, firstAnswerEstimateS: null };
36953
37512
  }
36954
37513
  async function getCommandOutput(params) {
36955
37514
  const { apiKey, apiBaseUrl, commandId, debug, timeoutMs } = params;
@@ -37079,18 +37638,29 @@ function buildJoeCommandText(command, input = {}) {
37079
37638
  var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
37080
37639
  async function runCommand(params) {
37081
37640
  const { apiKey, apiBaseUrl, instanceId, command, debug } = params;
37082
- const budgetMs = typeof params.budgetMs === "number" && Number.isFinite(params.budgetMs) && params.budgetMs >= 0 ? params.budgetMs : DEFAULT_BUDGET_MS;
37083
- const pollIntervalMs = params.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
37641
+ const explicitBudgetMs = typeof params.budgetMs === "number" && Number.isFinite(params.budgetMs) && params.budgetMs >= 0 ? params.budgetMs : null;
37084
37642
  const now = params.now ?? Date.now;
37085
37643
  const sleep = params.sleep ?? defaultSleep;
37086
- const commandId = await startCommand({ apiKey, apiBaseUrl, instanceId, command, debug });
37644
+ const started = await startCommandDetailed({ apiKey, apiBaseUrl, instanceId, command, debug });
37645
+ const commandId = started.commandId;
37646
+ const budgetMs = explicitBudgetMs ?? started.asyncWaitMs ?? DEFAULT_BUDGET_MS;
37647
+ const pollDelayMs = (attempt) => params.pollIntervalMs ?? (started.asyncWaitMs === null ? DEFAULT_POLL_INTERVAL_MS : asyncPollDelayMs(attempt));
37087
37648
  const deadline = now() + budgetMs;
37088
37649
  let status = "pending";
37089
37650
  const remainingRequestMs = () => Math.max(1, Math.min(DEFAULT_HTTP_REQUEST_TIMEOUT_MS, deadline - now()));
37090
37651
  if (now() >= deadline) {
37091
- return { commandId, status, output: null, budgetExpired: true };
37652
+ return { commandId, status, output: null, budgetExpired: true, budgetMs };
37092
37653
  }
37093
- for (;; ) {
37654
+ let announced = false;
37655
+ const announce = () => {
37656
+ if (announced || started.asyncWaitMs === null)
37657
+ return;
37658
+ announced = true;
37659
+ const estimate = started.firstAnswerEstimateS;
37660
+ console.error(`Queued on the Joe job channel (command ${commandId}` + (estimate ? `, reaches Joe in about ${estimate}s` : "") + `); waiting up to ${Math.round(budgetMs / 1000)}s...`);
37661
+ };
37662
+ let transientFailures = 0;
37663
+ for (let attempt = 0;; attempt++) {
37094
37664
  let output;
37095
37665
  try {
37096
37666
  output = await getCommandOutput({
@@ -37102,25 +37672,28 @@ async function runCommand(params) {
37102
37672
  });
37103
37673
  } catch (err) {
37104
37674
  if (err instanceof HttpRequestTimeoutError) {
37105
- return { commandId, status, output: null, budgetExpired: true };
37675
+ return { commandId, status, output: null, budgetExpired: true, budgetMs };
37106
37676
  }
37107
37677
  if (err instanceof HttpStatusError && isRetryableHttpStatus(err.status)) {
37108
- if (now() >= deadline) {
37109
- return { commandId, status, output: null, budgetExpired: true };
37678
+ transientFailures += 1;
37679
+ if (now() >= deadline || started.asyncWaitMs !== null && transientFailures >= JOE_ASYNC_MAX_POLL_FAILURES) {
37680
+ return { commandId, status, output: null, budgetExpired: true, budgetMs };
37110
37681
  }
37111
- await sleep(pollIntervalMs);
37682
+ await sleep(pollDelayMs(attempt));
37112
37683
  continue;
37113
37684
  }
37114
37685
  throw err;
37115
37686
  }
37687
+ transientFailures = 0;
37116
37688
  status = output.status;
37117
37689
  if (status === "ok" || status === "error") {
37118
- return { commandId, status, output, budgetExpired: false };
37690
+ return { commandId, status, output, budgetExpired: false, budgetMs };
37119
37691
  }
37120
37692
  if (now() >= deadline) {
37121
- return { commandId, status, output: null, budgetExpired: true };
37693
+ return { commandId, status, output: null, budgetExpired: true, budgetMs };
37122
37694
  }
37123
- await sleep(pollIntervalMs);
37695
+ announce();
37696
+ await sleep(pollDelayMs(attempt));
37124
37697
  }
37125
37698
  }
37126
37699
  async function executeJoeCommand(params) {
@@ -37305,22 +37878,6 @@ async function postDblabRpc(rpc, bodyObj, params) {
37305
37878
  }
37306
37879
  return { response, text };
37307
37880
  }
37308
- function isUnknownRpcSignature(status, text, rpc) {
37309
- if (status !== 404)
37310
- return false;
37311
- try {
37312
- const parsed = JSON.parse(text);
37313
- return typeof parsed.message === "string" && /Could not find the .* function/.test(parsed.message) && parsed.message.includes(`${rpc}(`);
37314
- } catch {
37315
- return false;
37316
- }
37317
- }
37318
- function positiveSeconds(v) {
37319
- return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null;
37320
- }
37321
- function dblabPollDelay(attempt) {
37322
- return Math.min(1000 * 2 ** attempt, 15000);
37323
- }
37324
37881
  var DBLAB_ASYNC_FALLBACK_WAIT_MS = 15 * 60 * 1000;
37325
37882
  var DBLAB_ASYNC_MAX_WAIT_MS = 60 * 60 * 1000;
37326
37883
  var DBLAB_POLL_MAX_CONSECUTIVE_FAILURES = 5;
@@ -37407,7 +37964,7 @@ async function awaitDblabCall(handle, params) {
37407
37964
  announced = true;
37408
37965
  console.error(`Waiting for the DBLab instance to pick up the call (job ${jobId}` + (estimate ? `, typically ~${estimate}s` : "") + ")...");
37409
37966
  }
37410
- const delay = dblabPollDelay(attempt);
37967
+ const delay = asyncPollDelayMs(attempt);
37411
37968
  if (now() + delay >= deadline) {
37412
37969
  throw new Error(`${operation}: timed out waiting for the DBLab instance (job ${jobId}, last status ${lastStatus}). ` + dblabNotResentAdvice(jobId));
37413
37970
  }
@@ -40129,7 +40686,7 @@ function feedbackMessage() {
40129
40686
  }
40130
40687
 
40131
40688
  // bin/postgres-ai.ts
40132
- import { createInterface } from "readline";
40689
+ import { createInterface as createInterface2 } from "readline";
40133
40690
  import { Writable } from "stream";
40134
40691
  import * as childProcess from "child_process";
40135
40692
 
@@ -42722,48 +43279,11 @@ async function verifyApiKey(params) {
42722
43279
  clearTimeout(timer);
42723
43280
  }
42724
43281
  }
42725
- async function createCheckupReport(params) {
42726
- const { apiKey, apiBaseUrl, project, status } = params;
42727
- const bodyObj = {
42728
- access_token: apiKey,
42729
- project
42730
- };
42731
- if (status)
42732
- bodyObj.status = status;
42733
- const resp = await postRpc({
42734
- apiKey,
42735
- apiBaseUrl,
42736
- rpcName: "checkup_report_create",
42737
- bodyObj
42738
- });
42739
- const reportId = Number(resp?.report_id);
42740
- if (!Number.isFinite(reportId) || reportId <= 0) {
42741
- throw new Error(`Unexpected checkup_report_create response: ${JSON.stringify(resp)}`);
42742
- }
42743
- return { reportId };
43282
+ async function createCheckupReport2(params) {
43283
+ return createCheckupReport((rpcName, bodyObj) => postRpc({ ...params, rpcName, bodyObj }), params);
42744
43284
  }
42745
- async function uploadCheckupReportJson(params) {
42746
- const { apiKey, apiBaseUrl, reportId, filename, checkId, jsonText } = params;
42747
- const bodyObj = {
42748
- access_token: apiKey,
42749
- checkup_report_id: reportId,
42750
- filename,
42751
- check_id: checkId,
42752
- data: jsonText,
42753
- type: "json",
42754
- generate_issue: true
42755
- };
42756
- const resp = await postRpc({
42757
- apiKey,
42758
- apiBaseUrl,
42759
- rpcName: "checkup_report_file_post",
42760
- bodyObj
42761
- });
42762
- const chunkId = Number(resp?.report_chunck_id ?? resp?.report_chunk_id);
42763
- if (!Number.isFinite(chunkId) || chunkId <= 0) {
42764
- throw new Error(`Unexpected checkup_report_file_post response: ${JSON.stringify(resp)}`);
42765
- }
42766
- return { reportChunkId: chunkId };
43285
+ async function uploadCheckupReportJson2(params) {
43286
+ return uploadCheckupReportJson((rpcName, bodyObj) => postRpc({ ...params, rpcName, bodyObj }), params);
42767
43287
  }
42768
43288
  async function convertCheckupReportJsonToMarkdown(params) {
42769
43289
  const { apiKey, apiBaseUrl, checkId, jsonPayload, reportType } = params;
@@ -42998,7 +43518,8 @@ function summarizeF0012(nodeData) {
42998
43518
  return `Autovacuum config: ${parts.join(", ")} finding${fired.length > 1 ? "s" : ""}`;
42999
43519
  };
43000
43520
  if (severity === "CRITICAL" || severity === "WARNING") {
43001
- return { status: "warning", message: summarize() };
43521
+ const top = fired.find((r) => r.severity === "CRITICAL") ?? fired.find((r) => r.severity === "WARNING");
43522
+ return { status: "warning", message: top?.conclusion ? `${summarize()}. ${top.conclusion}` : summarize() };
43002
43523
  }
43003
43524
  return { status: "info", message: summarize() };
43004
43525
  }
@@ -43155,6 +43676,48 @@ function summarizeG0032(nodeData) {
43155
43676
  };
43156
43677
  }
43157
43678
 
43679
+ // lib/json-stderr.ts
43680
+ import { spawn, spawnSync } from "node:child_process";
43681
+ import { createInterface } from "node:readline";
43682
+ import { format } from "node:util";
43683
+ function writeEvent(event) {
43684
+ process.stderr.write(`${JSON.stringify(event)}
43685
+ `);
43686
+ }
43687
+ function jsonConsole() {
43688
+ const { error, warn } = console;
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 () => {
43696
+ console.error = error;
43697
+ console.warn = warn;
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
+ });
43719
+ }
43720
+
43158
43721
  // lib/instances.ts
43159
43722
  import * as fs10 from "fs";
43160
43723
  class InstancesParseError2 extends Error {
@@ -43300,12 +43863,22 @@ function buildClientConfig(connStr, extra = {}) {
43300
43863
  port: parsed.port ? Number(parsed.port) : undefined,
43301
43864
  user: parsed.user,
43302
43865
  password: parsed.password,
43303
- database: parsed.database || undefined,
43866
+ database: databaseName3(withoutSslmode(channelBinding.uri), parsed.database),
43304
43867
  ssl: sslOptionFromSslmode(sslmode),
43305
43868
  ...enableChannelBinding ? { enableChannelBinding: true } : {},
43306
43869
  ...extra
43307
43870
  };
43308
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
+ }
43309
43882
  function withoutSslmode(connStr) {
43310
43883
  try {
43311
43884
  const u = new URL(connStr);
@@ -43349,7 +43922,7 @@ Download: https://nodejs.org/`);
43349
43922
  var rl = null;
43350
43923
  function getReadline() {
43351
43924
  if (!rl) {
43352
- rl = createInterface({ input: process.stdin, output: process.stdout });
43925
+ rl = createInterface2({ input: process.stdin, output: process.stdout });
43353
43926
  }
43354
43927
  return rl;
43355
43928
  }
@@ -43574,7 +44147,7 @@ async function execFilePromise(file, args, options = {}) {
43574
44147
  });
43575
44148
  });
43576
44149
  }
43577
- function spawnSync2(cmd, args, options) {
44150
+ function spawnSync3(cmd, args, options) {
43578
44151
  const result = childProcess.spawnSync(cmd, args, {
43579
44152
  stdio: options?.stdio === "inherit" ? "inherit" : "pipe",
43580
44153
  env: options?.env,
@@ -43587,7 +44160,7 @@ function spawnSync2(cmd, args, options) {
43587
44160
  stderr: typeof result.stderr === "string" ? result.stderr : ""
43588
44161
  };
43589
44162
  }
43590
- function spawn2(cmd, args, options) {
44163
+ function spawn3(cmd, args, options) {
43591
44164
  const proc = childProcess.spawn(cmd, args, {
43592
44165
  stdio: options?.stdio ?? "pipe",
43593
44166
  env: options?.env,
@@ -43618,7 +44191,7 @@ async function question(prompt) {
43618
44191
  }
43619
44192
  async function questionHidden(prompt) {
43620
44193
  closeReadline();
43621
- 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 });
43622
44195
  process.stdout.write(prompt);
43623
44196
  return new Promise((resolve) => {
43624
44197
  hidden.question("", (answer) => {
@@ -43749,7 +44322,7 @@ function prepareUploadConfig(opts, rootOpts, shouldUpload, uploadExplicitlyReque
43749
44322
  }
43750
44323
  async function uploadCheckupReports(uploadCfg, reports, spinner, logUpload) {
43751
44324
  spinner.update("Creating remote checkup report");
43752
- const created = await withRetry(() => createCheckupReport({
44325
+ const created = await withRetry(() => createCheckupReport2({
43753
44326
  apiKey: uploadCfg.apiKey,
43754
44327
  apiBaseUrl: uploadCfg.apiBaseUrl,
43755
44328
  project: uploadCfg.project
@@ -43762,7 +44335,7 @@ async function uploadCheckupReports(uploadCfg, reports, spinner, logUpload) {
43762
44335
  for (const [checkId, report] of Object.entries(reports)) {
43763
44336
  spinner.update(`Uploading ${checkId}.json`);
43764
44337
  const jsonText = JSON.stringify(report, null, 2);
43765
- const r = await withRetry(() => uploadCheckupReportJson({
44338
+ const r = await withRetry(() => uploadCheckupReportJson2({
43766
44339
  apiKey: uploadCfg.apiKey,
43767
44340
  apiBaseUrl: uploadCfg.apiBaseUrl,
43768
44341
  reportId,
@@ -44009,6 +44582,56 @@ function printResult(result, json) {
44009
44582
  var program2 = new Command2;
44010
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)");
44011
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
+ }
44012
44635
  program2.addHelpText("after", () => `
44013
44636
  \uD83D\uDCA1 Ideas / feedback: ${FEEDBACK_URL}
44014
44637
  `);
@@ -44030,6 +44653,15 @@ function withOrgOptions(command) {
44030
44653
  ORG_SCOPED_COMMANDS.add(command);
44031
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`);
44032
44655
  }
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
+ });
44033
44665
  program2.hook("preAction", (_thisCommand, actionCommand) => {
44034
44666
  const initPointsToConnect = actionCommand.parent === program2 && actionCommand.name() === "init" && !interactive(actionCommand.opts().json);
44035
44667
  if (!ORG_SCOPED_COMMANDS.has(actionCommand) || initPointsToConnect) {
@@ -44057,7 +44689,7 @@ program2.command("feedback").description("share ideas or feedback about the CLI"
44057
44689
  if (opts.open) {
44058
44690
  const openCommand = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
44059
44691
  try {
44060
- spawn2(openCommand, [FEEDBACK_URL], { detached: true, stdio: "ignore" }).unref();
44692
+ spawn3(openCommand, [FEEDBACK_URL], { detached: true, stdio: "ignore" }).unref();
44061
44693
  } catch {}
44062
44694
  }
44063
44695
  });
@@ -45342,7 +45974,7 @@ function ensureHostMetricsDir(projectDir) {
45342
45974
  }
45343
45975
  function isDockerRunning() {
45344
45976
  try {
45345
- const result = spawnSync2("docker", ["info"], { stdio: "pipe", timeout: 5000 });
45977
+ const result = spawnSync3("docker", ["info"], { stdio: "pipe", timeout: 5000 });
45346
45978
  return result.status === 0;
45347
45979
  } catch {
45348
45980
  return false;
@@ -45355,7 +45987,7 @@ function shouldScaleOutNodeExporter(args) {
45355
45987
  return !args.slice(upIndex + 1).some((a) => !a.startsWith("-"));
45356
45988
  }
45357
45989
  function getComposeCmd() {
45358
- 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;
45359
45991
  if (tryCmd("docker", ["compose", "version"]))
45360
45992
  return ["docker", "compose"];
45361
45993
  if (tryCmd("docker-compose", ["version"]))
@@ -45364,7 +45996,7 @@ function getComposeCmd() {
45364
45996
  }
45365
45997
  function checkRunningContainers() {
45366
45998
  try {
45367
- 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 });
45368
46000
  if (result.status === 0 && result.stdout) {
45369
46001
  const containers = result.stdout.trim().split(`
45370
46002
  `).filter(Boolean);
@@ -45709,7 +46341,7 @@ async function runCompose(args, grafanaPassword, extraEnv) {
45709
46341
  finalArgs.push("--scale", "self-node-exporter=0");
45710
46342
  }
45711
46343
  return new Promise((resolve) => {
45712
- const child = spawn2(cmd[0], [...cmd.slice(1), "-f", composeFile, ...finalArgs], {
46344
+ const child = spawn3(cmd[0], [...cmd.slice(1), "-f", composeFile, ...finalArgs], {
45713
46345
  stdio: "inherit",
45714
46346
  env,
45715
46347
  cwd: projectDir
@@ -45899,7 +46531,7 @@ function emitConnect(result, json) {
45899
46531
  process.exitCode = CONNECT_EXIT[result.status];
45900
46532
  }
45901
46533
  function failCloud(err, json) {
45902
- printResult({ status: "failed", next: err instanceof Error ? err.message : String(err) }, json);
46534
+ printResult({ status: "failed", next: errorText2(err) }, json);
45903
46535
  process.exitCode = 1;
45904
46536
  }
45905
46537
  function signedIn(opts) {
@@ -45911,6 +46543,7 @@ function signedIn(opts) {
45911
46543
  return !!cloudApi().apiKey;
45912
46544
  }
45913
46545
  async function runConnect(url, opts) {
46546
+ const json = jsonOutput(opts.json);
45914
46547
  const name = (() => {
45915
46548
  try {
45916
46549
  return databaseName2(url);
@@ -45926,42 +46559,61 @@ async function runConnect(url, opts) {
45926
46559
  if (!Number.isFinite(waitMinutes) || waitMinutes < 0) {
45927
46560
  return emitConnect({ status: "failed", provider, name, next: "--wait must be a number of minutes (0 = do not wait)" }, opts.json);
45928
46561
  }
45929
- if (!opts.selfHosted && !signedIn(opts)) {
45930
- return emitConnect({ status: "action_required", provider, name, next: "Sign in: pgai auth login (agents: set PGAI_API_KEY), then re-run" }, opts.json);
45931
- }
45932
- const api = cloudApi(opts.debug);
45933
46562
  try {
46563
+ if (!opts.selfHosted && !signedIn(opts)) {
46564
+ return emitConnect({ status: "action_required", provider, name, next: "Sign in: pgai auth login (agents: set PGAI_API_KEY), then re-run" }, opts.json);
46565
+ }
46566
+ const api = cloudApi(opts.debug);
45934
46567
  const result = await connect2(url, { ...opts, waitMs: waitMinutes * 60000 }, {
45935
46568
  ...api,
45936
46569
  selfHosted: async (monitoringUrl, env) => {
45937
46570
  const scope = getActiveOrgScope2();
45938
46571
  const org = scope?.alias ? ["--org", scope.alias] : scope?.id ? ["--org-id", String(scope.id)] : [];
45939
46572
  const project = name.replace(/[^A-Za-z0-9._-]+/g, "-");
45940
- const r = childProcess.spawnSync(process.execPath, [process.argv[1], "mon", "local-install", "-y", ...org, "--project", project], {
45941
- stdio: ["ignore", 2, 2],
45942
- env: { ...process.env, ...env, PGAI_DB_URL: monitoringUrl, PGAI_API_BASE_URL: api.apiBaseUrl, ...api.apiKey ? { PGAI_API_KEY: api.apiKey } : {} }
45943
- });
45944
- if (r.status !== 0)
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");
46574
+ if (status !== 0)
45945
46575
  throw new Error("mon local-install failed (see above)");
45946
46576
  },
45947
46577
  localStackRunning: () => checkRunningContainers().running,
45948
- progress: (line) => console.error(line)
46578
+ confirm: async (q) => {
46579
+ if (!interactive(opts.json))
46580
+ return false;
46581
+ process.exitCode = 130;
46582
+ const answer = await question(q);
46583
+ process.exitCode = undefined;
46584
+ closeReadline();
46585
+ return /^(y|yes)$/i.test(answer.trim());
46586
+ },
46587
+ progress: (e) => json ? writeEvent(e) : console.error(progressText(e))
45949
46588
  });
45950
46589
  emitConnect(result, opts.json);
45951
46590
  } catch (err) {
45952
- 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);
45953
46592
  }
45954
46593
  }
45955
- 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("-y, --yes", "never prompt").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", [
45956
46595
  "",
45957
46596
  "Steps (each skipped when already done; safe to re-run): sign in, prepare the database",
45958
46597
  "(an admin URL creates the postgres_ai_mon role; otherwise the SQL is printed), provision",
45959
46598
  "the monitoring box, run the express checkup while it starts, wait, print the dashboard URL.",
45960
46599
  "",
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',
46602
+ "box is provisioned only when accepted: at the prompt, or with --yes. With no payment method",
46603
+ "it stops (exit 3) and names the console page to add one. --coupon applies a promotion code.",
46604
+ "",
45961
46605
  'Exit codes: 0 connected or provisioning, 1 failed, 3 action required (see "next").',
45962
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
+ "",
45963
46612
  "Environment: PGAI_API_KEY (instead of signing in), PGAI_MON_PASSWORD (the password of",
45964
- "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),",
45965
46617
  "PGPASSWORD (the password for a URL without one),",
45966
46618
  "CLICKHOUSE_KEY_ID + CLICKHOUSE_KEY_SECRET (instead of --clickhouse-key).",
45967
46619
  "",
@@ -45972,6 +46624,9 @@ withOrgOptions(program2.command("connect <database-url>")).description("put a da
45972
46624
  ].join(`
45973
46625
  `)).action(runConnect);
45974
46626
  var interactive = (json) => !!process.stdin.isTTY && !!process.stdout.isTTY && !json;
46627
+ function jsonOutput(json) {
46628
+ return !!json || !process.stdout.isTTY;
46629
+ }
45975
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) => {
45976
46631
  if (!interactive(opts.json)) {
45977
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);
@@ -46014,8 +46669,8 @@ withOrgOptions(program2.command("disconnect <name>")).description("stop monitori
46014
46669
  if (!opts.yes && !(process.stdin.isTTY && /^y/i.test(await question(`Disconnect ${row.name} and delete its monitoring box? (y/N): `)))) {
46015
46670
  return emitConnect({ status: "action_required", provider: row.provider, name: row.name, id: row.id, next: `pgai disconnect ${row.name} --yes` }, opts.json);
46016
46671
  }
46017
- await cloudApi(opts.debug).disconnect(row.id);
46018
- 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);
46019
46674
  } catch (err) {
46020
46675
  failCloud(err, opts.json);
46021
46676
  }
@@ -46601,9 +47256,9 @@ mon.command("health").description("health check for monitoring services").option
46601
47256
  allHealthy = true;
46602
47257
  for (const service of services) {
46603
47258
  try {
46604
- 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" });
46605
47260
  const present = result.status === 0;
46606
- 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() : "";
46607
47262
  const verdict = judgeServiceHealth(service, present, (result.stdout || "").trim(), health);
46608
47263
  console.log(`${verdict.mark} ${service.name}: ${verdict.label}`);
46609
47264
  if (!verdict.ok)
@@ -46871,7 +47526,7 @@ async function sinkPrometheusMaybeUp() {
46871
47526
  } catch {
46872
47527
  return true;
46873
47528
  }
46874
- 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"]);
46875
47530
  return ps.status !== 0 || ps.stdout.trim() !== "";
46876
47531
  }
46877
47532
  async function reloadHostMetrics(projectDir, job, file) {
@@ -46906,14 +47561,36 @@ async function reloadHostMetrics(projectDir, job, file) {
46906
47561
  }
46907
47562
  return true;
46908
47563
  }
47564
+ var CONN_STR_FORMAT = "Invalid connection string format: use postgresql://user:password@host[:port]/database";
46909
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;
46910
47577
  try {
46911
- new URL(connStr);
47578
+ url = new URL(connStr);
46912
47579
  } catch {
46913
- return null;
46914
- }
46915
- const m = connStr.match(/^postgres(?:ql)?:\/\/[^:]+:[^@]+@([^:\/]+)(?::\d+)?\/([^?]+)/);
46916
- 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, "-") };
46917
47594
  }
46918
47595
  function parseEnvValue(content, key) {
46919
47596
  const last = [...content.matchAll(new RegExp(`^[ \\t]*(?:export[ \\t]+)?${key}=(.*)$`, "gm"))].at(-1);
@@ -46958,7 +47635,7 @@ async function setRdsHostStats(projectDir, values) {
46958
47635
  if (!cmd)
46959
47636
  return true;
46960
47637
  const { composeFile } = await resolveOrInitPaths();
46961
- 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"]);
46962
47639
  if (ps.status !== 0) {
46963
47640
  console.error("If rds-host-stats is running, recreate it: docker compose --profile rds up -d rds-host-stats");
46964
47641
  return true;
@@ -47042,6 +47719,8 @@ async function syncVmalert(projectDir, apply, addOnly = false) {
47042
47719
  setEnvValues(projectDir, { COMPOSE_PROFILES: composeProfilesValue(current, want, "host-metrics") });
47043
47720
  return true;
47044
47721
  }
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);
47045
47724
  async function addTarget(file, projectDir, connStr, name, env, { apply = true } = {}) {
47046
47725
  if (!connStr) {
47047
47726
  console.error("Connection string required: postgresql://user:pass@host:port/db");
@@ -47051,15 +47730,15 @@ async function addTarget(file, projectDir, connStr, name, env, { apply = true }
47051
47730
  const channelBinding = splitChannelBinding2(connStr);
47052
47731
  connStr = channelBinding.uri;
47053
47732
  const defaultName = defaultTargetName(connStr);
47054
- if (!defaultName) {
47055
- console.error("Invalid connection string format");
47733
+ if ("error" in defaultName) {
47734
+ console.error(defaultName.error);
47056
47735
  process.exitCode = 1;
47057
47736
  return false;
47058
47737
  }
47059
47738
  if (channelBinding.value !== null) {
47060
47739
  console.error("Note: removed channel_binding from the connection string; the collector does not support it (TLS is kept)");
47061
47740
  }
47062
- const instanceName = name && name.trim() ? name.trim() : defaultName;
47741
+ const instanceName = name && name.trim() ? name.trim() : defaultName.name;
47063
47742
  if (detectProvider(connStr) !== "clickhouse" && extractProjectRefFromUrl(connStr) && supabaseHostMetricsOn(projectDir, env) && !SUPABASE_TARGET_NAME_RE.test(instanceName)) {
47064
47743
  console.error(SUPABASE_TARGET_NAME_ERROR);
47065
47744
  process.exitCode = 1;
@@ -47140,8 +47819,43 @@ Re-running with the same name and connection string is safe; retry after fixing
47140
47819
 
47141
47820
  Supabase: with PGAI_SUPABASE_HOST_METRICS=true (environment or .env), writes host-metrics/supabase-<name>.yml.
47142
47821
  RDS instance endpoint: writes RDS_DB_INSTANCE_IDENTIFIER, AWS_REGION, PGAI_CLUSTER and PGAI_NODE_NAME to .env for rds-host-stats.
47822
+
47823
+ Environment:
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'
47143
47836
  `).action(async (connStr, name) => {
47144
47837
  const { instancesFile: file, projectDir } = await resolveOrInitPaths();
47838
+ const envUrl = process.env.PGAI_DB_URL || undefined;
47839
+ delete process.env.PGAI_DB_URL;
47840
+ if (envUrl && name === undefined && (connStr === undefined || !looksLikeConnStr(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;
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})` : ""}`);
47858
+ }
47145
47859
  await addTarget(file, projectDir, connStr, name, process.env);
47146
47860
  });
47147
47861
  targets.command("remove <name>").description("remove monitoring target database").action(async (name) => {
@@ -47341,7 +48055,7 @@ Opening browser for authentication...`);
47341
48055
  ${authUrl}
47342
48056
  `);
47343
48057
  const openCommand = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
47344
- const browserProcess = spawn2(openCommand, [authUrl], { detached: true, stdio: "ignore" });
48058
+ const browserProcess = spawn3(openCommand, [authUrl], { detached: true, stdio: "ignore" });
47345
48059
  browserProcess.on("error", () => {});
47346
48060
  browserProcess.unref();
47347
48061
  console.log("Waiting for authorization...");
@@ -48541,7 +49255,7 @@ function tryParseJson(s) {
48541
49255
  }
48542
49256
  }
48543
49257
  function printJoeOutcome(outcome, json, budgetMs) {
48544
- const effectiveBudgetMs = typeof budgetMs === "number" && Number.isFinite(budgetMs) ? budgetMs : DEFAULT_BUDGET_MS;
49258
+ const effectiveBudgetMs = Number.isFinite(outcome.budgetMs) ? outcome.budgetMs : typeof budgetMs === "number" && Number.isFinite(budgetMs) ? budgetMs : DEFAULT_BUDGET_MS;
48545
49259
  const budgetSeconds = Math.round(effectiveBudgetMs / 1000);
48546
49260
  if (outcome.budgetExpired) {
48547
49261
  if (json) {