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.
- package/bin/postgres-ai.ts +24 -3
- package/dist/bin/postgres-ai.js +53 -18
- package/lib/dblab.ts +9 -2
- package/lib/joe.ts +14 -6
- package/lib/util.ts +17 -0
- package/package.json +1 -1
- package/test/joe.cli.test.ts +33 -1
- package/test/util.test.ts +26 -1
package/bin/postgres-ai.ts
CHANGED
|
@@ -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
|
-
|
|
5623
|
-
|
|
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:
|
|
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")
|
package/dist/bin/postgres-ai.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
17361
|
-
|
|
17362
|
-
|
|
17363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27013
|
-
|
|
27014
|
-
|
|
27015
|
-
|
|
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
|
-
|
|
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
|
|
39843
|
-
if (
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
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 {
|
|
@@ -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", () => {
|