postgresai 0.16.0-dev.7 → 0.16.0-dev.9

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.
@@ -5619,13 +5619,18 @@ withJoeOptions(
5619
5619
  .command("terminate <pid>")
5620
5620
  .description("pg_terminate_backend(pid) on the clone")
5621
5621
  ).action(async (pid: string, opts: JoeCliOpts) => {
5622
- const pidNum = parseInt(pid, 10);
5623
- if (Number.isNaN(pidNum)) {
5622
+ // A pid must be a bare non-negative integer. parseInt() silently accepts
5623
+ // trailing garbage ("12x"→12), a sign ("-5"→-5), decimals ("1.5"→1) and hex
5624
+ // ("0x10"→0) — any of which would submit a terminate against the WRONG
5625
+ // backend pid. Require a fully-numeric token so a mistyped pid is a clean,
5626
+ // typed rejection that never reaches the submit rpc.
5627
+ const pidStr = (pid ?? "").trim();
5628
+ if (!/^[0-9]+$/.test(pidStr)) {
5624
5629
  console.error("pid must be a number");
5625
5630
  process.exitCode = 1;
5626
5631
  return;
5627
5632
  }
5628
- await runJoeCli("terminate", null, { pid: pidNum }, opts);
5633
+ await runJoeCli("terminate", null, { pid: parseInt(pidStr, 10) }, opts);
5629
5634
  });
5630
5635
 
5631
5636
  withJoeOptions(
@@ -5647,6 +5652,22 @@ withJoeOptions(
5647
5652
  await runJoeCli("describe", null, args, opts);
5648
5653
  });
5649
5654
 
5655
+ // history search is roadmapped for M1b; the search backend is not deployed yet.
5656
+ // Ship a recognized subcommand so the documented `pgai joe history …` example
5657
+ // degrades cleanly (an informative "not yet available" line + exit 1) instead of
5658
+ // Commander's generic "unknown command 'history'". Wire it to the rpc when M1b lands.
5659
+ withJoeOptions(
5660
+ joe
5661
+ .command("history <terms>")
5662
+ .description("search prior Joe analyses, metadata-only (M1b — not yet available)")
5663
+ ).action(async (_terms: string, _opts: JoeCliOpts) => {
5664
+ console.error(
5665
+ "Joe history search is not available yet — it is planned for M1b. " +
5666
+ "No history-search backend is deployed; this command is a placeholder until then.",
5667
+ );
5668
+ process.exitCode = 1;
5669
+ });
5670
+
5650
5671
  // Org-level discovery — a general postgresai command, NOT a Joe endpoint (SPEC §6).
5651
5672
  program
5652
5673
  .command("projects")
@@ -13325,7 +13325,8 @@ __export(exports_util2, {
13325
13325
  resolveBaseUrls: () => resolveBaseUrls2,
13326
13326
  normalizeBaseUrl: () => normalizeBaseUrl2,
13327
13327
  maskSecret: () => maskSecret2,
13328
- formatHttpError: () => formatHttpError2
13328
+ formatHttpError: () => formatHttpError2,
13329
+ describeFetchError: () => describeFetchError2
13329
13330
  });
13330
13331
  function isHtmlContent2(text) {
13331
13332
  const trimmed = text.trim();
@@ -13365,6 +13366,11 @@ ${bodyDetails}`;
13365
13366
  }
13366
13367
  return errMsg + remediation;
13367
13368
  }
13369
+ function describeFetchError2(operation, url, err) {
13370
+ const cause = err?.cause;
13371
+ const detail = cause?.code || cause?.message || (err instanceof Error && err.message ? err.message : String(err));
13372
+ return `${operation}: could not reach ${url} (${detail})`;
13373
+ }
13368
13374
  function maskSecret2(secret) {
13369
13375
  if (!secret)
13370
13376
  return "";
@@ -13444,7 +13450,7 @@ var {
13444
13450
  // package.json
13445
13451
  var package_default = {
13446
13452
  name: "postgresai",
13447
- version: "0.16.0-dev.7",
13453
+ version: "0.16.0-dev.9",
13448
13454
  description: "postgres_ai CLI",
13449
13455
  license: "Apache-2.0",
13450
13456
  private: false,
@@ -16275,7 +16281,7 @@ var Result = import_lib.default.Result;
16275
16281
  var TypeOverrides = import_lib.default.TypeOverrides;
16276
16282
  var defaults = import_lib.default.defaults;
16277
16283
  // package.json
16278
- var version = "0.16.0-dev.7";
16284
+ var version = "0.16.0-dev.9";
16279
16285
  var package_default2 = {
16280
16286
  name: "postgresai",
16281
16287
  version,
@@ -16465,6 +16471,11 @@ ${bodyDetails}`;
16465
16471
  }
16466
16472
  return errMsg + remediation;
16467
16473
  }
16474
+ function describeFetchError(operation, url, err) {
16475
+ const cause = err?.cause;
16476
+ const detail = cause?.code || cause?.message || (err instanceof Error && err.message ? err.message : String(err));
16477
+ return `${operation}: could not reach ${url} (${detail})`;
16478
+ }
16468
16479
  function maskSecret(secret) {
16469
16480
  if (!secret)
16470
16481
  return "";
@@ -17357,11 +17368,16 @@ async function callRpc(params) {
17357
17368
  console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
17358
17369
  console.error(`Debug: Request body: ${payload}`);
17359
17370
  }
17360
- const response = await fetch(url.toString(), {
17361
- method: "POST",
17362
- headers,
17363
- body: payload
17364
- });
17371
+ let response;
17372
+ try {
17373
+ response = await fetch(url.toString(), {
17374
+ method: "POST",
17375
+ headers,
17376
+ body: payload
17377
+ });
17378
+ } catch (err) {
17379
+ throw new Error(describeFetchError(operation, base, err));
17380
+ }
17365
17381
  const text = await response.text();
17366
17382
  if (debug) {
17367
17383
  console.error(`Debug: Response status: ${response.status}`);
@@ -17686,7 +17702,12 @@ async function callDblabApi(params) {
17686
17702
  console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
17687
17703
  console.error(`Debug: Request body: ${body}`);
17688
17704
  }
17689
- const response = await fetch(url.toString(), { method: "POST", headers, body });
17705
+ let response;
17706
+ try {
17707
+ response = await fetch(url.toString(), { method: "POST", headers, body });
17708
+ } catch (err) {
17709
+ throw new Error(describeFetchError(operation, base, err));
17710
+ }
17690
17711
  const text = await response.text();
17691
17712
  if (debug) {
17692
17713
  console.error(`Debug: Response status: ${response.status}`);
@@ -27009,11 +27030,16 @@ async function callRpc2(params) {
27009
27030
  console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
27010
27031
  console.error(`Debug: Request body: ${payload}`);
27011
27032
  }
27012
- const response = await fetch(url.toString(), {
27013
- method: "POST",
27014
- headers,
27015
- body: payload
27016
- });
27033
+ let response;
27034
+ try {
27035
+ response = await fetch(url.toString(), {
27036
+ method: "POST",
27037
+ headers,
27038
+ body: payload
27039
+ });
27040
+ } catch (err) {
27041
+ throw new Error(describeFetchError(operation, base, err));
27042
+ }
27017
27043
  const text = await response.text();
27018
27044
  if (debug) {
27019
27045
  console.error(`Debug: Response status: ${response.status}`);
@@ -27424,7 +27450,12 @@ async function callDblabApi2(params) {
27424
27450
  console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
27425
27451
  console.error(`Debug: Request body: ${body}`);
27426
27452
  }
27427
- const response = await fetch(url.toString(), { method: "POST", headers, body });
27453
+ let response;
27454
+ try {
27455
+ response = await fetch(url.toString(), { method: "POST", headers, body });
27456
+ } catch (err) {
27457
+ throw new Error(describeFetchError(operation, base, err));
27458
+ }
27428
27459
  const text = await response.text();
27429
27460
  if (debug) {
27430
27461
  console.error(`Debug: Response status: ${response.status}`);
@@ -39839,13 +39870,13 @@ withJoeOptions(joe.command("activity").description("running-activity snapshot (p
39839
39870
  await runJoeCli("activity", null, null, opts);
39840
39871
  });
39841
39872
  withJoeOptions(joe.command("terminate <pid>").description("pg_terminate_backend(pid) on the clone")).action(async (pid, opts) => {
39842
- const pidNum = parseInt(pid, 10);
39843
- if (Number.isNaN(pidNum)) {
39873
+ const pidStr = (pid ?? "").trim();
39874
+ if (!/^[0-9]+$/.test(pidStr)) {
39844
39875
  console.error("pid must be a number");
39845
39876
  process.exitCode = 1;
39846
39877
  return;
39847
39878
  }
39848
- await runJoeCli("terminate", null, { pid: pidNum }, opts);
39879
+ await runJoeCli("terminate", null, { pid: parseInt(pidStr, 10) }, opts);
39849
39880
  });
39850
39881
  withJoeOptions(joe.command("reset").description("reset/recreate the session's thin clone")).action(async (opts) => {
39851
39882
  await runJoeCli("reset", null, null, opts);
@@ -39856,6 +39887,10 @@ withJoeOptions(joe.command("describe <object>").description("\\d-family schema/r
39856
39887
  args.variant = opts.variant;
39857
39888
  await runJoeCli("describe", null, args, opts);
39858
39889
  });
39890
+ withJoeOptions(joe.command("history <terms>").description("search prior Joe analyses, metadata-only (M1b \u2014 not yet available)")).action(async (_terms, _opts) => {
39891
+ 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.");
39892
+ process.exitCode = 1;
39893
+ });
39859
39894
  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) => {
39860
39895
  try {
39861
39896
  const rootOpts = program2.opts();
package/lib/dblab.ts CHANGED
@@ -23,7 +23,7 @@
23
23
  * read/create verbs by `joe:plan` — a `PT403` surfaces as an HTTP 403 here.
24
24
  */
25
25
 
26
- import { formatHttpError, maskSecret, normalizeBaseUrl } from "./util";
26
+ import { formatHttpError, maskSecret, normalizeBaseUrl, describeFetchError } from "./util";
27
27
  import { listProjects, type ProjectListItem } from "./joe";
28
28
 
29
29
  // ---------------------------------------------------------------------------
@@ -186,7 +186,14 @@ async function callDblabApi<T>(params: DblabApiCallParams): Promise<T> {
186
186
  console.error(`Debug: Request body: ${body}`);
187
187
  }
188
188
 
189
- const response = await fetch(url.toString(), { method: "POST", headers, body });
189
+ let response: Response;
190
+ try {
191
+ response = await fetch(url.toString(), { method: "POST", headers, body });
192
+ } catch (err) {
193
+ // Transport failure (connection refused, DNS, TLS, bad host/port) — surface
194
+ // the real cause + URL rather than undici's opaque "fetch failed".
195
+ throw new Error(describeFetchError(operation, base, err));
196
+ }
190
197
  const text = await response.text();
191
198
 
192
199
  if (debug) {
package/lib/joe.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import * as crypto from "node:crypto";
4
- import { formatHttpError, maskSecret, normalizeBaseUrl } from "./util";
4
+ import { formatHttpError, maskSecret, normalizeBaseUrl, describeFetchError } from "./util";
5
5
  import * as config from "./config";
6
6
 
7
7
  /**
@@ -162,11 +162,19 @@ async function callRpc<T>(params: RpcCallParams): Promise<T> {
162
162
  console.error(`Debug: Request body: ${payload}`);
163
163
  }
164
164
 
165
- const response = await fetch(url.toString(), {
166
- method: "POST",
167
- headers,
168
- body: payload,
169
- });
165
+ let response: Response;
166
+ try {
167
+ response = await fetch(url.toString(), {
168
+ method: "POST",
169
+ headers,
170
+ body: payload,
171
+ });
172
+ } catch (err) {
173
+ // A transport failure (connection refused, DNS, TLS, bad host/port) never
174
+ // reaches `response.ok`; undici throws with the real reason in `err.cause`.
175
+ // Surface it — a bare "fetch failed" hides which URL/why. See util.describeFetchError.
176
+ throw new Error(describeFetchError(operation, base, err));
177
+ }
170
178
 
171
179
  const text = await response.text();
172
180
 
package/lib/util.ts CHANGED
@@ -116,6 +116,23 @@ export function formatHttpError(
116
116
  return errMsg + remediation;
117
117
  }
118
118
 
119
+ /**
120
+ * Turn a low-level `fetch` failure into an actionable message. Node's fetch
121
+ * (undici) throws a `TypeError('fetch failed')` and stashes the real cause
122
+ * (`ECONNREFUSED`, DNS failure, `bad port`, TLS error, …) in `err.cause` — the
123
+ * opaque top-level message on its own tells the user nothing. This surfaces the
124
+ * cause and the URL that could not be reached, e.g.
125
+ * "Failed to list projects: could not reach http://127.0.0.1:1 (ECONNREFUSED)"
126
+ */
127
+ export function describeFetchError(operation: string, url: string, err: unknown): string {
128
+ const cause = (err as { cause?: { code?: string; message?: string } } | null | undefined)?.cause;
129
+ const detail =
130
+ cause?.code ||
131
+ cause?.message ||
132
+ (err instanceof Error && err.message ? err.message : String(err));
133
+ return `${operation}: could not reach ${url} (${detail})`;
134
+ }
135
+
119
136
  export function maskSecret(secret: string): string {
120
137
  if (!secret) return "";
121
138
  if (secret.length <= 8) return "****";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.16.0-dev.7",
3
+ "version": "0.16.0-dev.9",
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 {
@@ -279,6 +291,26 @@ describe("CLI Joe command surface (grouped under `pgai joe …`)", () => {
279
291
  }
280
292
  });
281
293
 
294
+ test("joe terminate rejects a non-numeric / trailing-garbage pid (no submit)", async () => {
295
+ // A pid must be a bare non-negative integer. parseInt() would silently accept
296
+ // "12x"→12, "-5"→-5, "1.5"→1 and submit a WRONG pg_terminate_backend target.
297
+ // Those must be a clean, typed rejection that never reaches the submit rpc.
298
+ const api = startFakeApi();
299
+ try {
300
+ for (const bad of ["12x", "abc", "1.5", "0x10", " "]) {
301
+ const r = await runCliAsync(
302
+ ["joe", "terminate", bad, "--project", "12"],
303
+ isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
304
+ );
305
+ expect(r.status).toBe(1);
306
+ expect(`${r.stdout}\n${r.stderr}`).toContain("pid must be a number");
307
+ }
308
+ expect(api.requests.some((x) => x.pathname.endsWith("/rpc/joe_command_submit"))).toBe(false);
309
+ } finally {
310
+ api.stop();
311
+ }
312
+ });
313
+
282
314
  test("joe describe maps args.object (and --variant)", async () => {
283
315
  const api = startFakeApi();
284
316
  try {
package/test/util.test.ts CHANGED
@@ -1,6 +1,31 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { formatHttpError } from "../lib/util";
3
+ import { formatHttpError, describeFetchError } from "../lib/util";
4
+
5
+ describe("describeFetchError", () => {
6
+ test("surfaces the connection cause + url instead of a bare 'fetch failed'", () => {
7
+ // Node's fetch (undici) throws TypeError('fetch failed') and stashes the real
8
+ // reason (ECONNREFUSED etc.) in err.cause — the CLI must surface it, not the
9
+ // opaque top-level message.
10
+ const err = Object.assign(new TypeError("fetch failed"), {
11
+ cause: { code: "ECONNREFUSED", message: "connect ECONNREFUSED 127.0.0.1:1" },
12
+ });
13
+ const msg = describeFetchError("Failed to list projects", "http://127.0.0.1:1", err);
14
+ expect(msg).toContain("Failed to list projects");
15
+ expect(msg).toContain("could not reach http://127.0.0.1:1");
16
+ expect(msg).toContain("ECONNREFUSED");
17
+ expect(msg).not.toBe("fetch failed");
18
+ });
19
+
20
+ test("falls back to cause.message, then to the error message", () => {
21
+ const withMsg = Object.assign(new TypeError("fetch failed"), {
22
+ cause: { message: "bad port" },
23
+ });
24
+ expect(describeFetchError("op", "http://h:1", withMsg)).toContain("bad port");
25
+ const bare = new Error("boom");
26
+ expect(describeFetchError("op", "http://h", bare)).toContain("boom");
27
+ });
28
+ });
4
29
 
5
30
  describe("formatHttpError", () => {
6
31
  test("appends auth remediation hint on 401 with JSON body", () => {