postgresai 0.14.0-dev.37 → 0.14.0-dev.38
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 +316 -0
- package/dist/bin/postgres-ai.js +293 -0
- package/dist/bin/postgres-ai.js.map +1 -1
- package/dist/lib/checkup-api.d.ts +33 -0
- package/dist/lib/checkup-api.d.ts.map +1 -0
- package/dist/lib/checkup-api.js +187 -0
- package/dist/lib/checkup-api.js.map +1 -0
- package/dist/lib/checkup.d.ts +153 -0
- package/dist/lib/checkup.d.ts.map +1 -0
- package/dist/lib/checkup.js +536 -0
- package/dist/lib/checkup.js.map +1 -0
- package/dist/lib/config.d.ts +1 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +2 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/package.json +1 -1
- package/lib/checkup-api.ts +177 -0
- package/lib/checkup.ts +622 -0
- package/lib/config.ts +3 -0
- package/package.json +1 -1
- package/reports/A002.json +23 -0
- package/reports/A003.json +3343 -0
- package/reports/A004.json +134 -0
- package/reports/A007.json +683 -0
- package/reports/A013.json +23 -0
- package/test/checkup.test.cjs +645 -0
package/bin/postgres-ai.ts
CHANGED
|
@@ -17,10 +17,68 @@ import { startMcpServer } from "../lib/mcp-server";
|
|
|
17
17
|
import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue } from "../lib/issues";
|
|
18
18
|
import { resolveBaseUrls } from "../lib/util";
|
|
19
19
|
import { applyInitPlan, buildInitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, verifyInitSetup } from "../lib/init";
|
|
20
|
+
import { REPORT_GENERATORS, CHECK_INFO, generateAllReports } from "../lib/checkup";
|
|
21
|
+
import { createCheckupReport, uploadCheckupReportJson, RpcError, formatRpcErrorForDisplay } from "../lib/checkup-api";
|
|
20
22
|
|
|
21
23
|
const execPromise = promisify(exec);
|
|
22
24
|
const execFilePromise = promisify(execFile);
|
|
23
25
|
|
|
26
|
+
function expandHomePath(p: string): string {
|
|
27
|
+
const s = (p || "").trim();
|
|
28
|
+
if (!s) return s;
|
|
29
|
+
if (s === "~") return os.homedir();
|
|
30
|
+
if (s.startsWith("~/") || s.startsWith("~\\")) {
|
|
31
|
+
return path.join(os.homedir(), s.slice(2));
|
|
32
|
+
}
|
|
33
|
+
return s;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createTtySpinner(
|
|
37
|
+
enabled: boolean,
|
|
38
|
+
initialText: string
|
|
39
|
+
): { update: (text: string) => void; stop: (finalText?: string) => void } {
|
|
40
|
+
if (!enabled) {
|
|
41
|
+
return {
|
|
42
|
+
update: () => {},
|
|
43
|
+
stop: () => {},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const frames = ["|", "/", "-", "\\"];
|
|
48
|
+
const startTs = Date.now();
|
|
49
|
+
let text = initialText;
|
|
50
|
+
let frameIdx = 0;
|
|
51
|
+
let stopped = false;
|
|
52
|
+
|
|
53
|
+
const render = (): void => {
|
|
54
|
+
if (stopped) return;
|
|
55
|
+
const elapsedSec = ((Date.now() - startTs) / 1000).toFixed(1);
|
|
56
|
+
const frame = frames[frameIdx % frames.length]!;
|
|
57
|
+
frameIdx += 1;
|
|
58
|
+
process.stdout.write(`\r\x1b[2K${frame} ${text} (${elapsedSec}s)`);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const timer = setInterval(render, 120);
|
|
62
|
+
render(); // immediate feedback
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
update: (t: string) => {
|
|
66
|
+
text = t;
|
|
67
|
+
render();
|
|
68
|
+
},
|
|
69
|
+
stop: (finalText?: string) => {
|
|
70
|
+
if (stopped) return;
|
|
71
|
+
stopped = true;
|
|
72
|
+
clearInterval(timer);
|
|
73
|
+
process.stdout.write("\r\x1b[2K");
|
|
74
|
+
if (finalText && finalText.trim()) {
|
|
75
|
+
process.stdout.write(finalText);
|
|
76
|
+
}
|
|
77
|
+
process.stdout.write("\n");
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
24
82
|
/**
|
|
25
83
|
* CLI configuration options
|
|
26
84
|
*/
|
|
@@ -202,6 +260,20 @@ program
|
|
|
202
260
|
"UI base URL for browser routes (overrides PGAI_UI_BASE_URL)"
|
|
203
261
|
);
|
|
204
262
|
|
|
263
|
+
program
|
|
264
|
+
.command("set-default-project <project>")
|
|
265
|
+
.description("store default project for checkup uploads")
|
|
266
|
+
.action(async (project: string) => {
|
|
267
|
+
const value = (project || "").trim();
|
|
268
|
+
if (!value) {
|
|
269
|
+
console.error("Error: project is required");
|
|
270
|
+
process.exitCode = 1;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
config.writeConfig({ defaultProject: value });
|
|
274
|
+
console.log(`Default project saved: ${value}`);
|
|
275
|
+
});
|
|
276
|
+
|
|
205
277
|
program
|
|
206
278
|
.command("prepare-db [conn]")
|
|
207
279
|
.description("Prepare database for monitoring: create monitoring user, required view(s), and grant permissions (idempotent)")
|
|
@@ -529,6 +601,250 @@ program
|
|
|
529
601
|
}
|
|
530
602
|
});
|
|
531
603
|
|
|
604
|
+
program
|
|
605
|
+
.command("checkup [conn]")
|
|
606
|
+
.description("generate health check reports directly from PostgreSQL (express mode)")
|
|
607
|
+
.option("--check-id <id>", `specific check to run: ${Object.keys(CHECK_INFO).join(", ")}, or ALL`, "ALL")
|
|
608
|
+
.option("--node-name <name>", "node name for reports", "node-01")
|
|
609
|
+
.option("--output <path>", "output directory for JSON files")
|
|
610
|
+
.option("--json", "output to stdout as JSON instead of files")
|
|
611
|
+
.option("--upload", "create a remote checkup report and upload JSON results (requires API key)", false)
|
|
612
|
+
.option("--project <project>", "project name or ID for remote upload (used with --upload; defaults to config defaultProject)")
|
|
613
|
+
.addHelpText(
|
|
614
|
+
"after",
|
|
615
|
+
[
|
|
616
|
+
"",
|
|
617
|
+
"Available checks:",
|
|
618
|
+
...Object.entries(CHECK_INFO).map(([id, title]) => ` ${id}: ${title}`),
|
|
619
|
+
"",
|
|
620
|
+
"Examples:",
|
|
621
|
+
" postgresai checkup postgresql://user:pass@host:5432/db",
|
|
622
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --check-id A003",
|
|
623
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --json",
|
|
624
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --output ./reports",
|
|
625
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --upload --project my_project",
|
|
626
|
+
" postgresai set-default-project my_project",
|
|
627
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --upload",
|
|
628
|
+
].join("\n")
|
|
629
|
+
)
|
|
630
|
+
.action(async (conn: string | undefined, opts: {
|
|
631
|
+
checkId: string;
|
|
632
|
+
nodeName: string;
|
|
633
|
+
output?: string;
|
|
634
|
+
json?: boolean;
|
|
635
|
+
upload?: boolean;
|
|
636
|
+
project?: string;
|
|
637
|
+
}, cmd: Command) => {
|
|
638
|
+
if (!conn) {
|
|
639
|
+
// No args — show help like other commands do (instead of a bare error).
|
|
640
|
+
cmd.outputHelp();
|
|
641
|
+
process.exitCode = 1;
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Preflight: validate/create output directory BEFORE connecting / running checks.
|
|
646
|
+
// This avoids waiting on network/DB work only to fail at the very end.
|
|
647
|
+
let outputPath: string | undefined;
|
|
648
|
+
if (opts.output && !opts.json) {
|
|
649
|
+
const outputDir = expandHomePath(opts.output);
|
|
650
|
+
outputPath = path.isAbsolute(outputDir) ? outputDir : path.resolve(process.cwd(), outputDir);
|
|
651
|
+
if (!fs.existsSync(outputPath)) {
|
|
652
|
+
try {
|
|
653
|
+
fs.mkdirSync(outputPath, { recursive: true });
|
|
654
|
+
} catch (e) {
|
|
655
|
+
const errAny = e as any;
|
|
656
|
+
const code = typeof errAny?.code === "string" ? errAny.code : "";
|
|
657
|
+
const msg = errAny instanceof Error ? errAny.message : String(errAny);
|
|
658
|
+
if (code === "EACCES" || code === "EPERM" || code === "ENOENT") {
|
|
659
|
+
console.error(`Error: Failed to create output directory: ${outputPath}`);
|
|
660
|
+
console.error(`Reason: ${msg}`);
|
|
661
|
+
console.error("Tip: choose a writable path, e.g. --output ./reports or --output ~/reports");
|
|
662
|
+
process.exitCode = 1;
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
throw e;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Preflight: validate upload flags/credentials BEFORE connecting / running checks.
|
|
671
|
+
// This allows "fast-fail" for missing API key / project name.
|
|
672
|
+
let uploadCfg:
|
|
673
|
+
| { apiKey: string; apiBaseUrl: string; project: string; epoch: number }
|
|
674
|
+
| undefined;
|
|
675
|
+
if (opts.upload) {
|
|
676
|
+
const rootOpts = program.opts() as CliOptions;
|
|
677
|
+
const { apiKey } = getConfig(rootOpts);
|
|
678
|
+
if (!apiKey) {
|
|
679
|
+
console.error("Error: API key is required for --upload");
|
|
680
|
+
console.error("Tip: run 'postgresai auth' or pass --api-key / set PGAI_API_KEY");
|
|
681
|
+
process.exitCode = 1;
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const cfg = config.readConfig();
|
|
686
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
687
|
+
const project = ((opts.project || cfg.defaultProject) || "").trim();
|
|
688
|
+
if (!project) {
|
|
689
|
+
console.error("Error: --project is required (or set a default via 'postgresai set-default-project <project>')");
|
|
690
|
+
process.exitCode = 1;
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
const epoch = Math.floor(Date.now() / 1000);
|
|
694
|
+
uploadCfg = {
|
|
695
|
+
apiKey,
|
|
696
|
+
apiBaseUrl,
|
|
697
|
+
project,
|
|
698
|
+
epoch,
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Use the same SSL behavior as prepare-db:
|
|
703
|
+
// - Default: sslmode=prefer (try SSL first, fallback to non-SSL)
|
|
704
|
+
// - Respect PGSSLMODE env and ?sslmode=... in connection URI
|
|
705
|
+
const adminConn = resolveAdminConnection({
|
|
706
|
+
conn,
|
|
707
|
+
envPassword: process.env.PGPASSWORD,
|
|
708
|
+
});
|
|
709
|
+
let client: Client | undefined;
|
|
710
|
+
const spinnerEnabled = !!process.stdout.isTTY && !opts.json;
|
|
711
|
+
const spinner = createTtySpinner(spinnerEnabled, "Connecting to Postgres");
|
|
712
|
+
|
|
713
|
+
try {
|
|
714
|
+
spinner.update("Connecting to Postgres");
|
|
715
|
+
const connResult = await connectWithSslFallback(Client, adminConn);
|
|
716
|
+
client = connResult.client as Client;
|
|
717
|
+
|
|
718
|
+
let reports: Record<string, any>;
|
|
719
|
+
let uploadSummary:
|
|
720
|
+
| { project: string; reportId: number; uploaded: Array<{ checkId: string; filename: string; chunkId: number }> }
|
|
721
|
+
| undefined;
|
|
722
|
+
|
|
723
|
+
if (opts.checkId === "ALL") {
|
|
724
|
+
reports = await generateAllReports(client, opts.nodeName, (p) => {
|
|
725
|
+
spinner.update(`Running ${p.checkId}: ${p.checkTitle} (${p.index}/${p.total})`);
|
|
726
|
+
});
|
|
727
|
+
} else {
|
|
728
|
+
const checkId = opts.checkId.toUpperCase();
|
|
729
|
+
const generator = REPORT_GENERATORS[checkId];
|
|
730
|
+
if (!generator) {
|
|
731
|
+
spinner.stop();
|
|
732
|
+
console.error(`Unknown check ID: ${opts.checkId}`);
|
|
733
|
+
console.error(`Available: ${Object.keys(CHECK_INFO).join(", ")}, ALL`);
|
|
734
|
+
process.exitCode = 1;
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
spinner.update(`Running ${checkId}: ${CHECK_INFO[checkId] || checkId}`);
|
|
738
|
+
reports = { [checkId]: await generator(client, opts.nodeName) };
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Optional: upload to PostgresAI API.
|
|
742
|
+
if (uploadCfg) {
|
|
743
|
+
spinner.update("Creating remote checkup report");
|
|
744
|
+
const created = await createCheckupReport({
|
|
745
|
+
apiKey: uploadCfg.apiKey,
|
|
746
|
+
apiBaseUrl: uploadCfg.apiBaseUrl,
|
|
747
|
+
project: uploadCfg.project,
|
|
748
|
+
epoch: uploadCfg.epoch,
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
const reportId = created.reportId;
|
|
752
|
+
// Keep upload progress out of stdout when --json is used.
|
|
753
|
+
const logUpload = (msg: string): void => {
|
|
754
|
+
if (opts.json) console.error(msg);
|
|
755
|
+
};
|
|
756
|
+
logUpload(`Created remote checkup report: ${reportId}`);
|
|
757
|
+
|
|
758
|
+
const uploaded: Array<{ checkId: string; filename: string; chunkId: number }> = [];
|
|
759
|
+
for (const [checkId, report] of Object.entries(reports)) {
|
|
760
|
+
spinner.update(`Uploading ${checkId}.json`);
|
|
761
|
+
const jsonText = JSON.stringify(report, null, 2);
|
|
762
|
+
const r = await uploadCheckupReportJson({
|
|
763
|
+
apiKey: uploadCfg.apiKey,
|
|
764
|
+
apiBaseUrl: uploadCfg.apiBaseUrl,
|
|
765
|
+
reportId,
|
|
766
|
+
filename: `${checkId}.json`,
|
|
767
|
+
checkId,
|
|
768
|
+
jsonText,
|
|
769
|
+
});
|
|
770
|
+
uploaded.push({ checkId, filename: `${checkId}.json`, chunkId: r.reportChunkId });
|
|
771
|
+
}
|
|
772
|
+
logUpload("Upload completed");
|
|
773
|
+
uploadSummary = { project: uploadCfg.project, reportId, uploaded };
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
spinner.stop();
|
|
777
|
+
// Output results
|
|
778
|
+
if (opts.json) {
|
|
779
|
+
console.log(JSON.stringify(reports, null, 2));
|
|
780
|
+
} else if (opts.output) {
|
|
781
|
+
// Write to files
|
|
782
|
+
// outputPath is preflight-validated above
|
|
783
|
+
const outDir = outputPath || path.resolve(process.cwd(), expandHomePath(opts.output));
|
|
784
|
+
for (const [checkId, report] of Object.entries(reports)) {
|
|
785
|
+
const filePath = path.join(outDir, `${checkId}.json`);
|
|
786
|
+
fs.writeFileSync(filePath, JSON.stringify(report, null, 2), "utf8");
|
|
787
|
+
console.log(`✓ ${checkId}: ${filePath}`);
|
|
788
|
+
}
|
|
789
|
+
} else if (uploadSummary) {
|
|
790
|
+
// Default with --upload: show upload result instead of local-only summary.
|
|
791
|
+
console.log("\nCheckup report uploaded");
|
|
792
|
+
console.log("======================\n");
|
|
793
|
+
console.log(`Project: ${uploadSummary.project}`);
|
|
794
|
+
console.log(`Report ID: ${uploadSummary.reportId}`);
|
|
795
|
+
console.log("View in Console: console.postgres.ai → Support → checkup reports");
|
|
796
|
+
console.log("");
|
|
797
|
+
console.log("Files:");
|
|
798
|
+
for (const item of uploadSummary.uploaded) {
|
|
799
|
+
console.log(`- ${item.checkId}: ${item.filename}`);
|
|
800
|
+
}
|
|
801
|
+
} else {
|
|
802
|
+
// Default: print summary
|
|
803
|
+
console.log("\nHealth Check Reports Generated:");
|
|
804
|
+
console.log("================================\n");
|
|
805
|
+
for (const [checkId, report] of Object.entries(reports)) {
|
|
806
|
+
const r = report as any;
|
|
807
|
+
console.log(`${checkId}: ${r.checkTitle}`);
|
|
808
|
+
if (r.results && r.results[opts.nodeName]) {
|
|
809
|
+
const nodeData = r.results[opts.nodeName];
|
|
810
|
+
if (nodeData.postgres_version) {
|
|
811
|
+
console.log(` PostgreSQL: ${nodeData.postgres_version.version}`);
|
|
812
|
+
}
|
|
813
|
+
if (checkId === "A007" && nodeData.data) {
|
|
814
|
+
const count = Object.keys(nodeData.data).length;
|
|
815
|
+
console.log(` Altered settings: ${count}`);
|
|
816
|
+
}
|
|
817
|
+
if (checkId === "A004" && nodeData.data) {
|
|
818
|
+
if (nodeData.data.database_sizes) {
|
|
819
|
+
const dbCount = Object.keys(nodeData.data.database_sizes).length;
|
|
820
|
+
console.log(` Databases: ${dbCount}`);
|
|
821
|
+
}
|
|
822
|
+
if (nodeData.data.general_info?.cache_hit_ratio) {
|
|
823
|
+
console.log(` Cache hit ratio: ${nodeData.data.general_info.cache_hit_ratio.value}%`);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
console.log("\nUse --json for full output or --output <dir> to save files");
|
|
829
|
+
}
|
|
830
|
+
} catch (error) {
|
|
831
|
+
spinner.stop();
|
|
832
|
+
if (error instanceof RpcError) {
|
|
833
|
+
for (const line of formatRpcErrorForDisplay(error)) {
|
|
834
|
+
console.error(line);
|
|
835
|
+
}
|
|
836
|
+
} else {
|
|
837
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
838
|
+
console.error(`Error: ${message}`);
|
|
839
|
+
}
|
|
840
|
+
process.exitCode = 1;
|
|
841
|
+
} finally {
|
|
842
|
+
if (client) {
|
|
843
|
+
await client.end();
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
|
|
532
848
|
/**
|
|
533
849
|
* Stub function for not implemented commands
|
|
534
850
|
*/
|
package/dist/bin/postgres-ai.js
CHANGED
|
@@ -51,8 +51,61 @@ const mcp_server_1 = require("../lib/mcp-server");
|
|
|
51
51
|
const issues_1 = require("../lib/issues");
|
|
52
52
|
const util_2 = require("../lib/util");
|
|
53
53
|
const init_1 = require("../lib/init");
|
|
54
|
+
const checkup_1 = require("../lib/checkup");
|
|
55
|
+
const checkup_api_1 = require("../lib/checkup-api");
|
|
54
56
|
const execPromise = (0, util_1.promisify)(child_process_1.exec);
|
|
55
57
|
const execFilePromise = (0, util_1.promisify)(child_process_1.execFile);
|
|
58
|
+
function expandHomePath(p) {
|
|
59
|
+
const s = (p || "").trim();
|
|
60
|
+
if (!s)
|
|
61
|
+
return s;
|
|
62
|
+
if (s === "~")
|
|
63
|
+
return os.homedir();
|
|
64
|
+
if (s.startsWith("~/") || s.startsWith("~\\")) {
|
|
65
|
+
return path.join(os.homedir(), s.slice(2));
|
|
66
|
+
}
|
|
67
|
+
return s;
|
|
68
|
+
}
|
|
69
|
+
function createTtySpinner(enabled, initialText) {
|
|
70
|
+
if (!enabled) {
|
|
71
|
+
return {
|
|
72
|
+
update: () => { },
|
|
73
|
+
stop: () => { },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const frames = ["|", "/", "-", "\\"];
|
|
77
|
+
const startTs = Date.now();
|
|
78
|
+
let text = initialText;
|
|
79
|
+
let frameIdx = 0;
|
|
80
|
+
let stopped = false;
|
|
81
|
+
const render = () => {
|
|
82
|
+
if (stopped)
|
|
83
|
+
return;
|
|
84
|
+
const elapsedSec = ((Date.now() - startTs) / 1000).toFixed(1);
|
|
85
|
+
const frame = frames[frameIdx % frames.length];
|
|
86
|
+
frameIdx += 1;
|
|
87
|
+
process.stdout.write(`\r\x1b[2K${frame} ${text} (${elapsedSec}s)`);
|
|
88
|
+
};
|
|
89
|
+
const timer = setInterval(render, 120);
|
|
90
|
+
render(); // immediate feedback
|
|
91
|
+
return {
|
|
92
|
+
update: (t) => {
|
|
93
|
+
text = t;
|
|
94
|
+
render();
|
|
95
|
+
},
|
|
96
|
+
stop: (finalText) => {
|
|
97
|
+
if (stopped)
|
|
98
|
+
return;
|
|
99
|
+
stopped = true;
|
|
100
|
+
clearInterval(timer);
|
|
101
|
+
process.stdout.write("\r\x1b[2K");
|
|
102
|
+
if (finalText && finalText.trim()) {
|
|
103
|
+
process.stdout.write(finalText);
|
|
104
|
+
}
|
|
105
|
+
process.stdout.write("\n");
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
56
109
|
function getDefaultMonitoringProjectDir() {
|
|
57
110
|
const override = process.env.PGAI_PROJECT_DIR;
|
|
58
111
|
if (override && override.trim())
|
|
@@ -174,6 +227,19 @@ program
|
|
|
174
227
|
.option("--api-key <key>", "API key (overrides PGAI_API_KEY)")
|
|
175
228
|
.option("--api-base-url <url>", "API base URL for backend RPC (overrides PGAI_API_BASE_URL)")
|
|
176
229
|
.option("--ui-base-url <url>", "UI base URL for browser routes (overrides PGAI_UI_BASE_URL)");
|
|
230
|
+
program
|
|
231
|
+
.command("set-default-project <project>")
|
|
232
|
+
.description("store default project for checkup uploads")
|
|
233
|
+
.action(async (project) => {
|
|
234
|
+
const value = (project || "").trim();
|
|
235
|
+
if (!value) {
|
|
236
|
+
console.error("Error: project is required");
|
|
237
|
+
process.exitCode = 1;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
config.writeConfig({ defaultProject: value });
|
|
241
|
+
console.log(`Default project saved: ${value}`);
|
|
242
|
+
});
|
|
177
243
|
program
|
|
178
244
|
.command("prepare-db [conn]")
|
|
179
245
|
.description("Prepare database for monitoring: create monitoring user, required view(s), and grant permissions (idempotent)")
|
|
@@ -475,6 +541,233 @@ program
|
|
|
475
541
|
}
|
|
476
542
|
}
|
|
477
543
|
});
|
|
544
|
+
program
|
|
545
|
+
.command("checkup [conn]")
|
|
546
|
+
.description("generate health check reports directly from PostgreSQL (express mode)")
|
|
547
|
+
.option("--check-id <id>", `specific check to run: ${Object.keys(checkup_1.CHECK_INFO).join(", ")}, or ALL`, "ALL")
|
|
548
|
+
.option("--node-name <name>", "node name for reports", "node-01")
|
|
549
|
+
.option("--output <path>", "output directory for JSON files")
|
|
550
|
+
.option("--json", "output to stdout as JSON instead of files")
|
|
551
|
+
.option("--upload", "create a remote checkup report and upload JSON results (requires API key)", false)
|
|
552
|
+
.option("--project <project>", "project name or ID for remote upload (used with --upload; defaults to config defaultProject)")
|
|
553
|
+
.addHelpText("after", [
|
|
554
|
+
"",
|
|
555
|
+
"Available checks:",
|
|
556
|
+
...Object.entries(checkup_1.CHECK_INFO).map(([id, title]) => ` ${id}: ${title}`),
|
|
557
|
+
"",
|
|
558
|
+
"Examples:",
|
|
559
|
+
" postgresai checkup postgresql://user:pass@host:5432/db",
|
|
560
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --check-id A003",
|
|
561
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --json",
|
|
562
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --output ./reports",
|
|
563
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --upload --project my_project",
|
|
564
|
+
" postgresai set-default-project my_project",
|
|
565
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --upload",
|
|
566
|
+
].join("\n"))
|
|
567
|
+
.action(async (conn, opts, cmd) => {
|
|
568
|
+
if (!conn) {
|
|
569
|
+
// No args — show help like other commands do (instead of a bare error).
|
|
570
|
+
cmd.outputHelp();
|
|
571
|
+
process.exitCode = 1;
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
// Preflight: validate/create output directory BEFORE connecting / running checks.
|
|
575
|
+
// This avoids waiting on network/DB work only to fail at the very end.
|
|
576
|
+
let outputPath;
|
|
577
|
+
if (opts.output && !opts.json) {
|
|
578
|
+
const outputDir = expandHomePath(opts.output);
|
|
579
|
+
outputPath = path.isAbsolute(outputDir) ? outputDir : path.resolve(process.cwd(), outputDir);
|
|
580
|
+
if (!fs.existsSync(outputPath)) {
|
|
581
|
+
try {
|
|
582
|
+
fs.mkdirSync(outputPath, { recursive: true });
|
|
583
|
+
}
|
|
584
|
+
catch (e) {
|
|
585
|
+
const errAny = e;
|
|
586
|
+
const code = typeof errAny?.code === "string" ? errAny.code : "";
|
|
587
|
+
const msg = errAny instanceof Error ? errAny.message : String(errAny);
|
|
588
|
+
if (code === "EACCES" || code === "EPERM" || code === "ENOENT") {
|
|
589
|
+
console.error(`Error: Failed to create output directory: ${outputPath}`);
|
|
590
|
+
console.error(`Reason: ${msg}`);
|
|
591
|
+
console.error("Tip: choose a writable path, e.g. --output ./reports or --output ~/reports");
|
|
592
|
+
process.exitCode = 1;
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
throw e;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
// Preflight: validate upload flags/credentials BEFORE connecting / running checks.
|
|
600
|
+
// This allows "fast-fail" for missing API key / project name.
|
|
601
|
+
let uploadCfg;
|
|
602
|
+
if (opts.upload) {
|
|
603
|
+
const rootOpts = program.opts();
|
|
604
|
+
const { apiKey } = getConfig(rootOpts);
|
|
605
|
+
if (!apiKey) {
|
|
606
|
+
console.error("Error: API key is required for --upload");
|
|
607
|
+
console.error("Tip: run 'postgresai auth' or pass --api-key / set PGAI_API_KEY");
|
|
608
|
+
process.exitCode = 1;
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const cfg = config.readConfig();
|
|
612
|
+
const { apiBaseUrl } = (0, util_2.resolveBaseUrls)(rootOpts, cfg);
|
|
613
|
+
const project = ((opts.project || cfg.defaultProject) || "").trim();
|
|
614
|
+
if (!project) {
|
|
615
|
+
console.error("Error: --project is required (or set a default via 'postgresai set-default-project <project>')");
|
|
616
|
+
process.exitCode = 1;
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const epoch = Math.floor(Date.now() / 1000);
|
|
620
|
+
uploadCfg = {
|
|
621
|
+
apiKey,
|
|
622
|
+
apiBaseUrl,
|
|
623
|
+
project,
|
|
624
|
+
epoch,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
// Use the same SSL behavior as prepare-db:
|
|
628
|
+
// - Default: sslmode=prefer (try SSL first, fallback to non-SSL)
|
|
629
|
+
// - Respect PGSSLMODE env and ?sslmode=... in connection URI
|
|
630
|
+
const adminConn = (0, init_1.resolveAdminConnection)({
|
|
631
|
+
conn,
|
|
632
|
+
envPassword: process.env.PGPASSWORD,
|
|
633
|
+
});
|
|
634
|
+
let client;
|
|
635
|
+
const spinnerEnabled = !!process.stdout.isTTY && !opts.json;
|
|
636
|
+
const spinner = createTtySpinner(spinnerEnabled, "Connecting to Postgres");
|
|
637
|
+
try {
|
|
638
|
+
spinner.update("Connecting to Postgres");
|
|
639
|
+
const connResult = await (0, init_1.connectWithSslFallback)(pg_1.Client, adminConn);
|
|
640
|
+
client = connResult.client;
|
|
641
|
+
let reports;
|
|
642
|
+
let uploadSummary;
|
|
643
|
+
if (opts.checkId === "ALL") {
|
|
644
|
+
reports = await (0, checkup_1.generateAllReports)(client, opts.nodeName, (p) => {
|
|
645
|
+
spinner.update(`Running ${p.checkId}: ${p.checkTitle} (${p.index}/${p.total})`);
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
const checkId = opts.checkId.toUpperCase();
|
|
650
|
+
const generator = checkup_1.REPORT_GENERATORS[checkId];
|
|
651
|
+
if (!generator) {
|
|
652
|
+
spinner.stop();
|
|
653
|
+
console.error(`Unknown check ID: ${opts.checkId}`);
|
|
654
|
+
console.error(`Available: ${Object.keys(checkup_1.CHECK_INFO).join(", ")}, ALL`);
|
|
655
|
+
process.exitCode = 1;
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
spinner.update(`Running ${checkId}: ${checkup_1.CHECK_INFO[checkId] || checkId}`);
|
|
659
|
+
reports = { [checkId]: await generator(client, opts.nodeName) };
|
|
660
|
+
}
|
|
661
|
+
// Optional: upload to PostgresAI API.
|
|
662
|
+
if (uploadCfg) {
|
|
663
|
+
spinner.update("Creating remote checkup report");
|
|
664
|
+
const created = await (0, checkup_api_1.createCheckupReport)({
|
|
665
|
+
apiKey: uploadCfg.apiKey,
|
|
666
|
+
apiBaseUrl: uploadCfg.apiBaseUrl,
|
|
667
|
+
project: uploadCfg.project,
|
|
668
|
+
epoch: uploadCfg.epoch,
|
|
669
|
+
});
|
|
670
|
+
const reportId = created.reportId;
|
|
671
|
+
// Keep upload progress out of stdout when --json is used.
|
|
672
|
+
const logUpload = (msg) => {
|
|
673
|
+
if (opts.json)
|
|
674
|
+
console.error(msg);
|
|
675
|
+
};
|
|
676
|
+
logUpload(`Created remote checkup report: ${reportId}`);
|
|
677
|
+
const uploaded = [];
|
|
678
|
+
for (const [checkId, report] of Object.entries(reports)) {
|
|
679
|
+
spinner.update(`Uploading ${checkId}.json`);
|
|
680
|
+
const jsonText = JSON.stringify(report, null, 2);
|
|
681
|
+
const r = await (0, checkup_api_1.uploadCheckupReportJson)({
|
|
682
|
+
apiKey: uploadCfg.apiKey,
|
|
683
|
+
apiBaseUrl: uploadCfg.apiBaseUrl,
|
|
684
|
+
reportId,
|
|
685
|
+
filename: `${checkId}.json`,
|
|
686
|
+
checkId,
|
|
687
|
+
jsonText,
|
|
688
|
+
});
|
|
689
|
+
uploaded.push({ checkId, filename: `${checkId}.json`, chunkId: r.reportChunkId });
|
|
690
|
+
}
|
|
691
|
+
logUpload("Upload completed");
|
|
692
|
+
uploadSummary = { project: uploadCfg.project, reportId, uploaded };
|
|
693
|
+
}
|
|
694
|
+
spinner.stop();
|
|
695
|
+
// Output results
|
|
696
|
+
if (opts.json) {
|
|
697
|
+
console.log(JSON.stringify(reports, null, 2));
|
|
698
|
+
}
|
|
699
|
+
else if (opts.output) {
|
|
700
|
+
// Write to files
|
|
701
|
+
// outputPath is preflight-validated above
|
|
702
|
+
const outDir = outputPath || path.resolve(process.cwd(), expandHomePath(opts.output));
|
|
703
|
+
for (const [checkId, report] of Object.entries(reports)) {
|
|
704
|
+
const filePath = path.join(outDir, `${checkId}.json`);
|
|
705
|
+
fs.writeFileSync(filePath, JSON.stringify(report, null, 2), "utf8");
|
|
706
|
+
console.log(`✓ ${checkId}: ${filePath}`);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
else if (uploadSummary) {
|
|
710
|
+
// Default with --upload: show upload result instead of local-only summary.
|
|
711
|
+
console.log("\nCheckup report uploaded");
|
|
712
|
+
console.log("======================\n");
|
|
713
|
+
console.log(`Project: ${uploadSummary.project}`);
|
|
714
|
+
console.log(`Report ID: ${uploadSummary.reportId}`);
|
|
715
|
+
console.log("View in Console: console.postgres.ai → Support → checkup reports");
|
|
716
|
+
console.log("");
|
|
717
|
+
console.log("Files:");
|
|
718
|
+
for (const item of uploadSummary.uploaded) {
|
|
719
|
+
console.log(`- ${item.checkId}: ${item.filename}`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
else {
|
|
723
|
+
// Default: print summary
|
|
724
|
+
console.log("\nHealth Check Reports Generated:");
|
|
725
|
+
console.log("================================\n");
|
|
726
|
+
for (const [checkId, report] of Object.entries(reports)) {
|
|
727
|
+
const r = report;
|
|
728
|
+
console.log(`${checkId}: ${r.checkTitle}`);
|
|
729
|
+
if (r.results && r.results[opts.nodeName]) {
|
|
730
|
+
const nodeData = r.results[opts.nodeName];
|
|
731
|
+
if (nodeData.postgres_version) {
|
|
732
|
+
console.log(` PostgreSQL: ${nodeData.postgres_version.version}`);
|
|
733
|
+
}
|
|
734
|
+
if (checkId === "A007" && nodeData.data) {
|
|
735
|
+
const count = Object.keys(nodeData.data).length;
|
|
736
|
+
console.log(` Altered settings: ${count}`);
|
|
737
|
+
}
|
|
738
|
+
if (checkId === "A004" && nodeData.data) {
|
|
739
|
+
if (nodeData.data.database_sizes) {
|
|
740
|
+
const dbCount = Object.keys(nodeData.data.database_sizes).length;
|
|
741
|
+
console.log(` Databases: ${dbCount}`);
|
|
742
|
+
}
|
|
743
|
+
if (nodeData.data.general_info?.cache_hit_ratio) {
|
|
744
|
+
console.log(` Cache hit ratio: ${nodeData.data.general_info.cache_hit_ratio.value}%`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
console.log("\nUse --json for full output or --output <dir> to save files");
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
catch (error) {
|
|
753
|
+
spinner.stop();
|
|
754
|
+
if (error instanceof checkup_api_1.RpcError) {
|
|
755
|
+
for (const line of (0, checkup_api_1.formatRpcErrorForDisplay)(error)) {
|
|
756
|
+
console.error(line);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
else {
|
|
760
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
761
|
+
console.error(`Error: ${message}`);
|
|
762
|
+
}
|
|
763
|
+
process.exitCode = 1;
|
|
764
|
+
}
|
|
765
|
+
finally {
|
|
766
|
+
if (client) {
|
|
767
|
+
await client.end();
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
});
|
|
478
771
|
/**
|
|
479
772
|
* Stub function for not implemented commands
|
|
480
773
|
*/
|