postgresai 0.14.0 → 0.15.0-dev.10
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/README.md +3 -1
- package/bin/postgres-ai.ts +712 -108
- package/bun.lock +4 -4
- package/dist/bin/postgres-ai.js +2755 -572
- package/instances.demo.yml +14 -0
- package/lib/checkup-api.ts +25 -6
- package/lib/checkup.ts +465 -8
- package/lib/config.ts +7 -0
- package/lib/init.ts +196 -4
- package/lib/issues.ts +72 -72
- package/lib/mcp-server.ts +90 -0
- package/lib/metrics-loader.ts +6 -1
- package/lib/reports.ts +373 -0
- package/lib/storage.ts +291 -0
- package/lib/supabase.ts +8 -1
- package/lib/util.ts +7 -1
- package/package.json +4 -4
- package/scripts/embed-checkup-dictionary.ts +9 -0
- package/scripts/embed-metrics.ts +5 -0
- package/scripts/generate-release-notes.ts +283 -48
- package/test/PERMISSION_CHECK_TEST_SUMMARY.md +139 -0
- package/test/checkup.test.ts +1316 -2
- package/test/compose-cmd.test.ts +120 -0
- package/test/config-consistency.test.ts +321 -5
- package/test/init.integration.test.ts +27 -28
- package/test/init.test.ts +534 -6
- package/test/issues.cli.test.ts +230 -1
- package/test/mcp-server.test.ts +516 -0
- package/test/monitoring.test.ts +339 -0
- package/test/permission-check-sql.test.ts +116 -0
- package/test/reports.cli.test.ts +793 -0
- package/test/reports.test.ts +977 -0
- package/test/schema-validation.test.ts +81 -0
- package/test/storage.test.ts +761 -0
- package/test/test-utils.ts +51 -2
- package/test/upgrade.test.ts +422 -0
package/bin/postgres-ai.ts
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
import { Command } from "commander";
|
|
3
|
+
import { Command, Option } from "commander";
|
|
4
4
|
import pkg from "../package.json";
|
|
5
5
|
import * as config from "../lib/config";
|
|
6
6
|
import * as yaml from "js-yaml";
|
|
7
7
|
import * as fs from "fs";
|
|
8
8
|
import * as path from "path";
|
|
9
9
|
import * as os from "os";
|
|
10
|
+
import { fileURLToPath } from "url";
|
|
10
11
|
import * as crypto from "node:crypto";
|
|
11
12
|
import { Client } from "pg";
|
|
12
13
|
import { startMcpServer } from "../lib/mcp-server";
|
|
13
14
|
import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment, fetchActionItem, fetchActionItems, createActionItem, updateActionItem, type ConfigChange } from "../lib/issues";
|
|
15
|
+
import { fetchReports, fetchAllReports, fetchReportFiles, fetchReportFileData, renderMarkdownForTerminal, parseFlexibleDate } from "../lib/reports";
|
|
14
16
|
import { resolveBaseUrls } from "../lib/util";
|
|
15
|
-
import {
|
|
17
|
+
import { uploadFile, downloadFile, buildMarkdownLink } from "../lib/storage";
|
|
18
|
+
import { applyInitPlan, applyUninitPlan, buildInitPlan, buildUninitPlan, checkCurrentUserPermissions, connectWithSslFallback, DEFAULT_MONITORING_USER, formatPermissionCheckMessages, KNOWN_PROVIDERS, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, validateProvider, verifyInitSetup } from "../lib/init";
|
|
16
19
|
import { SupabaseClient, resolveSupabaseConfig, extractProjectRefFromUrl, applyInitPlanViaSupabase, verifyInitSetupViaSupabase, fetchPoolerDatabaseUrl, type PgCompatibleError } from "../lib/supabase";
|
|
17
20
|
import * as pkce from "../lib/pkce";
|
|
18
21
|
import * as authServer from "../lib/auth-server";
|
|
@@ -24,6 +27,18 @@ import { getCheckupEntry } from "../lib/checkup-dictionary";
|
|
|
24
27
|
import { createCheckupReport, uploadCheckupReportJson, convertCheckupReportJsonToMarkdown, RpcError, formatRpcErrorForDisplay, withRetry } from "../lib/checkup-api";
|
|
25
28
|
import { generateCheckSummary } from "../lib/checkup-summary";
|
|
26
29
|
|
|
30
|
+
// Node.js version check - require Node 18+
|
|
31
|
+
// Node 14 reached EOL in April 2023, Node 16 in September 2023.
|
|
32
|
+
// Node 18+ is required for native ESM, modern crypto APIs, and security updates.
|
|
33
|
+
const nodeVersion = parseInt(process.versions.node.split('.')[0], 10);
|
|
34
|
+
if (nodeVersion < 18) {
|
|
35
|
+
console.error(`\x1b[31mError: postgresai requires Node 18 or higher.\x1b[0m`);
|
|
36
|
+
console.error(`You are running Node.js ${process.versions.node}.`);
|
|
37
|
+
console.error(`Please upgrade to Node.js 20 LTS or Node.js 22 for security updates.`);
|
|
38
|
+
console.error(`\nDownload: https://nodejs.org/`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
27
42
|
// Singleton readline interface for stdin prompts
|
|
28
43
|
let rl: ReturnType<typeof createInterface> | null = null;
|
|
29
44
|
function getReadline() {
|
|
@@ -39,21 +54,16 @@ function closeReadline() {
|
|
|
39
54
|
}
|
|
40
55
|
}
|
|
41
56
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
reject(err);
|
|
50
|
-
} else {
|
|
51
|
-
resolve({ stdout, stderr });
|
|
52
|
-
}
|
|
53
|
-
});
|
|
54
|
-
});
|
|
57
|
+
function stripMatchingQuotes(value: string): string {
|
|
58
|
+
const trimmed = value.trim();
|
|
59
|
+
const quote = trimmed[0];
|
|
60
|
+
if (trimmed.length >= 2 && (quote === '"' || quote === "'") && trimmed.endsWith(quote)) {
|
|
61
|
+
return trimmed.slice(1, -1);
|
|
62
|
+
}
|
|
63
|
+
return trimmed;
|
|
55
64
|
}
|
|
56
65
|
|
|
66
|
+
// Helper functions for spawning processes - use Node.js child_process for compatibility
|
|
57
67
|
async function execFilePromise(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
|
58
68
|
return new Promise((resolve, reject) => {
|
|
59
69
|
childProcess.execFile(file, args, (error, stdout, stderr) => {
|
|
@@ -330,7 +340,8 @@ function writeReportFiles(reports: Record<string, any>, outputPath: string): voi
|
|
|
330
340
|
for (const [checkId, report] of Object.entries(reports)) {
|
|
331
341
|
const filePath = path.join(outputPath, `${checkId}.json`);
|
|
332
342
|
fs.writeFileSync(filePath, JSON.stringify(report, null, 2), "utf8");
|
|
333
|
-
|
|
343
|
+
const title = report.checkTitle || checkId;
|
|
344
|
+
console.log(`✓ ${checkId} ${title}: ${filePath}`);
|
|
334
345
|
}
|
|
335
346
|
}
|
|
336
347
|
|
|
@@ -398,6 +409,7 @@ interface CliOptions {
|
|
|
398
409
|
apiKey?: string;
|
|
399
410
|
apiBaseUrl?: string;
|
|
400
411
|
uiBaseUrl?: string;
|
|
412
|
+
storageBaseUrl?: string;
|
|
401
413
|
}
|
|
402
414
|
|
|
403
415
|
/**
|
|
@@ -487,7 +499,11 @@ async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
|
|
|
487
499
|
}
|
|
488
500
|
}
|
|
489
501
|
|
|
490
|
-
// Ensure instances.yml exists as a FILE (avoid Docker creating a directory)
|
|
502
|
+
// Ensure instances.yml exists as a FILE (avoid Docker creating a directory).
|
|
503
|
+
// Docker bind-mounts create missing paths as directories; replace if so.
|
|
504
|
+
if (fs.existsSync(instancesFile) && fs.lstatSync(instancesFile).isDirectory()) {
|
|
505
|
+
fs.rmSync(instancesFile, { recursive: true, force: true });
|
|
506
|
+
}
|
|
491
507
|
if (!fs.existsSync(instancesFile)) {
|
|
492
508
|
const header =
|
|
493
509
|
"# PostgreSQL instances to monitor\n" +
|
|
@@ -566,6 +582,10 @@ program
|
|
|
566
582
|
.option(
|
|
567
583
|
"--ui-base-url <url>",
|
|
568
584
|
"UI base URL for browser routes (overrides PGAI_UI_BASE_URL)"
|
|
585
|
+
)
|
|
586
|
+
.option(
|
|
587
|
+
"--storage-base-url <url>",
|
|
588
|
+
"Storage base URL for file uploads (overrides PGAI_STORAGE_BASE_URL)"
|
|
569
589
|
);
|
|
570
590
|
|
|
571
591
|
program
|
|
@@ -582,6 +602,27 @@ program
|
|
|
582
602
|
console.log(`Default project saved: ${value}`);
|
|
583
603
|
});
|
|
584
604
|
|
|
605
|
+
program
|
|
606
|
+
.command("set-storage-url <url>")
|
|
607
|
+
.description("store storage base URL for file uploads")
|
|
608
|
+
.action(async (url: string) => {
|
|
609
|
+
const value = (url || "").trim();
|
|
610
|
+
if (!value) {
|
|
611
|
+
console.error("Error: url is required");
|
|
612
|
+
process.exitCode = 1;
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
try {
|
|
616
|
+
const { normalizeBaseUrl } = await import("../lib/util");
|
|
617
|
+
const normalized = normalizeBaseUrl(value);
|
|
618
|
+
config.writeConfig({ storageBaseUrl: normalized });
|
|
619
|
+
console.log(`Storage URL saved: ${normalized}`);
|
|
620
|
+
} catch {
|
|
621
|
+
console.error(`Error: invalid URL: ${value}`);
|
|
622
|
+
process.exitCode = 1;
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
|
|
585
626
|
program
|
|
586
627
|
.command("prepare-db [conn]")
|
|
587
628
|
.description("prepare database for monitoring: create monitoring user, required view(s), and grant permissions (idempotent)")
|
|
@@ -827,8 +868,8 @@ program
|
|
|
827
868
|
} else {
|
|
828
869
|
console.log("✓ prepare-db verify: OK");
|
|
829
870
|
if (v.missingOptional.length > 0) {
|
|
830
|
-
console.
|
|
831
|
-
for (const m of v.missingOptional) console.
|
|
871
|
+
console.error("⚠ Optional items missing:");
|
|
872
|
+
for (const m of v.missingOptional) console.error(`- ${m}`);
|
|
832
873
|
}
|
|
833
874
|
}
|
|
834
875
|
return;
|
|
@@ -959,8 +1000,8 @@ program
|
|
|
959
1000
|
} else {
|
|
960
1001
|
console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
|
|
961
1002
|
if (skippedOptional.length > 0) {
|
|
962
|
-
console.
|
|
963
|
-
for (const s of skippedOptional) console.
|
|
1003
|
+
console.error("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
|
|
1004
|
+
for (const s of skippedOptional) console.error(`- ${s}`);
|
|
964
1005
|
}
|
|
965
1006
|
if (process.stdout.isTTY) {
|
|
966
1007
|
console.log(`Applied ${applied.length} steps`);
|
|
@@ -1141,8 +1182,8 @@ program
|
|
|
1141
1182
|
} else {
|
|
1142
1183
|
console.log(`✓ prepare-db verify: OK${opts.provider ? ` (provider: ${opts.provider})` : ""}`);
|
|
1143
1184
|
if (v.missingOptional.length > 0) {
|
|
1144
|
-
console.
|
|
1145
|
-
for (const m of v.missingOptional) console.
|
|
1185
|
+
console.error("⚠ Optional items missing:");
|
|
1186
|
+
for (const m of v.missingOptional) console.error(`- ${m}`);
|
|
1146
1187
|
}
|
|
1147
1188
|
}
|
|
1148
1189
|
return;
|
|
@@ -1269,8 +1310,8 @@ program
|
|
|
1269
1310
|
} else {
|
|
1270
1311
|
console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
|
|
1271
1312
|
if (skippedOptional.length > 0) {
|
|
1272
|
-
console.
|
|
1273
|
-
for (const s of skippedOptional) console.
|
|
1313
|
+
console.error("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
|
|
1314
|
+
for (const s of skippedOptional) console.error(`- ${s}`);
|
|
1274
1315
|
}
|
|
1275
1316
|
// Keep output compact but still useful
|
|
1276
1317
|
if (process.stdout.isTTY) {
|
|
@@ -1586,11 +1627,11 @@ program
|
|
|
1586
1627
|
console.log("✓ unprepare-db completed");
|
|
1587
1628
|
console.log(`Applied ${applied.length} steps`);
|
|
1588
1629
|
} else {
|
|
1589
|
-
console.
|
|
1630
|
+
console.error("⚠ unprepare-db completed with errors");
|
|
1590
1631
|
console.log(`Applied ${applied.length} steps`);
|
|
1591
|
-
console.
|
|
1632
|
+
console.error("Errors:");
|
|
1592
1633
|
for (const err of errors) {
|
|
1593
|
-
console.
|
|
1634
|
+
console.error(` - ${err}`);
|
|
1594
1635
|
}
|
|
1595
1636
|
process.exitCode = 1;
|
|
1596
1637
|
}
|
|
@@ -1785,6 +1826,24 @@ program
|
|
|
1785
1826
|
const connResult = await connectWithSslFallback(Client, adminConn);
|
|
1786
1827
|
client = connResult.client as Client;
|
|
1787
1828
|
|
|
1829
|
+
// Preflight: verify the connected user has sufficient permissions
|
|
1830
|
+
spinner.update("Checking database permissions");
|
|
1831
|
+
const permCheck = await checkCurrentUserPermissions(client);
|
|
1832
|
+
const permMessages = formatPermissionCheckMessages(permCheck);
|
|
1833
|
+
|
|
1834
|
+
for (const w of permMessages.warnings) {
|
|
1835
|
+
console.error(w);
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
if (permMessages.failed) {
|
|
1839
|
+
spinner.stop();
|
|
1840
|
+
for (const e of permMessages.errors) {
|
|
1841
|
+
console.error(e);
|
|
1842
|
+
}
|
|
1843
|
+
process.exitCode = 1;
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1788
1847
|
// Generate reports
|
|
1789
1848
|
let reports: Record<string, any>;
|
|
1790
1849
|
if (checkId === "ALL") {
|
|
@@ -1912,8 +1971,8 @@ program
|
|
|
1912
1971
|
}
|
|
1913
1972
|
}
|
|
1914
1973
|
|
|
1915
|
-
// Output JSON to stdout
|
|
1916
|
-
if (shouldPrintJson) {
|
|
1974
|
+
// Output JSON to stdout (unless --output is specified, in which case files are written instead)
|
|
1975
|
+
if (shouldPrintJson && !outputPath) {
|
|
1917
1976
|
console.log(JSON.stringify(reports, null, 2));
|
|
1918
1977
|
}
|
|
1919
1978
|
|
|
@@ -2030,13 +2089,16 @@ function isDockerRunning(): boolean {
|
|
|
2030
2089
|
}
|
|
2031
2090
|
|
|
2032
2091
|
/**
|
|
2033
|
-
* Get docker compose command
|
|
2092
|
+
* Get docker compose command.
|
|
2093
|
+
* Prefer "docker compose" (V2 plugin) over legacy "docker-compose" (V1 standalone)
|
|
2094
|
+
* because docker-compose V1 (<=1.29) is incompatible with modern Docker engines
|
|
2095
|
+
* (KeyError: 'ContainerConfig' on container recreation).
|
|
2034
2096
|
*/
|
|
2035
2097
|
function getComposeCmd(): string[] | null {
|
|
2036
2098
|
const tryCmd = (cmd: string, args: string[]): boolean =>
|
|
2037
2099
|
spawnSync(cmd, args, { stdio: "ignore", timeout: 5000 } as Parameters<typeof spawnSync>[2]).status === 0;
|
|
2038
|
-
if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
|
|
2039
2100
|
if (tryCmd("docker", ["compose", "version"])) return ["docker", "compose"];
|
|
2101
|
+
if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
|
|
2040
2102
|
return null;
|
|
2041
2103
|
}
|
|
2042
2104
|
|
|
@@ -2061,6 +2123,85 @@ function checkRunningContainers(): { running: boolean; containers: string[] } {
|
|
|
2061
2123
|
}
|
|
2062
2124
|
}
|
|
2063
2125
|
|
|
2126
|
+
/**
|
|
2127
|
+
* Register monitoring instance with the API (non-blocking).
|
|
2128
|
+
* Returns immediately, logs result in background.
|
|
2129
|
+
*/
|
|
2130
|
+
function registerMonitoringInstance(
|
|
2131
|
+
apiKey: string,
|
|
2132
|
+
projectName: string,
|
|
2133
|
+
opts?: { apiBaseUrl?: string; debug?: boolean }
|
|
2134
|
+
): void {
|
|
2135
|
+
const { apiBaseUrl } = resolveBaseUrls(opts);
|
|
2136
|
+
const url = `${apiBaseUrl}/rpc/monitoring_instance_register`;
|
|
2137
|
+
const debug = opts?.debug;
|
|
2138
|
+
|
|
2139
|
+
if (debug) {
|
|
2140
|
+
console.error(`\nDebug: Registering monitoring instance...`);
|
|
2141
|
+
console.error(`Debug: POST ${url}`);
|
|
2142
|
+
console.error(`Debug: project_name=${projectName}`);
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
// Fire and forget - don't block the main flow
|
|
2146
|
+
fetch(url, {
|
|
2147
|
+
method: "POST",
|
|
2148
|
+
headers: {
|
|
2149
|
+
"Content-Type": "application/json",
|
|
2150
|
+
},
|
|
2151
|
+
body: JSON.stringify({
|
|
2152
|
+
api_token: apiKey,
|
|
2153
|
+
project_name: projectName,
|
|
2154
|
+
}),
|
|
2155
|
+
})
|
|
2156
|
+
.then(async (res) => {
|
|
2157
|
+
const body = await res.text().catch(() => "");
|
|
2158
|
+
if (!res.ok) {
|
|
2159
|
+
if (debug) {
|
|
2160
|
+
console.error(`Debug: Monitoring registration failed: HTTP ${res.status}`);
|
|
2161
|
+
console.error(`Debug: Response: ${body}`);
|
|
2162
|
+
}
|
|
2163
|
+
return;
|
|
2164
|
+
}
|
|
2165
|
+
if (debug) {
|
|
2166
|
+
console.error(`Debug: Monitoring registration response: ${body}`);
|
|
2167
|
+
}
|
|
2168
|
+
})
|
|
2169
|
+
.catch((err) => {
|
|
2170
|
+
if (debug) {
|
|
2171
|
+
console.error(`Debug: Monitoring registration error: ${err.message}`);
|
|
2172
|
+
}
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
/**
|
|
2177
|
+
* Update .pgwatch-config file with key=value pairs.
|
|
2178
|
+
* Preserves existing values not being updated.
|
|
2179
|
+
*/
|
|
2180
|
+
function updatePgwatchConfig(configPath: string, updates: Record<string, string>): void {
|
|
2181
|
+
let lines: string[] = [];
|
|
2182
|
+
|
|
2183
|
+
// Read existing config if it exists
|
|
2184
|
+
if (fs.existsSync(configPath)) {
|
|
2185
|
+
const stats = fs.statSync(configPath);
|
|
2186
|
+
if (!stats.isDirectory()) {
|
|
2187
|
+
const content = fs.readFileSync(configPath, "utf8");
|
|
2188
|
+
lines = content.split(/\r?\n/).filter(l => l.trim() !== "");
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
// Update or add each key
|
|
2193
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
2194
|
+
const existingIndex = lines.findIndex(l => l.startsWith(key + "="));
|
|
2195
|
+
if (existingIndex >= 0) {
|
|
2196
|
+
lines[existingIndex] = `${key}=${value}`;
|
|
2197
|
+
} else {
|
|
2198
|
+
lines.push(`${key}=${value}`);
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
fs.writeFileSync(configPath, lines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2064
2205
|
/**
|
|
2065
2206
|
* Run docker compose command
|
|
2066
2207
|
*/
|
|
@@ -2115,6 +2256,26 @@ async function runCompose(args: string[], grafanaPassword?: string): Promise<num
|
|
|
2115
2256
|
}
|
|
2116
2257
|
}
|
|
2117
2258
|
|
|
2259
|
+
// Load VM auth credentials from .env if not already set
|
|
2260
|
+
const envFilePath = path.resolve(projectDir, ".env");
|
|
2261
|
+
if (fs.existsSync(envFilePath)) {
|
|
2262
|
+
try {
|
|
2263
|
+
const envContent = fs.readFileSync(envFilePath, "utf8");
|
|
2264
|
+
if (!env.VM_AUTH_USERNAME) {
|
|
2265
|
+
const m = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
|
|
2266
|
+
if (m) env.VM_AUTH_USERNAME = stripMatchingQuotes(m[1]);
|
|
2267
|
+
}
|
|
2268
|
+
if (!env.VM_AUTH_PASSWORD) {
|
|
2269
|
+
const m = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
|
|
2270
|
+
if (m) env.VM_AUTH_PASSWORD = stripMatchingQuotes(m[1]);
|
|
2271
|
+
}
|
|
2272
|
+
} catch (err) {
|
|
2273
|
+
if (process.env.DEBUG) {
|
|
2274
|
+
console.warn(`Warning: Could not read VM auth from .env: ${err instanceof Error ? err.message : String(err)}`);
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2118
2279
|
// On macOS, self-node-exporter can't mount host root filesystem - skip it
|
|
2119
2280
|
const finalArgs = [...args];
|
|
2120
2281
|
if (process.platform === "darwin" && args.includes("up")) {
|
|
@@ -2145,12 +2306,13 @@ mon
|
|
|
2145
2306
|
.option("--api-key <key>", "Postgres AI API key for automated report uploads")
|
|
2146
2307
|
.option("--db-url <url>", "PostgreSQL connection URL to monitor")
|
|
2147
2308
|
.option("--tag <tag>", "Docker image tag to use (e.g., 0.14.0, 0.14.0-dev.33)")
|
|
2309
|
+
.option("--project <name>", "Docker Compose project name (default: postgres_ai)")
|
|
2148
2310
|
.option("-y, --yes", "accept all defaults and skip interactive prompts", false)
|
|
2149
|
-
.action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; tag?: string; yes: boolean }) => {
|
|
2311
|
+
.action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; tag?: string; project?: string; yes: boolean }) => {
|
|
2150
2312
|
// Get apiKey from global program options (--api-key is defined globally)
|
|
2151
2313
|
// This is needed because Commander.js routes --api-key to the global option, not the subcommand's option
|
|
2152
2314
|
const globalOpts = program.opts<CliOptions>();
|
|
2153
|
-
|
|
2315
|
+
let apiKey = opts.apiKey || globalOpts.apiKey;
|
|
2154
2316
|
|
|
2155
2317
|
console.log("\n=================================");
|
|
2156
2318
|
console.log(" PostgresAI monitoring local install");
|
|
@@ -2158,16 +2320,26 @@ mon
|
|
|
2158
2320
|
console.log("This will install, configure, and start the monitoring system\n");
|
|
2159
2321
|
|
|
2160
2322
|
// Ensure we have a project directory with docker-compose.yml even if running from elsewhere
|
|
2161
|
-
const { projectDir } = await resolveOrInitPaths();
|
|
2323
|
+
const { projectDir, instancesFile: instancesPath } = await resolveOrInitPaths();
|
|
2162
2324
|
console.log(`Project directory: ${projectDir}\n`);
|
|
2163
2325
|
|
|
2326
|
+
// Save project name to .pgwatch-config if provided (used by reporter container)
|
|
2327
|
+
if (opts.project) {
|
|
2328
|
+
const cfgPath = path.resolve(projectDir, ".pgwatch-config");
|
|
2329
|
+
updatePgwatchConfig(cfgPath, { project_name: opts.project });
|
|
2330
|
+
console.log(`Using project name: ${opts.project}\n`);
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2164
2333
|
// Update .env with custom tag if provided
|
|
2165
2334
|
const envFile = path.resolve(projectDir, ".env");
|
|
2166
2335
|
|
|
2167
|
-
// Build .env content, preserving important existing values
|
|
2168
|
-
// Note: PGAI_TAG is intentionally NOT preserved - the CLI version should always match Docker images
|
|
2336
|
+
// Build .env content, preserving important existing values.
|
|
2337
|
+
// Note: PGAI_TAG is intentionally NOT preserved - the CLI version should always match Docker images.
|
|
2169
2338
|
let existingRegistry: string | null = null;
|
|
2170
2339
|
let existingPassword: string | null = null;
|
|
2340
|
+
let existingReplicatorPassword: string | null = null;
|
|
2341
|
+
let existingVmAuthUsername: string | null = null;
|
|
2342
|
+
let existingVmAuthPassword: string | null = null;
|
|
2171
2343
|
|
|
2172
2344
|
if (fs.existsSync(envFile)) {
|
|
2173
2345
|
const existingEnv = fs.readFileSync(envFile, "utf8");
|
|
@@ -2176,6 +2348,12 @@ mon
|
|
|
2176
2348
|
if (registryMatch) existingRegistry = registryMatch[1].trim();
|
|
2177
2349
|
const pwdMatch = existingEnv.match(/^GF_SECURITY_ADMIN_PASSWORD=(.+)$/m);
|
|
2178
2350
|
if (pwdMatch) existingPassword = pwdMatch[1].trim();
|
|
2351
|
+
const replicatorPwdMatch = existingEnv.match(/^REPLICATOR_PASSWORD=(.+)$/m);
|
|
2352
|
+
if (replicatorPwdMatch) existingReplicatorPassword = replicatorPwdMatch[1].trim();
|
|
2353
|
+
const vmAuthUserMatch = existingEnv.match(/^VM_AUTH_USERNAME=(.+)$/m);
|
|
2354
|
+
if (vmAuthUserMatch) existingVmAuthUsername = stripMatchingQuotes(vmAuthUserMatch[1]);
|
|
2355
|
+
const vmAuthPasswordMatch = existingEnv.match(/^VM_AUTH_PASSWORD=(.+)$/m);
|
|
2356
|
+
if (vmAuthPasswordMatch) existingVmAuthPassword = stripMatchingQuotes(vmAuthPasswordMatch[1]);
|
|
2179
2357
|
}
|
|
2180
2358
|
|
|
2181
2359
|
// Priority: CLI --tag flag > package version
|
|
@@ -2191,6 +2369,11 @@ mon
|
|
|
2191
2369
|
if (existingPassword) {
|
|
2192
2370
|
envLines.push(`GF_SECURITY_ADMIN_PASSWORD=${existingPassword}`);
|
|
2193
2371
|
}
|
|
2372
|
+
envLines.push(
|
|
2373
|
+
`REPLICATOR_PASSWORD=${existingReplicatorPassword || crypto.randomBytes(32).toString("hex")}`,
|
|
2374
|
+
);
|
|
2375
|
+
envLines.push(`VM_AUTH_USERNAME=${existingVmAuthUsername || "vmauth"}`);
|
|
2376
|
+
envLines.push(`VM_AUTH_PASSWORD=${existingVmAuthPassword || crypto.randomBytes(18).toString("base64")}`);
|
|
2194
2377
|
fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
|
|
2195
2378
|
|
|
2196
2379
|
if (opts.tag) {
|
|
@@ -2199,8 +2382,8 @@ mon
|
|
|
2199
2382
|
|
|
2200
2383
|
// Validate conflicting options
|
|
2201
2384
|
if (opts.demo && opts.dbUrl) {
|
|
2202
|
-
console.
|
|
2203
|
-
console.
|
|
2385
|
+
console.error("⚠ Both --demo and --db-url provided. Demo mode includes its own database.");
|
|
2386
|
+
console.error("⚠ The --db-url will be ignored in demo mode.\n");
|
|
2204
2387
|
opts.dbUrl = undefined;
|
|
2205
2388
|
}
|
|
2206
2389
|
|
|
@@ -2216,7 +2399,7 @@ mon
|
|
|
2216
2399
|
// Check if containers are already running
|
|
2217
2400
|
const { running, containers } = checkRunningContainers();
|
|
2218
2401
|
if (running) {
|
|
2219
|
-
console.
|
|
2402
|
+
console.error(`⚠ Monitoring services are already running: ${containers.join(", ")}`);
|
|
2220
2403
|
console.log("Use 'postgres-ai mon restart' to restart them\n");
|
|
2221
2404
|
return;
|
|
2222
2405
|
}
|
|
@@ -2230,15 +2413,12 @@ mon
|
|
|
2230
2413
|
console.log("Using API key provided via --api-key parameter");
|
|
2231
2414
|
config.writeConfig({ apiKey });
|
|
2232
2415
|
// Keep reporter compatibility (docker-compose mounts .pgwatch-config)
|
|
2233
|
-
|
|
2234
|
-
encoding: "utf8",
|
|
2235
|
-
mode: 0o600
|
|
2236
|
-
});
|
|
2416
|
+
updatePgwatchConfig(path.resolve(projectDir, ".pgwatch-config"), { api_key: apiKey });
|
|
2237
2417
|
console.log("✓ API key saved\n");
|
|
2238
2418
|
} else if (opts.yes) {
|
|
2239
2419
|
// Auto-yes mode without API key - skip API key setup
|
|
2240
2420
|
console.log("Auto-yes mode: no API key provided, skipping API key setup");
|
|
2241
|
-
console.
|
|
2421
|
+
console.error("⚠ Reports will be generated locally only");
|
|
2242
2422
|
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
2243
2423
|
} else {
|
|
2244
2424
|
const answer = await question("Do you have a Postgres AI API key? (Y/n): ");
|
|
@@ -2252,24 +2432,22 @@ mon
|
|
|
2252
2432
|
if (trimmedKey) {
|
|
2253
2433
|
config.writeConfig({ apiKey: trimmedKey });
|
|
2254
2434
|
// Keep reporter compatibility (docker-compose mounts .pgwatch-config)
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
mode: 0o600
|
|
2258
|
-
});
|
|
2435
|
+
updatePgwatchConfig(path.resolve(projectDir, ".pgwatch-config"), { api_key: trimmedKey });
|
|
2436
|
+
apiKey = trimmedKey; // Update for later use in registerMonitoringInstance
|
|
2259
2437
|
console.log("✓ API key saved\n");
|
|
2260
2438
|
break;
|
|
2261
2439
|
}
|
|
2262
2440
|
|
|
2263
|
-
console.
|
|
2441
|
+
console.error("⚠ API key cannot be empty");
|
|
2264
2442
|
const retry = await question("Try again or skip API key setup, retry? (Y/n): ");
|
|
2265
2443
|
if (retry.toLowerCase() === "n") {
|
|
2266
|
-
console.
|
|
2444
|
+
console.error("⚠ Skipping API key setup - reports will be generated locally only");
|
|
2267
2445
|
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
2268
2446
|
break;
|
|
2269
2447
|
}
|
|
2270
2448
|
}
|
|
2271
2449
|
} else {
|
|
2272
|
-
console.
|
|
2450
|
+
console.error("⚠ Skipping API key setup - reports will be generated locally only");
|
|
2273
2451
|
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
2274
2452
|
}
|
|
2275
2453
|
}
|
|
@@ -2315,21 +2493,25 @@ mon
|
|
|
2315
2493
|
|
|
2316
2494
|
// Test connection
|
|
2317
2495
|
console.log("Testing connection to the added instance...");
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2496
|
+
{
|
|
2497
|
+
let testClient: InstanceType<typeof Client> | null = null;
|
|
2498
|
+
try {
|
|
2499
|
+
testClient = new Client({ connectionString: connStr, connectionTimeoutMillis: 10000 });
|
|
2500
|
+
await testClient.connect();
|
|
2501
|
+
const result = await testClient.query("select version();");
|
|
2502
|
+
console.log("✓ Connection successful");
|
|
2503
|
+
console.log(`${result.rows[0].version}\n`);
|
|
2504
|
+
} catch (error) {
|
|
2505
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2506
|
+
console.error(`✗ Connection failed: ${message}\n`);
|
|
2507
|
+
} finally {
|
|
2508
|
+
if (testClient) await testClient.end();
|
|
2509
|
+
}
|
|
2328
2510
|
}
|
|
2329
2511
|
} else if (opts.yes) {
|
|
2330
2512
|
// Auto-yes mode without database URL - skip database setup
|
|
2331
2513
|
console.log("Auto-yes mode: no database URL provided, skipping database setup");
|
|
2332
|
-
console.
|
|
2514
|
+
console.error("⚠ No PostgreSQL instance added");
|
|
2333
2515
|
console.log("You can add one later with: postgres-ai mon targets add\n");
|
|
2334
2516
|
} else {
|
|
2335
2517
|
console.log("You need to add at least one PostgreSQL instance to monitor");
|
|
@@ -2347,7 +2529,7 @@ mon
|
|
|
2347
2529
|
const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
|
|
2348
2530
|
if (!m) {
|
|
2349
2531
|
console.error("✗ Invalid connection string format");
|
|
2350
|
-
console.
|
|
2532
|
+
console.error("⚠ Continuing without adding instance\n");
|
|
2351
2533
|
} else {
|
|
2352
2534
|
const host = m[3];
|
|
2353
2535
|
const db = m[5];
|
|
@@ -2359,27 +2541,62 @@ mon
|
|
|
2359
2541
|
|
|
2360
2542
|
// Test connection
|
|
2361
2543
|
console.log("Testing connection to the added instance...");
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2544
|
+
{
|
|
2545
|
+
let testClient: InstanceType<typeof Client> | null = null;
|
|
2546
|
+
try {
|
|
2547
|
+
testClient = new Client({ connectionString: connStr, connectionTimeoutMillis: 10000 });
|
|
2548
|
+
await testClient.connect();
|
|
2549
|
+
const result = await testClient.query("select version();");
|
|
2550
|
+
console.log("✓ Connection successful");
|
|
2551
|
+
console.log(`${result.rows[0].version}\n`);
|
|
2552
|
+
} catch (error) {
|
|
2553
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2554
|
+
console.error(`✗ Connection failed: ${message}\n`);
|
|
2555
|
+
} finally {
|
|
2556
|
+
if (testClient) await testClient.end();
|
|
2557
|
+
}
|
|
2372
2558
|
}
|
|
2373
2559
|
}
|
|
2374
2560
|
} else {
|
|
2375
|
-
console.
|
|
2561
|
+
console.error("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
|
|
2376
2562
|
}
|
|
2377
2563
|
} else {
|
|
2378
|
-
console.
|
|
2564
|
+
console.error("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
|
|
2379
2565
|
}
|
|
2380
2566
|
}
|
|
2381
2567
|
} else {
|
|
2382
|
-
|
|
2568
|
+
// Demo mode: configure instances.yml from the bundled demo template.
|
|
2569
|
+
//
|
|
2570
|
+
// Side effects:
|
|
2571
|
+
// - Writes instancesPath (instances.yml next to docker-compose.yml)
|
|
2572
|
+
// - If Docker previously bind-mounted instances.yml as a directory, removes it first.
|
|
2573
|
+
//
|
|
2574
|
+
// Failure modes:
|
|
2575
|
+
// - Exits with code 1 if instances.demo.yml is not found in any candidate path.
|
|
2576
|
+
// This is fatal because starting without a target produces empty dashboards that
|
|
2577
|
+
// look like a bug rather than a misconfiguration.
|
|
2578
|
+
//
|
|
2579
|
+
// Template search order (import.meta.url is resolved at runtime, not baked in at build):
|
|
2580
|
+
// 1. npm layout: dist/bin/../../instances.demo.yml → package-root/instances.demo.yml
|
|
2581
|
+
// 2. dev layout: cli/bin/../../../instances.demo.yml → repo-root/instances.demo.yml
|
|
2582
|
+
console.log("Step 2: Demo mode enabled - using included demo PostgreSQL database");
|
|
2583
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
2584
|
+
const demoCandidates = [
|
|
2585
|
+
path.resolve(currentDir, "..", "..", "instances.demo.yml"), // npm: dist/bin -> package root
|
|
2586
|
+
path.resolve(currentDir, "..", "..", "..", "instances.demo.yml"), // dev: cli/bin -> repo root
|
|
2587
|
+
];
|
|
2588
|
+
const demoSrc = demoCandidates.find(p => fs.existsSync(p));
|
|
2589
|
+
if (demoSrc) {
|
|
2590
|
+
// Remove directory artifact left by Docker bind-mounts before copying
|
|
2591
|
+
if (fs.existsSync(instancesPath) && fs.lstatSync(instancesPath).isDirectory()) {
|
|
2592
|
+
fs.rmSync(instancesPath, { recursive: true, force: true });
|
|
2593
|
+
}
|
|
2594
|
+
fs.copyFileSync(demoSrc, instancesPath);
|
|
2595
|
+
console.log("✓ Demo monitoring target configured\n");
|
|
2596
|
+
} else {
|
|
2597
|
+
console.error(`Error: instances.demo.yml not found — cannot configure demo target.\nSearched: ${demoCandidates.join(", ")}\n`);
|
|
2598
|
+
process.exit(1);
|
|
2599
|
+
}
|
|
2383
2600
|
}
|
|
2384
2601
|
|
|
2385
2602
|
// Step 3: Update configuration
|
|
@@ -2395,6 +2612,8 @@ mon
|
|
|
2395
2612
|
console.log(opts.demo ? "Step 4: Configuring Grafana security..." : "Step 4: Configuring Grafana security...");
|
|
2396
2613
|
const cfgPath = path.resolve(projectDir, ".pgwatch-config");
|
|
2397
2614
|
let grafanaPassword = "";
|
|
2615
|
+
let vmAuthUsername = "";
|
|
2616
|
+
let vmAuthPassword = "";
|
|
2398
2617
|
|
|
2399
2618
|
try {
|
|
2400
2619
|
if (fs.existsSync(cfgPath)) {
|
|
@@ -2410,8 +2629,8 @@ mon
|
|
|
2410
2629
|
|
|
2411
2630
|
if (!grafanaPassword) {
|
|
2412
2631
|
console.log("Generating secure Grafana password...");
|
|
2413
|
-
const { stdout: password } = await
|
|
2414
|
-
grafanaPassword = password.trim();
|
|
2632
|
+
const { stdout: password } = await execFilePromise("openssl", ["rand", "-base64", "12"]);
|
|
2633
|
+
grafanaPassword = password.trim().replace(/\n/g, "");
|
|
2415
2634
|
|
|
2416
2635
|
let configContent = "";
|
|
2417
2636
|
if (fs.existsSync(cfgPath)) {
|
|
@@ -2428,12 +2647,58 @@ mon
|
|
|
2428
2647
|
|
|
2429
2648
|
console.log("✓ Grafana password configured\n");
|
|
2430
2649
|
} catch (error) {
|
|
2431
|
-
console.
|
|
2650
|
+
console.error("⚠ Could not generate Grafana password automatically");
|
|
2432
2651
|
console.log("Using default password: demo\n");
|
|
2433
2652
|
grafanaPassword = "demo";
|
|
2434
2653
|
}
|
|
2435
2654
|
|
|
2655
|
+
// Generate VictoriaMetrics auth credentials
|
|
2656
|
+
try {
|
|
2657
|
+
const envFile = path.resolve(projectDir, ".env");
|
|
2658
|
+
|
|
2659
|
+
// Read existing VM auth from .env if present
|
|
2660
|
+
if (fs.existsSync(envFile)) {
|
|
2661
|
+
const envContent = fs.readFileSync(envFile, "utf8");
|
|
2662
|
+
const userMatch = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
|
|
2663
|
+
const passMatch = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
|
|
2664
|
+
if (userMatch) vmAuthUsername = stripMatchingQuotes(userMatch[1]);
|
|
2665
|
+
if (passMatch) vmAuthPassword = stripMatchingQuotes(passMatch[1]);
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
if (!vmAuthUsername || !vmAuthPassword) {
|
|
2669
|
+
console.log("Generating VictoriaMetrics auth credentials...");
|
|
2670
|
+
vmAuthUsername = vmAuthUsername || "vmauth";
|
|
2671
|
+
if (!vmAuthPassword) {
|
|
2672
|
+
const { stdout: vmPass } = await execFilePromise("openssl", ["rand", "-base64", "12"]);
|
|
2673
|
+
vmAuthPassword = vmPass.trim().replace(/\n/g, "");
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
// Update .env file with VM auth credentials
|
|
2677
|
+
let envContent = "";
|
|
2678
|
+
if (fs.existsSync(envFile)) {
|
|
2679
|
+
envContent = fs.readFileSync(envFile, "utf8");
|
|
2680
|
+
}
|
|
2681
|
+
const envLines = envContent.split(/\r?\n/)
|
|
2682
|
+
.filter((l) => !/^VM_AUTH_USERNAME=/.test(l) && !/^VM_AUTH_PASSWORD=/.test(l))
|
|
2683
|
+
.filter((l, i, arr) => !(i === arr.length - 1 && l === ''));
|
|
2684
|
+
envLines.push(`VM_AUTH_USERNAME=${vmAuthUsername}`);
|
|
2685
|
+
envLines.push(`VM_AUTH_PASSWORD=${vmAuthPassword}`);
|
|
2686
|
+
fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
console.log("✓ VictoriaMetrics auth configured\n");
|
|
2690
|
+
} catch (error) {
|
|
2691
|
+
console.error("⚠ Could not generate VictoriaMetrics auth credentials automatically");
|
|
2692
|
+
if (process.env.DEBUG) {
|
|
2693
|
+
console.warn(` ${error instanceof Error ? error.message : String(error)}`);
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2436
2697
|
// Step 5: Start services
|
|
2698
|
+
// Remove stopped containers left by "run --rm" dependencies (e.g. config-init)
|
|
2699
|
+
// to avoid docker-compose v1 'ContainerConfig' error on recreation.
|
|
2700
|
+
// Best-effort: ignore exit code — container may not exist, failure here is non-fatal.
|
|
2701
|
+
await runCompose(["rm", "-f", "-s", "config-init"]);
|
|
2437
2702
|
console.log("Step 5: Starting monitoring services...");
|
|
2438
2703
|
const code2 = await runCompose(["up", "-d", "--force-recreate"], grafanaPassword);
|
|
2439
2704
|
if (code2 !== 0) {
|
|
@@ -2442,6 +2707,15 @@ mon
|
|
|
2442
2707
|
}
|
|
2443
2708
|
console.log("✓ Services started\n");
|
|
2444
2709
|
|
|
2710
|
+
// Register monitoring instance with API (non-blocking, only if API key is configured)
|
|
2711
|
+
if (apiKey && !opts.demo) {
|
|
2712
|
+
const projectName = opts.project || "postgres-ai-monitoring";
|
|
2713
|
+
registerMonitoringInstance(apiKey, projectName, {
|
|
2714
|
+
apiBaseUrl: globalOpts.apiBaseUrl,
|
|
2715
|
+
debug: !!process.env.DEBUG,
|
|
2716
|
+
});
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2445
2719
|
// Final summary
|
|
2446
2720
|
console.log("=================================");
|
|
2447
2721
|
console.log(" Local install completed!");
|
|
@@ -2474,6 +2748,9 @@ mon
|
|
|
2474
2748
|
console.log("🚀 MAIN ACCESS POINT - Start here:");
|
|
2475
2749
|
console.log(" Grafana Dashboard: http://localhost:3000");
|
|
2476
2750
|
console.log(` Login: monitor / ${grafanaPassword}`);
|
|
2751
|
+
if (vmAuthUsername && vmAuthPassword) {
|
|
2752
|
+
console.log(` VictoriaMetrics Auth: ${vmAuthUsername} / ${vmAuthPassword}`);
|
|
2753
|
+
}
|
|
2477
2754
|
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
|
|
2478
2755
|
});
|
|
2479
2756
|
|
|
@@ -2674,7 +2951,7 @@ mon
|
|
|
2674
2951
|
console.log(`Project Directory: ${projectDir}`);
|
|
2675
2952
|
console.log(`Docker Compose File: ${composeFile}`);
|
|
2676
2953
|
console.log(`Instances File: ${instancesFile}`);
|
|
2677
|
-
if (fs.existsSync(instancesFile)) {
|
|
2954
|
+
if (fs.existsSync(instancesFile) && !fs.lstatSync(instancesFile).isDirectory()) {
|
|
2678
2955
|
console.log("\nInstances configuration:\n");
|
|
2679
2956
|
const text = fs.readFileSync(instancesFile, "utf8");
|
|
2680
2957
|
process.stdout.write(text);
|
|
@@ -2705,16 +2982,16 @@ mon
|
|
|
2705
2982
|
|
|
2706
2983
|
// Fetch latest changes
|
|
2707
2984
|
console.log("Fetching latest changes...");
|
|
2708
|
-
await
|
|
2985
|
+
await execFilePromise("git", ["fetch", "origin"]);
|
|
2709
2986
|
|
|
2710
2987
|
// Check current branch
|
|
2711
|
-
const { stdout: branch } = await
|
|
2988
|
+
const { stdout: branch } = await execFilePromise("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
2712
2989
|
const currentBranch = branch.trim();
|
|
2713
2990
|
console.log(`Current branch: ${currentBranch}`);
|
|
2714
2991
|
|
|
2715
2992
|
// Pull latest changes
|
|
2716
2993
|
console.log("Pulling latest changes...");
|
|
2717
|
-
const { stdout: pullOut } = await
|
|
2994
|
+
const { stdout: pullOut } = await execFilePromise("git", ["pull", "origin", currentBranch]);
|
|
2718
2995
|
console.log(pullOut);
|
|
2719
2996
|
|
|
2720
2997
|
// Update Docker images
|
|
@@ -2811,7 +3088,7 @@ mon
|
|
|
2811
3088
|
if (downCode === 0) {
|
|
2812
3089
|
console.log("✓ Monitoring services stopped and removed");
|
|
2813
3090
|
} else {
|
|
2814
|
-
console.
|
|
3091
|
+
console.error("⚠ Could not stop services (may not be running)");
|
|
2815
3092
|
}
|
|
2816
3093
|
|
|
2817
3094
|
// Remove any orphaned containers that docker compose down missed
|
|
@@ -2890,7 +3167,7 @@ targets
|
|
|
2890
3167
|
.description("list monitoring target databases")
|
|
2891
3168
|
.action(async () => {
|
|
2892
3169
|
const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
|
|
2893
|
-
if (!fs.existsSync(instancesPath)) {
|
|
3170
|
+
if (!fs.existsSync(instancesPath) || fs.lstatSync(instancesPath).isDirectory()) {
|
|
2894
3171
|
console.error(`instances.yml not found in ${projectDir}`);
|
|
2895
3172
|
process.exitCode = 1;
|
|
2896
3173
|
return;
|
|
@@ -2956,7 +3233,7 @@ targets
|
|
|
2956
3233
|
|
|
2957
3234
|
// Check if instance already exists
|
|
2958
3235
|
try {
|
|
2959
|
-
if (fs.existsSync(file)) {
|
|
3236
|
+
if (fs.existsSync(file) && !fs.lstatSync(file).isDirectory()) {
|
|
2960
3237
|
const content = fs.readFileSync(file, "utf8");
|
|
2961
3238
|
const instances = yaml.load(content) as Instance[] | null || [];
|
|
2962
3239
|
if (Array.isArray(instances)) {
|
|
@@ -2970,15 +3247,20 @@ targets
|
|
|
2970
3247
|
}
|
|
2971
3248
|
} catch (err) {
|
|
2972
3249
|
// If YAML parsing fails, fall back to simple check
|
|
2973
|
-
const
|
|
2974
|
-
|
|
3250
|
+
const isFile = fs.existsSync(file) && !fs.lstatSync(file).isDirectory();
|
|
3251
|
+
const content = isFile ? fs.readFileSync(file, "utf8") : "";
|
|
3252
|
+
const escapedName = instanceName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3253
|
+
if (new RegExp(`^- name: ${escapedName}$`, "m").test(content)) {
|
|
2975
3254
|
console.error(`Monitoring target '${instanceName}' already exists`);
|
|
2976
3255
|
process.exitCode = 1;
|
|
2977
3256
|
return;
|
|
2978
3257
|
}
|
|
2979
3258
|
}
|
|
2980
3259
|
|
|
2981
|
-
// Add new instance
|
|
3260
|
+
// Add new instance — if instances.yml is a directory (Docker artifact), replace it with a file
|
|
3261
|
+
if (fs.existsSync(file) && fs.lstatSync(file).isDirectory()) {
|
|
3262
|
+
fs.rmSync(file, { recursive: true, force: true });
|
|
3263
|
+
}
|
|
2982
3264
|
const body = `- name: ${instanceName}\n conn_str: ${connStr}\n preset_metrics: full\n custom_metrics:\n is_enabled: true\n group: default\n custom_tags:\n env: production\n cluster: default\n node_name: ${instanceName}\n sink_type: ~sink_type~\n`;
|
|
2983
3265
|
const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
2984
3266
|
fs.appendFileSync(file, (content && !/\n$/.test(content) ? "\n" : "") + body, "utf8");
|
|
@@ -2989,7 +3271,7 @@ targets
|
|
|
2989
3271
|
.description("remove monitoring target database")
|
|
2990
3272
|
.action(async (name: string) => {
|
|
2991
3273
|
const { instancesFile: file } = await resolveOrInitPaths();
|
|
2992
|
-
if (!fs.existsSync(file)) {
|
|
3274
|
+
if (!fs.existsSync(file) || fs.lstatSync(file).isDirectory()) {
|
|
2993
3275
|
console.error("instances.yml not found");
|
|
2994
3276
|
process.exitCode = 1;
|
|
2995
3277
|
return;
|
|
@@ -3026,7 +3308,7 @@ targets
|
|
|
3026
3308
|
.description("test monitoring target database connectivity")
|
|
3027
3309
|
.action(async (name: string) => {
|
|
3028
3310
|
const { instancesFile: instancesPath } = await resolveOrInitPaths();
|
|
3029
|
-
if (!fs.existsSync(instancesPath)) {
|
|
3311
|
+
if (!fs.existsSync(instancesPath) || fs.lstatSync(instancesPath).isDirectory()) {
|
|
3030
3312
|
console.error("instances.yml not found");
|
|
3031
3313
|
process.exitCode = 1;
|
|
3032
3314
|
return;
|
|
@@ -3059,7 +3341,7 @@ targets
|
|
|
3059
3341
|
console.log(`Testing connection to monitoring target '${name}'...`);
|
|
3060
3342
|
|
|
3061
3343
|
// Use native pg client instead of requiring psql to be installed
|
|
3062
|
-
const client = new Client({ connectionString: instance.conn_str });
|
|
3344
|
+
const client = new Client({ connectionString: instance.conn_str, connectionTimeoutMillis: 10000 });
|
|
3063
3345
|
|
|
3064
3346
|
try {
|
|
3065
3347
|
await client.connect();
|
|
@@ -3124,8 +3406,8 @@ auth
|
|
|
3124
3406
|
const { apiBaseUrl, uiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
3125
3407
|
|
|
3126
3408
|
if (opts.debug) {
|
|
3127
|
-
console.
|
|
3128
|
-
console.
|
|
3409
|
+
console.error(`Debug: Resolved API base URL: ${apiBaseUrl}`);
|
|
3410
|
+
console.error(`Debug: Resolved UI base URL: ${uiBaseUrl}`);
|
|
3129
3411
|
}
|
|
3130
3412
|
|
|
3131
3413
|
try {
|
|
@@ -3155,8 +3437,8 @@ auth
|
|
|
3155
3437
|
const initUrl = new URL(`${apiBaseUrl}/rpc/oauth_init`);
|
|
3156
3438
|
|
|
3157
3439
|
if (opts.debug) {
|
|
3158
|
-
console.
|
|
3159
|
-
console.
|
|
3440
|
+
console.error(`Debug: Trying to POST to: ${initUrl.toString()}`);
|
|
3441
|
+
console.error(`Debug: Request data: ${initData}`);
|
|
3160
3442
|
}
|
|
3161
3443
|
|
|
3162
3444
|
// Step 2: Initialize OAuth session on backend using fetch
|
|
@@ -3202,7 +3484,7 @@ auth
|
|
|
3202
3484
|
const authUrl = `${uiBaseUrl}/cli/auth?state=${encodeURIComponent(params.state)}&code_challenge=${encodeURIComponent(params.codeChallenge)}&code_challenge_method=S256&redirect_uri=${encodeURIComponent(redirectUri)}&api_url=${encodeURIComponent(apiBaseUrl)}`;
|
|
3203
3485
|
|
|
3204
3486
|
if (opts.debug) {
|
|
3205
|
-
console.
|
|
3487
|
+
console.error(`Debug: Auth URL: ${authUrl}`);
|
|
3206
3488
|
}
|
|
3207
3489
|
|
|
3208
3490
|
console.log(`\nOpening browser for authentication...`);
|
|
@@ -3412,10 +3694,10 @@ mon
|
|
|
3412
3694
|
|
|
3413
3695
|
try {
|
|
3414
3696
|
// Generate secure password using openssl
|
|
3415
|
-
const { stdout: password } = await
|
|
3416
|
-
"openssl rand -base64 12
|
|
3697
|
+
const { stdout: password } = await execFilePromise(
|
|
3698
|
+
"openssl", ["rand", "-base64", "12"]
|
|
3417
3699
|
);
|
|
3418
|
-
const newPassword = password.trim();
|
|
3700
|
+
const newPassword = password.trim().replace(/\n/g, "");
|
|
3419
3701
|
|
|
3420
3702
|
if (!newPassword) {
|
|
3421
3703
|
console.error("Failed to generate password");
|
|
@@ -3493,6 +3775,19 @@ mon
|
|
|
3493
3775
|
console.log(" URL: http://localhost:3000");
|
|
3494
3776
|
console.log(" Username: monitor");
|
|
3495
3777
|
console.log(` Password: ${password}`);
|
|
3778
|
+
|
|
3779
|
+
// Show VM auth credentials from .env
|
|
3780
|
+
const envFile = path.resolve(projectDir, ".env");
|
|
3781
|
+
if (fs.existsSync(envFile)) {
|
|
3782
|
+
const envContent = fs.readFileSync(envFile, "utf8");
|
|
3783
|
+
const vmUser = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
|
|
3784
|
+
const vmPass = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
|
|
3785
|
+
if (vmUser && vmPass) {
|
|
3786
|
+
console.log("\nVictoriaMetrics credentials:");
|
|
3787
|
+
console.log(` Username: ${stripMatchingQuotes(vmUser[1])}`);
|
|
3788
|
+
console.log(` Password: ${stripMatchingQuotes(vmPass[1])}`);
|
|
3789
|
+
}
|
|
3790
|
+
}
|
|
3496
3791
|
console.log("");
|
|
3497
3792
|
});
|
|
3498
3793
|
|
|
@@ -3626,12 +3921,12 @@ issues
|
|
|
3626
3921
|
// Interpret escape sequences in content (e.g., \n -> newline)
|
|
3627
3922
|
if (opts.debug) {
|
|
3628
3923
|
// eslint-disable-next-line no-console
|
|
3629
|
-
console.
|
|
3924
|
+
console.error(`Debug: Original content: ${JSON.stringify(content)}`);
|
|
3630
3925
|
}
|
|
3631
3926
|
content = interpretEscapes(content);
|
|
3632
3927
|
if (opts.debug) {
|
|
3633
3928
|
// eslint-disable-next-line no-console
|
|
3634
|
-
console.
|
|
3929
|
+
console.error(`Debug: Interpreted content: ${JSON.stringify(content)}`);
|
|
3635
3930
|
}
|
|
3636
3931
|
|
|
3637
3932
|
const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Posting comment...");
|
|
@@ -3825,12 +4120,12 @@ issues
|
|
|
3825
4120
|
.action(async (commentId: string, content: string, opts: { debug?: boolean; json?: boolean }) => {
|
|
3826
4121
|
if (opts.debug) {
|
|
3827
4122
|
// eslint-disable-next-line no-console
|
|
3828
|
-
console.
|
|
4123
|
+
console.error(`Debug: Original content: ${JSON.stringify(content)}`);
|
|
3829
4124
|
}
|
|
3830
4125
|
content = interpretEscapes(content);
|
|
3831
4126
|
if (opts.debug) {
|
|
3832
4127
|
// eslint-disable-next-line no-console
|
|
3833
|
-
console.
|
|
4128
|
+
console.error(`Debug: Interpreted content: ${JSON.stringify(content)}`);
|
|
3834
4129
|
}
|
|
3835
4130
|
|
|
3836
4131
|
const rootOpts = program.opts<CliOptions>();
|
|
@@ -3863,6 +4158,93 @@ issues
|
|
|
3863
4158
|
}
|
|
3864
4159
|
});
|
|
3865
4160
|
|
|
4161
|
+
// File upload/download (subcommands of issues)
|
|
4162
|
+
const issueFiles = issues.command("files").description("upload and download files for issues");
|
|
4163
|
+
|
|
4164
|
+
issueFiles
|
|
4165
|
+
.command("upload <path>")
|
|
4166
|
+
.description("upload a file to storage and get a markdown link")
|
|
4167
|
+
.option("--debug", "enable debug output")
|
|
4168
|
+
.option("--json", "output raw JSON")
|
|
4169
|
+
.action(async (filePath: string, opts: { debug?: boolean; json?: boolean }) => {
|
|
4170
|
+
const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Uploading file...");
|
|
4171
|
+
try {
|
|
4172
|
+
const rootOpts = program.opts<CliOptions>();
|
|
4173
|
+
const cfg = config.readConfig();
|
|
4174
|
+
const { apiKey } = getConfig(rootOpts);
|
|
4175
|
+
if (!apiKey) {
|
|
4176
|
+
spinner.stop();
|
|
4177
|
+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
4178
|
+
process.exitCode = 1;
|
|
4179
|
+
return;
|
|
4180
|
+
}
|
|
4181
|
+
|
|
4182
|
+
const { storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
4183
|
+
|
|
4184
|
+
const result = await uploadFile({
|
|
4185
|
+
apiKey,
|
|
4186
|
+
storageBaseUrl,
|
|
4187
|
+
filePath,
|
|
4188
|
+
debug: !!opts.debug,
|
|
4189
|
+
});
|
|
4190
|
+
spinner.stop();
|
|
4191
|
+
|
|
4192
|
+
if (opts.json) {
|
|
4193
|
+
printResult(result, true);
|
|
4194
|
+
} else {
|
|
4195
|
+
const md = buildMarkdownLink(result.url, storageBaseUrl, result.metadata.originalName);
|
|
4196
|
+
const displayUrl = result.url.startsWith("/") ? `${storageBaseUrl}${result.url}` : `${storageBaseUrl}/${result.url}`;
|
|
4197
|
+
console.log(`URL: ${displayUrl}`);
|
|
4198
|
+
console.log(`File: ${result.metadata.originalName}`);
|
|
4199
|
+
console.log(`Size: ${result.metadata.size} bytes`);
|
|
4200
|
+
console.log(`Type: ${result.metadata.mimeType}`);
|
|
4201
|
+
console.log(`Markdown: ${md}`);
|
|
4202
|
+
}
|
|
4203
|
+
} catch (err) {
|
|
4204
|
+
spinner.stop();
|
|
4205
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4206
|
+
console.error(message);
|
|
4207
|
+
process.exitCode = 1;
|
|
4208
|
+
}
|
|
4209
|
+
});
|
|
4210
|
+
|
|
4211
|
+
issueFiles
|
|
4212
|
+
.command("download <url>")
|
|
4213
|
+
.description("download a file from storage")
|
|
4214
|
+
.option("-o, --output <path>", "output file path (default: derive from URL)")
|
|
4215
|
+
.option("--debug", "enable debug output")
|
|
4216
|
+
.action(async (fileUrl: string, opts: { output?: string; debug?: boolean }) => {
|
|
4217
|
+
const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Downloading file...");
|
|
4218
|
+
try {
|
|
4219
|
+
const rootOpts = program.opts<CliOptions>();
|
|
4220
|
+
const cfg = config.readConfig();
|
|
4221
|
+
const { apiKey } = getConfig(rootOpts);
|
|
4222
|
+
if (!apiKey) {
|
|
4223
|
+
spinner.stop();
|
|
4224
|
+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
4225
|
+
process.exitCode = 1;
|
|
4226
|
+
return;
|
|
4227
|
+
}
|
|
4228
|
+
|
|
4229
|
+
const { storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
4230
|
+
|
|
4231
|
+
const result = await downloadFile({
|
|
4232
|
+
apiKey,
|
|
4233
|
+
storageBaseUrl,
|
|
4234
|
+
fileUrl,
|
|
4235
|
+
outputPath: opts.output,
|
|
4236
|
+
debug: !!opts.debug,
|
|
4237
|
+
});
|
|
4238
|
+
spinner.stop();
|
|
4239
|
+
console.log(`Saved: ${result.savedTo}`);
|
|
4240
|
+
} catch (err) {
|
|
4241
|
+
spinner.stop();
|
|
4242
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4243
|
+
console.error(message);
|
|
4244
|
+
process.exitCode = 1;
|
|
4245
|
+
}
|
|
4246
|
+
});
|
|
4247
|
+
|
|
3866
4248
|
// Action Items management (subcommands of issues)
|
|
3867
4249
|
issues
|
|
3868
4250
|
.command("action-items <issueId>")
|
|
@@ -4087,6 +4469,228 @@ issues
|
|
|
4087
4469
|
}
|
|
4088
4470
|
});
|
|
4089
4471
|
|
|
4472
|
+
// Reports management
|
|
4473
|
+
const reports = program.command("reports").description("checkup reports management");
|
|
4474
|
+
|
|
4475
|
+
reports
|
|
4476
|
+
.command("list")
|
|
4477
|
+
.description("list checkup reports")
|
|
4478
|
+
.option("--project-id <id>", "filter by project id", (v: string) => parseInt(v, 10))
|
|
4479
|
+
.addOption(new Option("--status <status>", "filter by status (e.g., completed)").hideHelp())
|
|
4480
|
+
.option("--limit <n>", "max number of reports to return (default: 20, max: 100)", (v: string) => { const n = parseInt(v, 10); return Number.isNaN(n) ? 20 : Math.max(1, Math.min(n, 100)); })
|
|
4481
|
+
.option("--before <date>", "show reports created before this date (YYYY-MM-DD, DD.MM.YYYY, etc.)")
|
|
4482
|
+
.option("--all", "fetch all reports (paginated automatically)")
|
|
4483
|
+
.addOption(new Option("--debug", "enable debug output").hideHelp())
|
|
4484
|
+
.option("--json", "output raw JSON")
|
|
4485
|
+
.action(async (opts: { projectId?: number; status?: string; limit?: number; before?: string; all?: boolean; debug?: boolean; json?: boolean }) => {
|
|
4486
|
+
const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching reports...");
|
|
4487
|
+
try {
|
|
4488
|
+
const rootOpts = program.opts<CliOptions>();
|
|
4489
|
+
const cfg = config.readConfig();
|
|
4490
|
+
const { apiKey } = getConfig(rootOpts);
|
|
4491
|
+
if (!apiKey) {
|
|
4492
|
+
spinner.stop();
|
|
4493
|
+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
4494
|
+
process.exitCode = 1;
|
|
4495
|
+
return;
|
|
4496
|
+
}
|
|
4497
|
+
if (opts.all && opts.before) {
|
|
4498
|
+
spinner.stop();
|
|
4499
|
+
console.error("--all and --before cannot be used together");
|
|
4500
|
+
process.exitCode = 1;
|
|
4501
|
+
return;
|
|
4502
|
+
}
|
|
4503
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
4504
|
+
|
|
4505
|
+
let result;
|
|
4506
|
+
if (opts.all) {
|
|
4507
|
+
result = await fetchAllReports({
|
|
4508
|
+
apiKey,
|
|
4509
|
+
apiBaseUrl,
|
|
4510
|
+
projectId: opts.projectId,
|
|
4511
|
+
status: opts.status,
|
|
4512
|
+
limit: opts.limit,
|
|
4513
|
+
debug: !!opts.debug,
|
|
4514
|
+
});
|
|
4515
|
+
} else {
|
|
4516
|
+
result = await fetchReports({
|
|
4517
|
+
apiKey,
|
|
4518
|
+
apiBaseUrl,
|
|
4519
|
+
projectId: opts.projectId,
|
|
4520
|
+
status: opts.status,
|
|
4521
|
+
limit: opts.limit,
|
|
4522
|
+
beforeDate: opts.before ? parseFlexibleDate(opts.before) : undefined,
|
|
4523
|
+
debug: !!opts.debug,
|
|
4524
|
+
});
|
|
4525
|
+
}
|
|
4526
|
+
spinner.stop();
|
|
4527
|
+
printResult(result, opts.json);
|
|
4528
|
+
} catch (err) {
|
|
4529
|
+
spinner.stop();
|
|
4530
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4531
|
+
console.error(message);
|
|
4532
|
+
process.exitCode = 1;
|
|
4533
|
+
}
|
|
4534
|
+
});
|
|
4535
|
+
|
|
4536
|
+
reports
|
|
4537
|
+
.command("files [reportId]")
|
|
4538
|
+
.description("list files of a checkup report (metadata only, no content)")
|
|
4539
|
+
.option("--type <type>", "filter by file type: json, md")
|
|
4540
|
+
.option("--check-id <id>", "filter by check ID (e.g., H002)")
|
|
4541
|
+
.addOption(new Option("--debug", "enable debug output").hideHelp())
|
|
4542
|
+
.option("--json", "output raw JSON")
|
|
4543
|
+
.action(async (reportId: string | undefined, opts: { type?: "json" | "md"; checkId?: string; debug?: boolean; json?: boolean }) => {
|
|
4544
|
+
const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching report files...");
|
|
4545
|
+
try {
|
|
4546
|
+
const rootOpts = program.opts<CliOptions>();
|
|
4547
|
+
const cfg = config.readConfig();
|
|
4548
|
+
const { apiKey } = getConfig(rootOpts);
|
|
4549
|
+
if (!apiKey) {
|
|
4550
|
+
spinner.stop();
|
|
4551
|
+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
4552
|
+
process.exitCode = 1;
|
|
4553
|
+
return;
|
|
4554
|
+
}
|
|
4555
|
+
let numericId: number | undefined;
|
|
4556
|
+
if (reportId !== undefined) {
|
|
4557
|
+
numericId = parseInt(reportId, 10);
|
|
4558
|
+
if (isNaN(numericId)) {
|
|
4559
|
+
spinner.stop();
|
|
4560
|
+
console.error("reportId must be a number");
|
|
4561
|
+
process.exitCode = 1;
|
|
4562
|
+
return;
|
|
4563
|
+
}
|
|
4564
|
+
}
|
|
4565
|
+
if (numericId === undefined && !opts.checkId) {
|
|
4566
|
+
spinner.stop();
|
|
4567
|
+
console.error("Either reportId or --check-id is required");
|
|
4568
|
+
process.exitCode = 1;
|
|
4569
|
+
return;
|
|
4570
|
+
}
|
|
4571
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
4572
|
+
|
|
4573
|
+
const result = await fetchReportFiles({
|
|
4574
|
+
apiKey,
|
|
4575
|
+
apiBaseUrl,
|
|
4576
|
+
reportId: numericId,
|
|
4577
|
+
type: opts.type,
|
|
4578
|
+
checkId: opts.checkId,
|
|
4579
|
+
debug: !!opts.debug,
|
|
4580
|
+
});
|
|
4581
|
+
spinner.stop();
|
|
4582
|
+
printResult(result, opts.json);
|
|
4583
|
+
} catch (err) {
|
|
4584
|
+
spinner.stop();
|
|
4585
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4586
|
+
console.error(message);
|
|
4587
|
+
process.exitCode = 1;
|
|
4588
|
+
}
|
|
4589
|
+
});
|
|
4590
|
+
|
|
4591
|
+
reports
|
|
4592
|
+
.command("data [reportId]")
|
|
4593
|
+
.description("get checkup report file data (includes content)")
|
|
4594
|
+
.option("--type <type>", "filter by file type: json, md")
|
|
4595
|
+
.option("--check-id <id>", "filter by check ID (e.g., H002)")
|
|
4596
|
+
.option("--formatted", "render markdown with ANSI styling (experimental)")
|
|
4597
|
+
.option("-o, --output <dir>", "save files to directory (uses original filenames)")
|
|
4598
|
+
.addOption(new Option("--debug", "enable debug output").hideHelp())
|
|
4599
|
+
.option("--json", "output raw JSON")
|
|
4600
|
+
.action(async (reportId: string | undefined, opts: { type?: "json" | "md"; checkId?: string; formatted?: boolean; output?: string; debug?: boolean; json?: boolean }) => {
|
|
4601
|
+
const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching report data...");
|
|
4602
|
+
try {
|
|
4603
|
+
const rootOpts = program.opts<CliOptions>();
|
|
4604
|
+
const cfg = config.readConfig();
|
|
4605
|
+
const { apiKey } = getConfig(rootOpts);
|
|
4606
|
+
if (!apiKey) {
|
|
4607
|
+
spinner.stop();
|
|
4608
|
+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
4609
|
+
process.exitCode = 1;
|
|
4610
|
+
return;
|
|
4611
|
+
}
|
|
4612
|
+
let numericId: number | undefined;
|
|
4613
|
+
if (reportId !== undefined) {
|
|
4614
|
+
numericId = parseInt(reportId, 10);
|
|
4615
|
+
if (isNaN(numericId)) {
|
|
4616
|
+
spinner.stop();
|
|
4617
|
+
console.error("reportId must be a number");
|
|
4618
|
+
process.exitCode = 1;
|
|
4619
|
+
return;
|
|
4620
|
+
}
|
|
4621
|
+
}
|
|
4622
|
+
if (numericId === undefined && !opts.checkId) {
|
|
4623
|
+
spinner.stop();
|
|
4624
|
+
console.error("Either reportId or --check-id is required");
|
|
4625
|
+
process.exitCode = 1;
|
|
4626
|
+
return;
|
|
4627
|
+
}
|
|
4628
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
4629
|
+
|
|
4630
|
+
// Default to "md" for terminal output (human-readable); --json and --output get all types
|
|
4631
|
+
const effectiveType = opts.type ?? (!opts.json && !opts.output ? "md" as const : undefined);
|
|
4632
|
+
const result = await fetchReportFileData({
|
|
4633
|
+
apiKey,
|
|
4634
|
+
apiBaseUrl,
|
|
4635
|
+
reportId: numericId,
|
|
4636
|
+
type: effectiveType,
|
|
4637
|
+
checkId: opts.checkId,
|
|
4638
|
+
debug: !!opts.debug,
|
|
4639
|
+
});
|
|
4640
|
+
spinner.stop();
|
|
4641
|
+
|
|
4642
|
+
if (opts.output) {
|
|
4643
|
+
const dir = path.resolve(opts.output);
|
|
4644
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
4645
|
+
for (const f of result) {
|
|
4646
|
+
const safeName = path.basename(f.filename);
|
|
4647
|
+
const filePath = path.join(dir, safeName);
|
|
4648
|
+
const content = f.type === "json"
|
|
4649
|
+
? JSON.stringify(tryParseJson(f.data), null, 2)
|
|
4650
|
+
: f.data;
|
|
4651
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
4652
|
+
console.log(filePath);
|
|
4653
|
+
}
|
|
4654
|
+
} else if (opts.json) {
|
|
4655
|
+
const processed = result.map((f) => ({
|
|
4656
|
+
...f,
|
|
4657
|
+
data: f.type === "json" ? tryParseJson(f.data) : f.data,
|
|
4658
|
+
}));
|
|
4659
|
+
printResult(processed, true);
|
|
4660
|
+
} else if (opts.formatted && process.stdout.isTTY) {
|
|
4661
|
+
for (const f of result) {
|
|
4662
|
+
if (result.length > 1) {
|
|
4663
|
+
console.log(`\x1b[1m--- ${f.filename} (${f.check_id}, ${f.type}) ---\x1b[0m`);
|
|
4664
|
+
}
|
|
4665
|
+
if (f.type === "md") {
|
|
4666
|
+
console.log(renderMarkdownForTerminal(f.data));
|
|
4667
|
+
} else if (f.type === "json") {
|
|
4668
|
+
const parsed = tryParseJson(f.data);
|
|
4669
|
+
console.log(typeof parsed === "string" ? parsed : JSON.stringify(parsed, null, 2));
|
|
4670
|
+
} else {
|
|
4671
|
+
console.log(f.data);
|
|
4672
|
+
}
|
|
4673
|
+
}
|
|
4674
|
+
} else {
|
|
4675
|
+
for (const f of result) {
|
|
4676
|
+
if (result.length > 1) {
|
|
4677
|
+
console.log(`--- ${f.filename} (${f.check_id}, ${f.type}) ---`);
|
|
4678
|
+
}
|
|
4679
|
+
console.log(f.data);
|
|
4680
|
+
}
|
|
4681
|
+
}
|
|
4682
|
+
} catch (err) {
|
|
4683
|
+
spinner.stop();
|
|
4684
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4685
|
+
console.error(message);
|
|
4686
|
+
process.exitCode = 1;
|
|
4687
|
+
}
|
|
4688
|
+
});
|
|
4689
|
+
|
|
4690
|
+
function tryParseJson(s: string): unknown {
|
|
4691
|
+
try { return JSON.parse(s); } catch { return s; }
|
|
4692
|
+
}
|
|
4693
|
+
|
|
4090
4694
|
// MCP server
|
|
4091
4695
|
const mcp = program.command("mcp").description("MCP server integration");
|
|
4092
4696
|
|
|
@@ -4144,7 +4748,7 @@ mcp
|
|
|
4144
4748
|
// Get the path to the current pgai executable
|
|
4145
4749
|
let pgaiPath: string;
|
|
4146
4750
|
try {
|
|
4147
|
-
const execPath = await
|
|
4751
|
+
const execPath = await execFilePromise("which", ["pgai"]);
|
|
4148
4752
|
pgaiPath = execPath.stdout.trim();
|
|
4149
4753
|
} catch {
|
|
4150
4754
|
// Fallback to just "pgai" if which fails
|
|
@@ -4156,8 +4760,8 @@ mcp
|
|
|
4156
4760
|
console.log("Installing PostgresAI MCP server for Claude Code...");
|
|
4157
4761
|
|
|
4158
4762
|
try {
|
|
4159
|
-
const { stdout, stderr } = await
|
|
4160
|
-
|
|
4763
|
+
const { stdout, stderr } = await execFilePromise(
|
|
4764
|
+
"claude", ["mcp", "add", "-s", "user", "postgresai", pgaiPath, "mcp", "start"]
|
|
4161
4765
|
);
|
|
4162
4766
|
|
|
4163
4767
|
if (stdout) console.log(stdout);
|