postgresai 0.16.0-dev.1 → 0.16.0-dev.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/supabase.ts CHANGED
@@ -733,24 +733,6 @@ export async function verifyInitSetupViaSupabase(params: {
733
733
  }
734
734
 
735
735
  // Check helper functions - first verify they exist to avoid has_function_privilege errors
736
- const explainFnExistsRes = await params.client.query(
737
- "SELECT oid FROM pg_proc WHERE proname = 'explain_generic' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'postgres_ai')",
738
- true
739
- );
740
- if (explainFnExistsRes.rowCount === 0) {
741
- missingRequired.push("function postgres_ai.explain_generic exists");
742
- } else {
743
- const explainFnRes = await params.client.query(
744
- `SELECT has_function_privilege('${escapeLiteral(role)}', 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok`,
745
- true
746
- );
747
- if (!explainFnRes.rows?.[0]?.ok) {
748
- missingRequired.push(
749
- "EXECUTE on postgres_ai.explain_generic(text, text, text)"
750
- );
751
- }
752
- }
753
-
754
736
  const tableDescribeFnExistsRes = await params.client.query(
755
737
  "SELECT oid FROM pg_proc WHERE proname = 'table_describe' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'postgres_ai')",
756
738
  true
package/lib/util.ts CHANGED
@@ -28,45 +28,185 @@ function isHtmlContent(text: string): boolean {
28
28
  */
29
29
  const AUTH_REMEDIATION_HINT = "Run 'postgresai auth' to (re)authenticate, or set/update PGAI_API_KEY.";
30
30
 
31
+ /**
32
+ * Standard HTTP reason phrases we should NOT treat as a server-authored
33
+ * message: when the reason phrase equals the stock text for the status, it
34
+ * carries no extra information, so we prefer the friendlier generic label.
35
+ */
36
+ const STANDARD_REASON_PHRASES: Record<number, string> = {
37
+ 400: "Bad Request",
38
+ 401: "Unauthorized",
39
+ 403: "Forbidden",
40
+ 404: "Not Found",
41
+ 408: "Request Timeout",
42
+ 409: "Conflict",
43
+ 413: "Payload Too Large",
44
+ 429: "Too Many Requests",
45
+ 500: "Internal Server Error",
46
+ 502: "Bad Gateway",
47
+ 503: "Service Unavailable",
48
+ 504: "Gateway Timeout",
49
+ };
50
+
31
51
  /**
32
52
  * Format an HTTP error response into a clean, developer-friendly message.
33
53
  * Handles HTML error pages (e.g., from Cloudflare) by showing just the status code and message.
34
54
  * For 401 responses, appends a remediation hint pointing at `postgresai auth`.
55
+ *
56
+ * The platform's PostgREST layer uses the `PTxyz` custom-status convention:
57
+ * a raised `PT403`/`PT404`/… maps to the HTTP status and delivers the RPC's
58
+ * user-facing message in the HTTP **reason phrase** (`response.statusText`),
59
+ * NOT the JSON body — the body carries only `hint`/`details` (no `message`).
60
+ * So callers pass `statusText` and it is preferred over the built-in generic
61
+ * label. Behind an h2/h3 proxy the reason phrase is dropped entirely
62
+ * (`statusText` is empty), so the JSON body's `code`, `details`, and `hint`
63
+ * are all we get — they must be surfaced too (CLI half of
64
+ * https://gitlab.com/postgres-ai/platform-all/-/issues/537).
65
+ * Headline precedence: JSON body `message` → custom reason phrase → generic
66
+ * label, suffixed with the JSON `code` (e.g. `PT403`) when present; the JSON
67
+ * `details` (plural, PostgREST's spelling) is shown as a supplementary line,
68
+ * and the JSON `hint` (the remediation) as a trailing `Hint:` line.
35
69
  */
36
- export function formatHttpError(operation: string, status: number, responseBody?: string): string {
37
- const statusMessage = HTTP_STATUS_MESSAGES[status] || "Request failed";
38
- let errMsg = `${operation}: HTTP ${status} - ${statusMessage}`;
70
+ export function formatHttpError(
71
+ operation: string,
72
+ status: number,
73
+ responseBody?: string,
74
+ statusText?: string
75
+ ): string {
76
+ const generic = HTTP_STATUS_MESSAGES[status] || "Request failed";
39
77
  const remediation = status === 401 ? `\n${AUTH_REMEDIATION_HINT}` : "";
40
78
 
41
- if (responseBody) {
42
- // If it's HTML (like Cloudflare error pages), don't dump the raw HTML
43
- if (isHtmlContent(responseBody)) {
44
- // Just use the status message, don't append HTML
45
- return errMsg + remediation;
46
- }
79
+ let bodyMessage: string | undefined;
80
+ let bodyDetails: string | undefined;
81
+ let bodyCode: string | undefined;
82
+ let bodyHint: string | undefined;
47
83
 
48
- // Try to parse as JSON for structured error info
84
+ if (responseBody && !isHtmlContent(responseBody)) {
85
+ // If it's HTML (like Cloudflare error pages), we fall through with no
86
+ // parsed fields and never dump the raw HTML.
49
87
  try {
50
88
  const errObj = JSON.parse(responseBody);
51
- // Extract common error message fields
52
- const message = errObj.message || errObj.error || errObj.detail;
53
- if (message && typeof message === "string") {
54
- errMsg += `\n${message}`;
55
- } else {
56
- errMsg += `\n${JSON.stringify(errObj, null, 2)}`;
89
+ const message = errObj.message ?? errObj.error;
90
+ if (typeof message === "string" && message.trim().length > 0) {
91
+ bodyMessage = redactTextSecrets(message.trim());
92
+ }
93
+ // PostgREST spells it `details` (plural); accept `detail` too.
94
+ const details = errObj.details ?? errObj.detail;
95
+ if (typeof details === "string" && details.trim().length > 0) {
96
+ bodyDetails = redactTextSecrets(details.trim());
97
+ }
98
+ if (typeof errObj.code === "string" && errObj.code.trim().length > 0) {
99
+ bodyCode = errObj.code.trim();
100
+ }
101
+ if (typeof errObj.hint === "string" && errObj.hint.trim().length > 0) {
102
+ bodyHint = redactTextSecrets(errObj.hint.trim());
103
+ }
104
+ if (
105
+ bodyMessage === undefined &&
106
+ bodyDetails === undefined &&
107
+ bodyCode === undefined &&
108
+ bodyHint === undefined
109
+ ) {
110
+ // A JSON body with none of the known fields (message/error/details/
111
+ // detail/code/hint) still carries the only diagnostic there is —
112
+ // surface it (redacted, compact) instead of silently dropping it.
113
+ bodyDetails = redactSecretsForLog(JSON.stringify(errObj));
57
114
  }
58
115
  } catch {
59
- // Plain text error - append it if it's short and useful
116
+ // Plain text error - treat it as the details line if short and useful.
117
+ // Scrubbed: raw error bodies can echo credentials (e.g. a connStr) and
118
+ // this string ends up in thrown Errors / MCP isError responses.
60
119
  const trimmed = responseBody.trim();
61
120
  if (trimmed.length > 0 && trimmed.length < 500) {
62
- errMsg += `\n${trimmed}`;
121
+ bodyDetails = redactTextSecrets(trimmed);
63
122
  }
64
123
  }
65
124
  }
66
125
 
126
+ // A custom reason phrase (PTxyz message) is meaningful only when it differs
127
+ // from the stock HTTP reason phrase for this status.
128
+ const trimmedReason = statusText?.trim();
129
+ const reasonPhrase =
130
+ trimmedReason &&
131
+ trimmedReason !== STANDARD_REASON_PHRASES[status] &&
132
+ trimmedReason !== generic
133
+ ? trimmedReason
134
+ : undefined;
135
+ const safeReasonPhrase = reasonPhrase ? redactTextSecrets(reasonPhrase) : undefined;
136
+
137
+ const headline = bodyMessage ?? safeReasonPhrase ?? generic;
138
+ const codeSuffix = bodyCode && !headline.includes(bodyCode) ? ` (${bodyCode})` : "";
139
+ let errMsg = `${operation}: HTTP ${status} - ${headline}${codeSuffix}`;
140
+ if (bodyDetails && bodyDetails !== headline) {
141
+ errMsg += `\n${bodyDetails}`;
142
+ }
143
+ if (bodyHint) {
144
+ errMsg += `\nHint: ${bodyHint}`;
145
+ }
146
+
67
147
  return errMsg + remediation;
68
148
  }
69
149
 
150
+ /**
151
+ * Turn a low-level `fetch` failure into an actionable message. Node's fetch
152
+ * (undici) throws a `TypeError('fetch failed')` and stashes the real cause
153
+ * (`ECONNREFUSED`, DNS failure, `bad port`, TLS error, …) in `err.cause` — the
154
+ * opaque top-level message on its own tells the user nothing. This surfaces the
155
+ * cause and the URL that could not be reached, e.g.
156
+ * "Failed to list projects: could not reach http://127.0.0.1:1 (ECONNREFUSED)"
157
+ */
158
+ export function describeFetchError(operation: string, url: string, err: unknown): string {
159
+ const cause = (err as { cause?: { code?: string; message?: string } } | null | undefined)?.cause;
160
+ const detail =
161
+ cause?.code ||
162
+ cause?.message ||
163
+ (err instanceof Error && err.message ? err.message : String(err));
164
+ return `${operation}: could not reach ${url} (${detail})`;
165
+ }
166
+
167
+ /**
168
+ * An HTTP error response with the status attached, so callers can classify
169
+ * retryability without string-matching the formatted message.
170
+ */
171
+ export class HttpStatusError extends Error {
172
+ readonly status: number;
173
+ constructor(message: string, status: number) {
174
+ super(message);
175
+ this.name = "HttpStatusError";
176
+ this.status = status;
177
+ }
178
+ }
179
+
180
+ /** Transient statuses worth retrying/resuming on: server errors and rate limits. */
181
+ export function isRetryableHttpStatus(status: number): boolean {
182
+ return status >= 500 || status === 429;
183
+ }
184
+
185
+ /** Hard upper bound for a single platform request, even outside Joe polling. */
186
+ export const DEFAULT_HTTP_REQUEST_TIMEOUT_MS = 25_000;
187
+
188
+ export class HttpRequestTimeoutError extends Error {
189
+ constructor(operation: string, timeoutMs: number) {
190
+ super(`${operation}: request timed out after ${timeoutMs}ms`);
191
+ this.name = "HttpRequestTimeoutError";
192
+ }
193
+ }
194
+
195
+ /** Return a finite AbortSignal timeout acceptable to both Node and Bun fetch. */
196
+ export function requestTimeoutSignal(timeoutMs?: number): { signal: AbortSignal; timeoutMs: number } {
197
+ const requested =
198
+ typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
199
+ ? timeoutMs
200
+ : DEFAULT_HTTP_REQUEST_TIMEOUT_MS;
201
+ const bounded = Math.max(1, Math.min(DEFAULT_HTTP_REQUEST_TIMEOUT_MS, Math.floor(requested)));
202
+ return { signal: AbortSignal.timeout(bounded), timeoutMs: bounded };
203
+ }
204
+
205
+ export function isFetchTimeout(err: unknown): boolean {
206
+ const name = (err as { name?: unknown } | null)?.name;
207
+ return name === "AbortError" || name === "TimeoutError";
208
+ }
209
+
70
210
  export function maskSecret(secret: string): string {
71
211
  if (!secret) return "";
72
212
  if (secret.length <= 8) return "****";
@@ -74,6 +214,89 @@ export function maskSecret(secret: string): string {
74
214
  return `${secret.slice(0, Math.min(12, secret.length - 8))}${"*".repeat(Math.max(4, secret.length - 16))}${secret.slice(-4)}`;
75
215
  }
76
216
 
217
+ /**
218
+ * Credential-bearing field names. Match complete normalized names rather than
219
+ * substrings: MCP results can contain ordinary SQL columns such as `author_id`,
220
+ * `token_type`, `tokens`, and `credited_at`, and corrupting those values is worse
221
+ * than leaving an unfamiliar key untouched. CamelCase is normalized so DBLab's
222
+ * `connStr` and `dbPassword` remain covered.
223
+ */
224
+ function isSensitiveLogKey(key: string): boolean {
225
+ const normalized = key
226
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
227
+ .replace(/[-\s]+/g, "_")
228
+ .toLowerCase();
229
+ return /^(?:password|passwd|db_(?:pass|password)|conn_?str|secret|token|api_key|private_key|access_key|access_token|refresh_token|auth|auth_key|auth_token|authorization|credentials?|dsn)$/.test(normalized);
230
+ }
231
+
232
+ /**
233
+ * Return a deep copy with DBLab credential fields removed.
234
+ *
235
+ * Best-effort hygiene only, NOT a security barrier: redaction is key-name
236
+ * based, so secret VALUES under non-matching keys (e.g.
237
+ * `select rolpassword as x from pg_authid`) pass through untouched. The
238
+ * joe:exec scope and the org execution policy are the actual controls.
239
+ */
240
+ export function redactSecrets(value: unknown): unknown {
241
+ if (Array.isArray(value)) {
242
+ return value.map(redactSecrets);
243
+ }
244
+ if (value && typeof value === "object") {
245
+ const out: Record<string, unknown> = {};
246
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
247
+ out[key] = isSensitiveLogKey(key) && child != null
248
+ ? "[REDACTED]"
249
+ : redactSecrets(child);
250
+ }
251
+ return out;
252
+ }
253
+ return value;
254
+ }
255
+
256
+ /**
257
+ * Redact known credential fields from a serialized JSON payload before it is
258
+ * written to a debug log — the body-side counterpart of the `maskSecret`
259
+ * masking the `access-token` header already gets. Debug logging is reachable
260
+ * by MCP callers (`debug: true` is a caller-controlled tool argument), and
261
+ * DBLab bodies carry live credentials: the clone DB password rides in the clone
262
+ * create request (`data.db.password`), and clone create/status replies return
263
+ * the clone's `db.password` / `db.connStr`.
264
+ *
265
+ * A fixed placeholder is used instead of `maskSecret` because short passwords
266
+ * would leak most of their characters through partial masking. Non-JSON input
267
+ * cannot be redacted by key name, so it falls back to the pattern-based
268
+ * `redactTextSecrets` scrub — error paths (parse failures, plain-text error
269
+ * bodies) embed raw response text in thrown Errors that reach MCP `isError`
270
+ * responses and CLI stderr, and must not bypass redaction.
271
+ */
272
+ export function redactSecretsForLog(text: string): string {
273
+ let parsed: unknown;
274
+ try {
275
+ parsed = JSON.parse(text);
276
+ } catch {
277
+ return redactTextSecrets(text);
278
+ }
279
+ return JSON.stringify(redactSecrets(parsed));
280
+ }
281
+
282
+ /** URL userinfo credentials: `scheme://user:password@host` → password redacted. */
283
+ const TEXT_URL_USERINFO = /(\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+):[^\s/@]+@/gi;
284
+
285
+ /** `key=value` / `key: value` / `"key": "value"` pairs for credential-named keys. */
286
+ const TEXT_SENSITIVE_PAIR =
287
+ /((?:password|passwd|db[-_]?pass|connstr|secret|token|api[-_]?key|private[-_]?key|access[-_]?key|cred(?:ential)?s?|dsn)["']?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;&]+)/gi;
288
+
289
+ /**
290
+ * Best-effort scrub of credential-looking patterns in plain (non-JSON) text.
291
+ * Used for raw response text that ends up in thrown error messages, where the
292
+ * key-based `redactSecrets` cannot apply. Hygiene only — not an egress control.
293
+ */
294
+ export function redactTextSecrets(text: string): string {
295
+ return text
296
+ .replace(TEXT_URL_USERINFO, "$1:[REDACTED]@")
297
+ .replace(TEXT_SENSITIVE_PAIR, "$1[REDACTED]");
298
+ }
299
+
77
300
 
78
301
  export interface RootOptsLike {
79
302
  apiBaseUrl?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.16.0-dev.1",
3
+ "version": "0.16.0-dev.11",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -2,126 +2,6 @@
2
2
  -- These functions use SECURITY DEFINER to allow the monitoring user to perform
3
3
  -- operations they don't have direct permissions for.
4
4
 
5
- /*
6
- * explain_generic
7
- *
8
- * Function to get generic explain plans with optional HypoPG index testing.
9
- * Requires: PostgreSQL 16+ (for generic_plan option), HypoPG extension (optional).
10
- *
11
- * Security notes:
12
- * - EXPLAIN without ANALYZE is read-only (plans but doesn't execute the query)
13
- * - PostgreSQL's EXPLAIN only accepts a single statement (primary protection)
14
- * - Input validation uses a simple heuristic to detect multiple statements
15
- * (Note: may reject valid queries containing semicolons in string literals)
16
- *
17
- * Usage examples:
18
- * -- Basic generic plan
19
- * select postgres_ai.explain_generic('select * from users where id = $1');
20
- *
21
- * -- JSON format
22
- * select postgres_ai.explain_generic('select * from users where id = $1', 'json');
23
- *
24
- * -- Test a hypothetical index
25
- * select postgres_ai.explain_generic(
26
- * 'select * from users where email = $1',
27
- * 'text',
28
- * 'create index on users (email)'
29
- * );
30
- */
31
- create or replace function postgres_ai.explain_generic(
32
- in query text,
33
- in format text default 'text',
34
- in hypopg_index text default null,
35
- out result text
36
- )
37
- language plpgsql
38
- security definer
39
- set search_path = pg_catalog, public
40
- as $$
41
- declare
42
- v_line record;
43
- v_lines text[] := '{}';
44
- v_explain_query text;
45
- v_hypo_result record;
46
- v_version int;
47
- v_hypopg_available boolean;
48
- v_clean_query text;
49
- begin
50
- -- Check PostgreSQL version (generic_plan requires 16+)
51
- select current_setting('server_version_num')::int into v_version;
52
-
53
- if v_version < 160000 then
54
- raise exception 'generic_plan requires PostgreSQL 16+, current version: %',
55
- current_setting('server_version');
56
- end if;
57
-
58
- -- Input validation: reject empty queries
59
- if query is null or trim(query) = '' then
60
- raise exception 'query cannot be empty';
61
- end if;
62
-
63
- -- Input validation: detect multiple statements (defense-in-depth)
64
- -- Note: This is a simple heuristic - EXPLAIN itself only accepts single statements
65
- -- Limitation: Queries with semicolons inside string literals will be rejected
66
- v_clean_query := trim(query);
67
- if v_clean_query like '%;%' then
68
- -- Strip trailing semicolon if present (common user convenience)
69
- v_clean_query := regexp_replace(v_clean_query, ';\s*$', '');
70
- -- If there's still a semicolon, reject (likely multiple statements or semicolon in string)
71
- if v_clean_query like '%;%' then
72
- raise exception 'query contains semicolon (multiple statements not allowed; note: semicolons in string literals are also not supported)';
73
- end if;
74
- end if;
75
-
76
- -- Check if HypoPG extension is available
77
- if hypopg_index is not null then
78
- select exists(
79
- select 1 from pg_extension where extname = 'hypopg'
80
- ) into v_hypopg_available;
81
-
82
- if not v_hypopg_available then
83
- raise exception 'HypoPG extension is required for hypothetical index testing but is not installed';
84
- end if;
85
-
86
- -- Create hypothetical index
87
- select * into v_hypo_result from hypopg_create_index(hypopg_index);
88
- raise notice 'Created hypothetical index: % (oid: %)',
89
- v_hypo_result.indexname, v_hypo_result.indexrelid;
90
- end if;
91
-
92
- -- Build and execute EXPLAIN query
93
- -- Note: EXPLAIN is read-only (plans but doesn't execute), making this safe
94
- begin
95
- if lower(format) = 'json' then
96
- execute 'explain (verbose, settings, generic_plan, format json) ' || v_clean_query
97
- into result;
98
- else
99
- for v_line in execute 'explain (verbose, settings, generic_plan) ' || v_clean_query loop
100
- v_lines := array_append(v_lines, v_line."QUERY PLAN");
101
- end loop;
102
- result := array_to_string(v_lines, e'\n');
103
- end if;
104
- exception when others then
105
- -- Clean up hypothetical index before re-raising
106
- if hypopg_index is not null then
107
- perform hypopg_reset();
108
- end if;
109
- raise;
110
- end;
111
-
112
- -- Clean up hypothetical index
113
- if hypopg_index is not null then
114
- perform hypopg_reset();
115
- end if;
116
- end;
117
- $$;
118
-
119
- comment on function postgres_ai.explain_generic(text, text, text) is
120
- 'Returns generic EXPLAIN plan with optional HypoPG index testing (requires PG16+)';
121
-
122
- -- Grant execute to the monitoring user
123
- grant execute on function postgres_ai.explain_generic(text, text, text) to {{ROLE_IDENT}};
124
-
125
5
  /*
126
6
  * table_describe
127
7
  *
@@ -435,5 +315,3 @@ comment on function postgres_ai.table_describe(text) is
435
315
  'Returns comprehensive table information in compact text format for LLM analysis';
436
316
 
437
317
  grant execute on function postgres_ai.table_describe(text) to {{ROLE_IDENT}};
438
-
439
-
package/test/auth.test.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { describe, test, expect } from "bun:test";
2
- import { resolve } from "path";
2
+ import { mkdtempSync, readFileSync, rmSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join, resolve } from "path";
3
5
 
4
6
  import * as util from "../lib/util";
5
7
  import * as pkce from "../lib/pkce";
@@ -212,6 +214,33 @@ describe("Auth callback server", () => {
212
214
  });
213
215
 
214
216
  describe("CLI auth commands", () => {
217
+ test("cli: login --help shows all options", () => {
218
+ const r = runCli(["login", "--help"]);
219
+ expect(r.status).toBe(0);
220
+ expect(r.stdout).toMatch(/--set-key/);
221
+ expect(r.stdout).toMatch(/--debug/);
222
+ });
223
+
224
+ test("cli: login --set-key aliases auth login", () => {
225
+ const home = mkdtempSync(join(tmpdir(), "postgresai-login-"));
226
+
227
+ try {
228
+ const r = runCli(["login", "--set-key", "test-token"], {
229
+ HOME: home,
230
+ XDG_CONFIG_HOME: join(home, ".config"),
231
+ });
232
+ expect(r.status).toBe(0);
233
+ expect(r.stdout).toMatch(/API key saved/);
234
+
235
+ const saved = JSON.parse(
236
+ readFileSync(join(home, ".config", "postgresai", "config.json"), "utf8")
237
+ );
238
+ expect(saved.apiKey).toBe("test-token");
239
+ } finally {
240
+ rmSync(home, { recursive: true, force: true });
241
+ }
242
+ });
243
+
215
244
  test("cli: auth login --help shows all options", () => {
216
245
  const r = runCli(["auth", "login", "--help"]);
217
246
  expect(r.status).toBe(0);
@@ -14,6 +14,7 @@ import { readFileSync } from "fs";
14
14
  import Ajv2020 from "ajv/dist/2020";
15
15
 
16
16
  import * as checkup from "../lib/checkup";
17
+ import { checkCurrentUserPermissions } from "../lib/init";
17
18
 
18
19
  const ajv = new Ajv2020({ allErrors: true, strict: false });
19
20
  const schemasDir = resolve(import.meta.dir, "../../reporter/schemas");
@@ -191,6 +192,36 @@ describe.skipIf(!!skipReason)("checkup integration: express mode schema compatib
191
192
  // Test all checks supported by express mode
192
193
  const expressChecks = Object.keys(checkup.CHECK_INFO);
193
194
 
195
+ test("vanilla database preflight is non-fatal and full CLI output marks F004/F005 degraded", async () => {
196
+ const permissions = await checkCurrentUserPermissions(client);
197
+ expect(permissions.ok).toBe(true);
198
+ expect(permissions.missingOptional.some(
199
+ (row) => row.permission_name === "postgres_ai schema exists"
200
+ )).toBe(true);
201
+
202
+ const connString = `postgresql://postgres@localhost:${pg.port}/postgres?host=${encodeURIComponent(pg.socketDir)}`;
203
+ const cliPath = path.resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
204
+ const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
205
+ const result = Bun.spawnSync(
206
+ [bunBin, cliPath, "checkup", connString, "--no-upload", "--json"],
207
+ { env: { ...process.env, XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config" } }
208
+ );
209
+
210
+ const stderr = new TextDecoder().decode(result.stderr);
211
+ if (result.exitCode !== 0) {
212
+ throw new Error(`CLI exited ${result.exitCode}: ${stderr}`);
213
+ }
214
+ const reports = JSON.parse(new TextDecoder().decode(result.stdout));
215
+ expect(Object.keys(reports)).toHaveLength(17);
216
+ expect(stderr).toContain("optional: postgres_ai schema not found");
217
+ for (const checkId of ["F004", "F005"]) {
218
+ const dbEntry = reports[checkId].results["node-01"].data.postgres;
219
+ expect(dbEntry.status.ok).toBe(false);
220
+ expect(dbEntry.status.reason).toBe("missing_schema");
221
+ expect(dbEntry.status.error).toMatch(/schema "postgres_ai" does not exist/i);
222
+ }
223
+ }, { timeout: 60000 });
224
+
194
225
  for (const checkId of expressChecks) {
195
226
  test(`${checkId} report validates against shared schema`, async () => {
196
227
  const generator = checkup.REPORT_GENERATORS[checkId];
@@ -379,25 +410,4 @@ describe.skipIf(!!skipReason)("checkup integration: express mode schema compatib
379
410
  }
380
411
  });
381
412
 
382
- test("CLI --markdown flag works without API key", async () => {
383
- // Test that --markdown works even without an API key
384
- const connString = `postgresql://postgres@${pg.socketDir}:${pg.port}/postgres`;
385
- const cliPath = path.resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
386
- const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
387
-
388
- const result = Bun.spawnSync(
389
- [bunBin, cliPath, "checkup", connString, "--check-id", "H002", "--markdown", "--no-upload"],
390
- {
391
- env: {
392
- ...process.env,
393
- XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config",
394
- },
395
- }
396
- );
397
-
398
- const stderr = new TextDecoder().decode(result.stderr);
399
-
400
- // Should not complain about missing API key
401
- expect(stderr).not.toMatch(/API key is required/i);
402
- });
403
413
  });
@@ -1539,12 +1539,13 @@ describe("CLI tests", () => {
1539
1539
  const r = runCli(["checkup", "--help"]);
1540
1540
  expect(r.status).toBe(0);
1541
1541
  expect(r.stdout).toMatch(/--markdown/);
1542
- expect(r.stdout).toMatch(/output markdown to stdout/i);
1542
+ expect(r.stdout).toMatch(/PostgresAI API/i);
1543
+ expect(r.stdout).toMatch(/transmits the full\s+report JSON/i);
1543
1544
  });
1544
1545
 
1545
1546
  test("checkup --markdown is recognized as valid option", () => {
1546
1547
  // Should not produce "unknown option" error for --markdown
1547
- const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown", "--no-upload"]);
1548
+ const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown"]);
1548
1549
  // Connection will fail, but option parsing should succeed
1549
1550
  expect(r.stderr).not.toMatch(/unknown option/i);
1550
1551
  expect(r.stderr).not.toMatch(/did you mean/i);
@@ -1554,11 +1555,13 @@ describe("CLI tests", () => {
1554
1555
  // Use empty config dir to ensure no API key is configured
1555
1556
  const env = { XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config" };
1556
1557
  // --markdown should work even without API key
1557
- const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown", "--no-upload"], env);
1558
+ const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown"], env);
1558
1559
  // Connection will fail, but --markdown flag should be recognized
1559
1560
  expect(r.status).not.toBe(0);
1560
1561
  expect(r.stderr).not.toMatch(/unknown option/i);
1561
1562
  expect(r.stderr).not.toMatch(/API key is required/i);
1563
+ expect(r.stderr).toMatch(/full report JSON will still be sent/i);
1564
+ expect(r.stderr).toMatch(/markdown conversion/i);
1562
1565
  });
1563
1566
 
1564
1567
  test("checkup with --no-upload and no output flags shows summary", () => {
@@ -1663,6 +1666,21 @@ describe("checkup auth pre-flight (CLI)", () => {
1663
1666
  // failure mentions this address.
1664
1667
  const DEAD_DB = "postgresql://test:test@127.0.0.1:2/test";
1665
1668
 
1669
+ test("--no-upload rejects --markdown before database or API work", () => {
1670
+ const env = {
1671
+ XDG_CONFIG_HOME: `/tmp/postgresai-test-no-upload-markdown-${process.pid}`,
1672
+ PGAI_API_KEY: "configured-token",
1673
+ PGAI_API_BASE_URL: "http://127.0.0.1:1",
1674
+ };
1675
+ const r = runCli(["checkup", DEAD_DB, "--no-upload", "--markdown"], env);
1676
+
1677
+ expect(r.status).not.toBe(0);
1678
+ expect(r.stderr).toMatch(/--no-upload and --markdown are mutually exclusive/i);
1679
+ expect(r.stderr).toMatch(/markdown conversion is performed by the PostgresAI API/i);
1680
+ expect(r.stderr).toMatch(/use --json or --output/i);
1681
+ expect(r.stderr).not.toMatch(/127\.0\.0\.1:2|ECONNREFUSED/);
1682
+ });
1683
+
1666
1684
  test("no API key: prominent notice, run continues locally", () => {
1667
1685
  const env = {
1668
1686
  XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-nokey-${process.pid}`,
@@ -2173,6 +2191,31 @@ describe("checkup-summary", () => {
2173
2191
  );
2174
2192
  });
2175
2193
 
2194
+ for (const checkId of ["F004", "F005"]) {
2195
+ test(`generateCheckSummary for degraded ${checkId} is never ok`, () => {
2196
+ const report = {
2197
+ results: {
2198
+ node1: {
2199
+ data: {
2200
+ db1: {
2201
+ status: {
2202
+ ok: false,
2203
+ reason: "missing_schema",
2204
+ error: 'schema "postgres_ai" does not exist',
2205
+ },
2206
+ total_count: 0,
2207
+ },
2208
+ },
2209
+ },
2210
+ },
2211
+ };
2212
+
2213
+ const result = summary.generateCheckSummary(checkId, report);
2214
+ expect(result.status).toBe("warning");
2215
+ expect(result.message).toMatch(/degraded.*missing schema/i);
2216
+ });
2217
+ }
2218
+
2176
2219
  test("generateCheckSummary for H001 with no issues", () => {
2177
2220
  const report = {
2178
2221
  results: {
@@ -3371,6 +3414,7 @@ describe("Postgres version compatibility (PG13-PG18)", () => {
3371
3414
  autovacuum_vacuum_scale_factor: expectedAutovacuumSetting,
3372
3415
  });
3373
3416
  expect(reports.F004.results["test-node"].data.testdb).toEqual({
3417
+ status: { ok: true, reason: null, error: null },
3374
3418
  bloated_tables: [],
3375
3419
  total_count: 0,
3376
3420
  total_bloat_size_bytes: 0,
@@ -3379,6 +3423,7 @@ describe("Postgres version compatibility (PG13-PG18)", () => {
3379
3423
  database_size_pretty: "1.00 GiB",
3380
3424
  });
3381
3425
  expect(reports.F005.results["test-node"].data.testdb).toEqual({
3426
+ status: { ok: true, reason: null, error: null },
3382
3427
  bloated_indexes: [],
3383
3428
  total_count: 0,
3384
3429
  total_bloat_size_bytes: 0,