postgresai 0.16.0-dev.10 → 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/CHANGELOG.md +11 -0
- package/README.md +1 -84
- package/bin/postgres-ai.ts +140 -540
- package/bun.lock +4 -4
- package/dist/bin/postgres-ai.js +790 -2282
- package/lib/checkup-summary.ts +25 -0
- package/lib/checkup.ts +88 -2
- package/lib/init.ts +29 -0
- package/lib/joe.ts +333 -391
- package/lib/mcp-server.ts +0 -625
- package/lib/util.ts +145 -29
- package/package.json +1 -1
- package/test/auth.test.ts +30 -1
- package/test/checkup.integration.test.ts +31 -21
- package/test/checkup.test.ts +48 -3
- package/test/init.test.ts +35 -0
- package/test/joe.cli.test.ts +355 -196
- package/test/joe.test.ts +565 -568
- package/test/schema-validation.test.ts +40 -0
- package/test/test-utils.ts +5 -0
- package/test/util.test.ts +116 -0
- package/lib/dblab.ts +0 -449
- package/test/dblab.cli.test.ts +0 -373
- package/test/dblab.test.ts +0 -488
- package/test/e2e/pgai-e2e-smoke.sh +0 -192
package/lib/util.ts
CHANGED
|
@@ -58,9 +58,14 @@ const STANDARD_REASON_PHRASES: Record<number, string> = {
|
|
|
58
58
|
* user-facing message in the HTTP **reason phrase** (`response.statusText`),
|
|
59
59
|
* NOT the JSON body — the body carries only `hint`/`details` (no `message`).
|
|
60
60
|
* So callers pass `statusText` and it is preferred over the built-in generic
|
|
61
|
-
* label.
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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.
|
|
64
69
|
*/
|
|
65
70
|
export function formatHttpError(
|
|
66
71
|
operation: string,
|
|
@@ -73,6 +78,8 @@ export function formatHttpError(
|
|
|
73
78
|
|
|
74
79
|
let bodyMessage: string | undefined;
|
|
75
80
|
let bodyDetails: string | undefined;
|
|
81
|
+
let bodyCode: string | undefined;
|
|
82
|
+
let bodyHint: string | undefined;
|
|
76
83
|
|
|
77
84
|
if (responseBody && !isHtmlContent(responseBody)) {
|
|
78
85
|
// If it's HTML (like Cloudflare error pages), we fall through with no
|
|
@@ -81,18 +88,37 @@ export function formatHttpError(
|
|
|
81
88
|
const errObj = JSON.parse(responseBody);
|
|
82
89
|
const message = errObj.message ?? errObj.error;
|
|
83
90
|
if (typeof message === "string" && message.trim().length > 0) {
|
|
84
|
-
bodyMessage = message.trim();
|
|
91
|
+
bodyMessage = redactTextSecrets(message.trim());
|
|
85
92
|
}
|
|
86
93
|
// PostgREST spells it `details` (plural); accept `detail` too.
|
|
87
94
|
const details = errObj.details ?? errObj.detail;
|
|
88
95
|
if (typeof details === "string" && details.trim().length > 0) {
|
|
89
|
-
bodyDetails = details.trim();
|
|
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));
|
|
90
114
|
}
|
|
91
115
|
} catch {
|
|
92
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.
|
|
93
119
|
const trimmed = responseBody.trim();
|
|
94
120
|
if (trimmed.length > 0 && trimmed.length < 500) {
|
|
95
|
-
bodyDetails = trimmed;
|
|
121
|
+
bodyDetails = redactTextSecrets(trimmed);
|
|
96
122
|
}
|
|
97
123
|
}
|
|
98
124
|
}
|
|
@@ -106,12 +132,17 @@ export function formatHttpError(
|
|
|
106
132
|
trimmedReason !== generic
|
|
107
133
|
? trimmedReason
|
|
108
134
|
: undefined;
|
|
135
|
+
const safeReasonPhrase = reasonPhrase ? redactTextSecrets(reasonPhrase) : undefined;
|
|
109
136
|
|
|
110
|
-
const headline = bodyMessage ??
|
|
111
|
-
|
|
137
|
+
const headline = bodyMessage ?? safeReasonPhrase ?? generic;
|
|
138
|
+
const codeSuffix = bodyCode && !headline.includes(bodyCode) ? ` (${bodyCode})` : "";
|
|
139
|
+
let errMsg = `${operation}: HTTP ${status} - ${headline}${codeSuffix}`;
|
|
112
140
|
if (bodyDetails && bodyDetails !== headline) {
|
|
113
141
|
errMsg += `\n${bodyDetails}`;
|
|
114
142
|
}
|
|
143
|
+
if (bodyHint) {
|
|
144
|
+
errMsg += `\nHint: ${bodyHint}`;
|
|
145
|
+
}
|
|
115
146
|
|
|
116
147
|
return errMsg + remediation;
|
|
117
148
|
}
|
|
@@ -133,6 +164,49 @@ export function describeFetchError(operation: string, url: string, err: unknown)
|
|
|
133
164
|
return `${operation}: could not reach ${url} (${detail})`;
|
|
134
165
|
}
|
|
135
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
|
+
|
|
136
210
|
export function maskSecret(secret: string): string {
|
|
137
211
|
if (!secret) return "";
|
|
138
212
|
if (secret.length <= 8) return "****";
|
|
@@ -140,45 +214,87 @@ export function maskSecret(secret: string): string {
|
|
|
140
214
|
return `${secret.slice(0, Math.min(12, secret.length - 8))}${"*".repeat(Math.max(4, secret.length - 16))}${secret.slice(-4)}`;
|
|
141
215
|
}
|
|
142
216
|
|
|
143
|
-
/**
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
|
|
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
|
+
}
|
|
147
255
|
|
|
148
256
|
/**
|
|
149
257
|
* Redact known credential fields from a serialized JSON payload before it is
|
|
150
258
|
* written to a debug log — the body-side counterpart of the `maskSecret`
|
|
151
259
|
* masking the `access-token` header already gets. Debug logging is reachable
|
|
152
260
|
* by MCP callers (`debug: true` is a caller-controlled tool argument), and
|
|
153
|
-
* DBLab bodies carry live credentials:
|
|
261
|
+
* DBLab bodies carry live credentials: the clone DB password rides in the clone
|
|
154
262
|
* create request (`data.db.password`), and clone create/status replies return
|
|
155
263
|
* the clone's `db.password` / `db.connStr`.
|
|
156
264
|
*
|
|
157
265
|
* A fixed placeholder is used instead of `maskSecret` because short passwords
|
|
158
266
|
* would leak most of their characters through partial masking. Non-JSON input
|
|
159
|
-
*
|
|
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.
|
|
160
271
|
*/
|
|
161
272
|
export function redactSecretsForLog(text: string): string {
|
|
162
273
|
let parsed: unknown;
|
|
163
274
|
try {
|
|
164
275
|
parsed = JSON.parse(text);
|
|
165
276
|
} catch {
|
|
166
|
-
return text;
|
|
277
|
+
return redactTextSecrets(text);
|
|
167
278
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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]");
|
|
182
298
|
}
|
|
183
299
|
|
|
184
300
|
|
package/package.json
CHANGED
package/test/auth.test.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import {
|
|
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
|
});
|
package/test/checkup.test.ts
CHANGED
|
@@ -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(/
|
|
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"
|
|
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"
|
|
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,
|
package/test/init.test.ts
CHANGED
|
@@ -1489,6 +1489,24 @@ describe("checkCurrentUserPermissions", () => {
|
|
|
1489
1489
|
).rejects.toThrow("permission denied for relation pg_roles");
|
|
1490
1490
|
});
|
|
1491
1491
|
|
|
1492
|
+
test("guards optional postgres_ai privilege probes when the schema is absent", async () => {
|
|
1493
|
+
let capturedSql = "";
|
|
1494
|
+
const client = {
|
|
1495
|
+
query: async (sql: string) => {
|
|
1496
|
+
capturedSql = sql;
|
|
1497
|
+
return { rows: [] };
|
|
1498
|
+
},
|
|
1499
|
+
};
|
|
1500
|
+
|
|
1501
|
+
await init.checkCurrentUserPermissions(client as any);
|
|
1502
|
+
|
|
1503
|
+
expect(capturedSql).toContain("to_regnamespace('postgres_ai') is null");
|
|
1504
|
+
expect(capturedSql).toContain("'postgres_ai schema exists' as permission_name");
|
|
1505
|
+
expect(capturedSql).toMatch(
|
|
1506
|
+
/when to_regnamespace\('postgres_ai'\) is null then null\s+when not has_schema_privilege/
|
|
1507
|
+
);
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1492
1510
|
test("returns all rows for inspection", async () => {
|
|
1493
1511
|
const rows: init.PermissionCheckRow[] = [
|
|
1494
1512
|
{ permission_name: "connect on database postgres", status: "required", granted: true, fix_command: null },
|
|
@@ -1545,6 +1563,23 @@ describe("formatPermissionCheckMessages", () => {
|
|
|
1545
1563
|
expect(messages.errors).toHaveLength(0);
|
|
1546
1564
|
});
|
|
1547
1565
|
|
|
1566
|
+
test("explains that a missing postgres_ai schema only degrades F004/F005", () => {
|
|
1567
|
+
const result: init.PreflightPermissionResult = {
|
|
1568
|
+
ok: true,
|
|
1569
|
+
rows: [],
|
|
1570
|
+
missingRequired: [],
|
|
1571
|
+
missingOptional: [
|
|
1572
|
+
{ permission_name: "postgres_ai schema exists", status: "optional", granted: false, fix_command: null },
|
|
1573
|
+
],
|
|
1574
|
+
};
|
|
1575
|
+
|
|
1576
|
+
const messages = init.formatPermissionCheckMessages(result);
|
|
1577
|
+
expect(messages.failed).toBe(false);
|
|
1578
|
+
expect(messages.warnings).toEqual([
|
|
1579
|
+
"Warning: optional: postgres_ai schema not found — F004/F005 (bloat estimates) will be skipped; run prepare-db or create the view manually to enable them.",
|
|
1580
|
+
]);
|
|
1581
|
+
});
|
|
1582
|
+
|
|
1548
1583
|
test("returns errors with fix commands for missing required permissions", () => {
|
|
1549
1584
|
const result: init.PreflightPermissionResult = {
|
|
1550
1585
|
ok: false,
|