postgresai 0.15.0-dev.1 → 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 +606 -105
- package/bun.lock +4 -4
- package/dist/bin/postgres-ai.js +2355 -577
- package/instances.demo.yml +14 -0
- package/lib/checkup-api.ts +25 -6
- package/lib/checkup.ts +241 -10
- package/lib/config.ts +3 -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 +3 -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 +2 -0
- 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 +459 -0
- package/test/monitoring.test.ts +78 -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";
|
|
@@ -51,21 +54,16 @@ function closeReadline() {
|
|
|
51
54
|
}
|
|
52
55
|
}
|
|
53
56
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
reject(err);
|
|
62
|
-
} else {
|
|
63
|
-
resolve({ stdout, stderr });
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
});
|
|
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;
|
|
67
64
|
}
|
|
68
65
|
|
|
66
|
+
// Helper functions for spawning processes - use Node.js child_process for compatibility
|
|
69
67
|
async function execFilePromise(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
|
70
68
|
return new Promise((resolve, reject) => {
|
|
71
69
|
childProcess.execFile(file, args, (error, stdout, stderr) => {
|
|
@@ -342,7 +340,8 @@ function writeReportFiles(reports: Record<string, any>, outputPath: string): voi
|
|
|
342
340
|
for (const [checkId, report] of Object.entries(reports)) {
|
|
343
341
|
const filePath = path.join(outputPath, `${checkId}.json`);
|
|
344
342
|
fs.writeFileSync(filePath, JSON.stringify(report, null, 2), "utf8");
|
|
345
|
-
|
|
343
|
+
const title = report.checkTitle || checkId;
|
|
344
|
+
console.log(`✓ ${checkId} ${title}: ${filePath}`);
|
|
346
345
|
}
|
|
347
346
|
}
|
|
348
347
|
|
|
@@ -410,6 +409,7 @@ interface CliOptions {
|
|
|
410
409
|
apiKey?: string;
|
|
411
410
|
apiBaseUrl?: string;
|
|
412
411
|
uiBaseUrl?: string;
|
|
412
|
+
storageBaseUrl?: string;
|
|
413
413
|
}
|
|
414
414
|
|
|
415
415
|
/**
|
|
@@ -499,7 +499,11 @@ async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
|
|
|
499
499
|
}
|
|
500
500
|
}
|
|
501
501
|
|
|
502
|
-
// 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
|
+
}
|
|
503
507
|
if (!fs.existsSync(instancesFile)) {
|
|
504
508
|
const header =
|
|
505
509
|
"# PostgreSQL instances to monitor\n" +
|
|
@@ -578,6 +582,10 @@ program
|
|
|
578
582
|
.option(
|
|
579
583
|
"--ui-base-url <url>",
|
|
580
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)"
|
|
581
589
|
);
|
|
582
590
|
|
|
583
591
|
program
|
|
@@ -594,6 +602,27 @@ program
|
|
|
594
602
|
console.log(`Default project saved: ${value}`);
|
|
595
603
|
});
|
|
596
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
|
+
|
|
597
626
|
program
|
|
598
627
|
.command("prepare-db [conn]")
|
|
599
628
|
.description("prepare database for monitoring: create monitoring user, required view(s), and grant permissions (idempotent)")
|
|
@@ -839,8 +868,8 @@ program
|
|
|
839
868
|
} else {
|
|
840
869
|
console.log("✓ prepare-db verify: OK");
|
|
841
870
|
if (v.missingOptional.length > 0) {
|
|
842
|
-
console.
|
|
843
|
-
for (const m of v.missingOptional) console.
|
|
871
|
+
console.error("⚠ Optional items missing:");
|
|
872
|
+
for (const m of v.missingOptional) console.error(`- ${m}`);
|
|
844
873
|
}
|
|
845
874
|
}
|
|
846
875
|
return;
|
|
@@ -971,8 +1000,8 @@ program
|
|
|
971
1000
|
} else {
|
|
972
1001
|
console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
|
|
973
1002
|
if (skippedOptional.length > 0) {
|
|
974
|
-
console.
|
|
975
|
-
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}`);
|
|
976
1005
|
}
|
|
977
1006
|
if (process.stdout.isTTY) {
|
|
978
1007
|
console.log(`Applied ${applied.length} steps`);
|
|
@@ -1153,8 +1182,8 @@ program
|
|
|
1153
1182
|
} else {
|
|
1154
1183
|
console.log(`✓ prepare-db verify: OK${opts.provider ? ` (provider: ${opts.provider})` : ""}`);
|
|
1155
1184
|
if (v.missingOptional.length > 0) {
|
|
1156
|
-
console.
|
|
1157
|
-
for (const m of v.missingOptional) console.
|
|
1185
|
+
console.error("⚠ Optional items missing:");
|
|
1186
|
+
for (const m of v.missingOptional) console.error(`- ${m}`);
|
|
1158
1187
|
}
|
|
1159
1188
|
}
|
|
1160
1189
|
return;
|
|
@@ -1281,8 +1310,8 @@ program
|
|
|
1281
1310
|
} else {
|
|
1282
1311
|
console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
|
|
1283
1312
|
if (skippedOptional.length > 0) {
|
|
1284
|
-
console.
|
|
1285
|
-
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}`);
|
|
1286
1315
|
}
|
|
1287
1316
|
// Keep output compact but still useful
|
|
1288
1317
|
if (process.stdout.isTTY) {
|
|
@@ -1598,11 +1627,11 @@ program
|
|
|
1598
1627
|
console.log("✓ unprepare-db completed");
|
|
1599
1628
|
console.log(`Applied ${applied.length} steps`);
|
|
1600
1629
|
} else {
|
|
1601
|
-
console.
|
|
1630
|
+
console.error("⚠ unprepare-db completed with errors");
|
|
1602
1631
|
console.log(`Applied ${applied.length} steps`);
|
|
1603
|
-
console.
|
|
1632
|
+
console.error("Errors:");
|
|
1604
1633
|
for (const err of errors) {
|
|
1605
|
-
console.
|
|
1634
|
+
console.error(` - ${err}`);
|
|
1606
1635
|
}
|
|
1607
1636
|
process.exitCode = 1;
|
|
1608
1637
|
}
|
|
@@ -1797,6 +1826,24 @@ program
|
|
|
1797
1826
|
const connResult = await connectWithSslFallback(Client, adminConn);
|
|
1798
1827
|
client = connResult.client as Client;
|
|
1799
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
|
+
|
|
1800
1847
|
// Generate reports
|
|
1801
1848
|
let reports: Record<string, any>;
|
|
1802
1849
|
if (checkId === "ALL") {
|
|
@@ -1924,8 +1971,8 @@ program
|
|
|
1924
1971
|
}
|
|
1925
1972
|
}
|
|
1926
1973
|
|
|
1927
|
-
// Output JSON to stdout
|
|
1928
|
-
if (shouldPrintJson) {
|
|
1974
|
+
// Output JSON to stdout (unless --output is specified, in which case files are written instead)
|
|
1975
|
+
if (shouldPrintJson && !outputPath) {
|
|
1929
1976
|
console.log(JSON.stringify(reports, null, 2));
|
|
1930
1977
|
}
|
|
1931
1978
|
|
|
@@ -2042,13 +2089,16 @@ function isDockerRunning(): boolean {
|
|
|
2042
2089
|
}
|
|
2043
2090
|
|
|
2044
2091
|
/**
|
|
2045
|
-
* 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).
|
|
2046
2096
|
*/
|
|
2047
2097
|
function getComposeCmd(): string[] | null {
|
|
2048
2098
|
const tryCmd = (cmd: string, args: string[]): boolean =>
|
|
2049
2099
|
spawnSync(cmd, args, { stdio: "ignore", timeout: 5000 } as Parameters<typeof spawnSync>[2]).status === 0;
|
|
2050
|
-
if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
|
|
2051
2100
|
if (tryCmd("docker", ["compose", "version"])) return ["docker", "compose"];
|
|
2101
|
+
if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
|
|
2052
2102
|
return null;
|
|
2053
2103
|
}
|
|
2054
2104
|
|
|
@@ -2087,9 +2137,9 @@ function registerMonitoringInstance(
|
|
|
2087
2137
|
const debug = opts?.debug;
|
|
2088
2138
|
|
|
2089
2139
|
if (debug) {
|
|
2090
|
-
console.
|
|
2091
|
-
console.
|
|
2092
|
-
console.
|
|
2140
|
+
console.error(`\nDebug: Registering monitoring instance...`);
|
|
2141
|
+
console.error(`Debug: POST ${url}`);
|
|
2142
|
+
console.error(`Debug: project_name=${projectName}`);
|
|
2093
2143
|
}
|
|
2094
2144
|
|
|
2095
2145
|
// Fire and forget - don't block the main flow
|
|
@@ -2107,18 +2157,18 @@ function registerMonitoringInstance(
|
|
|
2107
2157
|
const body = await res.text().catch(() => "");
|
|
2108
2158
|
if (!res.ok) {
|
|
2109
2159
|
if (debug) {
|
|
2110
|
-
console.
|
|
2111
|
-
console.
|
|
2160
|
+
console.error(`Debug: Monitoring registration failed: HTTP ${res.status}`);
|
|
2161
|
+
console.error(`Debug: Response: ${body}`);
|
|
2112
2162
|
}
|
|
2113
2163
|
return;
|
|
2114
2164
|
}
|
|
2115
2165
|
if (debug) {
|
|
2116
|
-
console.
|
|
2166
|
+
console.error(`Debug: Monitoring registration response: ${body}`);
|
|
2117
2167
|
}
|
|
2118
2168
|
})
|
|
2119
2169
|
.catch((err) => {
|
|
2120
2170
|
if (debug) {
|
|
2121
|
-
console.
|
|
2171
|
+
console.error(`Debug: Monitoring registration error: ${err.message}`);
|
|
2122
2172
|
}
|
|
2123
2173
|
});
|
|
2124
2174
|
}
|
|
@@ -2206,6 +2256,26 @@ async function runCompose(args: string[], grafanaPassword?: string): Promise<num
|
|
|
2206
2256
|
}
|
|
2207
2257
|
}
|
|
2208
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
|
+
|
|
2209
2279
|
// On macOS, self-node-exporter can't mount host root filesystem - skip it
|
|
2210
2280
|
const finalArgs = [...args];
|
|
2211
2281
|
if (process.platform === "darwin" && args.includes("up")) {
|
|
@@ -2250,7 +2320,7 @@ mon
|
|
|
2250
2320
|
console.log("This will install, configure, and start the monitoring system\n");
|
|
2251
2321
|
|
|
2252
2322
|
// Ensure we have a project directory with docker-compose.yml even if running from elsewhere
|
|
2253
|
-
const { projectDir } = await resolveOrInitPaths();
|
|
2323
|
+
const { projectDir, instancesFile: instancesPath } = await resolveOrInitPaths();
|
|
2254
2324
|
console.log(`Project directory: ${projectDir}\n`);
|
|
2255
2325
|
|
|
2256
2326
|
// Save project name to .pgwatch-config if provided (used by reporter container)
|
|
@@ -2263,10 +2333,13 @@ mon
|
|
|
2263
2333
|
// Update .env with custom tag if provided
|
|
2264
2334
|
const envFile = path.resolve(projectDir, ".env");
|
|
2265
2335
|
|
|
2266
|
-
// Build .env content, preserving important existing values
|
|
2267
|
-
// 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.
|
|
2268
2338
|
let existingRegistry: string | null = null;
|
|
2269
2339
|
let existingPassword: string | null = null;
|
|
2340
|
+
let existingReplicatorPassword: string | null = null;
|
|
2341
|
+
let existingVmAuthUsername: string | null = null;
|
|
2342
|
+
let existingVmAuthPassword: string | null = null;
|
|
2270
2343
|
|
|
2271
2344
|
if (fs.existsSync(envFile)) {
|
|
2272
2345
|
const existingEnv = fs.readFileSync(envFile, "utf8");
|
|
@@ -2275,6 +2348,12 @@ mon
|
|
|
2275
2348
|
if (registryMatch) existingRegistry = registryMatch[1].trim();
|
|
2276
2349
|
const pwdMatch = existingEnv.match(/^GF_SECURITY_ADMIN_PASSWORD=(.+)$/m);
|
|
2277
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]);
|
|
2278
2357
|
}
|
|
2279
2358
|
|
|
2280
2359
|
// Priority: CLI --tag flag > package version
|
|
@@ -2290,6 +2369,11 @@ mon
|
|
|
2290
2369
|
if (existingPassword) {
|
|
2291
2370
|
envLines.push(`GF_SECURITY_ADMIN_PASSWORD=${existingPassword}`);
|
|
2292
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")}`);
|
|
2293
2377
|
fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
|
|
2294
2378
|
|
|
2295
2379
|
if (opts.tag) {
|
|
@@ -2298,8 +2382,8 @@ mon
|
|
|
2298
2382
|
|
|
2299
2383
|
// Validate conflicting options
|
|
2300
2384
|
if (opts.demo && opts.dbUrl) {
|
|
2301
|
-
console.
|
|
2302
|
-
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");
|
|
2303
2387
|
opts.dbUrl = undefined;
|
|
2304
2388
|
}
|
|
2305
2389
|
|
|
@@ -2315,7 +2399,7 @@ mon
|
|
|
2315
2399
|
// Check if containers are already running
|
|
2316
2400
|
const { running, containers } = checkRunningContainers();
|
|
2317
2401
|
if (running) {
|
|
2318
|
-
console.
|
|
2402
|
+
console.error(`⚠ Monitoring services are already running: ${containers.join(", ")}`);
|
|
2319
2403
|
console.log("Use 'postgres-ai mon restart' to restart them\n");
|
|
2320
2404
|
return;
|
|
2321
2405
|
}
|
|
@@ -2334,7 +2418,7 @@ mon
|
|
|
2334
2418
|
} else if (opts.yes) {
|
|
2335
2419
|
// Auto-yes mode without API key - skip API key setup
|
|
2336
2420
|
console.log("Auto-yes mode: no API key provided, skipping API key setup");
|
|
2337
|
-
console.
|
|
2421
|
+
console.error("⚠ Reports will be generated locally only");
|
|
2338
2422
|
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
2339
2423
|
} else {
|
|
2340
2424
|
const answer = await question("Do you have a Postgres AI API key? (Y/n): ");
|
|
@@ -2354,16 +2438,16 @@ mon
|
|
|
2354
2438
|
break;
|
|
2355
2439
|
}
|
|
2356
2440
|
|
|
2357
|
-
console.
|
|
2441
|
+
console.error("⚠ API key cannot be empty");
|
|
2358
2442
|
const retry = await question("Try again or skip API key setup, retry? (Y/n): ");
|
|
2359
2443
|
if (retry.toLowerCase() === "n") {
|
|
2360
|
-
console.
|
|
2444
|
+
console.error("⚠ Skipping API key setup - reports will be generated locally only");
|
|
2361
2445
|
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
2362
2446
|
break;
|
|
2363
2447
|
}
|
|
2364
2448
|
}
|
|
2365
2449
|
} else {
|
|
2366
|
-
console.
|
|
2450
|
+
console.error("⚠ Skipping API key setup - reports will be generated locally only");
|
|
2367
2451
|
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
2368
2452
|
}
|
|
2369
2453
|
}
|
|
@@ -2409,21 +2493,25 @@ mon
|
|
|
2409
2493
|
|
|
2410
2494
|
// Test connection
|
|
2411
2495
|
console.log("Testing connection to the added instance...");
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
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
|
+
}
|
|
2422
2510
|
}
|
|
2423
2511
|
} else if (opts.yes) {
|
|
2424
2512
|
// Auto-yes mode without database URL - skip database setup
|
|
2425
2513
|
console.log("Auto-yes mode: no database URL provided, skipping database setup");
|
|
2426
|
-
console.
|
|
2514
|
+
console.error("⚠ No PostgreSQL instance added");
|
|
2427
2515
|
console.log("You can add one later with: postgres-ai mon targets add\n");
|
|
2428
2516
|
} else {
|
|
2429
2517
|
console.log("You need to add at least one PostgreSQL instance to monitor");
|
|
@@ -2441,7 +2529,7 @@ mon
|
|
|
2441
2529
|
const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
|
|
2442
2530
|
if (!m) {
|
|
2443
2531
|
console.error("✗ Invalid connection string format");
|
|
2444
|
-
console.
|
|
2532
|
+
console.error("⚠ Continuing without adding instance\n");
|
|
2445
2533
|
} else {
|
|
2446
2534
|
const host = m[3];
|
|
2447
2535
|
const db = m[5];
|
|
@@ -2453,27 +2541,62 @@ mon
|
|
|
2453
2541
|
|
|
2454
2542
|
// Test connection
|
|
2455
2543
|
console.log("Testing connection to the added instance...");
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
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
|
+
}
|
|
2466
2558
|
}
|
|
2467
2559
|
}
|
|
2468
2560
|
} else {
|
|
2469
|
-
console.
|
|
2561
|
+
console.error("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
|
|
2470
2562
|
}
|
|
2471
2563
|
} else {
|
|
2472
|
-
console.
|
|
2564
|
+
console.error("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
|
|
2473
2565
|
}
|
|
2474
2566
|
}
|
|
2475
2567
|
} else {
|
|
2476
|
-
|
|
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
|
+
}
|
|
2477
2600
|
}
|
|
2478
2601
|
|
|
2479
2602
|
// Step 3: Update configuration
|
|
@@ -2489,6 +2612,8 @@ mon
|
|
|
2489
2612
|
console.log(opts.demo ? "Step 4: Configuring Grafana security..." : "Step 4: Configuring Grafana security...");
|
|
2490
2613
|
const cfgPath = path.resolve(projectDir, ".pgwatch-config");
|
|
2491
2614
|
let grafanaPassword = "";
|
|
2615
|
+
let vmAuthUsername = "";
|
|
2616
|
+
let vmAuthPassword = "";
|
|
2492
2617
|
|
|
2493
2618
|
try {
|
|
2494
2619
|
if (fs.existsSync(cfgPath)) {
|
|
@@ -2504,8 +2629,8 @@ mon
|
|
|
2504
2629
|
|
|
2505
2630
|
if (!grafanaPassword) {
|
|
2506
2631
|
console.log("Generating secure Grafana password...");
|
|
2507
|
-
const { stdout: password } = await
|
|
2508
|
-
grafanaPassword = password.trim();
|
|
2632
|
+
const { stdout: password } = await execFilePromise("openssl", ["rand", "-base64", "12"]);
|
|
2633
|
+
grafanaPassword = password.trim().replace(/\n/g, "");
|
|
2509
2634
|
|
|
2510
2635
|
let configContent = "";
|
|
2511
2636
|
if (fs.existsSync(cfgPath)) {
|
|
@@ -2522,12 +2647,58 @@ mon
|
|
|
2522
2647
|
|
|
2523
2648
|
console.log("✓ Grafana password configured\n");
|
|
2524
2649
|
} catch (error) {
|
|
2525
|
-
console.
|
|
2650
|
+
console.error("⚠ Could not generate Grafana password automatically");
|
|
2526
2651
|
console.log("Using default password: demo\n");
|
|
2527
2652
|
grafanaPassword = "demo";
|
|
2528
2653
|
}
|
|
2529
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
|
+
|
|
2530
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"]);
|
|
2531
2702
|
console.log("Step 5: Starting monitoring services...");
|
|
2532
2703
|
const code2 = await runCompose(["up", "-d", "--force-recreate"], grafanaPassword);
|
|
2533
2704
|
if (code2 !== 0) {
|
|
@@ -2577,6 +2748,9 @@ mon
|
|
|
2577
2748
|
console.log("🚀 MAIN ACCESS POINT - Start here:");
|
|
2578
2749
|
console.log(" Grafana Dashboard: http://localhost:3000");
|
|
2579
2750
|
console.log(` Login: monitor / ${grafanaPassword}`);
|
|
2751
|
+
if (vmAuthUsername && vmAuthPassword) {
|
|
2752
|
+
console.log(` VictoriaMetrics Auth: ${vmAuthUsername} / ${vmAuthPassword}`);
|
|
2753
|
+
}
|
|
2580
2754
|
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
|
|
2581
2755
|
});
|
|
2582
2756
|
|
|
@@ -2777,7 +2951,7 @@ mon
|
|
|
2777
2951
|
console.log(`Project Directory: ${projectDir}`);
|
|
2778
2952
|
console.log(`Docker Compose File: ${composeFile}`);
|
|
2779
2953
|
console.log(`Instances File: ${instancesFile}`);
|
|
2780
|
-
if (fs.existsSync(instancesFile)) {
|
|
2954
|
+
if (fs.existsSync(instancesFile) && !fs.lstatSync(instancesFile).isDirectory()) {
|
|
2781
2955
|
console.log("\nInstances configuration:\n");
|
|
2782
2956
|
const text = fs.readFileSync(instancesFile, "utf8");
|
|
2783
2957
|
process.stdout.write(text);
|
|
@@ -2808,16 +2982,16 @@ mon
|
|
|
2808
2982
|
|
|
2809
2983
|
// Fetch latest changes
|
|
2810
2984
|
console.log("Fetching latest changes...");
|
|
2811
|
-
await
|
|
2985
|
+
await execFilePromise("git", ["fetch", "origin"]);
|
|
2812
2986
|
|
|
2813
2987
|
// Check current branch
|
|
2814
|
-
const { stdout: branch } = await
|
|
2988
|
+
const { stdout: branch } = await execFilePromise("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
2815
2989
|
const currentBranch = branch.trim();
|
|
2816
2990
|
console.log(`Current branch: ${currentBranch}`);
|
|
2817
2991
|
|
|
2818
2992
|
// Pull latest changes
|
|
2819
2993
|
console.log("Pulling latest changes...");
|
|
2820
|
-
const { stdout: pullOut } = await
|
|
2994
|
+
const { stdout: pullOut } = await execFilePromise("git", ["pull", "origin", currentBranch]);
|
|
2821
2995
|
console.log(pullOut);
|
|
2822
2996
|
|
|
2823
2997
|
// Update Docker images
|
|
@@ -2914,7 +3088,7 @@ mon
|
|
|
2914
3088
|
if (downCode === 0) {
|
|
2915
3089
|
console.log("✓ Monitoring services stopped and removed");
|
|
2916
3090
|
} else {
|
|
2917
|
-
console.
|
|
3091
|
+
console.error("⚠ Could not stop services (may not be running)");
|
|
2918
3092
|
}
|
|
2919
3093
|
|
|
2920
3094
|
// Remove any orphaned containers that docker compose down missed
|
|
@@ -2993,7 +3167,7 @@ targets
|
|
|
2993
3167
|
.description("list monitoring target databases")
|
|
2994
3168
|
.action(async () => {
|
|
2995
3169
|
const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
|
|
2996
|
-
if (!fs.existsSync(instancesPath)) {
|
|
3170
|
+
if (!fs.existsSync(instancesPath) || fs.lstatSync(instancesPath).isDirectory()) {
|
|
2997
3171
|
console.error(`instances.yml not found in ${projectDir}`);
|
|
2998
3172
|
process.exitCode = 1;
|
|
2999
3173
|
return;
|
|
@@ -3059,7 +3233,7 @@ targets
|
|
|
3059
3233
|
|
|
3060
3234
|
// Check if instance already exists
|
|
3061
3235
|
try {
|
|
3062
|
-
if (fs.existsSync(file)) {
|
|
3236
|
+
if (fs.existsSync(file) && !fs.lstatSync(file).isDirectory()) {
|
|
3063
3237
|
const content = fs.readFileSync(file, "utf8");
|
|
3064
3238
|
const instances = yaml.load(content) as Instance[] | null || [];
|
|
3065
3239
|
if (Array.isArray(instances)) {
|
|
@@ -3073,15 +3247,20 @@ targets
|
|
|
3073
3247
|
}
|
|
3074
3248
|
} catch (err) {
|
|
3075
3249
|
// If YAML parsing fails, fall back to simple check
|
|
3076
|
-
const
|
|
3077
|
-
|
|
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)) {
|
|
3078
3254
|
console.error(`Monitoring target '${instanceName}' already exists`);
|
|
3079
3255
|
process.exitCode = 1;
|
|
3080
3256
|
return;
|
|
3081
3257
|
}
|
|
3082
3258
|
}
|
|
3083
3259
|
|
|
3084
|
-
// 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
|
+
}
|
|
3085
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`;
|
|
3086
3265
|
const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
3087
3266
|
fs.appendFileSync(file, (content && !/\n$/.test(content) ? "\n" : "") + body, "utf8");
|
|
@@ -3092,7 +3271,7 @@ targets
|
|
|
3092
3271
|
.description("remove monitoring target database")
|
|
3093
3272
|
.action(async (name: string) => {
|
|
3094
3273
|
const { instancesFile: file } = await resolveOrInitPaths();
|
|
3095
|
-
if (!fs.existsSync(file)) {
|
|
3274
|
+
if (!fs.existsSync(file) || fs.lstatSync(file).isDirectory()) {
|
|
3096
3275
|
console.error("instances.yml not found");
|
|
3097
3276
|
process.exitCode = 1;
|
|
3098
3277
|
return;
|
|
@@ -3129,7 +3308,7 @@ targets
|
|
|
3129
3308
|
.description("test monitoring target database connectivity")
|
|
3130
3309
|
.action(async (name: string) => {
|
|
3131
3310
|
const { instancesFile: instancesPath } = await resolveOrInitPaths();
|
|
3132
|
-
if (!fs.existsSync(instancesPath)) {
|
|
3311
|
+
if (!fs.existsSync(instancesPath) || fs.lstatSync(instancesPath).isDirectory()) {
|
|
3133
3312
|
console.error("instances.yml not found");
|
|
3134
3313
|
process.exitCode = 1;
|
|
3135
3314
|
return;
|
|
@@ -3162,7 +3341,7 @@ targets
|
|
|
3162
3341
|
console.log(`Testing connection to monitoring target '${name}'...`);
|
|
3163
3342
|
|
|
3164
3343
|
// Use native pg client instead of requiring psql to be installed
|
|
3165
|
-
const client = new Client({ connectionString: instance.conn_str });
|
|
3344
|
+
const client = new Client({ connectionString: instance.conn_str, connectionTimeoutMillis: 10000 });
|
|
3166
3345
|
|
|
3167
3346
|
try {
|
|
3168
3347
|
await client.connect();
|
|
@@ -3227,8 +3406,8 @@ auth
|
|
|
3227
3406
|
const { apiBaseUrl, uiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
3228
3407
|
|
|
3229
3408
|
if (opts.debug) {
|
|
3230
|
-
console.
|
|
3231
|
-
console.
|
|
3409
|
+
console.error(`Debug: Resolved API base URL: ${apiBaseUrl}`);
|
|
3410
|
+
console.error(`Debug: Resolved UI base URL: ${uiBaseUrl}`);
|
|
3232
3411
|
}
|
|
3233
3412
|
|
|
3234
3413
|
try {
|
|
@@ -3258,8 +3437,8 @@ auth
|
|
|
3258
3437
|
const initUrl = new URL(`${apiBaseUrl}/rpc/oauth_init`);
|
|
3259
3438
|
|
|
3260
3439
|
if (opts.debug) {
|
|
3261
|
-
console.
|
|
3262
|
-
console.
|
|
3440
|
+
console.error(`Debug: Trying to POST to: ${initUrl.toString()}`);
|
|
3441
|
+
console.error(`Debug: Request data: ${initData}`);
|
|
3263
3442
|
}
|
|
3264
3443
|
|
|
3265
3444
|
// Step 2: Initialize OAuth session on backend using fetch
|
|
@@ -3305,7 +3484,7 @@ auth
|
|
|
3305
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)}`;
|
|
3306
3485
|
|
|
3307
3486
|
if (opts.debug) {
|
|
3308
|
-
console.
|
|
3487
|
+
console.error(`Debug: Auth URL: ${authUrl}`);
|
|
3309
3488
|
}
|
|
3310
3489
|
|
|
3311
3490
|
console.log(`\nOpening browser for authentication...`);
|
|
@@ -3515,10 +3694,10 @@ mon
|
|
|
3515
3694
|
|
|
3516
3695
|
try {
|
|
3517
3696
|
// Generate secure password using openssl
|
|
3518
|
-
const { stdout: password } = await
|
|
3519
|
-
"openssl rand -base64 12
|
|
3697
|
+
const { stdout: password } = await execFilePromise(
|
|
3698
|
+
"openssl", ["rand", "-base64", "12"]
|
|
3520
3699
|
);
|
|
3521
|
-
const newPassword = password.trim();
|
|
3700
|
+
const newPassword = password.trim().replace(/\n/g, "");
|
|
3522
3701
|
|
|
3523
3702
|
if (!newPassword) {
|
|
3524
3703
|
console.error("Failed to generate password");
|
|
@@ -3596,6 +3775,19 @@ mon
|
|
|
3596
3775
|
console.log(" URL: http://localhost:3000");
|
|
3597
3776
|
console.log(" Username: monitor");
|
|
3598
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
|
+
}
|
|
3599
3791
|
console.log("");
|
|
3600
3792
|
});
|
|
3601
3793
|
|
|
@@ -3729,12 +3921,12 @@ issues
|
|
|
3729
3921
|
// Interpret escape sequences in content (e.g., \n -> newline)
|
|
3730
3922
|
if (opts.debug) {
|
|
3731
3923
|
// eslint-disable-next-line no-console
|
|
3732
|
-
console.
|
|
3924
|
+
console.error(`Debug: Original content: ${JSON.stringify(content)}`);
|
|
3733
3925
|
}
|
|
3734
3926
|
content = interpretEscapes(content);
|
|
3735
3927
|
if (opts.debug) {
|
|
3736
3928
|
// eslint-disable-next-line no-console
|
|
3737
|
-
console.
|
|
3929
|
+
console.error(`Debug: Interpreted content: ${JSON.stringify(content)}`);
|
|
3738
3930
|
}
|
|
3739
3931
|
|
|
3740
3932
|
const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Posting comment...");
|
|
@@ -3928,12 +4120,12 @@ issues
|
|
|
3928
4120
|
.action(async (commentId: string, content: string, opts: { debug?: boolean; json?: boolean }) => {
|
|
3929
4121
|
if (opts.debug) {
|
|
3930
4122
|
// eslint-disable-next-line no-console
|
|
3931
|
-
console.
|
|
4123
|
+
console.error(`Debug: Original content: ${JSON.stringify(content)}`);
|
|
3932
4124
|
}
|
|
3933
4125
|
content = interpretEscapes(content);
|
|
3934
4126
|
if (opts.debug) {
|
|
3935
4127
|
// eslint-disable-next-line no-console
|
|
3936
|
-
console.
|
|
4128
|
+
console.error(`Debug: Interpreted content: ${JSON.stringify(content)}`);
|
|
3937
4129
|
}
|
|
3938
4130
|
|
|
3939
4131
|
const rootOpts = program.opts<CliOptions>();
|
|
@@ -3966,6 +4158,93 @@ issues
|
|
|
3966
4158
|
}
|
|
3967
4159
|
});
|
|
3968
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
|
+
|
|
3969
4248
|
// Action Items management (subcommands of issues)
|
|
3970
4249
|
issues
|
|
3971
4250
|
.command("action-items <issueId>")
|
|
@@ -4190,6 +4469,228 @@ issues
|
|
|
4190
4469
|
}
|
|
4191
4470
|
});
|
|
4192
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
|
+
|
|
4193
4694
|
// MCP server
|
|
4194
4695
|
const mcp = program.command("mcp").description("MCP server integration");
|
|
4195
4696
|
|
|
@@ -4247,7 +4748,7 @@ mcp
|
|
|
4247
4748
|
// Get the path to the current pgai executable
|
|
4248
4749
|
let pgaiPath: string;
|
|
4249
4750
|
try {
|
|
4250
|
-
const execPath = await
|
|
4751
|
+
const execPath = await execFilePromise("which", ["pgai"]);
|
|
4251
4752
|
pgaiPath = execPath.stdout.trim();
|
|
4252
4753
|
} catch {
|
|
4253
4754
|
// Fallback to just "pgai" if which fails
|
|
@@ -4259,8 +4760,8 @@ mcp
|
|
|
4259
4760
|
console.log("Installing PostgresAI MCP server for Claude Code...");
|
|
4260
4761
|
|
|
4261
4762
|
try {
|
|
4262
|
-
const { stdout, stderr } = await
|
|
4263
|
-
|
|
4763
|
+
const { stdout, stderr } = await execFilePromise(
|
|
4764
|
+
"claude", ["mcp", "add", "-s", "user", "postgresai", pgaiPath, "mcp", "start"]
|
|
4264
4765
|
);
|
|
4265
4766
|
|
|
4266
4767
|
if (stdout) console.log(stdout);
|