postgresai 0.16.0-dev.5 → 0.16.0-dev.7
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 +37 -18
- package/dist/bin/postgres-ai.js +96 -56
- package/lib/dblab.ts +4 -2
- package/lib/joe.ts +5 -4
- package/lib/mcp-server.ts +3 -3
- package/lib/util.ts +67 -18
- package/package.json +1 -1
- package/test/dblab.cli.test.ts +25 -18
- package/test/joe.cli.test.ts +33 -22
- package/test/util.test.ts +35 -0
package/bin/postgres-ai.ts
CHANGED
|
@@ -5424,7 +5424,7 @@ interface JoeCliOpts {
|
|
|
5424
5424
|
// ============================================================================
|
|
5425
5425
|
// DBLab companion command surface (Joe API v2 · SPEC §8)
|
|
5426
5426
|
//
|
|
5427
|
-
// `pgai clone|branch|snapshot …` proxy the SAME Platform DBLab API the Console
|
|
5427
|
+
// `pgai dblab clone|branch|snapshot …` proxy the SAME Platform DBLab API the Console
|
|
5428
5428
|
// already drives (v1.dblab_api_call). Every verb is `--project <id|alias>`
|
|
5429
5429
|
// scoped: the project's single DBLab instance is resolved server-side listing,
|
|
5430
5430
|
// then the verb is proxied. Destructive verbs (clone reset/destroy, branch
|
|
@@ -5465,7 +5465,7 @@ function printJoeOutcome(command: JoeCommand, outcome: ExecuteJoeOutcome, json:
|
|
|
5465
5465
|
status: outcome.status,
|
|
5466
5466
|
session_id: outcome.sessionId,
|
|
5467
5467
|
budget_expired: true,
|
|
5468
|
-
resume: `pgai result ${outcome.commandId}`,
|
|
5468
|
+
resume: `pgai joe result ${outcome.commandId}`,
|
|
5469
5469
|
},
|
|
5470
5470
|
null,
|
|
5471
5471
|
2
|
|
@@ -5473,7 +5473,7 @@ function printJoeOutcome(command: JoeCommand, outcome: ExecuteJoeOutcome, json:
|
|
|
5473
5473
|
);
|
|
5474
5474
|
} else {
|
|
5475
5475
|
console.log(
|
|
5476
|
-
`submitted ${outcome.commandId} · ${outcome.status} · budget ${budgetSeconds}s reached — resume: pgai result ${outcome.commandId}`
|
|
5476
|
+
`submitted ${outcome.commandId} · ${outcome.status} · budget ${budgetSeconds}s reached — resume: pgai joe result ${outcome.commandId}`
|
|
5477
5477
|
);
|
|
5478
5478
|
}
|
|
5479
5479
|
return;
|
|
@@ -5501,7 +5501,7 @@ function printJoeOutcome(command: JoeCommand, outcome: ExecuteJoeOutcome, json:
|
|
|
5501
5501
|
// Server-side timed_out (the dispatcher gave up). The genuine reply may still
|
|
5502
5502
|
// land; suggest a resume, but signal failure with a non-zero exit.
|
|
5503
5503
|
console.error(
|
|
5504
|
-
`command ${outcome.commandId} timed out on the server — resume: pgai result ${outcome.commandId}`
|
|
5504
|
+
`command ${outcome.commandId} timed out on the server — resume: pgai joe result ${outcome.commandId}`
|
|
5505
5505
|
);
|
|
5506
5506
|
process.exitCode = 1;
|
|
5507
5507
|
}
|
|
@@ -5564,8 +5564,17 @@ function withJoeOptions(cmd: import("commander").Command): import("commander").C
|
|
|
5564
5564
|
.option("--json", "output raw JSON");
|
|
5565
5565
|
}
|
|
5566
5566
|
|
|
5567
|
+
// ---------------------------------------------------------------------------
|
|
5568
|
+
// Joe command group — every Joe verb lives under `pgai joe <verb>` (grouped CLI,
|
|
5569
|
+
// USER-AUTHORIZED restructure). The MCP tool names stay flat/unchanged; only the
|
|
5570
|
+
// CLI invocation path is grouped. `projects` stays top-level (org-level, SPEC §6).
|
|
5571
|
+
// ---------------------------------------------------------------------------
|
|
5572
|
+
const joe = program
|
|
5573
|
+
.command("joe")
|
|
5574
|
+
.description("Joe API v2 — plan/EXPLAIN/exec queries on ephemeral DBLab clones");
|
|
5575
|
+
|
|
5567
5576
|
withJoeOptions(
|
|
5568
|
-
|
|
5577
|
+
joe
|
|
5569
5578
|
.command("plan <sql>")
|
|
5570
5579
|
.description("plan a query (EXPLAIN, plan-only — no execution; the fast/safe default)")
|
|
5571
5580
|
).action(async (sql: string, opts: JoeCliOpts) => {
|
|
@@ -5573,7 +5582,7 @@ withJoeOptions(
|
|
|
5573
5582
|
});
|
|
5574
5583
|
|
|
5575
5584
|
withJoeOptions(
|
|
5576
|
-
|
|
5585
|
+
joe
|
|
5577
5586
|
.command("explain <sql>")
|
|
5578
5587
|
.description("EXPLAIN ANALYZE a query (EXECUTES on the hardened, ephemeral clone)")
|
|
5579
5588
|
).action(async (sql: string, opts: JoeCliOpts) => {
|
|
@@ -5581,7 +5590,7 @@ withJoeOptions(
|
|
|
5581
5590
|
});
|
|
5582
5591
|
|
|
5583
5592
|
withJoeOptions(
|
|
5584
|
-
|
|
5593
|
+
joe
|
|
5585
5594
|
.command("exec <sql>")
|
|
5586
5595
|
.description("run arbitrary DDL/DML on the clone (e.g. create index, analyze)")
|
|
5587
5596
|
).action(async (sql: string, opts: JoeCliOpts) => {
|
|
@@ -5589,7 +5598,7 @@ withJoeOptions(
|
|
|
5589
5598
|
});
|
|
5590
5599
|
|
|
5591
5600
|
withJoeOptions(
|
|
5592
|
-
|
|
5601
|
+
joe
|
|
5593
5602
|
.command("hypo <indexSql>")
|
|
5594
5603
|
.description("HypoPG hypothetical index + re-plan (no real index, no data change)")
|
|
5595
5604
|
.requiredOption("--query <sql>", "target query the hypothetical index is evaluated against")
|
|
@@ -5598,7 +5607,7 @@ withJoeOptions(
|
|
|
5598
5607
|
});
|
|
5599
5608
|
|
|
5600
5609
|
withJoeOptions(
|
|
5601
|
-
|
|
5610
|
+
joe
|
|
5602
5611
|
.command("activity")
|
|
5603
5612
|
.description("running-activity snapshot (pg_stat_activity; query text redacted)")
|
|
5604
5613
|
).action(async (opts: JoeCliOpts) => {
|
|
@@ -5606,7 +5615,7 @@ withJoeOptions(
|
|
|
5606
5615
|
});
|
|
5607
5616
|
|
|
5608
5617
|
withJoeOptions(
|
|
5609
|
-
|
|
5618
|
+
joe
|
|
5610
5619
|
.command("terminate <pid>")
|
|
5611
5620
|
.description("pg_terminate_backend(pid) on the clone")
|
|
5612
5621
|
).action(async (pid: string, opts: JoeCliOpts) => {
|
|
@@ -5620,7 +5629,7 @@ withJoeOptions(
|
|
|
5620
5629
|
});
|
|
5621
5630
|
|
|
5622
5631
|
withJoeOptions(
|
|
5623
|
-
|
|
5632
|
+
joe
|
|
5624
5633
|
.command("reset")
|
|
5625
5634
|
.description("reset/recreate the session's thin clone")
|
|
5626
5635
|
).action(async (opts: JoeCliOpts) => {
|
|
@@ -5628,7 +5637,7 @@ withJoeOptions(
|
|
|
5628
5637
|
});
|
|
5629
5638
|
|
|
5630
5639
|
withJoeOptions(
|
|
5631
|
-
|
|
5640
|
+
joe
|
|
5632
5641
|
.command("describe <object>")
|
|
5633
5642
|
.description("\\d-family schema/relation/index metadata")
|
|
5634
5643
|
.option("--variant <variant>", "\\d-family variant (e.g. \\d+, \\di, \\dt)")
|
|
@@ -5699,9 +5708,18 @@ async function resolveDblabTarget(
|
|
|
5699
5708
|
return { apiKey, apiBaseUrl, orgId, instanceId };
|
|
5700
5709
|
}
|
|
5701
5710
|
|
|
5711
|
+
// ---------------------------------------------------------------------------
|
|
5712
|
+
// DBLab command group — every DBLab verb lives under
|
|
5713
|
+
// `pgai dblab <clone|branch|snapshot> …` (grouped CLI, USER-AUTHORIZED restructure).
|
|
5714
|
+
// MCP tool names (clone_create/…) stay flat/unchanged; only the CLI path is grouped.
|
|
5715
|
+
// ---------------------------------------------------------------------------
|
|
5716
|
+
const dblab = program
|
|
5717
|
+
.command("dblab")
|
|
5718
|
+
.description("DBLab thin-clone / branch / snapshot management (proxies the Platform DBLab API)");
|
|
5719
|
+
|
|
5702
5720
|
// ---- clone ----------------------------------------------------------------
|
|
5703
5721
|
|
|
5704
|
-
const clone =
|
|
5722
|
+
const clone = dblab.command("clone").description("DBLab thin-clone management (proxies the Platform DBLab API)");
|
|
5705
5723
|
|
|
5706
5724
|
clone
|
|
5707
5725
|
.command("create")
|
|
@@ -5735,8 +5753,9 @@ clone
|
|
|
5735
5753
|
}
|
|
5736
5754
|
});
|
|
5737
5755
|
|
|
5738
|
-
// Resume / inspect a previously submitted command by id.
|
|
5739
|
-
|
|
5756
|
+
// Resume / inspect a previously submitted command by id. Part of the `joe` group
|
|
5757
|
+
// (`pgai joe status <commandId>`) — distinct from `pgai dblab clone status <cloneId>`.
|
|
5758
|
+
joe
|
|
5740
5759
|
.command("status <commandId>")
|
|
5741
5760
|
.description("show a Joe command's status (metadata only)")
|
|
5742
5761
|
.option("--debug", "enable debug output")
|
|
@@ -5782,7 +5801,7 @@ clone
|
|
|
5782
5801
|
}
|
|
5783
5802
|
});
|
|
5784
5803
|
|
|
5785
|
-
|
|
5804
|
+
joe
|
|
5786
5805
|
.command("result <commandId>")
|
|
5787
5806
|
.description("fetch a Joe command's result by id (resume a timed-out one-shot)")
|
|
5788
5807
|
.option("--debug", "enable debug output")
|
|
@@ -5881,7 +5900,7 @@ clone
|
|
|
5881
5900
|
|
|
5882
5901
|
// ---- branch ---------------------------------------------------------------
|
|
5883
5902
|
|
|
5884
|
-
const branch =
|
|
5903
|
+
const branch = dblab.command("branch").description("DBLab branch management (proxies the Platform DBLab API)");
|
|
5885
5904
|
|
|
5886
5905
|
branch
|
|
5887
5906
|
.command("list")
|
|
@@ -5961,7 +5980,7 @@ branch
|
|
|
5961
5980
|
|
|
5962
5981
|
// ---- snapshot -------------------------------------------------------------
|
|
5963
5982
|
|
|
5964
|
-
const snapshot =
|
|
5983
|
+
const snapshot = dblab.command("snapshot").description("DBLab snapshot management (proxies the Platform DBLab API)");
|
|
5965
5984
|
|
|
5966
5985
|
snapshot
|
|
5967
5986
|
.command("list")
|
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.7",
|
|
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.7";
|
|
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;
|
|
@@ -25145,7 +25183,7 @@ async function runJoeTool(params) {
|
|
|
25145
25183
|
session_id: outcome.sessionId,
|
|
25146
25184
|
status: outcome.status,
|
|
25147
25185
|
budget_expired: outcome.budgetExpired,
|
|
25148
|
-
resume: outcome.budgetExpired ? `pgai result ${outcome.commandId}` : undefined,
|
|
25186
|
+
resume: outcome.budgetExpired ? `pgai joe result ${outcome.commandId}` : undefined,
|
|
25149
25187
|
result: outcome.result
|
|
25150
25188
|
};
|
|
25151
25189
|
const isError = outcome.status === "error" || outcome.status === "timed_out";
|
|
@@ -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;
|
|
@@ -39715,10 +39753,10 @@ function printJoeOutcome(command, outcome, json3) {
|
|
|
39715
39753
|
status: outcome.status,
|
|
39716
39754
|
session_id: outcome.sessionId,
|
|
39717
39755
|
budget_expired: true,
|
|
39718
|
-
resume: `pgai result ${outcome.commandId}`
|
|
39756
|
+
resume: `pgai joe result ${outcome.commandId}`
|
|
39719
39757
|
}, null, 2));
|
|
39720
39758
|
} else {
|
|
39721
|
-
console.log(`submitted ${outcome.commandId} \xB7 ${outcome.status} \xB7 budget ${budgetSeconds}s reached \u2014 resume: pgai result ${outcome.commandId}`);
|
|
39759
|
+
console.log(`submitted ${outcome.commandId} \xB7 ${outcome.status} \xB7 budget ${budgetSeconds}s reached \u2014 resume: pgai joe result ${outcome.commandId}`);
|
|
39722
39760
|
}
|
|
39723
39761
|
return;
|
|
39724
39762
|
}
|
|
@@ -39740,7 +39778,7 @@ function printJoeOutcome(command, outcome, json3) {
|
|
|
39740
39778
|
process.exitCode = 1;
|
|
39741
39779
|
return;
|
|
39742
39780
|
}
|
|
39743
|
-
console.error(`command ${outcome.commandId} timed out on the server \u2014 resume: pgai result ${outcome.commandId}`);
|
|
39781
|
+
console.error(`command ${outcome.commandId} timed out on the server \u2014 resume: pgai joe result ${outcome.commandId}`);
|
|
39744
39782
|
process.exitCode = 1;
|
|
39745
39783
|
}
|
|
39746
39784
|
async function runJoeCli(command, sql, args, opts) {
|
|
@@ -39784,22 +39822,23 @@ async function runJoeCli(command, sql, args, opts) {
|
|
|
39784
39822
|
function withJoeOptions(cmd) {
|
|
39785
39823
|
return cmd.option("--project <id|alias>", "target project by numeric id OR alias/name").option("--session <id>", "run on a specific session's clone").option("--new-session", "start a fresh session/clone (null session)").option("--budget <seconds>", "one-shot poll budget in seconds (default 25)", (v) => parseInt(v, 10)).option("--debug", "enable debug output").option("--json", "output raw JSON");
|
|
39786
39824
|
}
|
|
39787
|
-
|
|
39825
|
+
var joe = program2.command("joe").description("Joe API v2 \u2014 plan/EXPLAIN/exec queries on ephemeral DBLab clones");
|
|
39826
|
+
withJoeOptions(joe.command("plan <sql>").description("plan a query (EXPLAIN, plan-only \u2014 no execution; the fast/safe default)")).action(async (sql, opts) => {
|
|
39788
39827
|
await runJoeCli("plan", sql, null, opts);
|
|
39789
39828
|
});
|
|
39790
|
-
withJoeOptions(
|
|
39829
|
+
withJoeOptions(joe.command("explain <sql>").description("EXPLAIN ANALYZE a query (EXECUTES on the hardened, ephemeral clone)")).action(async (sql, opts) => {
|
|
39791
39830
|
await runJoeCli("explain", sql, null, opts);
|
|
39792
39831
|
});
|
|
39793
|
-
withJoeOptions(
|
|
39832
|
+
withJoeOptions(joe.command("exec <sql>").description("run arbitrary DDL/DML on the clone (e.g. create index, analyze)")).action(async (sql, opts) => {
|
|
39794
39833
|
await runJoeCli("exec", sql, null, opts);
|
|
39795
39834
|
});
|
|
39796
|
-
withJoeOptions(
|
|
39835
|
+
withJoeOptions(joe.command("hypo <indexSql>").description("HypoPG hypothetical index + re-plan (no real index, no data change)").requiredOption("--query <sql>", "target query the hypothetical index is evaluated against")).action(async (indexSql, opts) => {
|
|
39797
39836
|
await runJoeCli("hypo", indexSql, { query: opts.query }, opts);
|
|
39798
39837
|
});
|
|
39799
|
-
withJoeOptions(
|
|
39838
|
+
withJoeOptions(joe.command("activity").description("running-activity snapshot (pg_stat_activity; query text redacted)")).action(async (opts) => {
|
|
39800
39839
|
await runJoeCli("activity", null, null, opts);
|
|
39801
39840
|
});
|
|
39802
|
-
withJoeOptions(
|
|
39841
|
+
withJoeOptions(joe.command("terminate <pid>").description("pg_terminate_backend(pid) on the clone")).action(async (pid, opts) => {
|
|
39803
39842
|
const pidNum = parseInt(pid, 10);
|
|
39804
39843
|
if (Number.isNaN(pidNum)) {
|
|
39805
39844
|
console.error("pid must be a number");
|
|
@@ -39808,10 +39847,10 @@ withJoeOptions(program2.command("terminate <pid>").description("pg_terminate_bac
|
|
|
39808
39847
|
}
|
|
39809
39848
|
await runJoeCli("terminate", null, { pid: pidNum }, opts);
|
|
39810
39849
|
});
|
|
39811
|
-
withJoeOptions(
|
|
39850
|
+
withJoeOptions(joe.command("reset").description("reset/recreate the session's thin clone")).action(async (opts) => {
|
|
39812
39851
|
await runJoeCli("reset", null, null, opts);
|
|
39813
39852
|
});
|
|
39814
|
-
withJoeOptions(
|
|
39853
|
+
withJoeOptions(joe.command("describe <object>").description("\\d-family schema/relation/index metadata").option("--variant <variant>", "\\d-family variant (e.g. \\d+, \\di, \\dt)")).action(async (object4, opts) => {
|
|
39815
39854
|
const args = { object: object4 };
|
|
39816
39855
|
if (opts.variant)
|
|
39817
39856
|
args.variant = opts.variant;
|
|
@@ -39861,7 +39900,8 @@ async function resolveDblabTarget(project, debug) {
|
|
|
39861
39900
|
const instanceId = await resolveDblabInstanceId2({ apiKey, apiBaseUrl, project: ref, orgId, debug });
|
|
39862
39901
|
return { apiKey, apiBaseUrl, orgId, instanceId };
|
|
39863
39902
|
}
|
|
39864
|
-
var
|
|
39903
|
+
var dblab = program2.command("dblab").description("DBLab thin-clone / branch / snapshot management (proxies the Platform DBLab API)");
|
|
39904
|
+
var clone2 = dblab.command("clone").description("DBLab thin-clone management (proxies the Platform DBLab API)");
|
|
39865
39905
|
clone2.command("create").description("create a thin clone of the project's database").requiredOption("--project <id|alias>", "project id or alias").option("--branch <branch>", "branch to clone from").option("--snapshot <id>", "snapshot id to clone from").option("--id <id>", "clone id (DBLab generates one when omitted)").option("--db-user <user>", "clone DB user").option("--db-password <password>", "clone DB password").option("--protected", "protect the clone from auto-deletion").option("--debug", "enable debug output").option("--json", "output raw JSON").action(async (opts) => {
|
|
39866
39906
|
try {
|
|
39867
39907
|
const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
|
|
@@ -39883,7 +39923,7 @@ clone2.command("create").description("create a thin clone of the project's datab
|
|
|
39883
39923
|
process.exitCode = 1;
|
|
39884
39924
|
}
|
|
39885
39925
|
});
|
|
39886
|
-
|
|
39926
|
+
joe.command("status <commandId>").description("show a Joe command's status (metadata only)").option("--debug", "enable debug output").option("--json", "output raw JSON").action(async (commandId, opts) => {
|
|
39887
39927
|
try {
|
|
39888
39928
|
const rootOpts = program2.opts();
|
|
39889
39929
|
const cfg = readConfig();
|
|
@@ -39916,7 +39956,7 @@ clone2.command("list").description("list the project's thin clones").requiredOpt
|
|
|
39916
39956
|
process.exitCode = 1;
|
|
39917
39957
|
}
|
|
39918
39958
|
});
|
|
39919
|
-
|
|
39959
|
+
joe.command("result <commandId>").description("fetch a Joe command's result by id (resume a timed-out one-shot)").option("--debug", "enable debug output").option("--json", "output raw JSON").action(async (commandId, opts) => {
|
|
39920
39960
|
try {
|
|
39921
39961
|
const rootOpts = program2.opts();
|
|
39922
39962
|
const cfg = readConfig();
|
|
@@ -39988,7 +40028,7 @@ clone2.command("destroy <cloneId>").description("destroy a clone (requires joe:a
|
|
|
39988
40028
|
process.exitCode = 1;
|
|
39989
40029
|
}
|
|
39990
40030
|
});
|
|
39991
|
-
var branch =
|
|
40031
|
+
var branch = dblab.command("branch").description("DBLab branch management (proxies the Platform DBLab API)");
|
|
39992
40032
|
branch.command("list").description("list the project's branches").requiredOption("--project <id|alias>", "project id or alias").option("--debug", "enable debug output").option("--json", "output raw JSON").action(async (opts) => {
|
|
39993
40033
|
try {
|
|
39994
40034
|
const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
|
|
@@ -40037,7 +40077,7 @@ branch.command("log <name>").description("show a branch's snapshot log").require
|
|
|
40037
40077
|
process.exitCode = 1;
|
|
40038
40078
|
}
|
|
40039
40079
|
});
|
|
40040
|
-
var snapshot =
|
|
40080
|
+
var snapshot = dblab.command("snapshot").description("DBLab snapshot management (proxies the Platform DBLab API)");
|
|
40041
40081
|
snapshot.command("list").description("list the project's snapshots").requiredOption("--project <id|alias>", "project id or alias").option("--branch <branch>", "filter by branch").option("--dataset <dataset>", "filter by dataset").option("--debug", "enable debug output").option("--json", "output raw JSON").action(async (opts) => {
|
|
40042
40082
|
try {
|
|
40043
40083
|
const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
|
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 {
|
|
@@ -496,7 +497,7 @@ const defaultSleep = (ms: number): Promise<void> => new Promise((r) => setTimeou
|
|
|
496
497
|
/**
|
|
497
498
|
* Submit a command then client-side poll status/result within the one-shot budget.
|
|
498
499
|
* On a terminal state returns the result; on budget expiry returns a resume handle
|
|
499
|
-
* (`budgetExpired: true`) so the caller can `pgai result <command_id>` later.
|
|
500
|
+
* (`budgetExpired: true`) so the caller can `pgai joe result <command_id>` later.
|
|
500
501
|
*/
|
|
501
502
|
export async function runCommand(params: RunCommandParams): Promise<RunCommandOutcome> {
|
|
502
503
|
const {
|
package/lib/mcp-server.ts
CHANGED
|
@@ -284,7 +284,7 @@ async function runJoeTool(params: RunJoeToolParams): Promise<McpToolResponse> {
|
|
|
284
284
|
session_id: outcome.sessionId,
|
|
285
285
|
status: outcome.status,
|
|
286
286
|
budget_expired: outcome.budgetExpired,
|
|
287
|
-
resume: outcome.budgetExpired ? `pgai result ${outcome.commandId}` : undefined,
|
|
287
|
+
resume: outcome.budgetExpired ? `pgai joe result ${outcome.commandId}` : undefined,
|
|
288
288
|
result: outcome.result,
|
|
289
289
|
};
|
|
290
290
|
const isError = outcome.status === "error" || outcome.status === "timed_out";
|
|
@@ -648,7 +648,7 @@ export async function handleToolCall(
|
|
|
648
648
|
}
|
|
649
649
|
|
|
650
650
|
// ---- DBLab companion tools (SPEC §8) --------------------------------
|
|
651
|
-
// Each mirrors the CLI `pgai clone|branch|snapshot …` verb 1:1: resolve the
|
|
651
|
+
// Each mirrors the CLI `pgai dblab clone|branch|snapshot …` verb 1:1: resolve the
|
|
652
652
|
// project's single DBLab instance, then proxy the existing Platform DBLab
|
|
653
653
|
// API (v1.dblab_api_call). Destructive verbs are `joe:admin`-gated backend
|
|
654
654
|
// side (a PT403 surfaces as an isError response via the outer catch).
|
|
@@ -1058,7 +1058,7 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
|
|
|
1058
1058
|
},
|
|
1059
1059
|
...joeToolDefinitions(),
|
|
1060
1060
|
// DBLab companion tools (SPEC §8) — proxy the existing Platform DBLab API
|
|
1061
|
-
// (v1.dblab_api_call) the Console drives; mirror `pgai clone|branch|snapshot`.
|
|
1061
|
+
// (v1.dblab_api_call) the Console drives; mirror `pgai dblab clone|branch|snapshot`.
|
|
1062
1062
|
// `project` (id or alias) resolves the project's single DBLab instance.
|
|
1063
1063
|
{
|
|
1064
1064
|
name: "clone_create",
|
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/dblab.cli.test.ts
CHANGED
|
@@ -93,17 +93,24 @@ async function startFakeApi(projects?: unknown[]) {
|
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
describe("CLI DBLab companion command groups", () => {
|
|
97
|
-
test("top-level help lists
|
|
96
|
+
describe("CLI DBLab companion command groups (grouped under `pgai dblab …`)", () => {
|
|
97
|
+
test("top-level help lists the dblab group", () => {
|
|
98
98
|
const r = runCli(["--help"], isolatedEnv());
|
|
99
99
|
const out = `${r.stdout}\n${r.stderr}`;
|
|
100
|
+
expect(out).toContain("dblab");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("dblab help exposes the clone, branch, and snapshot groups", () => {
|
|
104
|
+
const r = runCli(["dblab", "--help"], isolatedEnv());
|
|
105
|
+
expect(r.status).toBe(0);
|
|
106
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
100
107
|
expect(out).toContain("clone");
|
|
101
108
|
expect(out).toContain("branch");
|
|
102
109
|
expect(out).toContain("snapshot");
|
|
103
110
|
});
|
|
104
111
|
|
|
105
112
|
test("clone help exposes create/list/status/reset/destroy", () => {
|
|
106
|
-
const r = runCli(["clone", "--help"], isolatedEnv());
|
|
113
|
+
const r = runCli(["dblab", "clone", "--help"], isolatedEnv());
|
|
107
114
|
expect(r.status).toBe(0);
|
|
108
115
|
const out = `${r.stdout}\n${r.stderr}`;
|
|
109
116
|
expect(out).toContain("create");
|
|
@@ -114,7 +121,7 @@ describe("CLI DBLab companion command groups", () => {
|
|
|
114
121
|
});
|
|
115
122
|
|
|
116
123
|
test("branch help exposes list/create/delete/log", () => {
|
|
117
|
-
const r = runCli(["branch", "--help"], isolatedEnv());
|
|
124
|
+
const r = runCli(["dblab", "branch", "--help"], isolatedEnv());
|
|
118
125
|
expect(r.status).toBe(0);
|
|
119
126
|
const out = `${r.stdout}\n${r.stderr}`;
|
|
120
127
|
expect(out).toContain("list");
|
|
@@ -124,7 +131,7 @@ describe("CLI DBLab companion command groups", () => {
|
|
|
124
131
|
});
|
|
125
132
|
|
|
126
133
|
test("snapshot help exposes list/create/destroy", () => {
|
|
127
|
-
const r = runCli(["snapshot", "--help"], isolatedEnv());
|
|
134
|
+
const r = runCli(["dblab", "snapshot", "--help"], isolatedEnv());
|
|
128
135
|
expect(r.status).toBe(0);
|
|
129
136
|
const out = `${r.stdout}\n${r.stderr}`;
|
|
130
137
|
expect(out).toContain("list");
|
|
@@ -133,13 +140,13 @@ describe("CLI DBLab companion command groups", () => {
|
|
|
133
140
|
});
|
|
134
141
|
|
|
135
142
|
test("clone create fails fast without an API key", () => {
|
|
136
|
-
const r = runCli(["clone", "create", "--project", "main-db"], isolatedEnv());
|
|
143
|
+
const r = runCli(["dblab", "clone", "create", "--project", "main-db"], isolatedEnv());
|
|
137
144
|
expect(r.status).toBe(1);
|
|
138
145
|
expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
|
|
139
146
|
});
|
|
140
147
|
|
|
141
148
|
test("clone list without --project errors", () => {
|
|
142
|
-
const r = runCli(["clone", "list"], isolatedEnv({ PGAI_API_KEY: "test-key" }));
|
|
149
|
+
const r = runCli(["dblab", "clone", "list"], isolatedEnv({ PGAI_API_KEY: "test-key" }));
|
|
143
150
|
expect(r.status).not.toBe(0);
|
|
144
151
|
expect(`${r.stdout}\n${r.stderr}`.toLowerCase()).toContain("project");
|
|
145
152
|
});
|
|
@@ -148,7 +155,7 @@ describe("CLI DBLab companion command groups", () => {
|
|
|
148
155
|
const api = await startFakeApi();
|
|
149
156
|
try {
|
|
150
157
|
const r = await runCliAsync(
|
|
151
|
-
["clone", "create", "--project", "main-db", "--json"],
|
|
158
|
+
["dblab", "clone", "create", "--project", "main-db", "--json"],
|
|
152
159
|
isolatedEnv({ PGAI_API_KEY: "test-key", PGAI_API_BASE_URL: api.baseUrl })
|
|
153
160
|
);
|
|
154
161
|
expect(r.status).toBe(0);
|
|
@@ -170,7 +177,7 @@ describe("CLI DBLab companion command groups", () => {
|
|
|
170
177
|
const api = await startFakeApi();
|
|
171
178
|
try {
|
|
172
179
|
const r = await runCliAsync(
|
|
173
|
-
["clone", "create", "--project", "main-db", "--branch", "feature-idx", "--snapshot", "s-9", "--protected", "--json"],
|
|
180
|
+
["dblab", "clone", "create", "--project", "main-db", "--branch", "feature-idx", "--snapshot", "s-9", "--protected", "--json"],
|
|
174
181
|
isolatedEnv({ PGAI_API_KEY: "test-key", PGAI_API_BASE_URL: api.baseUrl })
|
|
175
182
|
);
|
|
176
183
|
expect(r.status).toBe(0);
|
|
@@ -185,7 +192,7 @@ describe("CLI DBLab companion command groups", () => {
|
|
|
185
192
|
const api = await startFakeApi();
|
|
186
193
|
try {
|
|
187
194
|
const r = await runCliAsync(
|
|
188
|
-
["clone", "reset", "c-8f21", "--project", "main-db", "--json"],
|
|
195
|
+
["dblab", "clone", "reset", "c-8f21", "--project", "main-db", "--json"],
|
|
189
196
|
isolatedEnv({ PGAI_API_KEY: "test-key", PGAI_API_BASE_URL: api.baseUrl })
|
|
190
197
|
);
|
|
191
198
|
expect(r.status).toBe(0);
|
|
@@ -202,7 +209,7 @@ describe("CLI DBLab companion command groups", () => {
|
|
|
202
209
|
const api = await startFakeApi();
|
|
203
210
|
try {
|
|
204
211
|
const r = await runCliAsync(
|
|
205
|
-
["clone", "destroy", "c-8f21", "--project", "12", "--json"],
|
|
212
|
+
["dblab", "clone", "destroy", "c-8f21", "--project", "12", "--json"],
|
|
206
213
|
isolatedEnv({ PGAI_API_KEY: "test-key", PGAI_API_BASE_URL: api.baseUrl })
|
|
207
214
|
);
|
|
208
215
|
expect(r.status).toBe(0);
|
|
@@ -219,7 +226,7 @@ describe("CLI DBLab companion command groups", () => {
|
|
|
219
226
|
const api = await startFakeApi();
|
|
220
227
|
try {
|
|
221
228
|
const r = await runCliAsync(
|
|
222
|
-
["branch", "create", "feature-idx", "--project", "main-db", "--snapshot", "latest", "--json"],
|
|
229
|
+
["dblab", "branch", "create", "feature-idx", "--project", "main-db", "--snapshot", "latest", "--json"],
|
|
223
230
|
isolatedEnv({ PGAI_API_KEY: "test-key", PGAI_API_BASE_URL: api.baseUrl })
|
|
224
231
|
);
|
|
225
232
|
expect(r.status).toBe(0);
|
|
@@ -236,7 +243,7 @@ describe("CLI DBLab companion command groups", () => {
|
|
|
236
243
|
const api = await startFakeApi();
|
|
237
244
|
try {
|
|
238
245
|
const r = await runCliAsync(
|
|
239
|
-
["branch", "log", "feature-idx", "--project", "main-db", "--json"],
|
|
246
|
+
["dblab", "branch", "log", "feature-idx", "--project", "main-db", "--json"],
|
|
240
247
|
isolatedEnv({ PGAI_API_KEY: "test-key", PGAI_API_BASE_URL: api.baseUrl })
|
|
241
248
|
);
|
|
242
249
|
expect(r.status).toBe(0);
|
|
@@ -252,7 +259,7 @@ describe("CLI DBLab companion command groups", () => {
|
|
|
252
259
|
const api = await startFakeApi();
|
|
253
260
|
try {
|
|
254
261
|
const r = await runCliAsync(
|
|
255
|
-
["snapshot", "list", "--project", "main-db", "--json"],
|
|
262
|
+
["dblab", "snapshot", "list", "--project", "main-db", "--json"],
|
|
256
263
|
isolatedEnv({ PGAI_API_KEY: "test-key", PGAI_API_BASE_URL: api.baseUrl })
|
|
257
264
|
);
|
|
258
265
|
expect(r.status).toBe(0);
|
|
@@ -268,7 +275,7 @@ describe("CLI DBLab companion command groups", () => {
|
|
|
268
275
|
const api = await startFakeApi();
|
|
269
276
|
try {
|
|
270
277
|
const r = await runCliAsync(
|
|
271
|
-
["snapshot", "create", "--project", "main-db", "--clone", "c-8f21", "--message", "before idx", "--json"],
|
|
278
|
+
["dblab", "snapshot", "create", "--project", "main-db", "--clone", "c-8f21", "--message", "before idx", "--json"],
|
|
272
279
|
isolatedEnv({ PGAI_API_KEY: "test-key", PGAI_API_BASE_URL: api.baseUrl })
|
|
273
280
|
);
|
|
274
281
|
expect(r.status).toBe(0);
|
|
@@ -284,7 +291,7 @@ describe("CLI DBLab companion command groups", () => {
|
|
|
284
291
|
const api = await startFakeApi();
|
|
285
292
|
try {
|
|
286
293
|
const r = await runCliAsync(
|
|
287
|
-
["snapshot", "destroy", "s-1", "--project", "main-db", "--force", "--json"],
|
|
294
|
+
["dblab", "snapshot", "destroy", "s-1", "--project", "main-db", "--force", "--json"],
|
|
288
295
|
isolatedEnv({ PGAI_API_KEY: "test-key", PGAI_API_BASE_URL: api.baseUrl })
|
|
289
296
|
);
|
|
290
297
|
expect(r.status).toBe(0);
|
|
@@ -300,7 +307,7 @@ describe("CLI DBLab companion command groups", () => {
|
|
|
300
307
|
const api = await startFakeApi();
|
|
301
308
|
try {
|
|
302
309
|
const r = await runCliAsync(
|
|
303
|
-
["clone", "list", "--project", "does-not-exist"],
|
|
310
|
+
["dblab", "clone", "list", "--project", "does-not-exist"],
|
|
304
311
|
isolatedEnv({ PGAI_API_KEY: "test-key", PGAI_API_BASE_URL: api.baseUrl })
|
|
305
312
|
);
|
|
306
313
|
expect(r.status).toBe(1);
|
|
@@ -327,7 +334,7 @@ describe("CLI DBLab companion command groups", () => {
|
|
|
327
334
|
try {
|
|
328
335
|
const baseUrl = `http://${server.hostname}:${server.port}/api/general`;
|
|
329
336
|
const r = await runCliAsync(
|
|
330
|
-
["clone", "destroy", "c-1", "--project", "main-db"],
|
|
337
|
+
["dblab", "clone", "destroy", "c-1", "--project", "main-db"],
|
|
331
338
|
isolatedEnv({ PGAI_API_KEY: "test-key", PGAI_API_BASE_URL: baseUrl })
|
|
332
339
|
);
|
|
333
340
|
expect(r.status).toBe(1);
|
package/test/joe.cli.test.ts
CHANGED
|
@@ -126,11 +126,22 @@ function startFakeApi() {
|
|
|
126
126
|
return { baseUrl, requests, stop: () => server.stop(true) };
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
describe("CLI Joe command surface", () => {
|
|
130
|
-
test("top-level help lists the
|
|
129
|
+
describe("CLI Joe command surface (grouped under `pgai joe …`)", () => {
|
|
130
|
+
test("top-level help lists the joe + dblab groups and top-level projects", () => {
|
|
131
131
|
const r = runCli(["--help"], isolatedEnv());
|
|
132
132
|
const out = `${r.stdout}\n${r.stderr}`;
|
|
133
|
-
|
|
133
|
+
// Joe verbs are grouped under `joe`; DBLab verbs under `dblab`. `projects`
|
|
134
|
+
// stays top-level (org-level, not a Joe endpoint).
|
|
135
|
+
for (const group of ["joe", "dblab", "projects"]) {
|
|
136
|
+
expect(out).toContain(group);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("pgai joe --help lists all the Joe verbs", () => {
|
|
141
|
+
const r = runCli(["joe", "--help"], isolatedEnv());
|
|
142
|
+
expect(r.status).toBe(0);
|
|
143
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
144
|
+
for (const verb of ["plan", "explain", "exec", "hypo", "activity", "terminate", "reset", "describe", "result", "status"]) {
|
|
134
145
|
expect(out).toContain(verb);
|
|
135
146
|
}
|
|
136
147
|
});
|
|
@@ -153,11 +164,11 @@ describe("CLI Joe command surface", () => {
|
|
|
153
164
|
}
|
|
154
165
|
});
|
|
155
166
|
|
|
156
|
-
test("pgai plan --project 12 submits command='plan' and prints the plan", async () => {
|
|
167
|
+
test("pgai joe plan --project 12 submits command='plan' and prints the plan", async () => {
|
|
157
168
|
const api = startFakeApi();
|
|
158
169
|
try {
|
|
159
170
|
const r = await runCliAsync(
|
|
160
|
-
["plan", "select * from users where email = 'x@acme.io'", "--project", "12"],
|
|
171
|
+
["joe", "plan", "select * from users where email = 'x@acme.io'", "--project", "12"],
|
|
161
172
|
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
162
173
|
);
|
|
163
174
|
expect(r.status).toBe(0);
|
|
@@ -174,11 +185,11 @@ describe("CLI Joe command surface", () => {
|
|
|
174
185
|
}
|
|
175
186
|
});
|
|
176
187
|
|
|
177
|
-
test("pgai plan --project main-db resolves the alias via projects_list", async () => {
|
|
188
|
+
test("pgai joe plan --project main-db resolves the alias via projects_list", async () => {
|
|
178
189
|
const api = startFakeApi();
|
|
179
190
|
try {
|
|
180
191
|
const r = await runCliAsync(
|
|
181
|
-
["plan", "select 1", "--project", "main-db"],
|
|
192
|
+
["joe", "plan", "select 1", "--project", "main-db"],
|
|
182
193
|
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
183
194
|
);
|
|
184
195
|
expect(r.status).toBe(0);
|
|
@@ -190,15 +201,15 @@ describe("CLI Joe command surface", () => {
|
|
|
190
201
|
}
|
|
191
202
|
});
|
|
192
203
|
|
|
193
|
-
test("optimize loop: plan then exec reuse the same session on one project", async () => {
|
|
204
|
+
test("optimize loop: joe plan then joe exec reuse the same session on one project", async () => {
|
|
194
205
|
const api = startFakeApi();
|
|
195
206
|
const env = isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl });
|
|
196
207
|
try {
|
|
197
208
|
// First command starts a fresh session; the server hands back session 88.
|
|
198
|
-
const first = await runCliAsync(["plan", "select 1", "--project", "12", "--new-session"], env);
|
|
209
|
+
const first = await runCliAsync(["joe", "plan", "select 1", "--project", "12", "--new-session"], env);
|
|
199
210
|
expect(first.status).toBe(0);
|
|
200
211
|
// Second command auto-reuses the stored session (88) on the same project.
|
|
201
|
-
const second = await runCliAsync(["exec", "create index on users (email)", "--project", "12"], env);
|
|
212
|
+
const second = await runCliAsync(["joe", "exec", "create index on users (email)", "--project", "12"], env);
|
|
202
213
|
expect(second.status).toBe(0);
|
|
203
214
|
expect(second.stdout).toContain("CREATE INDEX");
|
|
204
215
|
|
|
@@ -215,21 +226,21 @@ describe("CLI Joe command surface", () => {
|
|
|
215
226
|
}
|
|
216
227
|
});
|
|
217
228
|
|
|
218
|
-
test("cold clone exceeds the budget -> resume handle (exit 0), then pgai result", async () => {
|
|
229
|
+
test("cold clone exceeds the budget -> resume handle (exit 0), then pgai joe result", async () => {
|
|
219
230
|
const api = startFakeApi();
|
|
220
231
|
const env = isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl });
|
|
221
232
|
try {
|
|
222
233
|
// project 77 (COLD) stays `running`; --budget 0 forces immediate resume.
|
|
223
|
-
const r = await runCliAsync(["plan", "select 1", "--project", "77", "--budget", "0"], env);
|
|
234
|
+
const r = await runCliAsync(["joe", "plan", "select 1", "--project", "77", "--budget", "0"], env);
|
|
224
235
|
expect(r.status).toBe(0);
|
|
225
236
|
expect(r.stdout).toContain("resume:");
|
|
226
|
-
expect(r.stdout).toMatch(/pgai result \d+/);
|
|
237
|
+
expect(r.stdout).toMatch(/pgai joe result \d+/);
|
|
227
238
|
|
|
228
|
-
const match = r.stdout.match(/pgai result (\d+)/);
|
|
239
|
+
const match = r.stdout.match(/pgai joe result (\d+)/);
|
|
229
240
|
const commandId = match ? match[1] : "";
|
|
230
241
|
expect(commandId).not.toBe("");
|
|
231
242
|
|
|
232
|
-
const resumed = await runCliAsync(["result", commandId], env);
|
|
243
|
+
const resumed = await runCliAsync(["joe", "result", commandId], env);
|
|
233
244
|
expect(resumed.status).toBe(0);
|
|
234
245
|
expect(resumed.stdout).toContain("Seq Scan on users");
|
|
235
246
|
} finally {
|
|
@@ -241,7 +252,7 @@ describe("CLI Joe command surface", () => {
|
|
|
241
252
|
const api = startFakeApi();
|
|
242
253
|
try {
|
|
243
254
|
const r = await runCliAsync(
|
|
244
|
-
["plan", "select 1", "--project", "999"],
|
|
255
|
+
["joe", "plan", "select 1", "--project", "999"],
|
|
245
256
|
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
246
257
|
);
|
|
247
258
|
expect(r.status).toBe(1);
|
|
@@ -251,11 +262,11 @@ describe("CLI Joe command surface", () => {
|
|
|
251
262
|
}
|
|
252
263
|
});
|
|
253
264
|
|
|
254
|
-
test("terminate requires a numeric pid and maps args.pid", async () => {
|
|
265
|
+
test("joe terminate requires a numeric pid and maps args.pid", async () => {
|
|
255
266
|
const api = startFakeApi();
|
|
256
267
|
try {
|
|
257
268
|
const r = await runCliAsync(
|
|
258
|
-
["terminate", "4711", "--project", "12"],
|
|
269
|
+
["joe", "terminate", "4711", "--project", "12"],
|
|
259
270
|
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
260
271
|
);
|
|
261
272
|
expect(r.status).toBe(0);
|
|
@@ -268,11 +279,11 @@ describe("CLI Joe command surface", () => {
|
|
|
268
279
|
}
|
|
269
280
|
});
|
|
270
281
|
|
|
271
|
-
test("describe maps args.object (and --variant)", async () => {
|
|
282
|
+
test("joe describe maps args.object (and --variant)", async () => {
|
|
272
283
|
const api = startFakeApi();
|
|
273
284
|
try {
|
|
274
285
|
const r = await runCliAsync(
|
|
275
|
-
["describe", "users", "--project", "12", "--variant", "\\d+"],
|
|
286
|
+
["joe", "describe", "users", "--project", "12", "--variant", "\\d+"],
|
|
276
287
|
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
277
288
|
);
|
|
278
289
|
expect(r.status).toBe(0);
|
|
@@ -285,13 +296,13 @@ describe("CLI Joe command surface", () => {
|
|
|
285
296
|
});
|
|
286
297
|
|
|
287
298
|
test("missing --project fails fast", () => {
|
|
288
|
-
const r = runCli(["plan", "select 1"], isolatedEnv({ PGAI_API_KEY: "k" }));
|
|
299
|
+
const r = runCli(["joe", "plan", "select 1"], isolatedEnv({ PGAI_API_KEY: "k" }));
|
|
289
300
|
expect(r.status).toBe(1);
|
|
290
301
|
expect(`${r.stdout}\n${r.stderr}`).toContain("Project is required");
|
|
291
302
|
});
|
|
292
303
|
|
|
293
304
|
test("missing API key fails fast", () => {
|
|
294
|
-
const r = runCli(["plan", "select 1", "--project", "12"], isolatedEnv());
|
|
305
|
+
const r = runCli(["joe", "plan", "select 1", "--project", "12"], isolatedEnv());
|
|
295
306
|
expect(r.status).toBe(1);
|
|
296
307
|
expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
|
|
297
308
|
});
|
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
|
});
|