postgresai 0.16.0-rc.3 → 0.16.0-rc.4
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/CHANGELOG.md +11 -0
- package/bin/postgres-ai.ts +42 -19
- package/dist/bin/postgres-ai.js +149 -12
- package/lib/checkup-summary.ts +25 -0
- package/lib/checkup.ts +88 -2
- package/lib/init.ts +29 -0
- package/package.json +1 -1
- package/test/auth.test.ts +30 -1
- package/test/checkup.integration.test.ts +31 -21
- package/test/checkup.test.ts +48 -3
- package/test/init.test.ts +35 -0
- package/test/schema-validation.test.ts +40 -0
- package/test/test-utils.ts +5 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- `checkup --markdown` previously performed server-side conversion and sent the
|
|
8
|
+
full report JSON to the PostgresAI API even when `--no-upload` was set. The
|
|
9
|
+
flags are now mutually exclusive, and `--no-upload` prevents report data from
|
|
10
|
+
being sent to the PostgresAI API. Use `--json` or `--output` for local-only
|
|
11
|
+
output.
|
package/bin/postgres-ai.ts
CHANGED
|
@@ -335,12 +335,18 @@ function prepareUploadConfig(
|
|
|
335
335
|
console.error("Tip: run 'postgresai auth' or pass --api-key / set PGAI_API_KEY");
|
|
336
336
|
return null; // Signal to exit
|
|
337
337
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
338
|
+
if (opts.markdown) {
|
|
339
|
+
console.error("Notice: no API key configured — regular report upload is disabled.");
|
|
340
|
+
console.error(" The full report JSON will still be sent to the PostgresAI API for markdown conversion.");
|
|
341
|
+
console.error(" To avoid sending report data, replace --markdown with --no-upload and --json or --output.");
|
|
342
|
+
} else {
|
|
343
|
+
// No credentials and upload not explicitly requested: fall back to
|
|
344
|
+
// local-only mode, but say so prominently — skipping the upload silently
|
|
345
|
+
// hides the fact that results never reach the Console.
|
|
346
|
+
console.error("Notice: no API key configured — results will NOT be uploaded to PostgresAI.");
|
|
347
|
+
console.error(" To upload: run 'postgresai auth login' or pass --api-key / set PGAI_API_KEY.");
|
|
348
|
+
console.error(" To run locally without this notice, pass --no-upload.");
|
|
349
|
+
}
|
|
344
350
|
return undefined; // Skip upload, run checks locally
|
|
345
351
|
}
|
|
346
352
|
|
|
@@ -1996,7 +2002,7 @@ program
|
|
|
1996
2002
|
"project name or ID for remote upload (used with --upload; defaults to config defaultProject; auto-generated on first run)"
|
|
1997
2003
|
)
|
|
1998
2004
|
.option("--json", "output JSON to stdout")
|
|
1999
|
-
.option("--markdown", "output markdown
|
|
2005
|
+
.option("--markdown", "output markdown via PostgresAI API (transmits the full report JSON)")
|
|
2000
2006
|
.addHelpText(
|
|
2001
2007
|
"after",
|
|
2002
2008
|
[
|
|
@@ -2010,7 +2016,7 @@ program
|
|
|
2010
2016
|
" postgresai checkup postgresql://user:pass@host:5432/db --check-id H002",
|
|
2011
2017
|
" postgresai checkup postgresql://user:pass@host:5432/db --output ./reports",
|
|
2012
2018
|
" postgresai checkup postgresql://user:pass@host:5432/db --no-upload --json",
|
|
2013
|
-
" postgresai checkup postgresql://user:pass@host:5432/db --
|
|
2019
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --markdown",
|
|
2014
2020
|
].join("\n")
|
|
2015
2021
|
)
|
|
2016
2022
|
.action(async (checkIdOrConn: string | undefined, connArg: string | undefined, opts: CheckupOptions, cmd: Command) => {
|
|
@@ -2060,9 +2066,14 @@ program
|
|
|
2060
2066
|
process.exitCode = 1;
|
|
2061
2067
|
return;
|
|
2062
2068
|
}
|
|
2063
|
-
// Note: --json, --markdown and --upload/--no-upload are independent flags.
|
|
2064
|
-
// Use --no-upload to explicitly disable upload when using --json or --markdown.
|
|
2065
2069
|
const uploadExplicitlyDisabled = opts.upload === false;
|
|
2070
|
+
if (uploadExplicitlyDisabled && shouldConvertMarkdown) {
|
|
2071
|
+
console.error("Error: --no-upload and --markdown are mutually exclusive");
|
|
2072
|
+
console.error("Markdown conversion is performed by the PostgresAI API and transmits the full report JSON.");
|
|
2073
|
+
console.error("Drop --no-upload to allow transmission, or use --json or --output for local-only output.");
|
|
2074
|
+
process.exitCode = 1;
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2066
2077
|
let shouldUpload = !uploadExplicitlyDisabled;
|
|
2067
2078
|
|
|
2068
2079
|
// Preflight: validate/create output directory BEFORE connecting / running checks.
|
|
@@ -2308,7 +2319,9 @@ program
|
|
|
2308
2319
|
|
|
2309
2320
|
console.log('\nFor details:');
|
|
2310
2321
|
console.log(' --json Output JSON');
|
|
2311
|
-
|
|
2322
|
+
if (!uploadExplicitlyDisabled) {
|
|
2323
|
+
console.log(' --markdown Output markdown via PostgresAI API');
|
|
2324
|
+
}
|
|
2312
2325
|
console.log(' --output <dir> Save to directory');
|
|
2313
2326
|
}
|
|
2314
2327
|
} catch (error) {
|
|
@@ -3931,13 +3944,9 @@ targets
|
|
|
3931
3944
|
// Authentication and API key management
|
|
3932
3945
|
const auth = program.command("auth").description("authentication and API key management");
|
|
3933
3946
|
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
.option("--set-key <key>", "store API key directly without OAuth flow")
|
|
3938
|
-
.option("--port <port>", "local callback server port (default: random)", parseInt)
|
|
3939
|
-
.option("--debug", "enable debug output")
|
|
3940
|
-
.action(async (opts: { setKey?: string; port?: number; debug?: boolean }) => {
|
|
3947
|
+
type AuthLoginOptions = { setKey?: string; port?: number; debug?: boolean };
|
|
3948
|
+
|
|
3949
|
+
async function runAuthLogin(opts: AuthLoginOptions) {
|
|
3941
3950
|
// If --set-key is provided, store it directly without OAuth
|
|
3942
3951
|
if (opts.setKey) {
|
|
3943
3952
|
const trimmedKey = opts.setKey.trim();
|
|
@@ -4191,7 +4200,21 @@ auth
|
|
|
4191
4200
|
console.error(`Authentication error: ${message}`);
|
|
4192
4201
|
process.exit(1);
|
|
4193
4202
|
}
|
|
4194
|
-
|
|
4203
|
+
}
|
|
4204
|
+
|
|
4205
|
+
function configureLoginCommand(command: Command): Command {
|
|
4206
|
+
return command
|
|
4207
|
+
.description("authenticate via browser (OAuth) or store API key directly")
|
|
4208
|
+
.option("--set-key <key>", "store API key directly without OAuth flow")
|
|
4209
|
+
.option("--port <port>", "local callback server port (default: random)", parseInt)
|
|
4210
|
+
.option("--debug", "enable debug output");
|
|
4211
|
+
}
|
|
4212
|
+
|
|
4213
|
+
configureLoginCommand(auth.command("login", { isDefault: true }))
|
|
4214
|
+
.action(runAuthLogin);
|
|
4215
|
+
|
|
4216
|
+
configureLoginCommand(program.command("login"))
|
|
4217
|
+
.action(runAuthLogin);
|
|
4195
4218
|
|
|
4196
4219
|
auth
|
|
4197
4220
|
.command("show-key")
|
package/dist/bin/postgres-ai.js
CHANGED
|
@@ -13425,7 +13425,7 @@ var {
|
|
|
13425
13425
|
// package.json
|
|
13426
13426
|
var package_default = {
|
|
13427
13427
|
name: "postgresai",
|
|
13428
|
-
version: "0.16.0-rc.
|
|
13428
|
+
version: "0.16.0-rc.4",
|
|
13429
13429
|
description: "postgres_ai CLI",
|
|
13430
13430
|
license: "Apache-2.0",
|
|
13431
13431
|
private: false,
|
|
@@ -16256,7 +16256,7 @@ var Result = import_lib.default.Result;
|
|
|
16256
16256
|
var TypeOverrides = import_lib.default.TypeOverrides;
|
|
16257
16257
|
var defaults = import_lib.default.defaults;
|
|
16258
16258
|
// package.json
|
|
16259
|
-
var version = "0.16.0-rc.
|
|
16259
|
+
var version = "0.16.0-rc.4";
|
|
16260
16260
|
var package_default2 = {
|
|
16261
16261
|
name: "postgresai",
|
|
16262
16262
|
version,
|
|
@@ -29518,10 +29518,28 @@ async function checkCurrentUserPermissions(client) {
|
|
|
29518
29518
|
|
|
29519
29519
|
union all
|
|
29520
29520
|
|
|
29521
|
+
select
|
|
29522
|
+
'postgres_ai schema exists' as permission_name,
|
|
29523
|
+
'optional' as status,
|
|
29524
|
+
to_regnamespace('postgres_ai') is not null as granted
|
|
29525
|
+
|
|
29526
|
+
union all
|
|
29527
|
+
|
|
29528
|
+
select
|
|
29529
|
+
'usage on postgres_ai schema' as permission_name,
|
|
29530
|
+
'optional' as status,
|
|
29531
|
+
case
|
|
29532
|
+
when to_regnamespace('postgres_ai') is null then null
|
|
29533
|
+
else has_schema_privilege(current_user, 'postgres_ai', 'USAGE')
|
|
29534
|
+
end as granted
|
|
29535
|
+
|
|
29536
|
+
union all
|
|
29537
|
+
|
|
29521
29538
|
select
|
|
29522
29539
|
'postgres_ai.pg_statistic view exists' as permission_name,
|
|
29523
29540
|
'optional' as status,
|
|
29524
29541
|
case
|
|
29542
|
+
when to_regnamespace('postgres_ai') is null then null
|
|
29525
29543
|
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
|
|
29526
29544
|
else to_regclass('postgres_ai.pg_statistic') is not null
|
|
29527
29545
|
end as granted
|
|
@@ -29532,6 +29550,7 @@ async function checkCurrentUserPermissions(client) {
|
|
|
29532
29550
|
'select on postgres_ai.pg_statistic' as permission_name,
|
|
29533
29551
|
'optional' as status,
|
|
29534
29552
|
case
|
|
29553
|
+
when to_regnamespace('postgres_ai') is null then null
|
|
29535
29554
|
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
|
|
29536
29555
|
when to_regclass('postgres_ai.pg_statistic') is null then null
|
|
29537
29556
|
else has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'select')
|
|
@@ -29551,6 +29570,10 @@ async function checkCurrentUserPermissions(client) {
|
|
|
29551
29570
|
when permission_name like 'select on pg_catalog.pg_index' then
|
|
29552
29571
|
format('grant select on pg_catalog.pg_index to %I;', current_user)
|
|
29553
29572
|
end
|
|
29573
|
+
when permission_name = 'postgres_ai schema exists' and granted = false then
|
|
29574
|
+
'-- run postgresai prepare-db or create the postgres_ai schema and pg_statistic view manually'
|
|
29575
|
+
when permission_name = 'usage on postgres_ai schema' and granted = false then
|
|
29576
|
+
format('grant usage on schema postgres_ai to %I;', current_user)
|
|
29554
29577
|
when permission_name = 'postgres_ai.pg_statistic view exists' and granted = false then
|
|
29555
29578
|
'-- create postgres_ai.pg_statistic view (see setup script)'
|
|
29556
29579
|
when permission_name = 'select on postgres_ai.pg_statistic' and granted = false then
|
|
@@ -29577,6 +29600,10 @@ function formatPermissionCheckMessages(result) {
|
|
|
29577
29600
|
const warnings = [];
|
|
29578
29601
|
const errors3 = [];
|
|
29579
29602
|
for (const row of result.missingOptional) {
|
|
29603
|
+
if (row.permission_name === "postgres_ai schema exists") {
|
|
29604
|
+
warnings.push("Warning: optional: postgres_ai schema not found — F004/F005 (bloat estimates) will be skipped; run prepare-db or create the view manually to enable them.");
|
|
29605
|
+
continue;
|
|
29606
|
+
}
|
|
29580
29607
|
const fix = row.fix_command ? ` Fix: ${row.fix_command}` : "";
|
|
29581
29608
|
warnings.push(`Warning: optional permission missing — ${row.permission_name}.${fix}`);
|
|
29582
29609
|
}
|
|
@@ -32765,12 +32792,70 @@ async function generateF003(client, nodeName) {
|
|
|
32765
32792
|
};
|
|
32766
32793
|
return report;
|
|
32767
32794
|
}
|
|
32795
|
+
function bloatErrorStatus(err) {
|
|
32796
|
+
const error2 = err instanceof Error ? err.message : String(err);
|
|
32797
|
+
const code = typeof err === "object" && err !== null && "code" in err ? String(err.code || "") : "";
|
|
32798
|
+
const normalized = error2.toLowerCase();
|
|
32799
|
+
let reason = "query_error";
|
|
32800
|
+
if (code === "3F000" || normalized.includes('schema "postgres_ai" does not exist')) {
|
|
32801
|
+
reason = "missing_schema";
|
|
32802
|
+
} else if (code === "42P01" || normalized.includes('relation "postgres_ai.pg_statistic" does not exist')) {
|
|
32803
|
+
reason = "missing_view";
|
|
32804
|
+
} else if (code === "42501" || normalized.includes("permission denied")) {
|
|
32805
|
+
reason = "missing_grant";
|
|
32806
|
+
}
|
|
32807
|
+
return { ok: false, reason, error: error2 };
|
|
32808
|
+
}
|
|
32809
|
+
async function getBloatCheckStatus(client) {
|
|
32810
|
+
try {
|
|
32811
|
+
const result = await client.query(`
|
|
32812
|
+
select
|
|
32813
|
+
to_regnamespace('postgres_ai') is not null as schema_exists,
|
|
32814
|
+
case
|
|
32815
|
+
when to_regnamespace('postgres_ai') is null then false
|
|
32816
|
+
else has_schema_privilege(current_user, 'postgres_ai', 'USAGE')
|
|
32817
|
+
end as schema_usage,
|
|
32818
|
+
case
|
|
32819
|
+
when to_regnamespace('postgres_ai') is null then false
|
|
32820
|
+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then false
|
|
32821
|
+
else to_regclass('postgres_ai.pg_statistic') is not null
|
|
32822
|
+
end as view_exists,
|
|
32823
|
+
case
|
|
32824
|
+
when to_regnamespace('postgres_ai') is null then false
|
|
32825
|
+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then false
|
|
32826
|
+
when to_regclass('postgres_ai.pg_statistic') is null then false
|
|
32827
|
+
else has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'SELECT')
|
|
32828
|
+
end as view_select
|
|
32829
|
+
`);
|
|
32830
|
+
const capability = result.rows[0] || {};
|
|
32831
|
+
if (!capability.schema_exists) {
|
|
32832
|
+
return { ok: false, reason: "missing_schema", error: 'schema "postgres_ai" does not exist' };
|
|
32833
|
+
}
|
|
32834
|
+
if (!capability.schema_usage) {
|
|
32835
|
+
return { ok: false, reason: "missing_grant", error: "permission denied for schema postgres_ai" };
|
|
32836
|
+
}
|
|
32837
|
+
if (!capability.view_exists) {
|
|
32838
|
+
return { ok: false, reason: "missing_view", error: 'relation "postgres_ai.pg_statistic" does not exist' };
|
|
32839
|
+
}
|
|
32840
|
+
if (!capability.view_select) {
|
|
32841
|
+
return { ok: false, reason: "missing_grant", error: "permission denied for relation postgres_ai.pg_statistic" };
|
|
32842
|
+
}
|
|
32843
|
+
return { ok: true, reason: null, error: null };
|
|
32844
|
+
} catch (err) {
|
|
32845
|
+
return bloatErrorStatus(err);
|
|
32846
|
+
}
|
|
32847
|
+
}
|
|
32768
32848
|
async function generateF004(client, nodeName) {
|
|
32769
32849
|
const report = createBaseReport("F004", "Autovacuum: heap bloat (estimated)", nodeName);
|
|
32770
32850
|
const postgresVersion = await getPostgresVersion(client);
|
|
32771
32851
|
const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10);
|
|
32772
32852
|
let bloatedTables = [];
|
|
32853
|
+
let status = await getBloatCheckStatus(client);
|
|
32773
32854
|
try {
|
|
32855
|
+
if (!status.ok)
|
|
32856
|
+
throw Object.assign(new Error(status.error || "Bloat prerequisites unavailable"), {
|
|
32857
|
+
code: status.reason === "missing_schema" ? "3F000" : status.reason === "missing_view" ? "42P01" : status.reason === "missing_grant" ? "42501" : undefined
|
|
32858
|
+
});
|
|
32774
32859
|
const sql = getMetricSql(METRIC_NAMES.F004, pgMajorVersion);
|
|
32775
32860
|
const bloatResult = await client.query(sql);
|
|
32776
32861
|
const vacuumStatsResult = await client.query(`
|
|
@@ -32814,7 +32899,8 @@ async function generateF004(client, nodeName) {
|
|
|
32814
32899
|
};
|
|
32815
32900
|
});
|
|
32816
32901
|
} catch (err) {
|
|
32817
|
-
|
|
32902
|
+
status = bloatErrorStatus(err);
|
|
32903
|
+
const errorMsg = status.error || "Unknown error";
|
|
32818
32904
|
console.error(`[F004] Error estimating table bloat: ${errorMsg}`);
|
|
32819
32905
|
if (errorMsg.includes("postgres_ai.")) {
|
|
32820
32906
|
console.error(` Hint: Run "postgresai prepare-db <connection>" to create required objects.`);
|
|
@@ -32824,6 +32910,7 @@ async function generateF004(client, nodeName) {
|
|
|
32824
32910
|
const totalCount = bloatedTables.length;
|
|
32825
32911
|
const totalBloatSizeBytes = bloatedTables.reduce((sum, t) => sum + t.bloat_size, 0);
|
|
32826
32912
|
const dbEntry = {
|
|
32913
|
+
status,
|
|
32827
32914
|
bloated_tables: bloatedTables,
|
|
32828
32915
|
total_count: totalCount,
|
|
32829
32916
|
total_bloat_size_bytes: totalBloatSizeBytes,
|
|
@@ -32842,7 +32929,12 @@ async function generateF005(client, nodeName) {
|
|
|
32842
32929
|
const postgresVersion = await getPostgresVersion(client);
|
|
32843
32930
|
const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10);
|
|
32844
32931
|
let bloatedIndexes = [];
|
|
32932
|
+
let status = await getBloatCheckStatus(client);
|
|
32845
32933
|
try {
|
|
32934
|
+
if (!status.ok)
|
|
32935
|
+
throw Object.assign(new Error(status.error || "Bloat prerequisites unavailable"), {
|
|
32936
|
+
code: status.reason === "missing_schema" ? "3F000" : status.reason === "missing_view" ? "42P01" : status.reason === "missing_grant" ? "42501" : undefined
|
|
32937
|
+
});
|
|
32846
32938
|
const sql = getMetricSql(METRIC_NAMES.F005, pgMajorVersion);
|
|
32847
32939
|
const bloatResult = await client.query(sql);
|
|
32848
32940
|
const vacuumStatsResult = await client.query(`
|
|
@@ -32891,7 +32983,8 @@ async function generateF005(client, nodeName) {
|
|
|
32891
32983
|
};
|
|
32892
32984
|
});
|
|
32893
32985
|
} catch (err) {
|
|
32894
|
-
|
|
32986
|
+
status = bloatErrorStatus(err);
|
|
32987
|
+
const errorMsg = status.error || "Unknown error";
|
|
32895
32988
|
console.error(`[F005] Error estimating index bloat: ${errorMsg}`);
|
|
32896
32989
|
if (errorMsg.includes("postgres_ai.")) {
|
|
32897
32990
|
console.error(` Hint: Run "postgresai prepare-db <connection>" to create required objects.`);
|
|
@@ -32901,6 +32994,7 @@ async function generateF005(client, nodeName) {
|
|
|
32901
32994
|
const totalCount = bloatedIndexes.length;
|
|
32902
32995
|
const totalBloatSizeBytes = bloatedIndexes.reduce((sum, idx) => sum + idx.bloat_size, 0);
|
|
32903
32996
|
const dbEntry = {
|
|
32997
|
+
status,
|
|
32904
32998
|
bloated_indexes: bloatedIndexes,
|
|
32905
32999
|
total_count: totalCount,
|
|
32906
33000
|
total_bloat_size_bytes: totalBloatSizeBytes,
|
|
@@ -33573,6 +33667,10 @@ function generateCheckSummary(checkId, report) {
|
|
|
33573
33667
|
return summarizeF001(nodeData);
|
|
33574
33668
|
case "F003":
|
|
33575
33669
|
return summarizeF003(nodeData);
|
|
33670
|
+
case "F004":
|
|
33671
|
+
return summarizeBloat(nodeData, "table");
|
|
33672
|
+
case "F005":
|
|
33673
|
+
return summarizeBloat(nodeData, "index");
|
|
33576
33674
|
case "G001":
|
|
33577
33675
|
return summarizeG001(nodeData);
|
|
33578
33676
|
case "G003":
|
|
@@ -33758,6 +33856,25 @@ function summarizeF003(nodeData) {
|
|
|
33758
33856
|
}
|
|
33759
33857
|
return { status: "warning", message: parts.join(", ") };
|
|
33760
33858
|
}
|
|
33859
|
+
function summarizeBloat(nodeData, kind) {
|
|
33860
|
+
const data = nodeData?.data || {};
|
|
33861
|
+
let totalCount = 0;
|
|
33862
|
+
for (const dbData of Object.values(data)) {
|
|
33863
|
+
const dbEntry = dbData;
|
|
33864
|
+
if (dbEntry?.status?.ok === false) {
|
|
33865
|
+
const reason = String(dbEntry.status.reason || "query_error").replaceAll("_", " ");
|
|
33866
|
+
return { status: "warning", message: `Bloat estimate degraded: ${reason}` };
|
|
33867
|
+
}
|
|
33868
|
+
totalCount += dbEntry?.total_count || 0;
|
|
33869
|
+
}
|
|
33870
|
+
if (totalCount === 0) {
|
|
33871
|
+
return { status: "ok", message: `No bloated ${kind}${kind === "index" ? "es" : "s"} found` };
|
|
33872
|
+
}
|
|
33873
|
+
return {
|
|
33874
|
+
status: "warning",
|
|
33875
|
+
message: `Found ${totalCount} bloated ${kind}${totalCount === 1 ? "" : kind === "index" ? "es" : "s"}`
|
|
33876
|
+
};
|
|
33877
|
+
}
|
|
33761
33878
|
function summarizeG001(nodeData) {
|
|
33762
33879
|
const data = nodeData?.data || {};
|
|
33763
33880
|
const settingsCount = Object.keys(data).length;
|
|
@@ -34114,9 +34231,15 @@ function prepareUploadConfig(opts, rootOpts, shouldUpload, uploadExplicitlyReque
|
|
|
34114
34231
|
console.error("Tip: run 'postgresai auth' or pass --api-key / set PGAI_API_KEY");
|
|
34115
34232
|
return null;
|
|
34116
34233
|
}
|
|
34117
|
-
|
|
34118
|
-
|
|
34119
|
-
|
|
34234
|
+
if (opts.markdown) {
|
|
34235
|
+
console.error("Notice: no API key configured \u2014 regular report upload is disabled.");
|
|
34236
|
+
console.error(" The full report JSON will still be sent to the PostgresAI API for markdown conversion.");
|
|
34237
|
+
console.error(" To avoid sending report data, replace --markdown with --no-upload and --json or --output.");
|
|
34238
|
+
} else {
|
|
34239
|
+
console.error("Notice: no API key configured \u2014 results will NOT be uploaded to PostgresAI.");
|
|
34240
|
+
console.error(" To upload: run 'postgresai auth login' or pass --api-key / set PGAI_API_KEY.");
|
|
34241
|
+
console.error(" To run locally without this notice, pass --no-upload.");
|
|
34242
|
+
}
|
|
34120
34243
|
return;
|
|
34121
34244
|
}
|
|
34122
34245
|
const cfg = readConfig();
|
|
@@ -35341,7 +35464,7 @@ program2.command("unprepare-db [conn]").description("remove monitoring setup: dr
|
|
|
35341
35464
|
closeReadline();
|
|
35342
35465
|
}
|
|
35343
35466
|
});
|
|
35344
|
-
program2.command("checkup [checkIdOrConn] [conn]").description("generate health check reports directly from PostgreSQL (express mode)").option("--check-id <id>", `specific check to run (see list below), or ALL`).option("--node-name <name>", "node name for reports", "node-01").option("--output <path>", "output directory for JSON files").option("--upload", "upload JSON results to PostgresAI (requires API key)").option("--no-upload", "disable upload to PostgresAI").option("--project <project>", "project name or ID for remote upload (used with --upload; defaults to config defaultProject; auto-generated on first run)").option("--json", "output JSON to stdout").option("--markdown", "output markdown
|
|
35467
|
+
program2.command("checkup [checkIdOrConn] [conn]").description("generate health check reports directly from PostgreSQL (express mode)").option("--check-id <id>", `specific check to run (see list below), or ALL`).option("--node-name <name>", "node name for reports", "node-01").option("--output <path>", "output directory for JSON files").option("--upload", "upload JSON results to PostgresAI (requires API key)").option("--no-upload", "disable upload to PostgresAI").option("--project <project>", "project name or ID for remote upload (used with --upload; defaults to config defaultProject; auto-generated on first run)").option("--json", "output JSON to stdout").option("--markdown", "output markdown via PostgresAI API (transmits the full report JSON)").addHelpText("after", [
|
|
35345
35468
|
"",
|
|
35346
35469
|
"Available checks:",
|
|
35347
35470
|
...Object.entries(CHECK_INFO).map(([id, title]) => ` ${id}: ${title}`),
|
|
@@ -35352,7 +35475,7 @@ program2.command("checkup [checkIdOrConn] [conn]").description("generate health
|
|
|
35352
35475
|
" postgresai checkup postgresql://user:pass@host:5432/db --check-id H002",
|
|
35353
35476
|
" postgresai checkup postgresql://user:pass@host:5432/db --output ./reports",
|
|
35354
35477
|
" postgresai checkup postgresql://user:pass@host:5432/db --no-upload --json",
|
|
35355
|
-
" postgresai checkup postgresql://user:pass@host:5432/db --
|
|
35478
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --markdown"
|
|
35356
35479
|
].join(`
|
|
35357
35480
|
`)).action(async (checkIdOrConn, connArg, opts, cmd) => {
|
|
35358
35481
|
const checkIdPattern = /^[A-Z]\d{3}$/i;
|
|
@@ -35392,6 +35515,13 @@ Usage: postgresai checkup ${checkId} postgresql://user@host:5432/dbname
|
|
|
35392
35515
|
return;
|
|
35393
35516
|
}
|
|
35394
35517
|
const uploadExplicitlyDisabled = opts.upload === false;
|
|
35518
|
+
if (uploadExplicitlyDisabled && shouldConvertMarkdown) {
|
|
35519
|
+
console.error("Error: --no-upload and --markdown are mutually exclusive");
|
|
35520
|
+
console.error("Markdown conversion is performed by the PostgresAI API and transmits the full report JSON.");
|
|
35521
|
+
console.error("Drop --no-upload to allow transmission, or use --json or --output for local-only output.");
|
|
35522
|
+
process.exitCode = 1;
|
|
35523
|
+
return;
|
|
35524
|
+
}
|
|
35395
35525
|
let shouldUpload = !uploadExplicitlyDisabled;
|
|
35396
35526
|
const outputPath = prepareOutputDirectory(opts.output);
|
|
35397
35527
|
if (outputPath === null) {
|
|
@@ -35574,7 +35704,9 @@ Usage: postgresai checkup ${checkId} postgresql://user@host:5432/dbname
|
|
|
35574
35704
|
console.log(`
|
|
35575
35705
|
For details:`);
|
|
35576
35706
|
console.log(" --json Output JSON");
|
|
35577
|
-
|
|
35707
|
+
if (!uploadExplicitlyDisabled) {
|
|
35708
|
+
console.log(" --markdown Output markdown via PostgresAI API");
|
|
35709
|
+
}
|
|
35578
35710
|
console.log(" --output <dir> Save to directory");
|
|
35579
35711
|
}
|
|
35580
35712
|
} catch (error2) {
|
|
@@ -36789,7 +36921,7 @@ targets.command("test <name>").description("test monitoring target database conn
|
|
|
36789
36921
|
}
|
|
36790
36922
|
});
|
|
36791
36923
|
var auth = program2.command("auth").description("authentication and API key management");
|
|
36792
|
-
|
|
36924
|
+
async function runAuthLogin(opts) {
|
|
36793
36925
|
if (opts.setKey) {
|
|
36794
36926
|
const trimmedKey = opts.setKey.trim();
|
|
36795
36927
|
if (!trimmedKey) {
|
|
@@ -36990,7 +37122,12 @@ Authentication failed: ${message}`);
|
|
|
36990
37122
|
console.error(`Authentication error: ${message}`);
|
|
36991
37123
|
process.exit(1);
|
|
36992
37124
|
}
|
|
36993
|
-
}
|
|
37125
|
+
}
|
|
37126
|
+
function configureLoginCommand(command) {
|
|
37127
|
+
return command.description("authenticate via browser (OAuth) or store API key directly").option("--set-key <key>", "store API key directly without OAuth flow").option("--port <port>", "local callback server port (default: random)", parseInt).option("--debug", "enable debug output");
|
|
37128
|
+
}
|
|
37129
|
+
configureLoginCommand(auth.command("login", { isDefault: true })).action(runAuthLogin);
|
|
37130
|
+
configureLoginCommand(program2.command("login")).action(runAuthLogin);
|
|
36994
37131
|
auth.command("show-key").description("show API key (masked)").action(async () => {
|
|
36995
37132
|
const cfg = readConfig();
|
|
36996
37133
|
if (!cfg.apiKey) {
|
package/lib/checkup-summary.ts
CHANGED
|
@@ -41,6 +41,8 @@ export function generateCheckSummary(checkId: string, report: any): CheckSummary
|
|
|
41
41
|
case 'D004': return summarizeD004(nodeData);
|
|
42
42
|
case 'F001': return summarizeF001(nodeData);
|
|
43
43
|
case 'F003': return summarizeF003(nodeData);
|
|
44
|
+
case 'F004': return summarizeBloat(nodeData, 'table');
|
|
45
|
+
case 'F005': return summarizeBloat(nodeData, 'index');
|
|
44
46
|
case 'G001': return summarizeG001(nodeData);
|
|
45
47
|
case 'G003': return summarizeG003(nodeData);
|
|
46
48
|
default:
|
|
@@ -273,6 +275,29 @@ function summarizeF003(nodeData: any): CheckSummary {
|
|
|
273
275
|
return { status: 'warning', message: parts.join(', ') };
|
|
274
276
|
}
|
|
275
277
|
|
|
278
|
+
function summarizeBloat(nodeData: any, kind: 'table' | 'index'): CheckSummary {
|
|
279
|
+
const data = nodeData?.data || {};
|
|
280
|
+
let totalCount = 0;
|
|
281
|
+
|
|
282
|
+
for (const dbData of Object.values(data)) {
|
|
283
|
+
const dbEntry = dbData as any;
|
|
284
|
+
if (dbEntry?.status?.ok === false) {
|
|
285
|
+
const reason = String(dbEntry.status.reason || 'query_error').replaceAll('_', ' ');
|
|
286
|
+
return { status: 'warning', message: `Bloat estimate degraded: ${reason}` };
|
|
287
|
+
}
|
|
288
|
+
totalCount += dbEntry?.total_count || 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (totalCount === 0) {
|
|
292
|
+
return { status: 'ok', message: `No bloated ${kind}${kind === 'index' ? 'es' : 's'} found` };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
status: 'warning',
|
|
297
|
+
message: `Found ${totalCount} bloated ${kind}${totalCount === 1 ? '' : kind === 'index' ? 'es' : 's'}`,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
276
301
|
function summarizeG001(nodeData: any): CheckSummary {
|
|
277
302
|
const data = nodeData?.data || {};
|
|
278
303
|
const settingsCount = Object.keys(data).length;
|
package/lib/checkup.ts
CHANGED
|
@@ -1498,6 +1498,74 @@ async function generateF003(client: Client, nodeName: string): Promise<Report> {
|
|
|
1498
1498
|
* Uses pg_stats for column statistics to estimate row sizes.
|
|
1499
1499
|
* SQL loaded from config/pgwatch-prometheus/metrics.yml (pg_table_bloat metric).
|
|
1500
1500
|
*/
|
|
1501
|
+
type BloatCheckReason = "missing_schema" | "missing_view" | "missing_grant" | "query_error";
|
|
1502
|
+
|
|
1503
|
+
interface BloatCheckStatus {
|
|
1504
|
+
ok: boolean;
|
|
1505
|
+
reason: BloatCheckReason | null;
|
|
1506
|
+
error: string | null;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
function bloatErrorStatus(err: unknown): BloatCheckStatus {
|
|
1510
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
1511
|
+
const code = typeof err === "object" && err !== null && "code" in err
|
|
1512
|
+
? String((err as { code?: unknown }).code || "")
|
|
1513
|
+
: "";
|
|
1514
|
+
const normalized = error.toLowerCase();
|
|
1515
|
+
|
|
1516
|
+
let reason: BloatCheckReason = "query_error";
|
|
1517
|
+
if (code === "3F000" || normalized.includes('schema "postgres_ai" does not exist')) {
|
|
1518
|
+
reason = "missing_schema";
|
|
1519
|
+
} else if (code === "42P01" || normalized.includes('relation "postgres_ai.pg_statistic" does not exist')) {
|
|
1520
|
+
reason = "missing_view";
|
|
1521
|
+
} else if (code === "42501" || normalized.includes("permission denied")) {
|
|
1522
|
+
reason = "missing_grant";
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
return { ok: false, reason, error };
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
async function getBloatCheckStatus(client: Client): Promise<BloatCheckStatus> {
|
|
1529
|
+
try {
|
|
1530
|
+
const result = await client.query(`
|
|
1531
|
+
select
|
|
1532
|
+
to_regnamespace('postgres_ai') is not null as schema_exists,
|
|
1533
|
+
case
|
|
1534
|
+
when to_regnamespace('postgres_ai') is null then false
|
|
1535
|
+
else has_schema_privilege(current_user, 'postgres_ai', 'USAGE')
|
|
1536
|
+
end as schema_usage,
|
|
1537
|
+
case
|
|
1538
|
+
when to_regnamespace('postgres_ai') is null then false
|
|
1539
|
+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then false
|
|
1540
|
+
else to_regclass('postgres_ai.pg_statistic') is not null
|
|
1541
|
+
end as view_exists,
|
|
1542
|
+
case
|
|
1543
|
+
when to_regnamespace('postgres_ai') is null then false
|
|
1544
|
+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then false
|
|
1545
|
+
when to_regclass('postgres_ai.pg_statistic') is null then false
|
|
1546
|
+
else has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'SELECT')
|
|
1547
|
+
end as view_select
|
|
1548
|
+
`);
|
|
1549
|
+
const capability = result.rows[0] || {};
|
|
1550
|
+
|
|
1551
|
+
if (!capability.schema_exists) {
|
|
1552
|
+
return { ok: false, reason: "missing_schema", error: 'schema "postgres_ai" does not exist' };
|
|
1553
|
+
}
|
|
1554
|
+
if (!capability.schema_usage) {
|
|
1555
|
+
return { ok: false, reason: "missing_grant", error: "permission denied for schema postgres_ai" };
|
|
1556
|
+
}
|
|
1557
|
+
if (!capability.view_exists) {
|
|
1558
|
+
return { ok: false, reason: "missing_view", error: 'relation "postgres_ai.pg_statistic" does not exist' };
|
|
1559
|
+
}
|
|
1560
|
+
if (!capability.view_select) {
|
|
1561
|
+
return { ok: false, reason: "missing_grant", error: "permission denied for relation postgres_ai.pg_statistic" };
|
|
1562
|
+
}
|
|
1563
|
+
return { ok: true, reason: null, error: null };
|
|
1564
|
+
} catch (err) {
|
|
1565
|
+
return bloatErrorStatus(err);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1501
1569
|
async function generateF004(client: Client, nodeName: string): Promise<Report> {
|
|
1502
1570
|
const report = createBaseReport("F004", "Autovacuum: heap bloat (estimated)", nodeName);
|
|
1503
1571
|
const postgresVersion = await getPostgresVersion(client);
|
|
@@ -1520,8 +1588,15 @@ async function generateF004(client: Client, nodeName: string): Promise<Report> {
|
|
|
1520
1588
|
}
|
|
1521
1589
|
|
|
1522
1590
|
let bloatedTables: BloatedTable[] = [];
|
|
1591
|
+
let status = await getBloatCheckStatus(client);
|
|
1523
1592
|
|
|
1524
1593
|
try {
|
|
1594
|
+
if (!status.ok) throw Object.assign(new Error(status.error || "Bloat prerequisites unavailable"), {
|
|
1595
|
+
code: status.reason === "missing_schema" ? "3F000"
|
|
1596
|
+
: status.reason === "missing_view" ? "42P01"
|
|
1597
|
+
: status.reason === "missing_grant" ? "42501"
|
|
1598
|
+
: undefined,
|
|
1599
|
+
});
|
|
1525
1600
|
// Get bloat data
|
|
1526
1601
|
const sql = getMetricSql(METRIC_NAMES.F004, pgMajorVersion);
|
|
1527
1602
|
const bloatResult = await client.query(sql);
|
|
@@ -1572,7 +1647,8 @@ async function generateF004(client: Client, nodeName: string): Promise<Report> {
|
|
|
1572
1647
|
};
|
|
1573
1648
|
});
|
|
1574
1649
|
} catch (err) {
|
|
1575
|
-
|
|
1650
|
+
status = bloatErrorStatus(err);
|
|
1651
|
+
const errorMsg = status.error || "Unknown error";
|
|
1576
1652
|
console.error(`[F004] Error estimating table bloat: ${errorMsg}`);
|
|
1577
1653
|
if (errorMsg.includes("postgres_ai.")) {
|
|
1578
1654
|
console.error(` Hint: Run "postgresai prepare-db <connection>" to create required objects.`);
|
|
@@ -1587,6 +1663,7 @@ async function generateF004(client: Client, nodeName: string): Promise<Report> {
|
|
|
1587
1663
|
const totalBloatSizeBytes = bloatedTables.reduce((sum, t) => sum + t.bloat_size, 0);
|
|
1588
1664
|
|
|
1589
1665
|
const dbEntry = {
|
|
1666
|
+
status,
|
|
1590
1667
|
bloated_tables: bloatedTables,
|
|
1591
1668
|
total_count: totalCount,
|
|
1592
1669
|
total_bloat_size_bytes: totalBloatSizeBytes,
|
|
@@ -1634,8 +1711,15 @@ async function generateF005(client: Client, nodeName: string): Promise<Report> {
|
|
|
1634
1711
|
}
|
|
1635
1712
|
|
|
1636
1713
|
let bloatedIndexes: BloatedIndex[] = [];
|
|
1714
|
+
let status = await getBloatCheckStatus(client);
|
|
1637
1715
|
|
|
1638
1716
|
try {
|
|
1717
|
+
if (!status.ok) throw Object.assign(new Error(status.error || "Bloat prerequisites unavailable"), {
|
|
1718
|
+
code: status.reason === "missing_schema" ? "3F000"
|
|
1719
|
+
: status.reason === "missing_view" ? "42P01"
|
|
1720
|
+
: status.reason === "missing_grant" ? "42501"
|
|
1721
|
+
: undefined,
|
|
1722
|
+
});
|
|
1639
1723
|
// Get bloat data
|
|
1640
1724
|
const sql = getMetricSql(METRIC_NAMES.F005, pgMajorVersion);
|
|
1641
1725
|
const bloatResult = await client.query(sql);
|
|
@@ -1690,7 +1774,8 @@ async function generateF005(client: Client, nodeName: string): Promise<Report> {
|
|
|
1690
1774
|
};
|
|
1691
1775
|
});
|
|
1692
1776
|
} catch (err) {
|
|
1693
|
-
|
|
1777
|
+
status = bloatErrorStatus(err);
|
|
1778
|
+
const errorMsg = status.error || "Unknown error";
|
|
1694
1779
|
console.error(`[F005] Error estimating index bloat: ${errorMsg}`);
|
|
1695
1780
|
if (errorMsg.includes("postgres_ai.")) {
|
|
1696
1781
|
console.error(` Hint: Run "postgresai prepare-db <connection>" to create required objects.`);
|
|
@@ -1705,6 +1790,7 @@ async function generateF005(client: Client, nodeName: string): Promise<Report> {
|
|
|
1705
1790
|
const totalBloatSizeBytes = bloatedIndexes.reduce((sum, idx) => sum + idx.bloat_size, 0);
|
|
1706
1791
|
|
|
1707
1792
|
const dbEntry = {
|
|
1793
|
+
status,
|
|
1708
1794
|
bloated_indexes: bloatedIndexes,
|
|
1709
1795
|
total_count: totalCount,
|
|
1710
1796
|
total_bloat_size_bytes: totalBloatSizeBytes,
|
package/lib/init.ts
CHANGED
|
@@ -1019,10 +1019,28 @@ export async function checkCurrentUserPermissions(
|
|
|
1019
1019
|
|
|
1020
1020
|
union all
|
|
1021
1021
|
|
|
1022
|
+
select
|
|
1023
|
+
'postgres_ai schema exists' as permission_name,
|
|
1024
|
+
'optional' as status,
|
|
1025
|
+
to_regnamespace('postgres_ai') is not null as granted
|
|
1026
|
+
|
|
1027
|
+
union all
|
|
1028
|
+
|
|
1029
|
+
select
|
|
1030
|
+
'usage on postgres_ai schema' as permission_name,
|
|
1031
|
+
'optional' as status,
|
|
1032
|
+
case
|
|
1033
|
+
when to_regnamespace('postgres_ai') is null then null
|
|
1034
|
+
else has_schema_privilege(current_user, 'postgres_ai', 'USAGE')
|
|
1035
|
+
end as granted
|
|
1036
|
+
|
|
1037
|
+
union all
|
|
1038
|
+
|
|
1022
1039
|
select
|
|
1023
1040
|
'postgres_ai.pg_statistic view exists' as permission_name,
|
|
1024
1041
|
'optional' as status,
|
|
1025
1042
|
case
|
|
1043
|
+
when to_regnamespace('postgres_ai') is null then null
|
|
1026
1044
|
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
|
|
1027
1045
|
else to_regclass('postgres_ai.pg_statistic') is not null
|
|
1028
1046
|
end as granted
|
|
@@ -1033,6 +1051,7 @@ export async function checkCurrentUserPermissions(
|
|
|
1033
1051
|
'select on postgres_ai.pg_statistic' as permission_name,
|
|
1034
1052
|
'optional' as status,
|
|
1035
1053
|
case
|
|
1054
|
+
when to_regnamespace('postgres_ai') is null then null
|
|
1036
1055
|
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
|
|
1037
1056
|
when to_regclass('postgres_ai.pg_statistic') is null then null
|
|
1038
1057
|
else has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'select')
|
|
@@ -1052,6 +1071,10 @@ export async function checkCurrentUserPermissions(
|
|
|
1052
1071
|
when permission_name like 'select on pg_catalog.pg_index' then
|
|
1053
1072
|
format('grant select on pg_catalog.pg_index to %I;', current_user)
|
|
1054
1073
|
end
|
|
1074
|
+
when permission_name = 'postgres_ai schema exists' and granted = false then
|
|
1075
|
+
'-- run postgresai prepare-db or create the postgres_ai schema and pg_statistic view manually'
|
|
1076
|
+
when permission_name = 'usage on postgres_ai schema' and granted = false then
|
|
1077
|
+
format('grant usage on schema postgres_ai to %I;', current_user)
|
|
1055
1078
|
when permission_name = 'postgres_ai.pg_statistic view exists' and granted = false then
|
|
1056
1079
|
'-- create postgres_ai.pg_statistic view (see setup script)'
|
|
1057
1080
|
when permission_name = 'select on postgres_ai.pg_statistic' and granted = false then
|
|
@@ -1097,6 +1120,12 @@ export function formatPermissionCheckMessages(result: PreflightPermissionResult)
|
|
|
1097
1120
|
const errors: string[] = [];
|
|
1098
1121
|
|
|
1099
1122
|
for (const row of result.missingOptional) {
|
|
1123
|
+
if (row.permission_name === "postgres_ai schema exists") {
|
|
1124
|
+
warnings.push(
|
|
1125
|
+
"Warning: optional: postgres_ai schema not found — F004/F005 (bloat estimates) will be skipped; run prepare-db or create the view manually to enable them."
|
|
1126
|
+
);
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1100
1129
|
const fix = row.fix_command ? ` Fix: ${row.fix_command}` : "";
|
|
1101
1130
|
warnings.push(`Warning: optional permission missing — ${row.permission_name}.${fix}`);
|
|
1102
1131
|
}
|
package/package.json
CHANGED
package/test/auth.test.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join, resolve } from "path";
|
|
3
5
|
|
|
4
6
|
import * as util from "../lib/util";
|
|
5
7
|
import * as pkce from "../lib/pkce";
|
|
@@ -212,6 +214,33 @@ describe("Auth callback server", () => {
|
|
|
212
214
|
});
|
|
213
215
|
|
|
214
216
|
describe("CLI auth commands", () => {
|
|
217
|
+
test("cli: login --help shows all options", () => {
|
|
218
|
+
const r = runCli(["login", "--help"]);
|
|
219
|
+
expect(r.status).toBe(0);
|
|
220
|
+
expect(r.stdout).toMatch(/--set-key/);
|
|
221
|
+
expect(r.stdout).toMatch(/--debug/);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("cli: login --set-key aliases auth login", () => {
|
|
225
|
+
const home = mkdtempSync(join(tmpdir(), "postgresai-login-"));
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const r = runCli(["login", "--set-key", "test-token"], {
|
|
229
|
+
HOME: home,
|
|
230
|
+
XDG_CONFIG_HOME: join(home, ".config"),
|
|
231
|
+
});
|
|
232
|
+
expect(r.status).toBe(0);
|
|
233
|
+
expect(r.stdout).toMatch(/API key saved/);
|
|
234
|
+
|
|
235
|
+
const saved = JSON.parse(
|
|
236
|
+
readFileSync(join(home, ".config", "postgresai", "config.json"), "utf8")
|
|
237
|
+
);
|
|
238
|
+
expect(saved.apiKey).toBe("test-token");
|
|
239
|
+
} finally {
|
|
240
|
+
rmSync(home, { recursive: true, force: true });
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
215
244
|
test("cli: auth login --help shows all options", () => {
|
|
216
245
|
const r = runCli(["auth", "login", "--help"]);
|
|
217
246
|
expect(r.status).toBe(0);
|
|
@@ -14,6 +14,7 @@ import { readFileSync } from "fs";
|
|
|
14
14
|
import Ajv2020 from "ajv/dist/2020";
|
|
15
15
|
|
|
16
16
|
import * as checkup from "../lib/checkup";
|
|
17
|
+
import { checkCurrentUserPermissions } from "../lib/init";
|
|
17
18
|
|
|
18
19
|
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
19
20
|
const schemasDir = resolve(import.meta.dir, "../../reporter/schemas");
|
|
@@ -191,6 +192,36 @@ describe.skipIf(!!skipReason)("checkup integration: express mode schema compatib
|
|
|
191
192
|
// Test all checks supported by express mode
|
|
192
193
|
const expressChecks = Object.keys(checkup.CHECK_INFO);
|
|
193
194
|
|
|
195
|
+
test("vanilla database preflight is non-fatal and full CLI output marks F004/F005 degraded", async () => {
|
|
196
|
+
const permissions = await checkCurrentUserPermissions(client);
|
|
197
|
+
expect(permissions.ok).toBe(true);
|
|
198
|
+
expect(permissions.missingOptional.some(
|
|
199
|
+
(row) => row.permission_name === "postgres_ai schema exists"
|
|
200
|
+
)).toBe(true);
|
|
201
|
+
|
|
202
|
+
const connString = `postgresql://postgres@localhost:${pg.port}/postgres?host=${encodeURIComponent(pg.socketDir)}`;
|
|
203
|
+
const cliPath = path.resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
204
|
+
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
205
|
+
const result = Bun.spawnSync(
|
|
206
|
+
[bunBin, cliPath, "checkup", connString, "--no-upload", "--json"],
|
|
207
|
+
{ env: { ...process.env, XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config" } }
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const stderr = new TextDecoder().decode(result.stderr);
|
|
211
|
+
if (result.exitCode !== 0) {
|
|
212
|
+
throw new Error(`CLI exited ${result.exitCode}: ${stderr}`);
|
|
213
|
+
}
|
|
214
|
+
const reports = JSON.parse(new TextDecoder().decode(result.stdout));
|
|
215
|
+
expect(Object.keys(reports)).toHaveLength(17);
|
|
216
|
+
expect(stderr).toContain("optional: postgres_ai schema not found");
|
|
217
|
+
for (const checkId of ["F004", "F005"]) {
|
|
218
|
+
const dbEntry = reports[checkId].results["node-01"].data.postgres;
|
|
219
|
+
expect(dbEntry.status.ok).toBe(false);
|
|
220
|
+
expect(dbEntry.status.reason).toBe("missing_schema");
|
|
221
|
+
expect(dbEntry.status.error).toMatch(/schema "postgres_ai" does not exist/i);
|
|
222
|
+
}
|
|
223
|
+
}, { timeout: 60000 });
|
|
224
|
+
|
|
194
225
|
for (const checkId of expressChecks) {
|
|
195
226
|
test(`${checkId} report validates against shared schema`, async () => {
|
|
196
227
|
const generator = checkup.REPORT_GENERATORS[checkId];
|
|
@@ -379,25 +410,4 @@ describe.skipIf(!!skipReason)("checkup integration: express mode schema compatib
|
|
|
379
410
|
}
|
|
380
411
|
});
|
|
381
412
|
|
|
382
|
-
test("CLI --markdown flag works without API key", async () => {
|
|
383
|
-
// Test that --markdown works even without an API key
|
|
384
|
-
const connString = `postgresql://postgres@${pg.socketDir}:${pg.port}/postgres`;
|
|
385
|
-
const cliPath = path.resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
386
|
-
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
387
|
-
|
|
388
|
-
const result = Bun.spawnSync(
|
|
389
|
-
[bunBin, cliPath, "checkup", connString, "--check-id", "H002", "--markdown", "--no-upload"],
|
|
390
|
-
{
|
|
391
|
-
env: {
|
|
392
|
-
...process.env,
|
|
393
|
-
XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config",
|
|
394
|
-
},
|
|
395
|
-
}
|
|
396
|
-
);
|
|
397
|
-
|
|
398
|
-
const stderr = new TextDecoder().decode(result.stderr);
|
|
399
|
-
|
|
400
|
-
// Should not complain about missing API key
|
|
401
|
-
expect(stderr).not.toMatch(/API key is required/i);
|
|
402
|
-
});
|
|
403
413
|
});
|
package/test/checkup.test.ts
CHANGED
|
@@ -1539,12 +1539,13 @@ describe("CLI tests", () => {
|
|
|
1539
1539
|
const r = runCli(["checkup", "--help"]);
|
|
1540
1540
|
expect(r.status).toBe(0);
|
|
1541
1541
|
expect(r.stdout).toMatch(/--markdown/);
|
|
1542
|
-
expect(r.stdout).toMatch(/
|
|
1542
|
+
expect(r.stdout).toMatch(/PostgresAI API/i);
|
|
1543
|
+
expect(r.stdout).toMatch(/transmits the full\s+report JSON/i);
|
|
1543
1544
|
});
|
|
1544
1545
|
|
|
1545
1546
|
test("checkup --markdown is recognized as valid option", () => {
|
|
1546
1547
|
// Should not produce "unknown option" error for --markdown
|
|
1547
|
-
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown"
|
|
1548
|
+
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown"]);
|
|
1548
1549
|
// Connection will fail, but option parsing should succeed
|
|
1549
1550
|
expect(r.stderr).not.toMatch(/unknown option/i);
|
|
1550
1551
|
expect(r.stderr).not.toMatch(/did you mean/i);
|
|
@@ -1554,11 +1555,13 @@ describe("CLI tests", () => {
|
|
|
1554
1555
|
// Use empty config dir to ensure no API key is configured
|
|
1555
1556
|
const env = { XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config" };
|
|
1556
1557
|
// --markdown should work even without API key
|
|
1557
|
-
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown"
|
|
1558
|
+
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown"], env);
|
|
1558
1559
|
// Connection will fail, but --markdown flag should be recognized
|
|
1559
1560
|
expect(r.status).not.toBe(0);
|
|
1560
1561
|
expect(r.stderr).not.toMatch(/unknown option/i);
|
|
1561
1562
|
expect(r.stderr).not.toMatch(/API key is required/i);
|
|
1563
|
+
expect(r.stderr).toMatch(/full report JSON will still be sent/i);
|
|
1564
|
+
expect(r.stderr).toMatch(/markdown conversion/i);
|
|
1562
1565
|
});
|
|
1563
1566
|
|
|
1564
1567
|
test("checkup with --no-upload and no output flags shows summary", () => {
|
|
@@ -1663,6 +1666,21 @@ describe("checkup auth pre-flight (CLI)", () => {
|
|
|
1663
1666
|
// failure mentions this address.
|
|
1664
1667
|
const DEAD_DB = "postgresql://test:test@127.0.0.1:2/test";
|
|
1665
1668
|
|
|
1669
|
+
test("--no-upload rejects --markdown before database or API work", () => {
|
|
1670
|
+
const env = {
|
|
1671
|
+
XDG_CONFIG_HOME: `/tmp/postgresai-test-no-upload-markdown-${process.pid}`,
|
|
1672
|
+
PGAI_API_KEY: "configured-token",
|
|
1673
|
+
PGAI_API_BASE_URL: "http://127.0.0.1:1",
|
|
1674
|
+
};
|
|
1675
|
+
const r = runCli(["checkup", DEAD_DB, "--no-upload", "--markdown"], env);
|
|
1676
|
+
|
|
1677
|
+
expect(r.status).not.toBe(0);
|
|
1678
|
+
expect(r.stderr).toMatch(/--no-upload and --markdown are mutually exclusive/i);
|
|
1679
|
+
expect(r.stderr).toMatch(/markdown conversion is performed by the PostgresAI API/i);
|
|
1680
|
+
expect(r.stderr).toMatch(/use --json or --output/i);
|
|
1681
|
+
expect(r.stderr).not.toMatch(/127\.0\.0\.1:2|ECONNREFUSED/);
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1666
1684
|
test("no API key: prominent notice, run continues locally", () => {
|
|
1667
1685
|
const env = {
|
|
1668
1686
|
XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-nokey-${process.pid}`,
|
|
@@ -2173,6 +2191,31 @@ describe("checkup-summary", () => {
|
|
|
2173
2191
|
);
|
|
2174
2192
|
});
|
|
2175
2193
|
|
|
2194
|
+
for (const checkId of ["F004", "F005"]) {
|
|
2195
|
+
test(`generateCheckSummary for degraded ${checkId} is never ok`, () => {
|
|
2196
|
+
const report = {
|
|
2197
|
+
results: {
|
|
2198
|
+
node1: {
|
|
2199
|
+
data: {
|
|
2200
|
+
db1: {
|
|
2201
|
+
status: {
|
|
2202
|
+
ok: false,
|
|
2203
|
+
reason: "missing_schema",
|
|
2204
|
+
error: 'schema "postgres_ai" does not exist',
|
|
2205
|
+
},
|
|
2206
|
+
total_count: 0,
|
|
2207
|
+
},
|
|
2208
|
+
},
|
|
2209
|
+
},
|
|
2210
|
+
},
|
|
2211
|
+
};
|
|
2212
|
+
|
|
2213
|
+
const result = summary.generateCheckSummary(checkId, report);
|
|
2214
|
+
expect(result.status).toBe("warning");
|
|
2215
|
+
expect(result.message).toMatch(/degraded.*missing schema/i);
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2176
2219
|
test("generateCheckSummary for H001 with no issues", () => {
|
|
2177
2220
|
const report = {
|
|
2178
2221
|
results: {
|
|
@@ -3371,6 +3414,7 @@ describe("Postgres version compatibility (PG13-PG18)", () => {
|
|
|
3371
3414
|
autovacuum_vacuum_scale_factor: expectedAutovacuumSetting,
|
|
3372
3415
|
});
|
|
3373
3416
|
expect(reports.F004.results["test-node"].data.testdb).toEqual({
|
|
3417
|
+
status: { ok: true, reason: null, error: null },
|
|
3374
3418
|
bloated_tables: [],
|
|
3375
3419
|
total_count: 0,
|
|
3376
3420
|
total_bloat_size_bytes: 0,
|
|
@@ -3379,6 +3423,7 @@ describe("Postgres version compatibility (PG13-PG18)", () => {
|
|
|
3379
3423
|
database_size_pretty: "1.00 GiB",
|
|
3380
3424
|
});
|
|
3381
3425
|
expect(reports.F005.results["test-node"].data.testdb).toEqual({
|
|
3426
|
+
status: { ok: true, reason: null, error: null },
|
|
3382
3427
|
bloated_indexes: [],
|
|
3383
3428
|
total_count: 0,
|
|
3384
3429
|
total_bloat_size_bytes: 0,
|
package/test/init.test.ts
CHANGED
|
@@ -1489,6 +1489,24 @@ describe("checkCurrentUserPermissions", () => {
|
|
|
1489
1489
|
).rejects.toThrow("permission denied for relation pg_roles");
|
|
1490
1490
|
});
|
|
1491
1491
|
|
|
1492
|
+
test("guards optional postgres_ai privilege probes when the schema is absent", async () => {
|
|
1493
|
+
let capturedSql = "";
|
|
1494
|
+
const client = {
|
|
1495
|
+
query: async (sql: string) => {
|
|
1496
|
+
capturedSql = sql;
|
|
1497
|
+
return { rows: [] };
|
|
1498
|
+
},
|
|
1499
|
+
};
|
|
1500
|
+
|
|
1501
|
+
await init.checkCurrentUserPermissions(client as any);
|
|
1502
|
+
|
|
1503
|
+
expect(capturedSql).toContain("to_regnamespace('postgres_ai') is null");
|
|
1504
|
+
expect(capturedSql).toContain("'postgres_ai schema exists' as permission_name");
|
|
1505
|
+
expect(capturedSql).toMatch(
|
|
1506
|
+
/when to_regnamespace\('postgres_ai'\) is null then null\s+when not has_schema_privilege/
|
|
1507
|
+
);
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1492
1510
|
test("returns all rows for inspection", async () => {
|
|
1493
1511
|
const rows: init.PermissionCheckRow[] = [
|
|
1494
1512
|
{ permission_name: "connect on database postgres", status: "required", granted: true, fix_command: null },
|
|
@@ -1545,6 +1563,23 @@ describe("formatPermissionCheckMessages", () => {
|
|
|
1545
1563
|
expect(messages.errors).toHaveLength(0);
|
|
1546
1564
|
});
|
|
1547
1565
|
|
|
1566
|
+
test("explains that a missing postgres_ai schema only degrades F004/F005", () => {
|
|
1567
|
+
const result: init.PreflightPermissionResult = {
|
|
1568
|
+
ok: true,
|
|
1569
|
+
rows: [],
|
|
1570
|
+
missingRequired: [],
|
|
1571
|
+
missingOptional: [
|
|
1572
|
+
{ permission_name: "postgres_ai schema exists", status: "optional", granted: false, fix_command: null },
|
|
1573
|
+
],
|
|
1574
|
+
};
|
|
1575
|
+
|
|
1576
|
+
const messages = init.formatPermissionCheckMessages(result);
|
|
1577
|
+
expect(messages.failed).toBe(false);
|
|
1578
|
+
expect(messages.warnings).toEqual([
|
|
1579
|
+
"Warning: optional: postgres_ai schema not found — F004/F005 (bloat estimates) will be skipped; run prepare-db or create the view manually to enable them.",
|
|
1580
|
+
]);
|
|
1581
|
+
});
|
|
1582
|
+
|
|
1548
1583
|
test("returns errors with fix commands for missing required permissions", () => {
|
|
1549
1584
|
const result: init.PreflightPermissionResult = {
|
|
1550
1585
|
ok: false,
|
|
@@ -99,6 +99,46 @@ describe("Schema validation", () => {
|
|
|
99
99
|
validateAgainstSchema(report, "F003");
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
+
for (const checkId of ["F004", "F005"]) {
|
|
103
|
+
test(`${checkId} distinguishes a healthy empty result from missing schema`, async () => {
|
|
104
|
+
const healthyClient = createMockClient();
|
|
105
|
+
const healthy = await checkup.REPORT_GENERATORS[checkId](healthyClient as any, "node-01");
|
|
106
|
+
const healthyDb = healthy.results["node-01"].data.testdb;
|
|
107
|
+
expect(healthyDb.status).toEqual({ ok: true, reason: null, error: null });
|
|
108
|
+
validateAgainstSchema(healthy, checkId);
|
|
109
|
+
|
|
110
|
+
const missingSchemaClient = createMockClient({
|
|
111
|
+
bloatCapabilityRows: [
|
|
112
|
+
{ schema_exists: false, schema_usage: false, view_exists: false, view_select: false },
|
|
113
|
+
],
|
|
114
|
+
});
|
|
115
|
+
const degraded = await checkup.REPORT_GENERATORS[checkId](missingSchemaClient as any, "node-01");
|
|
116
|
+
const degradedDb = degraded.results["node-01"].data.testdb;
|
|
117
|
+
expect(degradedDb.status).toEqual({
|
|
118
|
+
ok: false,
|
|
119
|
+
reason: "missing_schema",
|
|
120
|
+
error: 'schema "postgres_ai" does not exist',
|
|
121
|
+
});
|
|
122
|
+
validateAgainstSchema(degraded, checkId);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test(`${checkId} exposes missing-grant degradation`, async () => {
|
|
126
|
+
const client = createMockClient({
|
|
127
|
+
bloatCapabilityRows: [
|
|
128
|
+
{ schema_exists: true, schema_usage: true, view_exists: true, view_select: false },
|
|
129
|
+
],
|
|
130
|
+
});
|
|
131
|
+
const report = await checkup.REPORT_GENERATORS[checkId](client as any, "node-01");
|
|
132
|
+
const dbEntry = report.results["node-01"].data.testdb;
|
|
133
|
+
expect(dbEntry.status).toEqual({
|
|
134
|
+
ok: false,
|
|
135
|
+
reason: "missing_grant",
|
|
136
|
+
error: "permission denied for relation postgres_ai.pg_statistic",
|
|
137
|
+
});
|
|
138
|
+
validateAgainstSchema(report, checkId);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
102
142
|
// Settings reports (D004, F001, G001) - single test each
|
|
103
143
|
for (const checkId of ["D004", "F001", "G001"]) {
|
|
104
144
|
test(`${checkId} validates against schema`, async () => {
|
package/test/test-utils.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface MockClientOptions {
|
|
|
19
19
|
indexBloatRows?: any[];
|
|
20
20
|
deadTuplesRows?: any[];
|
|
21
21
|
vacuumStatsRows?: any[];
|
|
22
|
+
bloatCapabilityRows?: any[];
|
|
22
23
|
deadlockStatsRows?: any[];
|
|
23
24
|
pgStatStatementsExtensionRows?: any[];
|
|
24
25
|
pgStatStatementsStatsRows?: any[];
|
|
@@ -61,6 +62,7 @@ export function createMockClient(options: MockClientOptions = {}) {
|
|
|
61
62
|
indexBloatRows = [],
|
|
62
63
|
deadTuplesRows = [],
|
|
63
64
|
vacuumStatsRows = [],
|
|
65
|
+
bloatCapabilityRows = [{ schema_exists: true, schema_usage: true, view_exists: true, view_select: true }],
|
|
64
66
|
deadlockStatsRows = [{ deadlocks: "0", conflicts: "0", stats_reset: null }],
|
|
65
67
|
pgStatStatementsExtensionRows = [],
|
|
66
68
|
pgStatStatementsStatsRows = [],
|
|
@@ -128,6 +130,9 @@ export function createMockClient(options: MockClientOptions = {}) {
|
|
|
128
130
|
return { rows: deadTuplesRows };
|
|
129
131
|
}
|
|
130
132
|
// F004/F005: bloat metrics from metrics.yml
|
|
133
|
+
if (sql.includes("to_regnamespace('postgres_ai')") && sql.includes("view_select")) {
|
|
134
|
+
return { rows: bloatCapabilityRows };
|
|
135
|
+
}
|
|
131
136
|
if (sql.includes("tag_idxname") && sql.includes("bloat_size")) {
|
|
132
137
|
return { rows: indexBloatRows };
|
|
133
138
|
}
|