postgresai 0.16.0-dev.6 → 0.16.0-dev.8
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/bin/postgres-ai.ts +16 -0
- package/dist/bin/postgres-ai.js +81 -39
- package/lib/dblab.ts +4 -2
- package/lib/joe.ts +4 -3
- package/lib/util.ts +67 -18
- package/package.json +1 -1
- package/test/joe.cli.test.ts +13 -1
- package/test/util.test.ts +35 -0
package/bin/postgres-ai.ts
CHANGED
|
@@ -5647,6 +5647,22 @@ withJoeOptions(
|
|
|
5647
5647
|
await runJoeCli("describe", null, args, opts);
|
|
5648
5648
|
});
|
|
5649
5649
|
|
|
5650
|
+
// history search is roadmapped for M1b; the search backend is not deployed yet.
|
|
5651
|
+
// Ship a recognized subcommand so the documented `pgai joe history …` example
|
|
5652
|
+
// degrades cleanly (an informative "not yet available" line + exit 1) instead of
|
|
5653
|
+
// Commander's generic "unknown command 'history'". Wire it to the rpc when M1b lands.
|
|
5654
|
+
withJoeOptions(
|
|
5655
|
+
joe
|
|
5656
|
+
.command("history <terms>")
|
|
5657
|
+
.description("search prior Joe analyses, metadata-only (M1b — not yet available)")
|
|
5658
|
+
).action(async (_terms: string, _opts: JoeCliOpts) => {
|
|
5659
|
+
console.error(
|
|
5660
|
+
"Joe history search is not available yet — it is planned for M1b. " +
|
|
5661
|
+
"No history-search backend is deployed; this command is a placeholder until then.",
|
|
5662
|
+
);
|
|
5663
|
+
process.exitCode = 1;
|
|
5664
|
+
});
|
|
5665
|
+
|
|
5650
5666
|
// Org-level discovery — a general postgresai command, NOT a Joe endpoint (SPEC §6).
|
|
5651
5667
|
program
|
|
5652
5668
|
.command("projects")
|
package/dist/bin/postgres-ai.js
CHANGED
|
@@ -13331,33 +13331,38 @@ function isHtmlContent2(text) {
|
|
|
13331
13331
|
const trimmed = text.trim();
|
|
13332
13332
|
return trimmed.startsWith("<!DOCTYPE") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML");
|
|
13333
13333
|
}
|
|
13334
|
-
function formatHttpError2(operation, status, responseBody) {
|
|
13335
|
-
const
|
|
13336
|
-
let errMsg = `${operation}: HTTP ${status} - ${statusMessage}`;
|
|
13334
|
+
function formatHttpError2(operation, status, responseBody, statusText) {
|
|
13335
|
+
const generic = HTTP_STATUS_MESSAGES2[status] || "Request failed";
|
|
13337
13336
|
const remediation = status === 401 ? `
|
|
13338
13337
|
${AUTH_REMEDIATION_HINT2}` : "";
|
|
13339
|
-
|
|
13340
|
-
|
|
13341
|
-
|
|
13342
|
-
}
|
|
13338
|
+
let bodyMessage;
|
|
13339
|
+
let bodyDetails;
|
|
13340
|
+
if (responseBody && !isHtmlContent2(responseBody)) {
|
|
13343
13341
|
try {
|
|
13344
13342
|
const errObj = JSON.parse(responseBody);
|
|
13345
|
-
const message = errObj.message
|
|
13346
|
-
if (
|
|
13347
|
-
|
|
13348
|
-
|
|
13349
|
-
|
|
13350
|
-
|
|
13351
|
-
|
|
13343
|
+
const message = errObj.message ?? errObj.error;
|
|
13344
|
+
if (typeof message === "string" && message.trim().length > 0) {
|
|
13345
|
+
bodyMessage = message.trim();
|
|
13346
|
+
}
|
|
13347
|
+
const details = errObj.details ?? errObj.detail;
|
|
13348
|
+
if (typeof details === "string" && details.trim().length > 0) {
|
|
13349
|
+
bodyDetails = details.trim();
|
|
13352
13350
|
}
|
|
13353
13351
|
} catch {
|
|
13354
13352
|
const trimmed = responseBody.trim();
|
|
13355
13353
|
if (trimmed.length > 0 && trimmed.length < 500) {
|
|
13356
|
-
|
|
13357
|
-
${trimmed}`;
|
|
13354
|
+
bodyDetails = trimmed;
|
|
13358
13355
|
}
|
|
13359
13356
|
}
|
|
13360
13357
|
}
|
|
13358
|
+
const trimmedReason = statusText?.trim();
|
|
13359
|
+
const reasonPhrase = trimmedReason && trimmedReason !== STANDARD_REASON_PHRASES2[status] && trimmedReason !== generic ? trimmedReason : undefined;
|
|
13360
|
+
const headline = bodyMessage ?? reasonPhrase ?? generic;
|
|
13361
|
+
let errMsg = `${operation}: HTTP ${status} - ${headline}`;
|
|
13362
|
+
if (bodyDetails && bodyDetails !== headline) {
|
|
13363
|
+
errMsg += `
|
|
13364
|
+
${bodyDetails}`;
|
|
13365
|
+
}
|
|
13361
13366
|
return errMsg + remediation;
|
|
13362
13367
|
}
|
|
13363
13368
|
function maskSecret2(secret) {
|
|
@@ -13391,7 +13396,7 @@ function resolveBaseUrls2(opts, cfg, defaults2 = {}) {
|
|
|
13391
13396
|
storageBaseUrl: normalizeBaseUrl2(storageCandidate)
|
|
13392
13397
|
};
|
|
13393
13398
|
}
|
|
13394
|
-
var HTTP_STATUS_MESSAGES2, AUTH_REMEDIATION_HINT2 = "Run 'postgresai auth' to (re)authenticate, or set/update PGAI_API_KEY.";
|
|
13399
|
+
var HTTP_STATUS_MESSAGES2, AUTH_REMEDIATION_HINT2 = "Run 'postgresai auth' to (re)authenticate, or set/update PGAI_API_KEY.", STANDARD_REASON_PHRASES2;
|
|
13395
13400
|
var init_util = __esm(() => {
|
|
13396
13401
|
HTTP_STATUS_MESSAGES2 = {
|
|
13397
13402
|
400: "Bad Request",
|
|
@@ -13405,6 +13410,20 @@ var init_util = __esm(() => {
|
|
|
13405
13410
|
503: "Service Unavailable - server temporarily unavailable",
|
|
13406
13411
|
504: "Gateway Timeout - server temporarily unavailable"
|
|
13407
13412
|
};
|
|
13413
|
+
STANDARD_REASON_PHRASES2 = {
|
|
13414
|
+
400: "Bad Request",
|
|
13415
|
+
401: "Unauthorized",
|
|
13416
|
+
403: "Forbidden",
|
|
13417
|
+
404: "Not Found",
|
|
13418
|
+
408: "Request Timeout",
|
|
13419
|
+
409: "Conflict",
|
|
13420
|
+
413: "Payload Too Large",
|
|
13421
|
+
429: "Too Many Requests",
|
|
13422
|
+
500: "Internal Server Error",
|
|
13423
|
+
502: "Bad Gateway",
|
|
13424
|
+
503: "Service Unavailable",
|
|
13425
|
+
504: "Gateway Timeout"
|
|
13426
|
+
};
|
|
13408
13427
|
});
|
|
13409
13428
|
|
|
13410
13429
|
// node_modules/commander/esm.mjs
|
|
@@ -13425,7 +13444,7 @@ var {
|
|
|
13425
13444
|
// package.json
|
|
13426
13445
|
var package_default = {
|
|
13427
13446
|
name: "postgresai",
|
|
13428
|
-
version: "0.16.0-dev.
|
|
13447
|
+
version: "0.16.0-dev.8",
|
|
13429
13448
|
description: "postgres_ai CLI",
|
|
13430
13449
|
license: "Apache-2.0",
|
|
13431
13450
|
private: false,
|
|
@@ -16256,7 +16275,7 @@ var Result = import_lib.default.Result;
|
|
|
16256
16275
|
var TypeOverrides = import_lib.default.TypeOverrides;
|
|
16257
16276
|
var defaults = import_lib.default.defaults;
|
|
16258
16277
|
// package.json
|
|
16259
|
-
var version = "0.16.0-dev.
|
|
16278
|
+
var version = "0.16.0-dev.8";
|
|
16260
16279
|
var package_default2 = {
|
|
16261
16280
|
name: "postgresai",
|
|
16262
16281
|
version,
|
|
@@ -16398,33 +16417,52 @@ function isHtmlContent(text) {
|
|
|
16398
16417
|
return trimmed.startsWith("<!DOCTYPE") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML");
|
|
16399
16418
|
}
|
|
16400
16419
|
var AUTH_REMEDIATION_HINT = "Run 'postgresai auth' to (re)authenticate, or set/update PGAI_API_KEY.";
|
|
16401
|
-
|
|
16402
|
-
|
|
16403
|
-
|
|
16420
|
+
var STANDARD_REASON_PHRASES = {
|
|
16421
|
+
400: "Bad Request",
|
|
16422
|
+
401: "Unauthorized",
|
|
16423
|
+
403: "Forbidden",
|
|
16424
|
+
404: "Not Found",
|
|
16425
|
+
408: "Request Timeout",
|
|
16426
|
+
409: "Conflict",
|
|
16427
|
+
413: "Payload Too Large",
|
|
16428
|
+
429: "Too Many Requests",
|
|
16429
|
+
500: "Internal Server Error",
|
|
16430
|
+
502: "Bad Gateway",
|
|
16431
|
+
503: "Service Unavailable",
|
|
16432
|
+
504: "Gateway Timeout"
|
|
16433
|
+
};
|
|
16434
|
+
function formatHttpError(operation, status, responseBody, statusText) {
|
|
16435
|
+
const generic = HTTP_STATUS_MESSAGES[status] || "Request failed";
|
|
16404
16436
|
const remediation = status === 401 ? `
|
|
16405
16437
|
${AUTH_REMEDIATION_HINT}` : "";
|
|
16406
|
-
|
|
16407
|
-
|
|
16408
|
-
|
|
16409
|
-
}
|
|
16438
|
+
let bodyMessage;
|
|
16439
|
+
let bodyDetails;
|
|
16440
|
+
if (responseBody && !isHtmlContent(responseBody)) {
|
|
16410
16441
|
try {
|
|
16411
16442
|
const errObj = JSON.parse(responseBody);
|
|
16412
|
-
const message = errObj.message
|
|
16413
|
-
if (
|
|
16414
|
-
|
|
16415
|
-
|
|
16416
|
-
|
|
16417
|
-
|
|
16418
|
-
|
|
16443
|
+
const message = errObj.message ?? errObj.error;
|
|
16444
|
+
if (typeof message === "string" && message.trim().length > 0) {
|
|
16445
|
+
bodyMessage = message.trim();
|
|
16446
|
+
}
|
|
16447
|
+
const details = errObj.details ?? errObj.detail;
|
|
16448
|
+
if (typeof details === "string" && details.trim().length > 0) {
|
|
16449
|
+
bodyDetails = details.trim();
|
|
16419
16450
|
}
|
|
16420
16451
|
} catch {
|
|
16421
16452
|
const trimmed = responseBody.trim();
|
|
16422
16453
|
if (trimmed.length > 0 && trimmed.length < 500) {
|
|
16423
|
-
|
|
16424
|
-
${trimmed}`;
|
|
16454
|
+
bodyDetails = trimmed;
|
|
16425
16455
|
}
|
|
16426
16456
|
}
|
|
16427
16457
|
}
|
|
16458
|
+
const trimmedReason = statusText?.trim();
|
|
16459
|
+
const reasonPhrase = trimmedReason && trimmedReason !== STANDARD_REASON_PHRASES[status] && trimmedReason !== generic ? trimmedReason : undefined;
|
|
16460
|
+
const headline = bodyMessage ?? reasonPhrase ?? generic;
|
|
16461
|
+
let errMsg = `${operation}: HTTP ${status} - ${headline}`;
|
|
16462
|
+
if (bodyDetails && bodyDetails !== headline) {
|
|
16463
|
+
errMsg += `
|
|
16464
|
+
${bodyDetails}`;
|
|
16465
|
+
}
|
|
16428
16466
|
return errMsg + remediation;
|
|
16429
16467
|
}
|
|
16430
16468
|
function maskSecret(secret) {
|
|
@@ -17330,7 +17368,7 @@ async function callRpc(params) {
|
|
|
17330
17368
|
console.error(`Debug: Response body: ${text}`);
|
|
17331
17369
|
}
|
|
17332
17370
|
if (!response.ok) {
|
|
17333
|
-
throw new Error(formatHttpError(operation, response.status, text));
|
|
17371
|
+
throw new Error(formatHttpError(operation, response.status, text, response.statusText));
|
|
17334
17372
|
}
|
|
17335
17373
|
try {
|
|
17336
17374
|
return JSON.parse(text);
|
|
@@ -17655,7 +17693,7 @@ async function callDblabApi(params) {
|
|
|
17655
17693
|
console.error(`Debug: Response body: ${text}`);
|
|
17656
17694
|
}
|
|
17657
17695
|
if (!response.ok) {
|
|
17658
|
-
throw new Error(formatHttpError(operation, response.status, text));
|
|
17696
|
+
throw new Error(formatHttpError(operation, response.status, text, response.statusText));
|
|
17659
17697
|
}
|
|
17660
17698
|
if (text.trim() === "") {
|
|
17661
17699
|
return null;
|
|
@@ -26982,7 +27020,7 @@ async function callRpc2(params) {
|
|
|
26982
27020
|
console.error(`Debug: Response body: ${text}`);
|
|
26983
27021
|
}
|
|
26984
27022
|
if (!response.ok) {
|
|
26985
|
-
throw new Error(formatHttpError(operation, response.status, text));
|
|
27023
|
+
throw new Error(formatHttpError(operation, response.status, text, response.statusText));
|
|
26986
27024
|
}
|
|
26987
27025
|
try {
|
|
26988
27026
|
return JSON.parse(text);
|
|
@@ -27393,7 +27431,7 @@ async function callDblabApi2(params) {
|
|
|
27393
27431
|
console.error(`Debug: Response body: ${text}`);
|
|
27394
27432
|
}
|
|
27395
27433
|
if (!response.ok) {
|
|
27396
|
-
throw new Error(formatHttpError(operation, response.status, text));
|
|
27434
|
+
throw new Error(formatHttpError(operation, response.status, text, response.statusText));
|
|
27397
27435
|
}
|
|
27398
27436
|
if (text.trim() === "") {
|
|
27399
27437
|
return null;
|
|
@@ -39818,6 +39856,10 @@ withJoeOptions(joe.command("describe <object>").description("\\d-family schema/r
|
|
|
39818
39856
|
args.variant = opts.variant;
|
|
39819
39857
|
await runJoeCli("describe", null, args, opts);
|
|
39820
39858
|
});
|
|
39859
|
+
withJoeOptions(joe.command("history <terms>").description("search prior Joe analyses, metadata-only (M1b \u2014 not yet available)")).action(async (_terms, _opts) => {
|
|
39860
|
+
console.error("Joe history search is not available yet \u2014 it is planned for M1b. " + "No history-search backend is deployed; this command is a placeholder until then.");
|
|
39861
|
+
process.exitCode = 1;
|
|
39862
|
+
});
|
|
39821
39863
|
program2.command("projects").description("list the org's projects (shows which have Joe ready) \u2014 org-level, not a Joe endpoint").option("--debug", "enable debug output").option("--json", "output raw JSON").action(async (opts) => {
|
|
39822
39864
|
try {
|
|
39823
39865
|
const rootOpts = program2.opts();
|
package/lib/dblab.ts
CHANGED
|
@@ -196,8 +196,10 @@ async function callDblabApi<T>(params: DblabApiCallParams): Promise<T> {
|
|
|
196
196
|
|
|
197
197
|
if (!response.ok) {
|
|
198
198
|
// PostgREST maps custom `PTxyz` sqlstates to HTTP status `xyz`, so a missing
|
|
199
|
-
// `joe:admin` scope on a destructive verb surfaces here as HTTP 403.
|
|
200
|
-
|
|
199
|
+
// `joe:admin` scope on a destructive verb surfaces here as HTTP 403. The
|
|
200
|
+
// RPC's user-facing message rides in the HTTP reason phrase (statusText),
|
|
201
|
+
// not the JSON body — pass it through.
|
|
202
|
+
throw new Error(formatHttpError(operation, response.status, text, response.statusText));
|
|
201
203
|
}
|
|
202
204
|
|
|
203
205
|
// Some DBLab actions (reset/destroy) reply with an empty body on success.
|
package/lib/joe.ts
CHANGED
|
@@ -177,9 +177,10 @@ async function callRpc<T>(params: RpcCallParams): Promise<T> {
|
|
|
177
177
|
|
|
178
178
|
if (!response.ok) {
|
|
179
179
|
// PostgREST maps a custom `PTxyz` sqlstate to HTTP status `xyz`, so PT403 →
|
|
180
|
-
// HTTP 403, PT404 → 404, PT429 → 429, etc.
|
|
181
|
-
//
|
|
182
|
-
|
|
180
|
+
// HTTP 403, PT404 → 404, PT429 → 429, etc. The RPC's user-facing message
|
|
181
|
+
// (e.g. the "AI features disabled for this org" text for AC36) rides in the
|
|
182
|
+
// HTTP reason phrase (statusText), not the JSON body — pass it through.
|
|
183
|
+
throw new Error(formatHttpError(operation, response.status, text, response.statusText));
|
|
183
184
|
}
|
|
184
185
|
|
|
185
186
|
try {
|
package/lib/util.ts
CHANGED
|
@@ -28,42 +28,91 @@ 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. Headline precedence: JSON body `message` → custom reason phrase →
|
|
62
|
+
* generic label; the JSON `details` (plural, PostgREST's spelling) is shown as
|
|
63
|
+
* a supplementary line.
|
|
35
64
|
*/
|
|
36
|
-
export function formatHttpError(
|
|
37
|
-
|
|
38
|
-
|
|
65
|
+
export function formatHttpError(
|
|
66
|
+
operation: string,
|
|
67
|
+
status: number,
|
|
68
|
+
responseBody?: string,
|
|
69
|
+
statusText?: string
|
|
70
|
+
): string {
|
|
71
|
+
const generic = HTTP_STATUS_MESSAGES[status] || "Request failed";
|
|
39
72
|
const remediation = status === 401 ? `\n${AUTH_REMEDIATION_HINT}` : "";
|
|
40
73
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (isHtmlContent(responseBody)) {
|
|
44
|
-
// Just use the status message, don't append HTML
|
|
45
|
-
return errMsg + remediation;
|
|
46
|
-
}
|
|
74
|
+
let bodyMessage: string | undefined;
|
|
75
|
+
let bodyDetails: string | undefined;
|
|
47
76
|
|
|
48
|
-
|
|
77
|
+
if (responseBody && !isHtmlContent(responseBody)) {
|
|
78
|
+
// If it's HTML (like Cloudflare error pages), we fall through with no
|
|
79
|
+
// parsed fields and never dump the raw HTML.
|
|
49
80
|
try {
|
|
50
81
|
const errObj = JSON.parse(responseBody);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
82
|
+
const message = errObj.message ?? errObj.error;
|
|
83
|
+
if (typeof message === "string" && message.trim().length > 0) {
|
|
84
|
+
bodyMessage = message.trim();
|
|
85
|
+
}
|
|
86
|
+
// PostgREST spells it `details` (plural); accept `detail` too.
|
|
87
|
+
const details = errObj.details ?? errObj.detail;
|
|
88
|
+
if (typeof details === "string" && details.trim().length > 0) {
|
|
89
|
+
bodyDetails = details.trim();
|
|
57
90
|
}
|
|
58
91
|
} catch {
|
|
59
|
-
// Plain text error -
|
|
92
|
+
// Plain text error - treat it as the details line if short and useful.
|
|
60
93
|
const trimmed = responseBody.trim();
|
|
61
94
|
if (trimmed.length > 0 && trimmed.length < 500) {
|
|
62
|
-
|
|
95
|
+
bodyDetails = trimmed;
|
|
63
96
|
}
|
|
64
97
|
}
|
|
65
98
|
}
|
|
66
99
|
|
|
100
|
+
// A custom reason phrase (PTxyz message) is meaningful only when it differs
|
|
101
|
+
// from the stock HTTP reason phrase for this status.
|
|
102
|
+
const trimmedReason = statusText?.trim();
|
|
103
|
+
const reasonPhrase =
|
|
104
|
+
trimmedReason &&
|
|
105
|
+
trimmedReason !== STANDARD_REASON_PHRASES[status] &&
|
|
106
|
+
trimmedReason !== generic
|
|
107
|
+
? trimmedReason
|
|
108
|
+
: undefined;
|
|
109
|
+
|
|
110
|
+
const headline = bodyMessage ?? reasonPhrase ?? generic;
|
|
111
|
+
let errMsg = `${operation}: HTTP ${status} - ${headline}`;
|
|
112
|
+
if (bodyDetails && bodyDetails !== headline) {
|
|
113
|
+
errMsg += `\n${bodyDetails}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
67
116
|
return errMsg + remediation;
|
|
68
117
|
}
|
|
69
118
|
|
package/package.json
CHANGED
package/test/joe.cli.test.ts
CHANGED
|
@@ -141,11 +141,23 @@ describe("CLI Joe command surface (grouped under `pgai joe …`)", () => {
|
|
|
141
141
|
const r = runCli(["joe", "--help"], isolatedEnv());
|
|
142
142
|
expect(r.status).toBe(0);
|
|
143
143
|
const out = `${r.stdout}\n${r.stderr}`;
|
|
144
|
-
for (const verb of ["plan", "explain", "exec", "hypo", "activity", "terminate", "reset", "describe", "result", "status"]) {
|
|
144
|
+
for (const verb of ["plan", "explain", "exec", "hypo", "activity", "terminate", "reset", "describe", "result", "status", "history"]) {
|
|
145
145
|
expect(out).toContain(verb);
|
|
146
146
|
}
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
+
test("pgai joe history (M1b) is recognized and degrades cleanly", () => {
|
|
150
|
+
// The brief's §2 documents `pgai joe history "users email" --project 12`, but the
|
|
151
|
+
// history-search backend is M1b/not built. The command must be recognized (NOT a
|
|
152
|
+
// Commander "unknown command") and exit 1 with an informative pending message.
|
|
153
|
+
const r = runCli(["joe", "history", "users email", "--project", "12"], isolatedEnv({ PGAI_API_KEY: "k" }));
|
|
154
|
+
expect(r.status).toBe(1);
|
|
155
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
156
|
+
expect(out).toContain("not available yet");
|
|
157
|
+
expect(out).toContain("M1b");
|
|
158
|
+
expect(out).not.toContain("unknown command");
|
|
159
|
+
});
|
|
160
|
+
|
|
149
161
|
test("pgai projects prints the brief's table", async () => {
|
|
150
162
|
const api = startFakeApi();
|
|
151
163
|
try {
|
package/test/util.test.ts
CHANGED
|
@@ -41,4 +41,39 @@ describe("formatHttpError", () => {
|
|
|
41
41
|
expect(msg.indexOf("Invalid token")).toBeGreaterThan(-1);
|
|
42
42
|
expect(msg.indexOf("Invalid token")).toBeLessThan(msg.indexOf("Run 'postgresai auth'"));
|
|
43
43
|
});
|
|
44
|
+
|
|
45
|
+
// PostgREST v9's PTxyz custom-status convention carries the RPC's user-facing
|
|
46
|
+
// `message` in the HTTP reason phrase (statusText); the JSON body holds only
|
|
47
|
+
// `hint`/`details` (no `message`/`code`). The formatter must surface the
|
|
48
|
+
// reason phrase as the headline instead of a hardcoded generic label, and
|
|
49
|
+
// still show the `details` (plural) line.
|
|
50
|
+
test("surfaces the PostgREST PTxyz reason-phrase message, not a generic label", () => {
|
|
51
|
+
const msg = formatHttpError(
|
|
52
|
+
"Failed to submit plan command",
|
|
53
|
+
403,
|
|
54
|
+
'{"hint":null,"details":"The LLM-facing Joe surface is gated on the organization\'s ai_enabled setting."}',
|
|
55
|
+
"AI features disabled for this org — enable in AI Assistant Settings"
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
expect(msg).toContain("HTTP 403");
|
|
59
|
+
expect(msg).toContain("AI features disabled for this org — enable in AI Assistant Settings");
|
|
60
|
+
// the real reason must not be masked by the hardcoded generic label
|
|
61
|
+
expect(msg).not.toContain("Forbidden - access denied");
|
|
62
|
+
// the supplementary `details` (plural) line is still shown
|
|
63
|
+
expect(msg).toContain("gated on the organization's ai_enabled setting");
|
|
64
|
+
// and we never dump the raw JSON object
|
|
65
|
+
expect(msg).not.toContain('"hint"');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("prefers a JSON body `message` over the reason phrase when both exist", () => {
|
|
69
|
+
const msg = formatHttpError("op", 400, '{"message":"real message","details":"more"}', "Bad Request");
|
|
70
|
+
expect(msg).toContain("real message");
|
|
71
|
+
expect(msg).toContain("more");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("falls back to the generic label for a standard (non-custom) reason phrase", () => {
|
|
75
|
+
// a bare standard reason phrase must not replace the friendlier generic label
|
|
76
|
+
const msg = formatHttpError("op", 403, undefined, "Forbidden");
|
|
77
|
+
expect(msg).toContain("Forbidden - access denied");
|
|
78
|
+
});
|
|
44
79
|
});
|