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.
@@ -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")
@@ -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 statusMessage = HTTP_STATUS_MESSAGES2[status] || "Request failed";
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
- if (responseBody) {
13340
- if (isHtmlContent2(responseBody)) {
13341
- return errMsg + remediation;
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 || errObj.error || errObj.detail;
13346
- if (message && typeof message === "string") {
13347
- errMsg += `
13348
- ${message}`;
13349
- } else {
13350
- errMsg += `
13351
- ${JSON.stringify(errObj, null, 2)}`;
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
- errMsg += `
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.6",
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.6";
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
- function formatHttpError(operation, status, responseBody) {
16402
- const statusMessage = HTTP_STATUS_MESSAGES[status] || "Request failed";
16403
- let errMsg = `${operation}: HTTP ${status} - ${statusMessage}`;
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
- if (responseBody) {
16407
- if (isHtmlContent(responseBody)) {
16408
- return errMsg + remediation;
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 || errObj.error || errObj.detail;
16413
- if (message && typeof message === "string") {
16414
- errMsg += `
16415
- ${message}`;
16416
- } else {
16417
- errMsg += `
16418
- ${JSON.stringify(errObj, null, 2)}`;
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
- errMsg += `
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
- throw new Error(formatHttpError(operation, response.status, text));
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. formatHttpError surfaces the JSON
181
- // `message` (e.g. the "AI features disabled for this org" text for AC36).
182
- throw new Error(formatHttpError(operation, response.status, text));
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(operation: string, status: number, responseBody?: string): string {
37
- const statusMessage = HTTP_STATUS_MESSAGES[status] || "Request failed";
38
- let errMsg = `${operation}: HTTP ${status} - ${statusMessage}`;
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
- 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
- }
74
+ let bodyMessage: string | undefined;
75
+ let bodyDetails: string | undefined;
47
76
 
48
- // Try to parse as JSON for structured error info
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
- // 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)}`;
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 - append it if it's short and useful
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
- errMsg += `\n${trimmed}`;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.16.0-dev.6",
3
+ "version": "0.16.0-dev.8",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -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
  });