postgresai 0.17.0-dev.4 → 0.17.0-dev.5

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.5",
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.5";
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
  }
@@ -21625,7 +21642,7 @@ function summarizeG003(nodeData) {
21625
21642
 
21626
21643
  // lib/checkup.ts
21627
21644
  var __dirname = "/builds/postgres-ai/postgresai/cli/lib";
21628
- var CONTRACT_VERSION = "1.0.0";
21645
+ var CONTRACT_VERSION = "1.1.0";
21629
21646
  var SECONDS_PER_DAY = 86400;
21630
21647
  var SECONDS_PER_HOUR = 3600;
21631
21648
  var SECONDS_PER_MINUTE = 60;
@@ -27817,46 +27834,58 @@ async function verifyInitSetup(params) {
27817
27834
  }
27818
27835
 
27819
27836
  // lib/joe.ts
27837
+ var JOE_ASYNC_FALLBACK_WAIT_MS = 10 * 60 * 1000;
27838
+ var JOE_ASYNC_MAX_WAIT_MS = 60 * 60 * 1000;
27820
27839
  async function callRpc(params) {
27840
+ return (await callRpcRaw(params)).data;
27841
+ }
27842
+ async function callRpcRaw(params) {
27821
27843
  const { apiKey, apiBaseUrl, fn, body, operation, debug } = params;
27822
27844
  if (!apiKey) {
27823
27845
  throw new Error("API key is required");
27824
27846
  }
27825
27847
  const base = normalizeBaseUrl(apiBaseUrl);
27826
27848
  const url = new URL(`${base}/rpc/${fn}`);
27827
- const payload = JSON.stringify(body);
27828
27849
  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);
27850
+ const send = async (bodyObj) => {
27851
+ const payload = JSON.stringify(bodyObj);
27852
+ if (debug) {
27853
+ const debugHeaders = { ...headers, "access-token": maskSecret(apiKey) };
27854
+ console.error(`Debug: POST URL: ${url.toString()}`);
27855
+ console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
27856
+ console.error(`Debug: Request body: ${redactSecretsForLog(payload)}`);
27857
+ }
27858
+ let response;
27859
+ const requestTimeout = requestTimeoutSignal(params.timeoutMs);
27860
+ try {
27861
+ response = await fetch(url.toString(), {
27862
+ method: "POST",
27863
+ headers,
27864
+ body: payload,
27865
+ signal: requestTimeout.signal
27866
+ });
27867
+ } catch (err) {
27868
+ if (isFetchTimeout(err)) {
27869
+ throw new HttpRequestTimeoutError(operation, requestTimeout.timeoutMs);
27870
+ }
27871
+ throw new Error(describeFetchError(operation, base, err));
27847
27872
  }
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)}`);
27873
+ const text = await response.text();
27874
+ if (debug) {
27875
+ console.error(`Debug: Response status: ${response.status}`);
27876
+ console.error(`Debug: Response body: ${redactSecretsForLog(text)}`);
27877
+ }
27878
+ return { response, text };
27879
+ };
27880
+ let { response, text } = await send(body);
27881
+ if (params.fallbackBody && isUnknownRpcSignature(response.status, text, fn)) {
27882
+ ({ response, text } = await send(params.fallbackBody));
27854
27883
  }
27855
27884
  if (!response.ok) {
27856
27885
  throw new HttpStatusError(formatHttpError(operation, response.status, text, response.statusText), response.status);
27857
27886
  }
27858
27887
  try {
27859
- return JSON.parse(text);
27888
+ return { data: JSON.parse(text), status: response.status };
27860
27889
  } catch {
27861
27890
  throw new Error(`${operation}: failed to parse response: ${redactSecretsForLog(text)}`);
27862
27891
  }
@@ -27905,6 +27934,23 @@ function isNumericProjectRef(ref) {
27905
27934
  }
27906
27935
 
27907
27936
  // lib/connect.ts
27937
+ var money = (cents, currency) => currency.toLowerCase() === "usd" ? `$${(cents / 100).toFixed(2)}` : `${(cents / 100).toFixed(2)} ${currency.toUpperCase()}`;
27938
+ function priceText(q) {
27939
+ if (!q.billed)
27940
+ return q.free_slots.total ? `free (${q.free_slots.remaining} of ${q.free_slots.total} free slots)` : "free";
27941
+ const { amount, currency, interval } = q.price;
27942
+ const base = `${money(amount, currency)}/${interval} per box (${q.plan} plan)${q.subscription ? `, box ${q.quantity + 1} on the subscription` : ""}`;
27943
+ if (q.promo?.valid && q.amount_after_promo !== undefined && q.amount_after_promo !== null) {
27944
+ const after = `${money(q.amount_after_promo, currency)}`;
27945
+ const how = `with ${q.promo.code} (${q.promo.discount_description})`;
27946
+ if (q.promo.duration === "forever")
27947
+ return `${after}/${interval} ${how}, instead of ${base}`;
27948
+ if (q.promo.duration === "repeating")
27949
+ return `${after}/${interval} for ${q.promo.duration_in_months} months ${how}, then ${base}`;
27950
+ return `${after} the first ${interval} ${how}, then ${base}`;
27951
+ }
27952
+ return base;
27953
+ }
27908
27954
  var FIRST_CHECKUP_DELAY_MS = 30 * 60000;
27909
27955
  var POLL_MS = 15000;
27910
27956
  var PROVIDERS = ["clickhouse", "rds", "supabase", "self-managed"];
@@ -27960,7 +28006,7 @@ function stateOf(raw) {
27960
28006
 
27961
28007
  class ClickhouseKeyError extends Error {
27962
28008
  }
27963
- var serverOf = (name) => name.split("/")[0];
28009
+ var serverOf = (name) => name.split("/")[0].toLowerCase().replace(/\.(?=$|:)/, "");
27964
28010
  var errorText = (err) => err instanceof Error ? err.message : String(err);
27965
28011
  var urlPassword = (u) => decodeURIComponent(u.password) || u.searchParams.get("password") || "";
27966
28012
  var URL_PARAMS_KEPT = ["sslmode", "channel_binding", "application_name"];
@@ -28023,15 +28069,39 @@ function checkupLines(c) {
28023
28069
  return [`Express checkup could not run: ${c.error}`];
28024
28070
  const warnings = c.findings.filter((f) => f.status === "warning");
28025
28071
  const ok = c.findings.filter((f) => f.status === "ok");
28072
+ const failed = c.failed ?? [];
28073
+ const counts = [`${warnings.length} warning${warnings.length === 1 ? "" : "s"}`, `${ok.length} ok`, `${c.info.length} info`, ...failed.length ? [`${failed.length} could not run`] : []];
28026
28074
  return [
28027
- `Express checkup while the box starts (${c.checks} checks: ${warnings.length} warning${warnings.length === 1 ? "" : "s"}, ${ok.length} ok):`,
28075
+ `Express checkup while the box starts (${c.checks} checks: ${counts.join(", ")}):`,
28028
28076
  ...warnings.map((f) => ` ${f.check_id} ${f.title}: ${f.message}`),
28029
28077
  ...ok.length ? [` ok: ${ok.map((f) => f.check_id).join(" ")}`] : [],
28030
- ...c.failed?.length ? [` could not run: ${c.failed.join(" ")}`] : [],
28078
+ ...c.info.length ? [` info: ${c.info.join(" ")}`] : [],
28079
+ ...failed.length ? [` could not run: ${failed.join(" ")}`] : [],
28080
+ c.report_id ? `Saved as report ${c.report_id}: pgai reports files ${c.report_id}` : `Not saved to PostgresAI: ${c.upload_error}`,
28031
28081
  "The full checkup (query analysis and trends) follows on the box."
28032
28082
  ];
28033
28083
  }
28084
+ var boxState = (row) => row.status === "launch_requested" && row.registered_at ? "registered" : row.status ?? "starting";
28085
+ async function billingFor(q, name, opts, deps) {
28086
+ const billing = { price: priceText(q), requires_payment_method: q.requires_payment_method };
28087
+ if (opts.coupon) {
28088
+ billing.coupon = q.promo?.valid ? { code: q.promo.code, valid: true, description: q.promo.discount_description } : { code: opts.coupon, valid: false, error: q.promo?.error ?? "not valid" };
28089
+ }
28090
+ const billingPage = { ...billing, requires_payment_method: true, next: `Add a payment method at ${deps.billingUrl(q.org_alias)}, then re-run` };
28091
+ const stop = (next) => ({ billing, billingPage, accepted: false, stop: { ...billing, next } });
28092
+ if (billing.coupon && !billing.coupon.valid) {
28093
+ return stop(`Promo code ${opts.coupon}: ${billing.coupon.error}. Nothing was changed: re-run with a valid code, or without ${opts.agent ? "coupon" : "--coupon"}`);
28094
+ }
28095
+ if (q.requires_payment_method)
28096
+ return { billing, billingPage, accepted: false, stop: billingPage };
28097
+ if (q.billed && !opts.yes && !await deps.confirm(`${name} is billed: ${billing.price}. Provision it? (y/N): `)) {
28098
+ return stop(opts.agent ? `Call connect_database again with yes: true to accept ${billing.price}` : `Re-run with --yes to accept ${billing.price}`);
28099
+ }
28100
+ return { billing, billingPage, accepted: q.billed };
28101
+ }
28034
28102
  async function connect(url, opts, deps) {
28103
+ const started = deps.now();
28104
+ const progress = (event, message, more = {}) => deps.progress({ event, elapsed_s: Math.round((deps.now() - started) / 1000), message, ...more });
28035
28105
  const provider = opts.provider ?? detectCloudProvider(url);
28036
28106
  if (!PROVIDERS.includes(provider))
28037
28107
  throw new Error(`--provider must be one of: ${PROVIDERS.join(", ")}`);
@@ -28054,6 +28124,9 @@ async function connect(url, opts, deps) {
28054
28124
  const fresh = !row;
28055
28125
  let note = "";
28056
28126
  let checkup;
28127
+ let billing = {};
28128
+ let billingPage;
28129
+ let accepted = false;
28057
28130
  const findOrg = async () => {
28058
28131
  try {
28059
28132
  return await deps.clickhouseOrg(new URL(url).hostname, key.keyId, key.keySecret);
@@ -28082,55 +28155,112 @@ async function connect(url, opts, deps) {
28082
28155
  }
28083
28156
  ch = found;
28084
28157
  }
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)
28158
+ const undo = async (p) => {
28159
+ if ("generated" in p && p.generated)
28104
28160
  await deps.unprepare(url).catch(() => false);
28105
28161
  };
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.` };
28162
+ let lockId;
28163
+ const othersOnServer = (list) => list.filter((d) => d.name !== name && !disconnecting(d.status) && serverOf(d.name) === serverOf(name));
28164
+ const cutOff = (others) => ({ status: "action_required", provider, name, next: `A new password for ${DEFAULT_MONITORING_USER} would cut off the monitoring of ${others.map((d) => d.name).join(", ")} on this server: set PGAI_MON_PASSWORD to its password instead` });
28165
+ if (opts.resetPassword) {
28166
+ if (othersOnServer(rows).length)
28167
+ return cutOff(othersOnServer(rows));
28168
+ }
28169
+ if (opts.coupon !== undefined && !opts.coupon.trim()) {
28170
+ return { status: "action_required", provider, name, next: `${opts.agent ? "coupon" : "--coupon"} is empty: pass a promotion code, or leave ${opts.agent ? "coupon" : "--coupon"} out` };
28171
+ }
28172
+ if (!opts.selfHosted) {
28173
+ const b = await billingFor(await deps.quote(opts.coupon), name, opts, deps);
28174
+ if (b.stop)
28175
+ return { status: "action_required", provider, name, ...b.stop };
28176
+ ({ billing, billingPage, accepted } = b);
28177
+ progress("billing", `Billing: ${billing.price}`);
28178
+ }
28179
+ if (opts.resetPassword) {
28180
+ try {
28181
+ lockId = (await deps.resetLock(serverOf(name))).lock_id;
28182
+ } catch (err) {
28183
+ if (err instanceof HttpStatusError && err.status === 409) {
28184
+ return { status: "action_required", provider, name, next: `Another pgai connect --reset-password for ${serverOf(name)} is running: wait for it to finish, then re-run` };
28185
+ }
28186
+ if (err instanceof HttpStatusError && err.status === 404) {
28187
+ return { status: "action_required", provider, name, next: `This platform cannot lock the server for --reset-password yet: set PGAI_MON_PASSWORD to ${DEFAULT_MONITORING_USER}'s password instead` };
28188
+ }
28189
+ throw err;
28190
+ }
28191
+ }
28192
+ let prepared;
28193
+ let created;
28194
+ try {
28195
+ if (lockId) {
28196
+ const now = await deps.list();
28197
+ const same = now.find((d) => d.name === name && !disconnecting(d.status));
28198
+ if (same)
28199
+ return { status: "action_required", provider, name, id: same.id, next: `${name} is already connected, and its monitoring uses the current password: pgai disconnect ${name} --yes first` };
28200
+ const others = othersOnServer(now);
28201
+ if (others.length)
28202
+ return cutOff(others);
28203
+ }
28204
+ progress("preparing", `Preparing ${maskConnectionString(url)}`);
28205
+ prepared = await deps.prepare(url, provider, { resetPassword: opts.resetPassword });
28206
+ if ("next" in prepared)
28207
+ return { status: "action_required", provider, name, ...billing, ...prepared };
28208
+ if (prepared.note)
28209
+ note = `; ${prepared.note}`;
28210
+ if (opts.selfHosted) {
28211
+ await deps.selfHosted(prepared.monitoringUrl, key && ch ? { CLICKHOUSE_ORG_ID: ch.orgId, CLICKHOUSE_KEY_ID: key.keyId, CLICKHOUSE_KEY_SECRET: key.keySecret } : {});
28212
+ return { status: "connected", provider, name, dashboard_url: "http://localhost:3000", host_metrics: !!ch, next: `pgai mon health${note}` };
28213
+ }
28214
+ progress("provisioning", `Provisioning monitoring for ${name}`);
28215
+ created = await deps.create({
28216
+ db_url: prepared.monitoringUrl,
28217
+ ...provider === "clickhouse" ? { provider } : {},
28218
+ ...key && ch ? { clickhouse_org_id: ch.orgId, clickhouse_key_id: key.keyId, clickhouse_key_secret: key.keySecret } : {},
28219
+ ...opts.coupon ? { promo_code: opts.coupon } : {},
28220
+ ...accepted ? { accept_price: true } : {}
28221
+ }).catch(async (err) => {
28222
+ if (err instanceof HttpStatusError && err.status >= 400 && err.status < 500)
28223
+ await undo(prepared);
28224
+ if (err instanceof HttpStatusError && err.status === 402)
28225
+ return /declined/i.test(err.message) ? "declined" : "payment";
28226
+ if (err instanceof HttpStatusError && err.status === 412)
28227
+ return "price";
28228
+ throw err;
28229
+ });
28230
+ if (created === "payment")
28231
+ return { status: "action_required", provider, name, ...billingPage };
28232
+ if (created === "declined") {
28233
+ 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 ") };
28234
+ }
28235
+ if (created === "price")
28236
+ return { status: "action_required", provider, name, ...billing, next: "The price changed since it was shown: re-run pgai connect to see it" };
28237
+ if (created.status === "failed") {
28238
+ await undo(prepared);
28239
+ return { status: "failed", provider, name, id: created.id, next: `${created.error} Re-run pgai connect later.` };
28240
+ }
28241
+ } finally {
28242
+ if (lockId)
28243
+ await deps.resetUnlock(lockId).catch(() => {});
28118
28244
  }
28119
28245
  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);
28246
+ checkup = await deps.checkup(withLocalTls(prepared.monitoringUrl, url), created.name).catch((err) => ({ error: errorText(err) }));
28247
+ progress("checkup", checkupLines(checkup).join(`
28248
+ `), { checkup });
28123
28249
  }
28124
- for (const deadline = Date.now() + opts.waitMs;; ) {
28250
+ let shown;
28251
+ for (const deadline = deps.now() + opts.waitMs;; ) {
28125
28252
  const result = connectStatus(row, provider, fresh);
28126
- if (result.status !== "provisioning" || Date.now() >= deadline) {
28253
+ const state = boxState(row);
28254
+ if (state !== shown && (shown !== undefined || result.status === "provisioning"))
28255
+ progress("box", `Monitoring box: ${state}`, { state });
28256
+ shown = state;
28257
+ if (result.status !== "provisioning" || deps.now() >= deadline) {
28127
28258
  if (result.status === "connected" && provider === "clickhouse" && !row.host_metrics) {
28128
28259
  result.next += "; for CPU, memory and disk, disconnect and reconnect with --clickhouse-key <key-id>:<key-secret>";
28129
28260
  }
28130
28261
  const { next, ...rest } = result;
28131
- return { ...rest, ...checkup ? { checkup } : {}, next: next + note };
28262
+ return { ...rest, ...billing, ...checkup ? { checkup } : {}, next: next + note };
28132
28263
  }
28133
- deps.progress(`Waiting for the monitoring box (${row.status ?? "starting"})`);
28134
28264
  await deps.sleep(POLL_MS);
28135
28265
  row = (await deps.list().catch(() => [])).find((d) => d.id === row.id) ?? row;
28136
28266
  }
@@ -28255,14 +28385,52 @@ ${redactPasswordsInSql(s.sql)}`).join(`
28255
28385
  }
28256
28386
  async function expressCheckup(url, opts = {}) {
28257
28387
  const { client } = await openConnection(url, opts);
28388
+ let reports;
28389
+ const failed = [];
28258
28390
  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 } : {} };
28391
+ reports = await generateAllReports(client, "node-01", undefined, (f) => failed.push(f.checkId));
28263
28392
  } finally {
28264
28393
  await client.end();
28265
28394
  }
28395
+ const all = Object.values(reports).map((r) => ({ check_id: r.checkId, title: r.checkTitle, status: r.summary.status, message: r.summary.message }));
28396
+ const findings = all.filter((f) => f.status !== "info").sort((a, b) => Number(b.status === "warning") - Number(a.status === "warning"));
28397
+ const result = { checks: all.length + failed.length, findings, info: all.filter((f) => f.status === "info").map((f) => f.check_id), ...failed.length ? { failed } : {} };
28398
+ if (!opts.save)
28399
+ return result;
28400
+ try {
28401
+ return { ...result, report_id: await opts.save(reports) };
28402
+ } catch (err) {
28403
+ return { ...result, upload_error: errorText(err) };
28404
+ }
28405
+ }
28406
+ async function saveCheckupReport(rpc, accessToken, project, reports) {
28407
+ const idOf = (fn, answer, ...keys) => {
28408
+ const a = answer ?? {};
28409
+ const id = Number(keys.map((k) => a[k]).find((v) => v !== undefined));
28410
+ if (!(id > 0))
28411
+ throw new Error(typeof a.message === "string" ? a.message : `Unexpected ${fn} answer: ${JSON.stringify(answer)}`);
28412
+ return id;
28413
+ };
28414
+ const reportId = idOf("checkup_report_create", await rpc("checkup_report_create", { access_token: accessToken, project }), "report_id");
28415
+ const setStatus = (status) => rpc("checkup_report_status_update", { access_token: accessToken, report_id: reportId, status });
28416
+ try {
28417
+ for (const [checkId, report] of Object.entries(reports)) {
28418
+ idOf("checkup_report_file_post", await rpc("checkup_report_file_post", {
28419
+ access_token: accessToken,
28420
+ checkup_report_id: reportId,
28421
+ filename: `${checkId}.json`,
28422
+ check_id: checkId,
28423
+ data: JSON.stringify(report, null, 2),
28424
+ type: "json",
28425
+ generate_issue: true
28426
+ }), "report_chunck_id", "report_chunk_id");
28427
+ }
28428
+ } catch (err) {
28429
+ await setStatus("failed").catch(() => {});
28430
+ throw err;
28431
+ }
28432
+ await setStatus("completed");
28433
+ return reportId;
28266
28434
  }
28267
28435
  async function clickhouseOrgFor(host, keyId, keySecret) {
28268
28436
  const apiUrl = process.env.CLICKHOUSE_API_URL || "https://api.clickhouse.cloud";
@@ -28299,16 +28467,21 @@ function platformDeps(p) {
28299
28467
  list: () => rpc("cloud_monitoring_list"),
28300
28468
  create: (body) => rpc("cloud_monitoring_connect", body),
28301
28469
  disconnect: (id) => rpc("cloud_monitoring_disconnect", { instance_id: id }),
28470
+ resetLock: (server) => rpc("cloud_monitoring_reset_lock", { server }),
28471
+ resetUnlock: (lockId) => rpc("cloud_monitoring_reset_unlock", { lock_id: lockId }),
28302
28472
  prepare: (url, provider, o = {}) => prepareDatabase(url, provider, { ...o, agent: p.agent }),
28303
28473
  unprepare: (url) => unprepareDatabase(url, { agent: p.agent }),
28304
28474
  clickhouseOrg: clickhouseOrgFor,
28305
- checkup: (url) => expressCheckup(url, { agent: p.agent }),
28475
+ checkup: (url, project) => expressCheckup(url, { agent: p.agent, save: (reports) => saveCheckupReport(rpc, p.apiKey, project, reports) }),
28476
+ quote: (coupon) => rpc("cloud_monitoring_quote", coupon ? { promo_code: coupon } : {}),
28477
+ billingUrl: (orgAlias) => `${p.uiBaseUrl}/${orgAlias}/billing`,
28306
28478
  handoffUrl: async (provider) => {
28307
28479
  const orgs = await listOrgs({ apiKey: p.apiKey, apiBaseUrl: p.apiBaseUrl });
28308
28480
  const org = orgs.length === 1 ? orgs[0] : orgs.find((o) => o.alias === p.orgScope?.alias || o.org_id === p.orgScope?.id);
28309
28481
  return `${p.uiBaseUrl}/${org?.alias ?? "<org>"}/monitoring/scale/create/${provider}`;
28310
28482
  },
28311
- sleep: (ms) => new Promise((r) => setTimeout(r, ms))
28483
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
28484
+ now: () => Date.now()
28312
28485
  };
28313
28486
  }
28314
28487
 
@@ -34995,6 +35168,8 @@ async function handleToolCall(req, rootOpts, extra) {
34995
35168
  const result = await connect(url, {
34996
35169
  provider: args.provider ? String(args.provider) : undefined,
34997
35170
  clickhouseKey: args.clickhouse_key ? String(args.clickhouse_key) : undefined,
35171
+ coupon: args.coupon === undefined || args.coupon === null ? undefined : String(args.coupon),
35172
+ yes: args.yes === true,
34998
35173
  waitMs: 0,
34999
35174
  agent: true
35000
35175
  }, {
@@ -35003,6 +35178,7 @@ async function handleToolCall(req, rootOpts, extra) {
35003
35178
  throw new Error("A self-hosted stack is set up from the CLI: pgai connect <url> --self-hosted");
35004
35179
  },
35005
35180
  localStackRunning: () => false,
35181
+ confirm: async () => false,
35006
35182
  progress: () => {}
35007
35183
  });
35008
35184
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], isError: result.status === "failed" };
@@ -35568,7 +35744,7 @@ async function startMcpServer(rootOpts, extra) {
35568
35744
  },
35569
35745
  {
35570
35746
  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).",
35747
+ description: "Put a Postgres database under PostgresAI Cloud monitoring (same as `pgai connect`): prepares the monitoring role (an admin URL creates it; otherwise returns the SQL), provisions the monitoring box, and returns JSON with status (connected | provisioning | disconnecting | action_required | failed), dashboard_url and next (the exact next action). A billed box is provisioned only with yes: true: without it the result is action_required with the price (and requires_payment_method), so show the price to the user first. It does not wait for the box: call it again to see the status. Safe to call again. ClickHouse Managed Postgres, RDS and Supabase are detected from the host. The URL must carry its password, and only these query parameters: sslmode, channel_binding, application_name. PGAI_MON_PASSWORD, PGPASSWORD and CLICKHOUSE_KEY_ID + CLICKHOUSE_KEY_SECRET of the server process are not used (pass clickhouse_key), and a TLS failure is not retried in plaintext (say sslmode=disable for a server without TLS).",
35572
35748
  inputSchema: {
35573
35749
  type: "object",
35574
35750
  properties: {
@@ -35576,6 +35752,8 @@ async function startMcpServer(rootOpts, extra) {
35576
35752
  database_url: { type: "string", description: "postgresql:// URL with the password in it (admin, or the postgres_ai_mon role)" },
35577
35753
  provider: { type: "string", description: "clickhouse | rds | supabase | self-managed (default: detected from the host)" },
35578
35754
  clickhouse_key: { type: "string", description: "ClickHouse Cloud API key as <key-id>:<key-secret> (Basic Service API Reader), for CPU, memory and disk" },
35755
+ 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" },
35756
+ coupon: { type: "string", description: "Stripe promotion code for the organization's monitoring subscription (applies when it is first created)" },
35579
35757
  debug: { type: "boolean", description: "Enable verbose debug logs" }
35580
35758
  },
35581
35759
  required: ["database_url"],
@@ -36478,6 +36656,23 @@ function renderMarkdownForTerminal(md) {
36478
36656
  }
36479
36657
 
36480
36658
  // lib/connect.ts
36659
+ var money2 = (cents, currency) => currency.toLowerCase() === "usd" ? `$${(cents / 100).toFixed(2)}` : `${(cents / 100).toFixed(2)} ${currency.toUpperCase()}`;
36660
+ function priceText2(q) {
36661
+ if (!q.billed)
36662
+ return q.free_slots.total ? `free (${q.free_slots.remaining} of ${q.free_slots.total} free slots)` : "free";
36663
+ const { amount, currency, interval } = q.price;
36664
+ const base = `${money2(amount, currency)}/${interval} per box (${q.plan} plan)${q.subscription ? `, box ${q.quantity + 1} on the subscription` : ""}`;
36665
+ if (q.promo?.valid && q.amount_after_promo !== undefined && q.amount_after_promo !== null) {
36666
+ const after = `${money2(q.amount_after_promo, currency)}`;
36667
+ const how = `with ${q.promo.code} (${q.promo.discount_description})`;
36668
+ if (q.promo.duration === "forever")
36669
+ return `${after}/${interval} ${how}, instead of ${base}`;
36670
+ if (q.promo.duration === "repeating")
36671
+ return `${after}/${interval} for ${q.promo.duration_in_months} months ${how}, then ${base}`;
36672
+ return `${after} the first ${interval} ${how}, then ${base}`;
36673
+ }
36674
+ return base;
36675
+ }
36481
36676
  var FIRST_CHECKUP_DELAY_MS2 = 30 * 60000;
36482
36677
  var POLL_MS2 = 15000;
36483
36678
  var PROVIDERS2 = ["clickhouse", "rds", "supabase", "self-managed"];
@@ -36533,7 +36728,7 @@ function stateOf2(raw) {
36533
36728
 
36534
36729
  class ClickhouseKeyError2 extends Error {
36535
36730
  }
36536
- var serverOf2 = (name) => name.split("/")[0];
36731
+ var serverOf2 = (name) => name.split("/")[0].toLowerCase().replace(/\.(?=$|:)/, "");
36537
36732
  var errorText2 = (err) => err instanceof Error ? err.message : String(err);
36538
36733
  var urlPassword2 = (u) => decodeURIComponent(u.password) || u.searchParams.get("password") || "";
36539
36734
  var URL_PARAMS_KEPT2 = ["sslmode", "channel_binding", "application_name"];
@@ -36596,15 +36791,48 @@ function checkupLines2(c) {
36596
36791
  return [`Express checkup could not run: ${c.error}`];
36597
36792
  const warnings = c.findings.filter((f) => f.status === "warning");
36598
36793
  const ok = c.findings.filter((f) => f.status === "ok");
36794
+ const failed = c.failed ?? [];
36795
+ const counts = [`${warnings.length} warning${warnings.length === 1 ? "" : "s"}`, `${ok.length} ok`, `${c.info.length} info`, ...failed.length ? [`${failed.length} could not run`] : []];
36599
36796
  return [
36600
- `Express checkup while the box starts (${c.checks} checks: ${warnings.length} warning${warnings.length === 1 ? "" : "s"}, ${ok.length} ok):`,
36797
+ `Express checkup while the box starts (${c.checks} checks: ${counts.join(", ")}):`,
36601
36798
  ...warnings.map((f) => ` ${f.check_id} ${f.title}: ${f.message}`),
36602
36799
  ...ok.length ? [` ok: ${ok.map((f) => f.check_id).join(" ")}`] : [],
36603
- ...c.failed?.length ? [` could not run: ${c.failed.join(" ")}`] : [],
36800
+ ...c.info.length ? [` info: ${c.info.join(" ")}`] : [],
36801
+ ...failed.length ? [` could not run: ${failed.join(" ")}`] : [],
36802
+ c.report_id ? `Saved as report ${c.report_id}: pgai reports files ${c.report_id}` : `Not saved to PostgresAI: ${c.upload_error}`,
36604
36803
  "The full checkup (query analysis and trends) follows on the box."
36605
36804
  ];
36606
36805
  }
36806
+ function progressText(e) {
36807
+ const s = Math.round(e.elapsed_s);
36808
+ const elapsed = s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
36809
+ const [first, ...rest] = e.message.split(`
36810
+ `);
36811
+ const colon = first.endsWith(":") ? ":" : "";
36812
+ return [`${first.slice(0, first.length - colon.length)} (+${elapsed})${colon}`, ...rest].join(`
36813
+ `);
36814
+ }
36815
+ var boxState2 = (row) => row.status === "launch_requested" && row.registered_at ? "registered" : row.status ?? "starting";
36816
+ async function billingFor2(q, name, opts, deps) {
36817
+ const billing = { price: priceText2(q), requires_payment_method: q.requires_payment_method };
36818
+ if (opts.coupon) {
36819
+ billing.coupon = q.promo?.valid ? { code: q.promo.code, valid: true, description: q.promo.discount_description } : { code: opts.coupon, valid: false, error: q.promo?.error ?? "not valid" };
36820
+ }
36821
+ const billingPage = { ...billing, requires_payment_method: true, next: `Add a payment method at ${deps.billingUrl(q.org_alias)}, then re-run` };
36822
+ const stop = (next) => ({ billing, billingPage, accepted: false, stop: { ...billing, next } });
36823
+ if (billing.coupon && !billing.coupon.valid) {
36824
+ return stop(`Promo code ${opts.coupon}: ${billing.coupon.error}. Nothing was changed: re-run with a valid code, or without ${opts.agent ? "coupon" : "--coupon"}`);
36825
+ }
36826
+ if (q.requires_payment_method)
36827
+ return { billing, billingPage, accepted: false, stop: billingPage };
36828
+ if (q.billed && !opts.yes && !await deps.confirm(`${name} is billed: ${billing.price}. Provision it? (y/N): `)) {
36829
+ return stop(opts.agent ? `Call connect_database again with yes: true to accept ${billing.price}` : `Re-run with --yes to accept ${billing.price}`);
36830
+ }
36831
+ return { billing, billingPage, accepted: q.billed };
36832
+ }
36607
36833
  async function connect2(url, opts, deps) {
36834
+ const started = deps.now();
36835
+ const progress = (event, message, more = {}) => deps.progress({ event, elapsed_s: Math.round((deps.now() - started) / 1000), message, ...more });
36608
36836
  const provider = opts.provider ?? detectCloudProvider2(url);
36609
36837
  if (!PROVIDERS2.includes(provider))
36610
36838
  throw new Error(`--provider must be one of: ${PROVIDERS2.join(", ")}`);
@@ -36627,6 +36855,9 @@ async function connect2(url, opts, deps) {
36627
36855
  const fresh = !row;
36628
36856
  let note = "";
36629
36857
  let checkup;
36858
+ let billing = {};
36859
+ let billingPage;
36860
+ let accepted = false;
36630
36861
  const findOrg = async () => {
36631
36862
  try {
36632
36863
  return await deps.clickhouseOrg(new URL(url).hostname, key.keyId, key.keySecret);
@@ -36655,55 +36886,112 @@ async function connect2(url, opts, deps) {
36655
36886
  }
36656
36887
  ch = found;
36657
36888
  }
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)
36889
+ const undo = async (p) => {
36890
+ if ("generated" in p && p.generated)
36677
36891
  await deps.unprepare(url).catch(() => false);
36678
36892
  };
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.` };
36893
+ let lockId;
36894
+ const othersOnServer = (list) => list.filter((d) => d.name !== name && !disconnecting2(d.status) && serverOf2(d.name) === serverOf2(name));
36895
+ const cutOff = (others) => ({ status: "action_required", provider, name, next: `A new password for ${DEFAULT_MONITORING_USER} would cut off the monitoring of ${others.map((d) => d.name).join(", ")} on this server: set PGAI_MON_PASSWORD to its password instead` });
36896
+ if (opts.resetPassword) {
36897
+ if (othersOnServer(rows).length)
36898
+ return cutOff(othersOnServer(rows));
36899
+ }
36900
+ if (opts.coupon !== undefined && !opts.coupon.trim()) {
36901
+ return { status: "action_required", provider, name, next: `${opts.agent ? "coupon" : "--coupon"} is empty: pass a promotion code, or leave ${opts.agent ? "coupon" : "--coupon"} out` };
36902
+ }
36903
+ if (!opts.selfHosted) {
36904
+ const b = await billingFor2(await deps.quote(opts.coupon), name, opts, deps);
36905
+ if (b.stop)
36906
+ return { status: "action_required", provider, name, ...b.stop };
36907
+ ({ billing, billingPage, accepted } = b);
36908
+ progress("billing", `Billing: ${billing.price}`);
36909
+ }
36910
+ if (opts.resetPassword) {
36911
+ try {
36912
+ lockId = (await deps.resetLock(serverOf2(name))).lock_id;
36913
+ } catch (err) {
36914
+ if (err instanceof HttpStatusError && err.status === 409) {
36915
+ return { status: "action_required", provider, name, next: `Another pgai connect --reset-password for ${serverOf2(name)} is running: wait for it to finish, then re-run` };
36916
+ }
36917
+ if (err instanceof HttpStatusError && err.status === 404) {
36918
+ return { status: "action_required", provider, name, next: `This platform cannot lock the server for --reset-password yet: set PGAI_MON_PASSWORD to ${DEFAULT_MONITORING_USER}'s password instead` };
36919
+ }
36920
+ throw err;
36921
+ }
36922
+ }
36923
+ let prepared;
36924
+ let created;
36925
+ try {
36926
+ if (lockId) {
36927
+ const now = await deps.list();
36928
+ const same = now.find((d) => d.name === name && !disconnecting2(d.status));
36929
+ if (same)
36930
+ return { status: "action_required", provider, name, id: same.id, next: `${name} is already connected, and its monitoring uses the current password: pgai disconnect ${name} --yes first` };
36931
+ const others = othersOnServer(now);
36932
+ if (others.length)
36933
+ return cutOff(others);
36934
+ }
36935
+ progress("preparing", `Preparing ${maskConnectionString(url)}`);
36936
+ prepared = await deps.prepare(url, provider, { resetPassword: opts.resetPassword });
36937
+ if ("next" in prepared)
36938
+ return { status: "action_required", provider, name, ...billing, ...prepared };
36939
+ if (prepared.note)
36940
+ note = `; ${prepared.note}`;
36941
+ if (opts.selfHosted) {
36942
+ await deps.selfHosted(prepared.monitoringUrl, key && ch ? { CLICKHOUSE_ORG_ID: ch.orgId, CLICKHOUSE_KEY_ID: key.keyId, CLICKHOUSE_KEY_SECRET: key.keySecret } : {});
36943
+ return { status: "connected", provider, name, dashboard_url: "http://localhost:3000", host_metrics: !!ch, next: `pgai mon health${note}` };
36944
+ }
36945
+ progress("provisioning", `Provisioning monitoring for ${name}`);
36946
+ created = await deps.create({
36947
+ db_url: prepared.monitoringUrl,
36948
+ ...provider === "clickhouse" ? { provider } : {},
36949
+ ...key && ch ? { clickhouse_org_id: ch.orgId, clickhouse_key_id: key.keyId, clickhouse_key_secret: key.keySecret } : {},
36950
+ ...opts.coupon ? { promo_code: opts.coupon } : {},
36951
+ ...accepted ? { accept_price: true } : {}
36952
+ }).catch(async (err) => {
36953
+ if (err instanceof HttpStatusError && err.status >= 400 && err.status < 500)
36954
+ await undo(prepared);
36955
+ if (err instanceof HttpStatusError && err.status === 402)
36956
+ return /declined/i.test(err.message) ? "declined" : "payment";
36957
+ if (err instanceof HttpStatusError && err.status === 412)
36958
+ return "price";
36959
+ throw err;
36960
+ });
36961
+ if (created === "payment")
36962
+ return { status: "action_required", provider, name, ...billingPage };
36963
+ if (created === "declined") {
36964
+ 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 ") };
36965
+ }
36966
+ if (created === "price")
36967
+ return { status: "action_required", provider, name, ...billing, next: "The price changed since it was shown: re-run pgai connect to see it" };
36968
+ if (created.status === "failed") {
36969
+ await undo(prepared);
36970
+ return { status: "failed", provider, name, id: created.id, next: `${created.error} Re-run pgai connect later.` };
36971
+ }
36972
+ } finally {
36973
+ if (lockId)
36974
+ await deps.resetUnlock(lockId).catch(() => {});
36691
36975
  }
36692
36976
  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);
36977
+ checkup = await deps.checkup(withLocalTls2(prepared.monitoringUrl, url), created.name).catch((err) => ({ error: errorText2(err) }));
36978
+ progress("checkup", checkupLines2(checkup).join(`
36979
+ `), { checkup });
36696
36980
  }
36697
- for (const deadline = Date.now() + opts.waitMs;; ) {
36981
+ let shown;
36982
+ for (const deadline = deps.now() + opts.waitMs;; ) {
36698
36983
  const result = connectStatus2(row, provider, fresh);
36699
- if (result.status !== "provisioning" || Date.now() >= deadline) {
36984
+ const state = boxState2(row);
36985
+ if (state !== shown && (shown !== undefined || result.status === "provisioning"))
36986
+ progress("box", `Monitoring box: ${state}`, { state });
36987
+ shown = state;
36988
+ if (result.status !== "provisioning" || deps.now() >= deadline) {
36700
36989
  if (result.status === "connected" && provider === "clickhouse" && !row.host_metrics) {
36701
36990
  result.next += "; for CPU, memory and disk, disconnect and reconnect with --clickhouse-key <key-id>:<key-secret>";
36702
36991
  }
36703
36992
  const { next, ...rest } = result;
36704
- return { ...rest, ...checkup ? { checkup } : {}, next: next + note };
36993
+ return { ...rest, ...billing, ...checkup ? { checkup } : {}, next: next + note };
36705
36994
  }
36706
- deps.progress(`Waiting for the monitoring box (${row.status ?? "starting"})`);
36707
36995
  await deps.sleep(POLL_MS2);
36708
36996
  row = (await deps.list().catch(() => [])).find((d) => d.id === row.id) ?? row;
36709
36997
  }
@@ -36828,14 +37116,52 @@ ${redactPasswordsInSql(s.sql)}`).join(`
36828
37116
  }
36829
37117
  async function expressCheckup2(url, opts = {}) {
36830
37118
  const { client } = await openConnection2(url, opts);
37119
+ let reports;
37120
+ const failed = [];
36831
37121
  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 } : {} };
37122
+ reports = await generateAllReports(client, "node-01", undefined, (f) => failed.push(f.checkId));
36836
37123
  } finally {
36837
37124
  await client.end();
36838
37125
  }
37126
+ const all = Object.values(reports).map((r) => ({ check_id: r.checkId, title: r.checkTitle, status: r.summary.status, message: r.summary.message }));
37127
+ const findings = all.filter((f) => f.status !== "info").sort((a, b) => Number(b.status === "warning") - Number(a.status === "warning"));
37128
+ const result = { checks: all.length + failed.length, findings, info: all.filter((f) => f.status === "info").map((f) => f.check_id), ...failed.length ? { failed } : {} };
37129
+ if (!opts.save)
37130
+ return result;
37131
+ try {
37132
+ return { ...result, report_id: await opts.save(reports) };
37133
+ } catch (err) {
37134
+ return { ...result, upload_error: errorText2(err) };
37135
+ }
37136
+ }
37137
+ async function saveCheckupReport2(rpc, accessToken, project, reports) {
37138
+ const idOf = (fn, answer, ...keys) => {
37139
+ const a = answer ?? {};
37140
+ const id = Number(keys.map((k) => a[k]).find((v) => v !== undefined));
37141
+ if (!(id > 0))
37142
+ throw new Error(typeof a.message === "string" ? a.message : `Unexpected ${fn} answer: ${JSON.stringify(answer)}`);
37143
+ return id;
37144
+ };
37145
+ const reportId = idOf("checkup_report_create", await rpc("checkup_report_create", { access_token: accessToken, project }), "report_id");
37146
+ const setStatus = (status) => rpc("checkup_report_status_update", { access_token: accessToken, report_id: reportId, status });
37147
+ try {
37148
+ for (const [checkId, report] of Object.entries(reports)) {
37149
+ idOf("checkup_report_file_post", await rpc("checkup_report_file_post", {
37150
+ access_token: accessToken,
37151
+ checkup_report_id: reportId,
37152
+ filename: `${checkId}.json`,
37153
+ check_id: checkId,
37154
+ data: JSON.stringify(report, null, 2),
37155
+ type: "json",
37156
+ generate_issue: true
37157
+ }), "report_chunck_id", "report_chunk_id");
37158
+ }
37159
+ } catch (err) {
37160
+ await setStatus("failed").catch(() => {});
37161
+ throw err;
37162
+ }
37163
+ await setStatus("completed");
37164
+ return reportId;
36839
37165
  }
36840
37166
  async function clickhouseOrgFor2(host, keyId, keySecret) {
36841
37167
  const apiUrl = process.env.CLICKHOUSE_API_URL || "https://api.clickhouse.cloud";
@@ -36872,84 +37198,124 @@ function platformDeps2(p) {
36872
37198
  list: () => rpc("cloud_monitoring_list"),
36873
37199
  create: (body) => rpc("cloud_monitoring_connect", body),
36874
37200
  disconnect: (id) => rpc("cloud_monitoring_disconnect", { instance_id: id }),
37201
+ resetLock: (server) => rpc("cloud_monitoring_reset_lock", { server }),
37202
+ resetUnlock: (lockId) => rpc("cloud_monitoring_reset_unlock", { lock_id: lockId }),
36875
37203
  prepare: (url, provider, o = {}) => prepareDatabase2(url, provider, { ...o, agent: p.agent }),
36876
37204
  unprepare: (url) => unprepareDatabase2(url, { agent: p.agent }),
36877
37205
  clickhouseOrg: clickhouseOrgFor2,
36878
- checkup: (url) => expressCheckup2(url, { agent: p.agent }),
37206
+ checkup: (url, project) => expressCheckup2(url, { agent: p.agent, save: (reports) => saveCheckupReport2(rpc, p.apiKey, project, reports) }),
37207
+ quote: (coupon) => rpc("cloud_monitoring_quote", coupon ? { promo_code: coupon } : {}),
37208
+ billingUrl: (orgAlias) => `${p.uiBaseUrl}/${orgAlias}/billing`,
36879
37209
  handoffUrl: async (provider) => {
36880
37210
  const orgs = await listOrgs({ apiKey: p.apiKey, apiBaseUrl: p.apiBaseUrl });
36881
37211
  const org = orgs.length === 1 ? orgs[0] : orgs.find((o) => o.alias === p.orgScope?.alias || o.org_id === p.orgScope?.id);
36882
37212
  return `${p.uiBaseUrl}/${org?.alias ?? "<org>"}/monitoring/scale/create/${provider}`;
36883
37213
  },
36884
- sleep: (ms) => new Promise((r) => setTimeout(r, ms))
37214
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
37215
+ now: () => Date.now()
36885
37216
  };
36886
37217
  }
36887
37218
 
36888
37219
  // lib/joe.ts
36889
37220
  var DEFAULT_BUDGET_MS = 25000;
36890
37221
  var DEFAULT_POLL_INTERVAL_MS = 800;
37222
+ var JOE_ASYNC_FALLBACK_WAIT_MS2 = 10 * 60 * 1000;
37223
+ var JOE_ASYNC_MAX_WAIT_MS2 = 60 * 60 * 1000;
37224
+ var JOE_ASYNC_MAX_POLL_FAILURES = 5;
36891
37225
  async function callRpc2(params) {
37226
+ return (await callRpcRaw2(params)).data;
37227
+ }
37228
+ async function callRpcRaw2(params) {
36892
37229
  const { apiKey, apiBaseUrl, fn, body, operation, debug } = params;
36893
37230
  if (!apiKey) {
36894
37231
  throw new Error("API key is required");
36895
37232
  }
36896
37233
  const base = normalizeBaseUrl(apiBaseUrl);
36897
37234
  const url = new URL(`${base}/rpc/${fn}`);
36898
- const payload = JSON.stringify(body);
36899
37235
  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);
37236
+ const send = async (bodyObj) => {
37237
+ const payload = JSON.stringify(bodyObj);
37238
+ if (debug) {
37239
+ const debugHeaders = { ...headers, "access-token": maskSecret(apiKey) };
37240
+ console.error(`Debug: POST URL: ${url.toString()}`);
37241
+ console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
37242
+ console.error(`Debug: Request body: ${redactSecretsForLog(payload)}`);
37243
+ }
37244
+ let response;
37245
+ const requestTimeout = requestTimeoutSignal(params.timeoutMs);
37246
+ try {
37247
+ response = await fetch(url.toString(), {
37248
+ method: "POST",
37249
+ headers,
37250
+ body: payload,
37251
+ signal: requestTimeout.signal
37252
+ });
37253
+ } catch (err) {
37254
+ if (isFetchTimeout(err)) {
37255
+ throw new HttpRequestTimeoutError(operation, requestTimeout.timeoutMs);
37256
+ }
37257
+ throw new Error(describeFetchError(operation, base, err));
36918
37258
  }
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)}`);
37259
+ const text = await response.text();
37260
+ if (debug) {
37261
+ console.error(`Debug: Response status: ${response.status}`);
37262
+ console.error(`Debug: Response body: ${redactSecretsForLog(text)}`);
37263
+ }
37264
+ return { response, text };
37265
+ };
37266
+ let { response, text } = await send(body);
37267
+ if (params.fallbackBody && isUnknownRpcSignature(response.status, text, fn)) {
37268
+ ({ response, text } = await send(params.fallbackBody));
36925
37269
  }
36926
37270
  if (!response.ok) {
36927
37271
  throw new HttpStatusError(formatHttpError(operation, response.status, text, response.statusText), response.status);
36928
37272
  }
36929
37273
  try {
36930
- return JSON.parse(text);
37274
+ return { data: JSON.parse(text), status: response.status };
36931
37275
  } catch {
36932
37276
  throw new Error(`${operation}: failed to parse response: ${redactSecretsForLog(text)}`);
36933
37277
  }
36934
37278
  }
36935
- async function startCommand(params) {
37279
+ function joeAsyncWaitMs(handle) {
37280
+ const expires = positiveSeconds(handle.expires_in_s);
37281
+ return Math.min(expires ? expires * 1000 : JOE_ASYNC_FALLBACK_WAIT_MS2, JOE_ASYNC_MAX_WAIT_MS2);
37282
+ }
37283
+ function asyncHandleCommandId(reply, operation) {
37284
+ const commandId = reply?.command_id;
37285
+ if (typeof commandId !== "string" || !/^[0-9]+$/.test(commandId)) {
37286
+ throw new Error(`${operation}: the platform accepted the command over the job channel but ` + `returned no command id: ${redactSecretsForLog(JSON.stringify(reply))}`);
37287
+ }
37288
+ return commandId;
37289
+ }
37290
+ async function startCommandDetailed(params) {
36936
37291
  const { apiKey, apiBaseUrl, instanceId, command, debug, timeoutMs } = params;
36937
37292
  if (!String(command ?? "").trim()) {
36938
37293
  throw new Error("command text is required");
36939
37294
  }
36940
- const commandId = await callRpc2({
37295
+ const operation = "Failed to run Joe command";
37296
+ const body = { instance_id: instanceId, command };
37297
+ const { data, status } = await callRpcRaw2({
36941
37298
  apiKey,
36942
37299
  apiBaseUrl,
36943
37300
  fn: "joe_command_run",
36944
- body: { instance_id: instanceId, command },
36945
- operation: "Failed to run Joe command",
37301
+ body: { ...body, accept_async: true },
37302
+ fallbackBody: body,
37303
+ operation,
36946
37304
  debug,
36947
37305
  timeoutMs
36948
37306
  });
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))}`);
37307
+ if (status === 202) {
37308
+ const handle = data ?? {};
37309
+ return {
37310
+ commandId: asyncHandleCommandId(data, operation),
37311
+ asyncWaitMs: joeAsyncWaitMs(handle),
37312
+ firstAnswerEstimateS: positiveSeconds(handle.first_answer_estimate_s)
37313
+ };
36951
37314
  }
36952
- return commandId;
37315
+ if (typeof data !== "string" || !/^[0-9]+$/.test(data)) {
37316
+ throw new Error(`${operation}: expected a command id string, got: ${redactSecretsForLog(JSON.stringify(data))}`);
37317
+ }
37318
+ return { commandId: data, asyncWaitMs: null, firstAnswerEstimateS: null };
36953
37319
  }
36954
37320
  async function getCommandOutput(params) {
36955
37321
  const { apiKey, apiBaseUrl, commandId, debug, timeoutMs } = params;
@@ -37079,18 +37445,29 @@ function buildJoeCommandText(command, input = {}) {
37079
37445
  var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
37080
37446
  async function runCommand(params) {
37081
37447
  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;
37448
+ const explicitBudgetMs = typeof params.budgetMs === "number" && Number.isFinite(params.budgetMs) && params.budgetMs >= 0 ? params.budgetMs : null;
37084
37449
  const now = params.now ?? Date.now;
37085
37450
  const sleep = params.sleep ?? defaultSleep;
37086
- const commandId = await startCommand({ apiKey, apiBaseUrl, instanceId, command, debug });
37451
+ const started = await startCommandDetailed({ apiKey, apiBaseUrl, instanceId, command, debug });
37452
+ const commandId = started.commandId;
37453
+ const budgetMs = explicitBudgetMs ?? started.asyncWaitMs ?? DEFAULT_BUDGET_MS;
37454
+ const pollDelayMs = (attempt) => params.pollIntervalMs ?? (started.asyncWaitMs === null ? DEFAULT_POLL_INTERVAL_MS : asyncPollDelayMs(attempt));
37087
37455
  const deadline = now() + budgetMs;
37088
37456
  let status = "pending";
37089
37457
  const remainingRequestMs = () => Math.max(1, Math.min(DEFAULT_HTTP_REQUEST_TIMEOUT_MS, deadline - now()));
37090
37458
  if (now() >= deadline) {
37091
- return { commandId, status, output: null, budgetExpired: true };
37459
+ return { commandId, status, output: null, budgetExpired: true, budgetMs };
37092
37460
  }
37093
- for (;; ) {
37461
+ let announced = false;
37462
+ const announce = () => {
37463
+ if (announced || started.asyncWaitMs === null)
37464
+ return;
37465
+ announced = true;
37466
+ const estimate = started.firstAnswerEstimateS;
37467
+ 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...`);
37468
+ };
37469
+ let transientFailures = 0;
37470
+ for (let attempt = 0;; attempt++) {
37094
37471
  let output;
37095
37472
  try {
37096
37473
  output = await getCommandOutput({
@@ -37102,25 +37479,28 @@ async function runCommand(params) {
37102
37479
  });
37103
37480
  } catch (err) {
37104
37481
  if (err instanceof HttpRequestTimeoutError) {
37105
- return { commandId, status, output: null, budgetExpired: true };
37482
+ return { commandId, status, output: null, budgetExpired: true, budgetMs };
37106
37483
  }
37107
37484
  if (err instanceof HttpStatusError && isRetryableHttpStatus(err.status)) {
37108
- if (now() >= deadline) {
37109
- return { commandId, status, output: null, budgetExpired: true };
37485
+ transientFailures += 1;
37486
+ if (now() >= deadline || started.asyncWaitMs !== null && transientFailures >= JOE_ASYNC_MAX_POLL_FAILURES) {
37487
+ return { commandId, status, output: null, budgetExpired: true, budgetMs };
37110
37488
  }
37111
- await sleep(pollIntervalMs);
37489
+ await sleep(pollDelayMs(attempt));
37112
37490
  continue;
37113
37491
  }
37114
37492
  throw err;
37115
37493
  }
37494
+ transientFailures = 0;
37116
37495
  status = output.status;
37117
37496
  if (status === "ok" || status === "error") {
37118
- return { commandId, status, output, budgetExpired: false };
37497
+ return { commandId, status, output, budgetExpired: false, budgetMs };
37119
37498
  }
37120
37499
  if (now() >= deadline) {
37121
- return { commandId, status, output: null, budgetExpired: true };
37500
+ return { commandId, status, output: null, budgetExpired: true, budgetMs };
37122
37501
  }
37123
- await sleep(pollIntervalMs);
37502
+ announce();
37503
+ await sleep(pollDelayMs(attempt));
37124
37504
  }
37125
37505
  }
37126
37506
  async function executeJoeCommand(params) {
@@ -37305,22 +37685,6 @@ async function postDblabRpc(rpc, bodyObj, params) {
37305
37685
  }
37306
37686
  return { response, text };
37307
37687
  }
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
37688
  var DBLAB_ASYNC_FALLBACK_WAIT_MS = 15 * 60 * 1000;
37325
37689
  var DBLAB_ASYNC_MAX_WAIT_MS = 60 * 60 * 1000;
37326
37690
  var DBLAB_POLL_MAX_CONSECUTIVE_FAILURES = 5;
@@ -37407,7 +37771,7 @@ async function awaitDblabCall(handle, params) {
37407
37771
  announced = true;
37408
37772
  console.error(`Waiting for the DBLab instance to pick up the call (job ${jobId}` + (estimate ? `, typically ~${estimate}s` : "") + ")...");
37409
37773
  }
37410
- const delay = dblabPollDelay(attempt);
37774
+ const delay = asyncPollDelayMs(attempt);
37411
37775
  if (now() + delay >= deadline) {
37412
37776
  throw new Error(`${operation}: timed out waiting for the DBLab instance (job ${jobId}, last status ${lastStatus}). ` + dblabNotResentAdvice(jobId));
37413
37777
  }
@@ -38965,6 +39329,10 @@ async function applyUninitPlan(params) {
38965
39329
  }
38966
39330
  return { applied, errors };
38967
39331
  }
39332
+ async function legacyHelperWarnings(client) {
39333
+ const r = await client.query("select to_regprocedure('postgres_ai.explain_generic(text,text,text)') is not null as present");
39334
+ return r.rows?.[0]?.present ? ["legacy SECURITY DEFINER helper postgres_ai.explain_generic(text, text, text) is still installed; its owner or a superuser must drop it"] : [];
39335
+ }
38968
39336
  async function verifyInitSetup2(params) {
38969
39337
  await params.client.query("begin isolation level repeatable read;");
38970
39338
  try {
@@ -40137,7 +40505,7 @@ import * as childProcess from "child_process";
40137
40505
  import * as fs9 from "fs";
40138
40506
  import * as path8 from "path";
40139
40507
  var __dirname = "/builds/postgres-ai/postgresai/cli/lib";
40140
- var CONTRACT_VERSION2 = "1.0.0";
40508
+ var CONTRACT_VERSION2 = "1.1.0";
40141
40509
  var SECONDS_PER_DAY2 = 86400;
40142
40510
  var SECONDS_PER_HOUR2 = 3600;
40143
40511
  var SECONDS_PER_MINUTE2 = 60;
@@ -42998,7 +43366,8 @@ function summarizeF0012(nodeData) {
42998
43366
  return `Autovacuum config: ${parts.join(", ")} finding${fired.length > 1 ? "s" : ""}`;
42999
43367
  };
43000
43368
  if (severity === "CRITICAL" || severity === "WARNING") {
43001
- return { status: "warning", message: summarize() };
43369
+ const top = fired.find((r) => r.severity === "CRITICAL") ?? fired.find((r) => r.severity === "WARNING");
43370
+ return { status: "warning", message: top?.conclusion ? `${summarize()}. ${top.conclusion}` : summarize() };
43002
43371
  }
43003
43372
  return { status: "info", message: summarize() };
43004
43373
  }
@@ -43155,6 +43524,24 @@ function summarizeG0032(nodeData) {
43155
43524
  };
43156
43525
  }
43157
43526
 
43527
+ // lib/json-stderr.ts
43528
+ import { format } from "node:util";
43529
+ function writeEvent(event) {
43530
+ process.stderr.write(`${JSON.stringify(event)}
43531
+ `);
43532
+ }
43533
+ async function withJsonStderr(fn) {
43534
+ const { error, warn } = console;
43535
+ console.error = (...args) => writeEvent({ event: "log", level: "error", message: format(...args) });
43536
+ console.warn = (...args) => writeEvent({ event: "log", level: "warn", message: format(...args) });
43537
+ try {
43538
+ return await fn();
43539
+ } finally {
43540
+ console.error = error;
43541
+ console.warn = warn;
43542
+ }
43543
+ }
43544
+
43158
43545
  // lib/instances.ts
43159
43546
  import * as fs10 from "fs";
43160
43547
  class InstancesParseError2 extends Error {
@@ -43903,12 +44290,47 @@ function sanitizeTagForBackup(tag) {
43903
44290
  const stripped = stripMatchingQuotes(tag);
43904
44291
  return /^[A-Za-z0-9._-]{1,64}$/.test(stripped) ? stripped : null;
43905
44292
  }
43906
- function readDeployedTag(projectDir) {
44293
+ function planUpdateTag(current, cliVersion) {
44294
+ const semver = (v) => {
44295
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(v);
44296
+ return m ? { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] } : null;
44297
+ };
44298
+ const cli = semver(cliVersion);
44299
+ if (!cli || cli.pre) {
44300
+ return { tag: null, note: `PGAI_TAG stays ${current ?? "unset"}: this CLI (${cliVersion}) is not a release. To pick a stack version, set PGAI_TAG=<version> in .env and re-run 'postgresai mon update'` };
44301
+ }
44302
+ if (current === cliVersion)
44303
+ return { tag: null, note: `PGAI_TAG is ${cliVersion}, matching this CLI` };
44304
+ const cur = current ? semver(current) : null;
44305
+ if (cur) {
44306
+ const i = cur.core.findIndex((part, j) => part !== cli.core[j]);
44307
+ if (i >= 0 && cur.core[i] > cli.core[i]) {
44308
+ return { tag: null, note: `PGAI_TAG stays ${current}: it is newer than this CLI (${cliVersion}). Upgrade the CLI to move the stack: npm install -g postgresai@latest` };
44309
+ }
44310
+ }
44311
+ return { tag: cliVersion, note: `PGAI_TAG: ${current ?? "unset"} -> ${cliVersion}` };
44312
+ }
44313
+ var ENV_TAG_LINE = /^([ \t]*(?:export[ \t]+)?PGAI_TAG[ \t]*=[ \t]*)(["']?)([A-Za-z0-9._-]*)\2((?:[ \t]+#[^\r\n]*)?[ \t]*\r?)$/gm;
44314
+ var ENV_TAG_ANY = /^[ \t]*(?:export[ \t]+)?PGAI_TAG[ \t]*[=:]/gm;
44315
+ function readEnvTag(projectDir) {
43907
44316
  const envFile = path9.resolve(projectDir, ".env");
43908
44317
  if (!fs11.existsSync(envFile))
43909
- return null;
43910
- const m = fs11.readFileSync(envFile, "utf8").match(/^PGAI_TAG=(.+)$/m);
43911
- return m ? sanitizeTagForBackup(m[1]) : null;
44318
+ return { tag: null, plain: true };
44319
+ const content = fs11.readFileSync(envFile, "utf8");
44320
+ const plainLines = [...content.matchAll(ENV_TAG_LINE)];
44321
+ if (plainLines.length !== [...content.matchAll(ENV_TAG_ANY)].length)
44322
+ return { tag: null, plain: false };
44323
+ const last = plainLines.at(-1);
44324
+ return { tag: last && last[3] ? last[3] : null, plain: true };
44325
+ }
44326
+ function writeEnvTag(projectDir, tag) {
44327
+ const envFile = path9.resolve(projectDir, ".env");
44328
+ const existing = fs11.existsSync(envFile) ? fs11.readFileSync(envFile, "utf8") : "";
44329
+ const content = new RegExp(ENV_TAG_LINE.source, "m").test(existing) ? existing.replace(ENV_TAG_LINE, (_match, prefix, quote, _value, rest) => `${prefix}${quote}${tag}${quote}${rest}`) : `${existing}${existing && !existing.endsWith(`
44330
+ `) ? `
44331
+ ` : ""}PGAI_TAG=${tag}
44332
+ `;
44333
+ writeEnvFile(envFile, content);
43912
44334
  }
43913
44335
  function isValidComposeYaml(text) {
43914
44336
  const trimmed = text.trim();
@@ -43959,7 +44381,7 @@ async function refreshBundledComposeIfStale(projectDir, oldTag) {
43959
44381
  const existing = fs11.readFileSync(composeFile, "utf8");
43960
44382
  if (composeContentEqual(existing, fetched))
43961
44383
  return false;
43962
- const deployedTag = sanitizeTagForBackup(oldTag ?? readDeployedTag(projectDir));
44384
+ const deployedTag = sanitizeTagForBackup(oldTag ?? readEnvTag(projectDir).tag);
43963
44385
  const tagPart = deployedTag ?? new Date().toISOString().replace(/[:.]/g, "-");
43964
44386
  const oldHash = crypto2.createHash("sha256").update(existing).digest("hex").slice(0, 8);
43965
44387
  const backup = path9.resolve(projectDir, `docker-compose.yml.bak-${tagPart}-${oldHash}`);
@@ -44030,6 +44452,9 @@ function withOrgOptions(command) {
44030
44452
  ORG_SCOPED_COMMANDS.add(command);
44031
44453
  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
44454
  }
44455
+ function jsonConnect(command) {
44456
+ return command.parent === program2 && command.name() === "connect" && (!!command.opts().json || !process.stdout.isTTY);
44457
+ }
44033
44458
  program2.hook("preAction", (_thisCommand, actionCommand) => {
44034
44459
  const initPointsToConnect = actionCommand.parent === program2 && actionCommand.name() === "init" && !interactive(actionCommand.opts().json);
44035
44460
  if (!ORG_SCOPED_COMMANDS.has(actionCommand) || initPointsToConnect) {
@@ -44042,7 +44467,10 @@ program2.hook("preAction", (_thisCommand, actionCommand) => {
44042
44467
  setActiveOrgScope(requireOrgScope(opts, apiKey));
44043
44468
  } catch (err) {
44044
44469
  if (err instanceof OrgScopeError2) {
44045
- console.error(err.message);
44470
+ if (jsonConnect(actionCommand))
44471
+ writeEvent({ event: "log", level: "error", message: err.message });
44472
+ else
44473
+ console.error(err.message);
44046
44474
  process.exit(1);
44047
44475
  }
44048
44476
  throw err;
@@ -44264,6 +44692,7 @@ program2.command("prepare-db [conn]").description("prepare database for monitori
44264
44692
  monitoringUser: opts.monitoringUser,
44265
44693
  includeOptionalPermissions
44266
44694
  });
44695
+ v.missingOptional.push(...await legacyHelperWarnings(supabaseClient));
44267
44696
  if (v.ok) {
44268
44697
  if (jsonOutput) {
44269
44698
  const result = {
@@ -44386,6 +44815,7 @@ program2.command("prepare-db [conn]").description("prepare database for monitori
44386
44815
  client: supabaseClient,
44387
44816
  plan: effectivePlan
44388
44817
  });
44818
+ const legacyWarnings = await legacyHelperWarnings(supabaseClient);
44389
44819
  if (jsonOutput) {
44390
44820
  const result = {
44391
44821
  success: true,
@@ -44395,7 +44825,10 @@ program2.command("prepare-db [conn]").description("prepare database for monitori
44395
44825
  monitoringUser: opts.monitoringUser,
44396
44826
  applied,
44397
44827
  skippedOptional,
44398
- warnings: skippedOptional.length > 0 ? ["Some optional steps were skipped (not supported or insufficient privileges)"] : []
44828
+ warnings: [
44829
+ ...skippedOptional.length > 0 ? ["Some optional steps were skipped (not supported or insufficient privileges)"] : [],
44830
+ ...legacyWarnings
44831
+ ]
44399
44832
  };
44400
44833
  if (passwordGenerated) {
44401
44834
  result.generatedPassword = monPassword;
@@ -44411,6 +44844,8 @@ program2.command("prepare-db [conn]").description("prepare database for monitori
44411
44844
  for (const s of skippedOptional)
44412
44845
  console.error(`- ${s}`);
44413
44846
  }
44847
+ for (const w of legacyWarnings)
44848
+ console.error(`\u26A0 ${w}`);
44414
44849
  if (process.stdout.isTTY) {
44415
44850
  console.log(`Applied ${applied.length} steps`);
44416
44851
  }
@@ -44552,6 +44987,7 @@ program2.command("prepare-db [conn]").description("prepare database for monitori
44552
44987
  includeOptionalPermissions,
44553
44988
  provider
44554
44989
  });
44990
+ v.missingOptional.push(...await legacyHelperWarnings(client));
44555
44991
  if (v.ok) {
44556
44992
  if (jsonOutput) {
44557
44993
  outputJson({
@@ -44670,6 +45106,7 @@ program2.command("prepare-db [conn]").description("prepare database for monitori
44670
45106
  return;
44671
45107
  }
44672
45108
  const { applied, skippedOptional } = await applyInitPlan2({ client, plan: effectivePlan });
45109
+ const legacyWarnings = await legacyHelperWarnings(client);
44673
45110
  if (jsonOutput) {
44674
45111
  const result = {
44675
45112
  success: true,
@@ -44679,7 +45116,10 @@ program2.command("prepare-db [conn]").description("prepare database for monitori
44679
45116
  monitoringUser: opts.monitoringUser,
44680
45117
  applied,
44681
45118
  skippedOptional,
44682
- warnings: skippedOptional.length > 0 ? ["Some optional steps were skipped (not supported or insufficient privileges)"] : []
45119
+ warnings: [
45120
+ ...skippedOptional.length > 0 ? ["Some optional steps were skipped (not supported or insufficient privileges)"] : [],
45121
+ ...legacyWarnings
45122
+ ]
44683
45123
  };
44684
45124
  if (passwordGenerated) {
44685
45125
  result.generatedPassword = monPassword;
@@ -44692,6 +45132,8 @@ program2.command("prepare-db [conn]").description("prepare database for monitori
44692
45132
  for (const s of skippedOptional)
44693
45133
  console.error(`- ${s}`);
44694
45134
  }
45135
+ for (const w of legacyWarnings)
45136
+ console.error(`\u26A0 ${w}`);
44695
45137
  if (process.stdout.isTTY) {
44696
45138
  console.log(`Applied ${applied.length} steps`);
44697
45139
  }
@@ -45910,7 +46352,22 @@ function signedIn(opts) {
45910
46352
  }
45911
46353
  return !!cloudApi().apiKey;
45912
46354
  }
45913
- async function runConnect(url, opts) {
46355
+ function runConnect(url, opts) {
46356
+ return opts.json || !process.stdout.isTTY ? withJsonStderr(() => connectCommand(url, opts, true)) : connectCommand(url, opts, false);
46357
+ }
46358
+ function runLocalInstall(args, env, json) {
46359
+ if (!json)
46360
+ return Promise.resolve(childProcess.spawnSync(process.execPath, args, { stdio: ["ignore", 2, 2], env }).status);
46361
+ const child = childProcess.spawn(process.execPath, args, { stdio: ["ignore", "pipe", "pipe"], env });
46362
+ for (const stream of [child.stdout, child.stderr]) {
46363
+ createInterface({ input: stream }).on("line", (line) => writeEvent({ event: "log", level: "info", source: "mon local-install", message: line }));
46364
+ }
46365
+ return new Promise((resolve) => {
46366
+ child.on("error", () => resolve(null));
46367
+ child.on("close", (code) => resolve(code));
46368
+ });
46369
+ }
46370
+ async function connectCommand(url, opts, json) {
45914
46371
  const name = (() => {
45915
46372
  try {
45916
46373
  return databaseName2(url);
@@ -45926,38 +46383,56 @@ async function runConnect(url, opts) {
45926
46383
  if (!Number.isFinite(waitMinutes) || waitMinutes < 0) {
45927
46384
  return emitConnect({ status: "failed", provider, name, next: "--wait must be a number of minutes (0 = do not wait)" }, opts.json);
45928
46385
  }
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
46386
  try {
46387
+ if (!opts.selfHosted && !signedIn(opts)) {
46388
+ return emitConnect({ status: "action_required", provider, name, next: "Sign in: pgai auth login (agents: set PGAI_API_KEY), then re-run" }, opts.json);
46389
+ }
46390
+ const api = cloudApi(opts.debug);
45934
46391
  const result = await connect2(url, { ...opts, waitMs: waitMinutes * 60000 }, {
45935
46392
  ...api,
45936
46393
  selfHosted: async (monitoringUrl, env) => {
45937
46394
  const scope = getActiveOrgScope2();
45938
46395
  const org = scope?.alias ? ["--org", scope.alias] : scope?.id ? ["--org-id", String(scope.id)] : [];
45939
46396
  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)
46397
+ const status = await runLocalInstall([process.argv[1], "mon", "local-install", "-y", ...org, "--project", project], { ...process.env, ...env, PGAI_DB_URL: monitoringUrl, PGAI_API_BASE_URL: api.apiBaseUrl, ...api.apiKey ? { PGAI_API_KEY: api.apiKey } : {} }, json);
46398
+ if (status !== 0)
45945
46399
  throw new Error("mon local-install failed (see above)");
45946
46400
  },
45947
46401
  localStackRunning: () => checkRunningContainers().running,
45948
- progress: (line) => console.error(line)
46402
+ confirm: async (q) => {
46403
+ if (!interactive(opts.json))
46404
+ return false;
46405
+ process.exitCode = 130;
46406
+ const answer = await question(q);
46407
+ process.exitCode = undefined;
46408
+ closeReadline();
46409
+ return /^(y|yes)$/i.test(answer.trim());
46410
+ },
46411
+ progress: (e) => json ? writeEvent(e) : console.error(progressText(e))
45949
46412
  });
45950
46413
  emitConnect(result, opts.json);
45951
46414
  } catch (err) {
45952
46415
  emitConnect({ status: "failed", provider, name, next: err instanceof Error ? err.message : String(err) }, opts.json);
45953
46416
  }
45954
46417
  }
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", [
46418
+ withOrgOptions(program2.command("connect <database-url>")).configureOutput({
46419
+ writeErr: (text) => {
46420
+ if (!(process.argv.includes("--json") || !process.stdout.isTTY))
46421
+ return void process.stderr.write(text);
46422
+ for (const line of text.split(`
46423
+ `).filter((l) => l.trim() !== ""))
46424
+ writeEvent({ event: "log", level: "error", message: line });
46425
+ }
46426
+ }).description("put a database under PostgresAI's care: prepare it, provision monitoring, print the dashboard").option("--provider <provider>", "clickhouse | rds | supabase | self-managed (default: detected from the host)").option("--clickhouse-key <id:secret>", "ClickHouse Cloud API key (Basic Service API Reader) for CPU, memory and disk; or CLICKHOUSE_KEY_ID + CLICKHOUSE_KEY_SECRET").option("--self-hosted", "run the monitoring stack on this machine (mon local-install) instead of PostgresAI Cloud").option("--reset-password", "postgres_ai_mon exists and its password is lost: set a new one (admin URL; refused while another database on the server is monitored)").option("--wait <minutes>", "how long to wait for the monitoring box (0 = do not wait)", "20").option("--coupon <code>", "promotion code for the organization's monitoring subscription (applies when it is first created)").option("-y, --yes", "never prompt: accept the price of a billed box").option("--json", "JSON output (default when stdout is not a TTY)").option("--debug", "print HTTP requests (secrets masked)").addHelpText("after", [
45956
46427
  "",
45957
46428
  "Steps (each skipped when already done; safe to re-run): sign in, prepare the database",
45958
46429
  "(an admin URL creates the postgres_ai_mon role; otherwise the SQL is printed), provision",
45959
46430
  "the monitoring box, run the express checkup while it starts, wait, print the dashboard URL.",
45960
46431
  "",
46432
+ 'Billing: before a new box, connect shows its price (or "free (N of M free slots)"). A billed',
46433
+ "box is provisioned only when accepted: at the prompt, or with --yes. With no payment method",
46434
+ "it stops (exit 3) and names the console page to add one. --coupon applies a promotion code.",
46435
+ "",
45961
46436
  'Exit codes: 0 connected or provisioning, 1 failed, 3 action required (see "next").',
45962
46437
  "",
45963
46438
  "Environment: PGAI_API_KEY (instead of signing in), PGAI_MON_PASSWORD (the password of",
@@ -46697,6 +47172,18 @@ mon.command("update").description("update monitoring stack (migrate .env, pull i
46697
47172
  } else {
46698
47173
  console.log("\u2713 .env is up to date");
46699
47174
  }
47175
+ const envTag = readEnvTag(projectDir);
47176
+ const deployedTag = envTag.tag;
47177
+ const tagPlan = envTag.plain ? planUpdateTag(deployedTag, package_default.version) : { tag: null, note: `PGAI_TAG in .env is not a plain value, so it is left as is. To move the stack to ${package_default.version}, set PGAI_TAG=${package_default.version} in .env and re-run 'postgresai mon update'` };
47178
+ if (process.env.PGAI_TAG && process.env.PGAI_TAG !== (tagPlan.tag ?? deployedTag)) {
47179
+ console.error(`\u26A0 PGAI_TAG=${process.env.PGAI_TAG} is set in the environment, and docker compose prefers it over .env: unset it before 'postgresai mon stop && postgresai mon start'`);
47180
+ }
47181
+ if (tagPlan.tag) {
47182
+ writeEnvTag(projectDir, tagPlan.tag);
47183
+ if (process.env.PGAI_TAG === deployedTag)
47184
+ process.env.PGAI_TAG = tagPlan.tag;
47185
+ }
47186
+ console.log(`${tagPlan.tag ? "\u2713 " : ""}${tagPlan.note}`);
46700
47187
  console.log();
46701
47188
  const gitDir = path9.resolve(projectDir, ".git");
46702
47189
  if (fs11.existsSync(gitDir)) {
@@ -46710,7 +47197,7 @@ mon.command("update").description("update monitoring stack (migrate .env, pull i
46710
47197
  console.log(pullOut);
46711
47198
  } else {
46712
47199
  console.log("(not a git checkout \u2014 checking bundled docker-compose.yml)");
46713
- await refreshBundledComposeIfStale(projectDir);
47200
+ await refreshBundledComposeIfStale(projectDir, deployedTag);
46714
47201
  }
46715
47202
  console.log(`
46716
47203
  Updating Docker images...`);
@@ -46730,7 +47217,7 @@ Updating Docker images...`);
46730
47217
  \u2713 Update completed successfully`);
46731
47218
  console.log(`
46732
47219
  To apply updates, restart monitoring services:`);
46733
- console.log(" postgres-ai mon restart");
47220
+ console.log(" postgresai mon stop && postgresai mon start");
46734
47221
  } else {
46735
47222
  console.error(`
46736
47223
  \u2717 Docker image update failed`);
@@ -47042,6 +47529,7 @@ async function syncVmalert(projectDir, apply, addOnly = false) {
47042
47529
  setEnvValues(projectDir, { COMPOSE_PROFILES: composeProfilesValue(current, want, "host-metrics") });
47043
47530
  return true;
47044
47531
  }
47532
+ var looksLikeConnStr = (value) => /^postgres(ql)?:\/\//i.test(value);
47045
47533
  async function addTarget(file, projectDir, connStr, name, env, { apply = true } = {}) {
47046
47534
  if (!connStr) {
47047
47535
  console.error("Connection string required: postgresql://user:pass@host:port/db");
@@ -47140,8 +47628,17 @@ Re-running with the same name and connection string is safe; retry after fixing
47140
47628
 
47141
47629
  Supabase: with PGAI_SUPABASE_HOST_METRICS=true (environment or .env), writes host-metrics/supabase-<name>.yml.
47142
47630
  RDS instance endpoint: writes RDS_DB_INSTANCE_IDENTIFIER, AWS_REGION, PGAI_CLUSTER and PGAI_NODE_NAME to .env for rds-host-stats.
47631
+
47632
+ Environment:
47633
+ PGAI_DB_URL the connection string, when none is given (keeps the password out of argv);
47634
+ the only argument is then the name: PGAI_DB_URL=... postgres-ai mon targets add my-db
47143
47635
  `).action(async (connStr, name) => {
47144
47636
  const { instancesFile: file, projectDir } = await resolveOrInitPaths();
47637
+ const envUrl = process.env.PGAI_DB_URL || undefined;
47638
+ if (envUrl && name === undefined && (connStr === undefined || !looksLikeConnStr(connStr))) {
47639
+ name = connStr;
47640
+ connStr = envUrl;
47641
+ }
47145
47642
  await addTarget(file, projectDir, connStr, name, process.env);
47146
47643
  });
47147
47644
  targets.command("remove <name>").description("remove monitoring target database").action(async (name) => {
@@ -48541,7 +49038,7 @@ function tryParseJson(s) {
48541
49038
  }
48542
49039
  }
48543
49040
  function printJoeOutcome(outcome, json, budgetMs) {
48544
- const effectiveBudgetMs = typeof budgetMs === "number" && Number.isFinite(budgetMs) ? budgetMs : DEFAULT_BUDGET_MS;
49041
+ const effectiveBudgetMs = Number.isFinite(outcome.budgetMs) ? outcome.budgetMs : typeof budgetMs === "number" && Number.isFinite(budgetMs) ? budgetMs : DEFAULT_BUDGET_MS;
48545
49042
  const budgetSeconds = Math.round(effectiveBudgetMs / 1000);
48546
49043
  if (outcome.budgetExpired) {
48547
49044
  if (json) {
@@ -49050,9 +49547,10 @@ export {
49050
49547
  monitoringHealthServices,
49051
49548
  persistInstanceId,
49052
49549
  planMonitoringRegistration,
49550
+ planUpdateTag,
49053
49551
  program2 as program,
49054
49552
  readComposeProfiles,
49055
- readDeployedTag,
49553
+ readEnvTag,
49056
49554
  refreshBundledComposeIfStale,
49057
49555
  registerMonitoringInstance,
49058
49556
  resolveAdoptedProject,
@@ -49060,5 +49558,6 @@ export {
49060
49558
  selfRegisterAndPersist,
49061
49559
  shouldScaleOutNodeExporter,
49062
49560
  statOwner,
49063
- updatePgwatchConfig
49561
+ updatePgwatchConfig,
49562
+ writeEnvTag
49064
49563
  };