postgresai 0.16.0-dev.8 → 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(
@@ -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.8",
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.8";
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);
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.8",
3
+ "version": "0.16.0-dev.9",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -291,6 +291,26 @@ describe("CLI Joe command surface (grouped under `pgai joe …`)", () => {
291
291
  }
292
292
  });
293
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
+
294
314
  test("joe describe maps args.object (and --variant)", async () => {
295
315
  const api = startFakeApi();
296
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", () => {