postgresai 0.15.0-dev.1 → 0.15.0-dev.11
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 +82 -9
- package/bin/postgres-ai.ts +813 -233
- package/bun.lock +4 -4
- package/dist/bin/postgres-ai.js +6193 -1059
- package/instances.demo.yml +14 -0
- package/lib/checkup-api.ts +25 -6
- package/lib/checkup-dictionary.ts +0 -11
- package/lib/checkup.ts +255 -24
- package/lib/config.ts +3 -0
- package/lib/init.ts +197 -5
- package/lib/instances.ts +245 -0
- package/lib/issues.ts +72 -72
- package/lib/mcp-server.ts +229 -18
- package/lib/metrics-loader.ts +6 -4
- package/lib/reports.ts +373 -0
- package/lib/storage.ts +367 -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 +625 -1
- package/test/mcp-server.test.ts +944 -2
- package/test/monitoring.test.ts +355 -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 +935 -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, uploadAttachments, appendAttachmentsToContent } 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";
|
|
@@ -23,6 +26,17 @@ import { REPORT_GENERATORS, CHECK_INFO, generateAllReports } from "../lib/checku
|
|
|
23
26
|
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";
|
|
29
|
+
import {
|
|
30
|
+
type Instance,
|
|
31
|
+
InstancesParseError,
|
|
32
|
+
loadInstances,
|
|
33
|
+
buildInstance,
|
|
34
|
+
addInstanceToFile,
|
|
35
|
+
removeInstanceFromFile,
|
|
36
|
+
buildClientConfig,
|
|
37
|
+
sslOptionFromConnString,
|
|
38
|
+
warnIfLaxSslmode,
|
|
39
|
+
} from "../lib/instances";
|
|
26
40
|
|
|
27
41
|
// Node.js version check - require Node 18+
|
|
28
42
|
// Node 14 reached EOL in April 2023, Node 16 in September 2023.
|
|
@@ -51,21 +65,16 @@ function closeReadline() {
|
|
|
51
65
|
}
|
|
52
66
|
}
|
|
53
67
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
reject(err);
|
|
62
|
-
} else {
|
|
63
|
-
resolve({ stdout, stderr });
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
});
|
|
68
|
+
function stripMatchingQuotes(value: string): string {
|
|
69
|
+
const trimmed = value.trim();
|
|
70
|
+
const quote = trimmed[0];
|
|
71
|
+
if (trimmed.length >= 2 && (quote === '"' || quote === "'") && trimmed.endsWith(quote)) {
|
|
72
|
+
return trimmed.slice(1, -1);
|
|
73
|
+
}
|
|
74
|
+
return trimmed;
|
|
67
75
|
}
|
|
68
76
|
|
|
77
|
+
// Helper functions for spawning processes - use Node.js child_process for compatibility
|
|
69
78
|
async function execFilePromise(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
|
70
79
|
return new Promise((resolve, reject) => {
|
|
71
80
|
childProcess.execFile(file, args, (error, stdout, stderr) => {
|
|
@@ -342,7 +351,8 @@ function writeReportFiles(reports: Record<string, any>, outputPath: string): voi
|
|
|
342
351
|
for (const [checkId, report] of Object.entries(reports)) {
|
|
343
352
|
const filePath = path.join(outputPath, `${checkId}.json`);
|
|
344
353
|
fs.writeFileSync(filePath, JSON.stringify(report, null, 2), "utf8");
|
|
345
|
-
|
|
354
|
+
const title = report.checkTitle || checkId;
|
|
355
|
+
console.log(`✓ ${checkId} ${title}: ${filePath}`);
|
|
346
356
|
}
|
|
347
357
|
}
|
|
348
358
|
|
|
@@ -410,6 +420,7 @@ interface CliOptions {
|
|
|
410
420
|
apiKey?: string;
|
|
411
421
|
apiBaseUrl?: string;
|
|
412
422
|
uiBaseUrl?: string;
|
|
423
|
+
storageBaseUrl?: string;
|
|
413
424
|
}
|
|
414
425
|
|
|
415
426
|
/**
|
|
@@ -419,19 +430,6 @@ interface ConfigResult {
|
|
|
419
430
|
apiKey: string;
|
|
420
431
|
}
|
|
421
432
|
|
|
422
|
-
/**
|
|
423
|
-
* Instance configuration
|
|
424
|
-
*/
|
|
425
|
-
interface Instance {
|
|
426
|
-
name: string;
|
|
427
|
-
conn_str?: string;
|
|
428
|
-
preset_metrics?: string;
|
|
429
|
-
custom_metrics?: any;
|
|
430
|
-
is_enabled?: boolean;
|
|
431
|
-
group?: string;
|
|
432
|
-
custom_tags?: Record<string, any>;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
433
|
/**
|
|
436
434
|
* Path resolution result
|
|
437
435
|
*/
|
|
@@ -499,7 +497,11 @@ async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
|
|
|
499
497
|
}
|
|
500
498
|
}
|
|
501
499
|
|
|
502
|
-
// Ensure instances.yml exists as a FILE (avoid Docker creating a directory)
|
|
500
|
+
// Ensure instances.yml exists as a FILE (avoid Docker creating a directory).
|
|
501
|
+
// Docker bind-mounts create missing paths as directories; replace if so.
|
|
502
|
+
if (fs.existsSync(instancesFile) && fs.lstatSync(instancesFile).isDirectory()) {
|
|
503
|
+
fs.rmSync(instancesFile, { recursive: true, force: true });
|
|
504
|
+
}
|
|
503
505
|
if (!fs.existsSync(instancesFile)) {
|
|
504
506
|
const header =
|
|
505
507
|
"# PostgreSQL instances to monitor\n" +
|
|
@@ -578,6 +580,10 @@ program
|
|
|
578
580
|
.option(
|
|
579
581
|
"--ui-base-url <url>",
|
|
580
582
|
"UI base URL for browser routes (overrides PGAI_UI_BASE_URL)"
|
|
583
|
+
)
|
|
584
|
+
.option(
|
|
585
|
+
"--storage-base-url <url>",
|
|
586
|
+
"Storage base URL for file uploads (overrides PGAI_STORAGE_BASE_URL)"
|
|
581
587
|
);
|
|
582
588
|
|
|
583
589
|
program
|
|
@@ -594,6 +600,27 @@ program
|
|
|
594
600
|
console.log(`Default project saved: ${value}`);
|
|
595
601
|
});
|
|
596
602
|
|
|
603
|
+
program
|
|
604
|
+
.command("set-storage-url <url>")
|
|
605
|
+
.description("store storage base URL for file uploads")
|
|
606
|
+
.action(async (url: string) => {
|
|
607
|
+
const value = (url || "").trim();
|
|
608
|
+
if (!value) {
|
|
609
|
+
console.error("Error: url is required");
|
|
610
|
+
process.exitCode = 1;
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
try {
|
|
614
|
+
const { normalizeBaseUrl } = await import("../lib/util");
|
|
615
|
+
const normalized = normalizeBaseUrl(value);
|
|
616
|
+
config.writeConfig({ storageBaseUrl: normalized });
|
|
617
|
+
console.log(`Storage URL saved: ${normalized}`);
|
|
618
|
+
} catch {
|
|
619
|
+
console.error(`Error: invalid URL: ${value}`);
|
|
620
|
+
process.exitCode = 1;
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
|
|
597
624
|
program
|
|
598
625
|
.command("prepare-db [conn]")
|
|
599
626
|
.description("prepare database for monitoring: create monitoring user, required view(s), and grant permissions (idempotent)")
|
|
@@ -839,8 +866,8 @@ program
|
|
|
839
866
|
} else {
|
|
840
867
|
console.log("✓ prepare-db verify: OK");
|
|
841
868
|
if (v.missingOptional.length > 0) {
|
|
842
|
-
console.
|
|
843
|
-
for (const m of v.missingOptional) console.
|
|
869
|
+
console.error("⚠ Optional items missing:");
|
|
870
|
+
for (const m of v.missingOptional) console.error(`- ${m}`);
|
|
844
871
|
}
|
|
845
872
|
}
|
|
846
873
|
return;
|
|
@@ -971,8 +998,8 @@ program
|
|
|
971
998
|
} else {
|
|
972
999
|
console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
|
|
973
1000
|
if (skippedOptional.length > 0) {
|
|
974
|
-
console.
|
|
975
|
-
for (const s of skippedOptional) console.
|
|
1001
|
+
console.error("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
|
|
1002
|
+
for (const s of skippedOptional) console.error(`- ${s}`);
|
|
976
1003
|
}
|
|
977
1004
|
if (process.stdout.isTTY) {
|
|
978
1005
|
console.log(`Applied ${applied.length} steps`);
|
|
@@ -1153,8 +1180,8 @@ program
|
|
|
1153
1180
|
} else {
|
|
1154
1181
|
console.log(`✓ prepare-db verify: OK${opts.provider ? ` (provider: ${opts.provider})` : ""}`);
|
|
1155
1182
|
if (v.missingOptional.length > 0) {
|
|
1156
|
-
console.
|
|
1157
|
-
for (const m of v.missingOptional) console.
|
|
1183
|
+
console.error("⚠ Optional items missing:");
|
|
1184
|
+
for (const m of v.missingOptional) console.error(`- ${m}`);
|
|
1158
1185
|
}
|
|
1159
1186
|
}
|
|
1160
1187
|
return;
|
|
@@ -1281,8 +1308,8 @@ program
|
|
|
1281
1308
|
} else {
|
|
1282
1309
|
console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
|
|
1283
1310
|
if (skippedOptional.length > 0) {
|
|
1284
|
-
console.
|
|
1285
|
-
for (const s of skippedOptional) console.
|
|
1311
|
+
console.error("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
|
|
1312
|
+
for (const s of skippedOptional) console.error(`- ${s}`);
|
|
1286
1313
|
}
|
|
1287
1314
|
// Keep output compact but still useful
|
|
1288
1315
|
if (process.stdout.isTTY) {
|
|
@@ -1598,11 +1625,11 @@ program
|
|
|
1598
1625
|
console.log("✓ unprepare-db completed");
|
|
1599
1626
|
console.log(`Applied ${applied.length} steps`);
|
|
1600
1627
|
} else {
|
|
1601
|
-
console.
|
|
1628
|
+
console.error("⚠ unprepare-db completed with errors");
|
|
1602
1629
|
console.log(`Applied ${applied.length} steps`);
|
|
1603
|
-
console.
|
|
1630
|
+
console.error("Errors:");
|
|
1604
1631
|
for (const err of errors) {
|
|
1605
|
-
console.
|
|
1632
|
+
console.error(` - ${err}`);
|
|
1606
1633
|
}
|
|
1607
1634
|
process.exitCode = 1;
|
|
1608
1635
|
}
|
|
@@ -1797,6 +1824,24 @@ program
|
|
|
1797
1824
|
const connResult = await connectWithSslFallback(Client, adminConn);
|
|
1798
1825
|
client = connResult.client as Client;
|
|
1799
1826
|
|
|
1827
|
+
// Preflight: verify the connected user has sufficient permissions
|
|
1828
|
+
spinner.update("Checking database permissions");
|
|
1829
|
+
const permCheck = await checkCurrentUserPermissions(client);
|
|
1830
|
+
const permMessages = formatPermissionCheckMessages(permCheck);
|
|
1831
|
+
|
|
1832
|
+
for (const w of permMessages.warnings) {
|
|
1833
|
+
console.error(w);
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
if (permMessages.failed) {
|
|
1837
|
+
spinner.stop();
|
|
1838
|
+
for (const e of permMessages.errors) {
|
|
1839
|
+
console.error(e);
|
|
1840
|
+
}
|
|
1841
|
+
process.exitCode = 1;
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1800
1845
|
// Generate reports
|
|
1801
1846
|
let reports: Record<string, any>;
|
|
1802
1847
|
if (checkId === "ALL") {
|
|
@@ -1924,8 +1969,8 @@ program
|
|
|
1924
1969
|
}
|
|
1925
1970
|
}
|
|
1926
1971
|
|
|
1927
|
-
// Output JSON to stdout
|
|
1928
|
-
if (shouldPrintJson) {
|
|
1972
|
+
// Output JSON to stdout (unless --output is specified, in which case files are written instead)
|
|
1973
|
+
if (shouldPrintJson && !outputPath) {
|
|
1929
1974
|
console.log(JSON.stringify(reports, null, 2));
|
|
1930
1975
|
}
|
|
1931
1976
|
|
|
@@ -2042,13 +2087,16 @@ function isDockerRunning(): boolean {
|
|
|
2042
2087
|
}
|
|
2043
2088
|
|
|
2044
2089
|
/**
|
|
2045
|
-
* Get docker compose command
|
|
2090
|
+
* Get docker compose command.
|
|
2091
|
+
* Prefer "docker compose" (V2 plugin) over legacy "docker-compose" (V1 standalone)
|
|
2092
|
+
* because docker-compose V1 (<=1.29) is incompatible with modern Docker engines
|
|
2093
|
+
* (KeyError: 'ContainerConfig' on container recreation).
|
|
2046
2094
|
*/
|
|
2047
2095
|
function getComposeCmd(): string[] | null {
|
|
2048
2096
|
const tryCmd = (cmd: string, args: string[]): boolean =>
|
|
2049
2097
|
spawnSync(cmd, args, { stdio: "ignore", timeout: 5000 } as Parameters<typeof spawnSync>[2]).status === 0;
|
|
2050
|
-
if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
|
|
2051
2098
|
if (tryCmd("docker", ["compose", "version"])) return ["docker", "compose"];
|
|
2099
|
+
if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
|
|
2052
2100
|
return null;
|
|
2053
2101
|
}
|
|
2054
2102
|
|
|
@@ -2087,9 +2135,9 @@ function registerMonitoringInstance(
|
|
|
2087
2135
|
const debug = opts?.debug;
|
|
2088
2136
|
|
|
2089
2137
|
if (debug) {
|
|
2090
|
-
console.
|
|
2091
|
-
console.
|
|
2092
|
-
console.
|
|
2138
|
+
console.error(`\nDebug: Registering monitoring instance...`);
|
|
2139
|
+
console.error(`Debug: POST ${url}`);
|
|
2140
|
+
console.error(`Debug: project_name=${projectName}`);
|
|
2093
2141
|
}
|
|
2094
2142
|
|
|
2095
2143
|
// Fire and forget - don't block the main flow
|
|
@@ -2107,18 +2155,18 @@ function registerMonitoringInstance(
|
|
|
2107
2155
|
const body = await res.text().catch(() => "");
|
|
2108
2156
|
if (!res.ok) {
|
|
2109
2157
|
if (debug) {
|
|
2110
|
-
console.
|
|
2111
|
-
console.
|
|
2158
|
+
console.error(`Debug: Monitoring registration failed: HTTP ${res.status}`);
|
|
2159
|
+
console.error(`Debug: Response: ${body}`);
|
|
2112
2160
|
}
|
|
2113
2161
|
return;
|
|
2114
2162
|
}
|
|
2115
2163
|
if (debug) {
|
|
2116
|
-
console.
|
|
2164
|
+
console.error(`Debug: Monitoring registration response: ${body}`);
|
|
2117
2165
|
}
|
|
2118
2166
|
})
|
|
2119
2167
|
.catch((err) => {
|
|
2120
2168
|
if (debug) {
|
|
2121
|
-
console.
|
|
2169
|
+
console.error(`Debug: Monitoring registration error: ${err.message}`);
|
|
2122
2170
|
}
|
|
2123
2171
|
});
|
|
2124
2172
|
}
|
|
@@ -2206,6 +2254,26 @@ async function runCompose(args: string[], grafanaPassword?: string): Promise<num
|
|
|
2206
2254
|
}
|
|
2207
2255
|
}
|
|
2208
2256
|
|
|
2257
|
+
// Load VM auth credentials from .env if not already set
|
|
2258
|
+
const envFilePath = path.resolve(projectDir, ".env");
|
|
2259
|
+
if (fs.existsSync(envFilePath)) {
|
|
2260
|
+
try {
|
|
2261
|
+
const envContent = fs.readFileSync(envFilePath, "utf8");
|
|
2262
|
+
if (!env.VM_AUTH_USERNAME) {
|
|
2263
|
+
const m = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
|
|
2264
|
+
if (m) env.VM_AUTH_USERNAME = stripMatchingQuotes(m[1]);
|
|
2265
|
+
}
|
|
2266
|
+
if (!env.VM_AUTH_PASSWORD) {
|
|
2267
|
+
const m = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
|
|
2268
|
+
if (m) env.VM_AUTH_PASSWORD = stripMatchingQuotes(m[1]);
|
|
2269
|
+
}
|
|
2270
|
+
} catch (err) {
|
|
2271
|
+
if (process.env.DEBUG) {
|
|
2272
|
+
console.warn(`Warning: Could not read VM auth from .env: ${err instanceof Error ? err.message : String(err)}`);
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2209
2277
|
// On macOS, self-node-exporter can't mount host root filesystem - skip it
|
|
2210
2278
|
const finalArgs = [...args];
|
|
2211
2279
|
if (process.platform === "darwin" && args.includes("up")) {
|
|
@@ -2232,6 +2300,18 @@ const mon = program.command("mon").description("monitoring services management")
|
|
|
2232
2300
|
mon
|
|
2233
2301
|
.command("local-install")
|
|
2234
2302
|
.description("install local monitoring stack (generate config, start services)")
|
|
2303
|
+
.addHelpText(
|
|
2304
|
+
"after",
|
|
2305
|
+
[
|
|
2306
|
+
"",
|
|
2307
|
+
"Networking:",
|
|
2308
|
+
" Compose enables IPv6 on the project's default network so containers can",
|
|
2309
|
+
" reach IPv6-only databases (e.g. Supabase free-tier db.<ref>.supabase.co).",
|
|
2310
|
+
" Override on hosts whose Docker daemon cannot create an IPv6 network:",
|
|
2311
|
+
" PGAI_ENABLE_IPV6=false (accepted: true|false|yes|no, lowercase)",
|
|
2312
|
+
"",
|
|
2313
|
+
].join("\n"),
|
|
2314
|
+
)
|
|
2235
2315
|
.option("--demo", "demo mode with sample database", false)
|
|
2236
2316
|
.option("--api-key <key>", "Postgres AI API key for automated report uploads")
|
|
2237
2317
|
.option("--db-url <url>", "PostgreSQL connection URL to monitor")
|
|
@@ -2250,7 +2330,7 @@ mon
|
|
|
2250
2330
|
console.log("This will install, configure, and start the monitoring system\n");
|
|
2251
2331
|
|
|
2252
2332
|
// Ensure we have a project directory with docker-compose.yml even if running from elsewhere
|
|
2253
|
-
const { projectDir } = await resolveOrInitPaths();
|
|
2333
|
+
const { projectDir, instancesFile: instancesPath } = await resolveOrInitPaths();
|
|
2254
2334
|
console.log(`Project directory: ${projectDir}\n`);
|
|
2255
2335
|
|
|
2256
2336
|
// Save project name to .pgwatch-config if provided (used by reporter container)
|
|
@@ -2263,10 +2343,13 @@ mon
|
|
|
2263
2343
|
// Update .env with custom tag if provided
|
|
2264
2344
|
const envFile = path.resolve(projectDir, ".env");
|
|
2265
2345
|
|
|
2266
|
-
// Build .env content, preserving important existing values
|
|
2267
|
-
// Note: PGAI_TAG is intentionally NOT preserved - the CLI version should always match Docker images
|
|
2346
|
+
// Build .env content, preserving important existing values.
|
|
2347
|
+
// Note: PGAI_TAG is intentionally NOT preserved - the CLI version should always match Docker images.
|
|
2268
2348
|
let existingRegistry: string | null = null;
|
|
2269
2349
|
let existingPassword: string | null = null;
|
|
2350
|
+
let existingReplicatorPassword: string | null = null;
|
|
2351
|
+
let existingVmAuthUsername: string | null = null;
|
|
2352
|
+
let existingVmAuthPassword: string | null = null;
|
|
2270
2353
|
|
|
2271
2354
|
if (fs.existsSync(envFile)) {
|
|
2272
2355
|
const existingEnv = fs.readFileSync(envFile, "utf8");
|
|
@@ -2275,6 +2358,12 @@ mon
|
|
|
2275
2358
|
if (registryMatch) existingRegistry = registryMatch[1].trim();
|
|
2276
2359
|
const pwdMatch = existingEnv.match(/^GF_SECURITY_ADMIN_PASSWORD=(.+)$/m);
|
|
2277
2360
|
if (pwdMatch) existingPassword = pwdMatch[1].trim();
|
|
2361
|
+
const replicatorPwdMatch = existingEnv.match(/^REPLICATOR_PASSWORD=(.+)$/m);
|
|
2362
|
+
if (replicatorPwdMatch) existingReplicatorPassword = replicatorPwdMatch[1].trim();
|
|
2363
|
+
const vmAuthUserMatch = existingEnv.match(/^VM_AUTH_USERNAME=(.+)$/m);
|
|
2364
|
+
if (vmAuthUserMatch) existingVmAuthUsername = stripMatchingQuotes(vmAuthUserMatch[1]);
|
|
2365
|
+
const vmAuthPasswordMatch = existingEnv.match(/^VM_AUTH_PASSWORD=(.+)$/m);
|
|
2366
|
+
if (vmAuthPasswordMatch) existingVmAuthPassword = stripMatchingQuotes(vmAuthPasswordMatch[1]);
|
|
2278
2367
|
}
|
|
2279
2368
|
|
|
2280
2369
|
// Priority: CLI --tag flag > package version
|
|
@@ -2290,6 +2379,11 @@ mon
|
|
|
2290
2379
|
if (existingPassword) {
|
|
2291
2380
|
envLines.push(`GF_SECURITY_ADMIN_PASSWORD=${existingPassword}`);
|
|
2292
2381
|
}
|
|
2382
|
+
envLines.push(
|
|
2383
|
+
`REPLICATOR_PASSWORD=${existingReplicatorPassword || crypto.randomBytes(32).toString("hex")}`,
|
|
2384
|
+
);
|
|
2385
|
+
envLines.push(`VM_AUTH_USERNAME=${existingVmAuthUsername || "vmauth"}`);
|
|
2386
|
+
envLines.push(`VM_AUTH_PASSWORD=${existingVmAuthPassword || crypto.randomBytes(18).toString("base64")}`);
|
|
2293
2387
|
fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
|
|
2294
2388
|
|
|
2295
2389
|
if (opts.tag) {
|
|
@@ -2298,8 +2392,8 @@ mon
|
|
|
2298
2392
|
|
|
2299
2393
|
// Validate conflicting options
|
|
2300
2394
|
if (opts.demo && opts.dbUrl) {
|
|
2301
|
-
console.
|
|
2302
|
-
console.
|
|
2395
|
+
console.error("⚠ Both --demo and --db-url provided. Demo mode includes its own database.");
|
|
2396
|
+
console.error("⚠ The --db-url will be ignored in demo mode.\n");
|
|
2303
2397
|
opts.dbUrl = undefined;
|
|
2304
2398
|
}
|
|
2305
2399
|
|
|
@@ -2315,7 +2409,7 @@ mon
|
|
|
2315
2409
|
// Check if containers are already running
|
|
2316
2410
|
const { running, containers } = checkRunningContainers();
|
|
2317
2411
|
if (running) {
|
|
2318
|
-
console.
|
|
2412
|
+
console.error(`⚠ Monitoring services are already running: ${containers.join(", ")}`);
|
|
2319
2413
|
console.log("Use 'postgres-ai mon restart' to restart them\n");
|
|
2320
2414
|
return;
|
|
2321
2415
|
}
|
|
@@ -2334,7 +2428,7 @@ mon
|
|
|
2334
2428
|
} else if (opts.yes) {
|
|
2335
2429
|
// Auto-yes mode without API key - skip API key setup
|
|
2336
2430
|
console.log("Auto-yes mode: no API key provided, skipping API key setup");
|
|
2337
|
-
console.
|
|
2431
|
+
console.error("⚠ Reports will be generated locally only");
|
|
2338
2432
|
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
2339
2433
|
} else {
|
|
2340
2434
|
const answer = await question("Do you have a Postgres AI API key? (Y/n): ");
|
|
@@ -2354,16 +2448,16 @@ mon
|
|
|
2354
2448
|
break;
|
|
2355
2449
|
}
|
|
2356
2450
|
|
|
2357
|
-
console.
|
|
2451
|
+
console.error("⚠ API key cannot be empty");
|
|
2358
2452
|
const retry = await question("Try again or skip API key setup, retry? (Y/n): ");
|
|
2359
2453
|
if (retry.toLowerCase() === "n") {
|
|
2360
|
-
console.
|
|
2454
|
+
console.error("⚠ Skipping API key setup - reports will be generated locally only");
|
|
2361
2455
|
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
2362
2456
|
break;
|
|
2363
2457
|
}
|
|
2364
2458
|
}
|
|
2365
2459
|
} else {
|
|
2366
|
-
console.
|
|
2460
|
+
console.error("⚠ Skipping API key setup - reports will be generated locally only");
|
|
2367
2461
|
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
2368
2462
|
}
|
|
2369
2463
|
}
|
|
@@ -2403,27 +2497,31 @@ mon
|
|
|
2403
2497
|
const db = m[5];
|
|
2404
2498
|
const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
|
|
2405
2499
|
|
|
2406
|
-
|
|
2407
|
-
fs.appendFileSync(instancesPath, body, "utf8");
|
|
2500
|
+
addInstanceToFile(instancesPath, buildInstance(instanceName, connStr));
|
|
2408
2501
|
console.log(`✓ Monitoring target '${instanceName}' added\n`);
|
|
2409
2502
|
|
|
2410
2503
|
// Test connection
|
|
2411
2504
|
console.log("Testing connection to the added instance...");
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2505
|
+
{
|
|
2506
|
+
let testClient: InstanceType<typeof Client> | null = null;
|
|
2507
|
+
try {
|
|
2508
|
+
warnIfLaxSslmode(connStr);
|
|
2509
|
+
testClient = new Client(buildClientConfig(connStr, { connectionTimeoutMillis: 10000 }));
|
|
2510
|
+
await testClient.connect();
|
|
2511
|
+
const result = await testClient.query("select version();");
|
|
2512
|
+
console.log("✓ Connection successful");
|
|
2513
|
+
console.log(`${result.rows[0].version}\n`);
|
|
2514
|
+
} catch (error) {
|
|
2515
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2516
|
+
console.error(`✗ Connection failed: ${message}\n`);
|
|
2517
|
+
} finally {
|
|
2518
|
+
if (testClient) await testClient.end();
|
|
2519
|
+
}
|
|
2422
2520
|
}
|
|
2423
2521
|
} else if (opts.yes) {
|
|
2424
2522
|
// Auto-yes mode without database URL - skip database setup
|
|
2425
2523
|
console.log("Auto-yes mode: no database URL provided, skipping database setup");
|
|
2426
|
-
console.
|
|
2524
|
+
console.error("⚠ No PostgreSQL instance added");
|
|
2427
2525
|
console.log("You can add one later with: postgres-ai mon targets add\n");
|
|
2428
2526
|
} else {
|
|
2429
2527
|
console.log("You need to add at least one PostgreSQL instance to monitor");
|
|
@@ -2441,39 +2539,74 @@ mon
|
|
|
2441
2539
|
const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
|
|
2442
2540
|
if (!m) {
|
|
2443
2541
|
console.error("✗ Invalid connection string format");
|
|
2444
|
-
console.
|
|
2542
|
+
console.error("⚠ Continuing without adding instance\n");
|
|
2445
2543
|
} else {
|
|
2446
2544
|
const host = m[3];
|
|
2447
2545
|
const db = m[5];
|
|
2448
2546
|
const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
|
|
2449
2547
|
|
|
2450
|
-
|
|
2451
|
-
fs.appendFileSync(instancesPath, body, "utf8");
|
|
2548
|
+
addInstanceToFile(instancesPath, buildInstance(instanceName, connStr));
|
|
2452
2549
|
console.log(`✓ Monitoring target '${instanceName}' added\n`);
|
|
2453
2550
|
|
|
2454
2551
|
// Test connection
|
|
2455
2552
|
console.log("Testing connection to the added instance...");
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2553
|
+
{
|
|
2554
|
+
let testClient: InstanceType<typeof Client> | null = null;
|
|
2555
|
+
try {
|
|
2556
|
+
warnIfLaxSslmode(connStr);
|
|
2557
|
+
testClient = new Client(buildClientConfig(connStr, { connectionTimeoutMillis: 10000 }));
|
|
2558
|
+
await testClient.connect();
|
|
2559
|
+
const result = await testClient.query("select version();");
|
|
2560
|
+
console.log("✓ Connection successful");
|
|
2561
|
+
console.log(`${result.rows[0].version}\n`);
|
|
2562
|
+
} catch (error) {
|
|
2563
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2564
|
+
console.error(`✗ Connection failed: ${message}\n`);
|
|
2565
|
+
} finally {
|
|
2566
|
+
if (testClient) await testClient.end();
|
|
2567
|
+
}
|
|
2466
2568
|
}
|
|
2467
2569
|
}
|
|
2468
2570
|
} else {
|
|
2469
|
-
console.
|
|
2571
|
+
console.error("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
|
|
2470
2572
|
}
|
|
2471
2573
|
} else {
|
|
2472
|
-
console.
|
|
2574
|
+
console.error("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
|
|
2473
2575
|
}
|
|
2474
2576
|
}
|
|
2475
2577
|
} else {
|
|
2476
|
-
|
|
2578
|
+
// Demo mode: configure instances.yml from the bundled demo template.
|
|
2579
|
+
//
|
|
2580
|
+
// Side effects:
|
|
2581
|
+
// - Writes instancesPath (instances.yml next to docker-compose.yml)
|
|
2582
|
+
// - If Docker previously bind-mounted instances.yml as a directory, removes it first.
|
|
2583
|
+
//
|
|
2584
|
+
// Failure modes:
|
|
2585
|
+
// - Exits with code 1 if instances.demo.yml is not found in any candidate path.
|
|
2586
|
+
// This is fatal because starting without a target produces empty dashboards that
|
|
2587
|
+
// look like a bug rather than a misconfiguration.
|
|
2588
|
+
//
|
|
2589
|
+
// Template search order (import.meta.url is resolved at runtime, not baked in at build):
|
|
2590
|
+
// 1. npm layout: dist/bin/../../instances.demo.yml → package-root/instances.demo.yml
|
|
2591
|
+
// 2. dev layout: cli/bin/../../../instances.demo.yml → repo-root/instances.demo.yml
|
|
2592
|
+
console.log("Step 2: Demo mode enabled - using included demo PostgreSQL database");
|
|
2593
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
2594
|
+
const demoCandidates = [
|
|
2595
|
+
path.resolve(currentDir, "..", "..", "instances.demo.yml"), // npm: dist/bin -> package root
|
|
2596
|
+
path.resolve(currentDir, "..", "..", "..", "instances.demo.yml"), // dev: cli/bin -> repo root
|
|
2597
|
+
];
|
|
2598
|
+
const demoSrc = demoCandidates.find(p => fs.existsSync(p));
|
|
2599
|
+
if (demoSrc) {
|
|
2600
|
+
// Remove directory artifact left by Docker bind-mounts before copying
|
|
2601
|
+
if (fs.existsSync(instancesPath) && fs.lstatSync(instancesPath).isDirectory()) {
|
|
2602
|
+
fs.rmSync(instancesPath, { recursive: true, force: true });
|
|
2603
|
+
}
|
|
2604
|
+
fs.copyFileSync(demoSrc, instancesPath);
|
|
2605
|
+
console.log("✓ Demo monitoring target configured\n");
|
|
2606
|
+
} else {
|
|
2607
|
+
console.error(`Error: instances.demo.yml not found — cannot configure demo target.\nSearched: ${demoCandidates.join(", ")}\n`);
|
|
2608
|
+
process.exit(1);
|
|
2609
|
+
}
|
|
2477
2610
|
}
|
|
2478
2611
|
|
|
2479
2612
|
// Step 3: Update configuration
|
|
@@ -2489,6 +2622,8 @@ mon
|
|
|
2489
2622
|
console.log(opts.demo ? "Step 4: Configuring Grafana security..." : "Step 4: Configuring Grafana security...");
|
|
2490
2623
|
const cfgPath = path.resolve(projectDir, ".pgwatch-config");
|
|
2491
2624
|
let grafanaPassword = "";
|
|
2625
|
+
let vmAuthUsername = "";
|
|
2626
|
+
let vmAuthPassword = "";
|
|
2492
2627
|
|
|
2493
2628
|
try {
|
|
2494
2629
|
if (fs.existsSync(cfgPath)) {
|
|
@@ -2504,8 +2639,8 @@ mon
|
|
|
2504
2639
|
|
|
2505
2640
|
if (!grafanaPassword) {
|
|
2506
2641
|
console.log("Generating secure Grafana password...");
|
|
2507
|
-
const { stdout: password } = await
|
|
2508
|
-
grafanaPassword = password.trim();
|
|
2642
|
+
const { stdout: password } = await execFilePromise("openssl", ["rand", "-base64", "12"]);
|
|
2643
|
+
grafanaPassword = password.trim().replace(/\n/g, "");
|
|
2509
2644
|
|
|
2510
2645
|
let configContent = "";
|
|
2511
2646
|
if (fs.existsSync(cfgPath)) {
|
|
@@ -2522,12 +2657,58 @@ mon
|
|
|
2522
2657
|
|
|
2523
2658
|
console.log("✓ Grafana password configured\n");
|
|
2524
2659
|
} catch (error) {
|
|
2525
|
-
console.
|
|
2660
|
+
console.error("⚠ Could not generate Grafana password automatically");
|
|
2526
2661
|
console.log("Using default password: demo\n");
|
|
2527
2662
|
grafanaPassword = "demo";
|
|
2528
2663
|
}
|
|
2529
2664
|
|
|
2665
|
+
// Generate VictoriaMetrics auth credentials
|
|
2666
|
+
try {
|
|
2667
|
+
const envFile = path.resolve(projectDir, ".env");
|
|
2668
|
+
|
|
2669
|
+
// Read existing VM auth from .env if present
|
|
2670
|
+
if (fs.existsSync(envFile)) {
|
|
2671
|
+
const envContent = fs.readFileSync(envFile, "utf8");
|
|
2672
|
+
const userMatch = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
|
|
2673
|
+
const passMatch = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
|
|
2674
|
+
if (userMatch) vmAuthUsername = stripMatchingQuotes(userMatch[1]);
|
|
2675
|
+
if (passMatch) vmAuthPassword = stripMatchingQuotes(passMatch[1]);
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
if (!vmAuthUsername || !vmAuthPassword) {
|
|
2679
|
+
console.log("Generating VictoriaMetrics auth credentials...");
|
|
2680
|
+
vmAuthUsername = vmAuthUsername || "vmauth";
|
|
2681
|
+
if (!vmAuthPassword) {
|
|
2682
|
+
const { stdout: vmPass } = await execFilePromise("openssl", ["rand", "-base64", "12"]);
|
|
2683
|
+
vmAuthPassword = vmPass.trim().replace(/\n/g, "");
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
// Update .env file with VM auth credentials
|
|
2687
|
+
let envContent = "";
|
|
2688
|
+
if (fs.existsSync(envFile)) {
|
|
2689
|
+
envContent = fs.readFileSync(envFile, "utf8");
|
|
2690
|
+
}
|
|
2691
|
+
const envLines = envContent.split(/\r?\n/)
|
|
2692
|
+
.filter((l) => !/^VM_AUTH_USERNAME=/.test(l) && !/^VM_AUTH_PASSWORD=/.test(l))
|
|
2693
|
+
.filter((l, i, arr) => !(i === arr.length - 1 && l === ''));
|
|
2694
|
+
envLines.push(`VM_AUTH_USERNAME=${vmAuthUsername}`);
|
|
2695
|
+
envLines.push(`VM_AUTH_PASSWORD=${vmAuthPassword}`);
|
|
2696
|
+
fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
console.log("✓ VictoriaMetrics auth configured\n");
|
|
2700
|
+
} catch (error) {
|
|
2701
|
+
console.error("⚠ Could not generate VictoriaMetrics auth credentials automatically");
|
|
2702
|
+
if (process.env.DEBUG) {
|
|
2703
|
+
console.warn(` ${error instanceof Error ? error.message : String(error)}`);
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2530
2707
|
// Step 5: Start services
|
|
2708
|
+
// Remove stopped containers left by "run --rm" dependencies (e.g. config-init)
|
|
2709
|
+
// to avoid docker-compose v1 'ContainerConfig' error on recreation.
|
|
2710
|
+
// Best-effort: ignore exit code — container may not exist, failure here is non-fatal.
|
|
2711
|
+
await runCompose(["rm", "-f", "-s", "config-init"]);
|
|
2531
2712
|
console.log("Step 5: Starting monitoring services...");
|
|
2532
2713
|
const code2 = await runCompose(["up", "-d", "--force-recreate"], grafanaPassword);
|
|
2533
2714
|
if (code2 !== 0) {
|
|
@@ -2577,6 +2758,9 @@ mon
|
|
|
2577
2758
|
console.log("🚀 MAIN ACCESS POINT - Start here:");
|
|
2578
2759
|
console.log(" Grafana Dashboard: http://localhost:3000");
|
|
2579
2760
|
console.log(` Login: monitor / ${grafanaPassword}`);
|
|
2761
|
+
if (vmAuthUsername && vmAuthPassword) {
|
|
2762
|
+
console.log(` VictoriaMetrics Auth: ${vmAuthUsername} / ${vmAuthPassword}`);
|
|
2763
|
+
}
|
|
2580
2764
|
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
|
|
2581
2765
|
});
|
|
2582
2766
|
|
|
@@ -2777,7 +2961,7 @@ mon
|
|
|
2777
2961
|
console.log(`Project Directory: ${projectDir}`);
|
|
2778
2962
|
console.log(`Docker Compose File: ${composeFile}`);
|
|
2779
2963
|
console.log(`Instances File: ${instancesFile}`);
|
|
2780
|
-
if (fs.existsSync(instancesFile)) {
|
|
2964
|
+
if (fs.existsSync(instancesFile) && !fs.lstatSync(instancesFile).isDirectory()) {
|
|
2781
2965
|
console.log("\nInstances configuration:\n");
|
|
2782
2966
|
const text = fs.readFileSync(instancesFile, "utf8");
|
|
2783
2967
|
process.stdout.write(text);
|
|
@@ -2808,16 +2992,16 @@ mon
|
|
|
2808
2992
|
|
|
2809
2993
|
// Fetch latest changes
|
|
2810
2994
|
console.log("Fetching latest changes...");
|
|
2811
|
-
await
|
|
2995
|
+
await execFilePromise("git", ["fetch", "origin"]);
|
|
2812
2996
|
|
|
2813
2997
|
// Check current branch
|
|
2814
|
-
const { stdout: branch } = await
|
|
2998
|
+
const { stdout: branch } = await execFilePromise("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
2815
2999
|
const currentBranch = branch.trim();
|
|
2816
3000
|
console.log(`Current branch: ${currentBranch}`);
|
|
2817
3001
|
|
|
2818
3002
|
// Pull latest changes
|
|
2819
3003
|
console.log("Pulling latest changes...");
|
|
2820
|
-
const { stdout: pullOut } = await
|
|
3004
|
+
const { stdout: pullOut } = await execFilePromise("git", ["pull", "origin", currentBranch]);
|
|
2821
3005
|
console.log(pullOut);
|
|
2822
3006
|
|
|
2823
3007
|
// Update Docker images
|
|
@@ -2914,7 +3098,7 @@ mon
|
|
|
2914
3098
|
if (downCode === 0) {
|
|
2915
3099
|
console.log("✓ Monitoring services stopped and removed");
|
|
2916
3100
|
} else {
|
|
2917
|
-
console.
|
|
3101
|
+
console.error("⚠ Could not stop services (may not be running)");
|
|
2918
3102
|
}
|
|
2919
3103
|
|
|
2920
3104
|
// Remove any orphaned containers that docker compose down missed
|
|
@@ -2993,48 +3177,38 @@ targets
|
|
|
2993
3177
|
.description("list monitoring target databases")
|
|
2994
3178
|
.action(async () => {
|
|
2995
3179
|
const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
|
|
2996
|
-
if (!fs.existsSync(instancesPath)) {
|
|
3180
|
+
if (!fs.existsSync(instancesPath) || fs.lstatSync(instancesPath).isDirectory()) {
|
|
2997
3181
|
console.error(`instances.yml not found in ${projectDir}`);
|
|
2998
3182
|
process.exitCode = 1;
|
|
2999
3183
|
return;
|
|
3000
3184
|
}
|
|
3001
3185
|
|
|
3186
|
+
let instances: Instance[];
|
|
3002
3187
|
try {
|
|
3003
|
-
|
|
3004
|
-
const instances = yaml.load(content) as Instance[] | null;
|
|
3005
|
-
|
|
3006
|
-
if (!instances || !Array.isArray(instances) || instances.length === 0) {
|
|
3007
|
-
console.log("No monitoring targets configured");
|
|
3008
|
-
console.log("");
|
|
3009
|
-
console.log("To add a monitoring target:");
|
|
3010
|
-
console.log(" postgres-ai mon targets add <connection-string> <name>");
|
|
3011
|
-
console.log("");
|
|
3012
|
-
console.log("Example:");
|
|
3013
|
-
console.log(" postgres-ai mon targets add 'postgresql://user:pass@host:5432/db' my-db");
|
|
3014
|
-
return;
|
|
3015
|
-
}
|
|
3016
|
-
|
|
3017
|
-
// Filter out disabled instances (e.g., demo placeholders)
|
|
3018
|
-
const filtered = instances.filter((inst) => inst.name && inst.is_enabled !== false);
|
|
3019
|
-
|
|
3020
|
-
if (filtered.length === 0) {
|
|
3021
|
-
console.log("No monitoring targets configured");
|
|
3022
|
-
console.log("");
|
|
3023
|
-
console.log("To add a monitoring target:");
|
|
3024
|
-
console.log(" postgres-ai mon targets add <connection-string> <name>");
|
|
3025
|
-
console.log("");
|
|
3026
|
-
console.log("Example:");
|
|
3027
|
-
console.log(" postgres-ai mon targets add 'postgresql://user:pass@host:5432/db' my-db");
|
|
3028
|
-
return;
|
|
3029
|
-
}
|
|
3030
|
-
|
|
3031
|
-
for (const inst of filtered) {
|
|
3032
|
-
console.log(`Target: ${inst.name}`);
|
|
3033
|
-
}
|
|
3188
|
+
instances = loadInstances(instancesPath);
|
|
3034
3189
|
} catch (err) {
|
|
3035
3190
|
const message = err instanceof Error ? err.message : String(err);
|
|
3036
3191
|
console.error(`Error parsing instances.yml: ${message}`);
|
|
3037
3192
|
process.exitCode = 1;
|
|
3193
|
+
return;
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
// Filter out disabled instances (e.g., demo placeholders)
|
|
3197
|
+
const filtered = instances.filter((inst) => inst.name && inst.is_enabled !== false);
|
|
3198
|
+
|
|
3199
|
+
if (filtered.length === 0) {
|
|
3200
|
+
console.log("No monitoring targets configured");
|
|
3201
|
+
console.log("");
|
|
3202
|
+
console.log("To add a monitoring target:");
|
|
3203
|
+
console.log(" postgres-ai mon targets add <connection-string> <name>");
|
|
3204
|
+
console.log("");
|
|
3205
|
+
console.log("Example:");
|
|
3206
|
+
console.log(" postgres-ai mon targets add 'postgresql://user:pass@host:5432/db' my-db");
|
|
3207
|
+
return;
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
for (const inst of filtered) {
|
|
3211
|
+
console.log(`Target: ${inst.name}`);
|
|
3038
3212
|
}
|
|
3039
3213
|
});
|
|
3040
3214
|
targets
|
|
@@ -3057,66 +3231,36 @@ targets
|
|
|
3057
3231
|
const db = m[5];
|
|
3058
3232
|
const instanceName = name && name.trim() ? name.trim() : `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
|
|
3059
3233
|
|
|
3060
|
-
// Check if instance already exists
|
|
3061
3234
|
try {
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
const instances = yaml.load(content) as Instance[] | null || [];
|
|
3065
|
-
if (Array.isArray(instances)) {
|
|
3066
|
-
const exists = instances.some((inst) => inst.name === instanceName);
|
|
3067
|
-
if (exists) {
|
|
3068
|
-
console.error(`Monitoring target '${instanceName}' already exists`);
|
|
3069
|
-
process.exitCode = 1;
|
|
3070
|
-
return;
|
|
3071
|
-
}
|
|
3072
|
-
}
|
|
3073
|
-
}
|
|
3235
|
+
addInstanceToFile(file, buildInstance(instanceName, connStr));
|
|
3236
|
+
console.log(`Monitoring target '${instanceName}' added`);
|
|
3074
3237
|
} catch (err) {
|
|
3075
|
-
//
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
}
|
|
3238
|
+
// Surface InstancesParseError as-is so we don't silently overwrite a
|
|
3239
|
+
// corrupted file (which could discard several targets, including the
|
|
3240
|
+
// credentials in their conn_str values).
|
|
3241
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3242
|
+
console.error(message);
|
|
3243
|
+
process.exitCode = 1;
|
|
3082
3244
|
}
|
|
3083
|
-
|
|
3084
|
-
// Add new instance
|
|
3085
|
-
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
|
-
const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
3087
|
-
fs.appendFileSync(file, (content && !/\n$/.test(content) ? "\n" : "") + body, "utf8");
|
|
3088
|
-
console.log(`Monitoring target '${instanceName}' added`);
|
|
3089
3245
|
});
|
|
3090
3246
|
targets
|
|
3091
3247
|
.command("remove <name>")
|
|
3092
3248
|
.description("remove monitoring target database")
|
|
3093
3249
|
.action(async (name: string) => {
|
|
3094
3250
|
const { instancesFile: file } = await resolveOrInitPaths();
|
|
3095
|
-
if (!fs.existsSync(file)) {
|
|
3251
|
+
if (!fs.existsSync(file) || fs.lstatSync(file).isDirectory()) {
|
|
3096
3252
|
console.error("instances.yml not found");
|
|
3097
3253
|
process.exitCode = 1;
|
|
3098
3254
|
return;
|
|
3099
3255
|
}
|
|
3100
3256
|
|
|
3101
3257
|
try {
|
|
3102
|
-
const
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
if (!instances || !Array.isArray(instances)) {
|
|
3106
|
-
console.error("Invalid instances.yml format");
|
|
3107
|
-
process.exitCode = 1;
|
|
3108
|
-
return;
|
|
3109
|
-
}
|
|
3110
|
-
|
|
3111
|
-
const filtered = instances.filter((inst) => inst.name !== name);
|
|
3112
|
-
|
|
3113
|
-
if (filtered.length === instances.length) {
|
|
3258
|
+
const removed = removeInstanceFromFile(file, name);
|
|
3259
|
+
if (!removed) {
|
|
3114
3260
|
console.error(`Monitoring target '${name}' not found`);
|
|
3115
3261
|
process.exitCode = 1;
|
|
3116
3262
|
return;
|
|
3117
3263
|
}
|
|
3118
|
-
|
|
3119
|
-
fs.writeFileSync(file, yaml.dump(filtered), "utf8");
|
|
3120
3264
|
console.log(`Monitoring target '${name}' removed`);
|
|
3121
3265
|
} catch (err) {
|
|
3122
3266
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -3129,41 +3273,41 @@ targets
|
|
|
3129
3273
|
.description("test monitoring target database connectivity")
|
|
3130
3274
|
.action(async (name: string) => {
|
|
3131
3275
|
const { instancesFile: instancesPath } = await resolveOrInitPaths();
|
|
3132
|
-
if (!fs.existsSync(instancesPath)) {
|
|
3276
|
+
if (!fs.existsSync(instancesPath) || fs.lstatSync(instancesPath).isDirectory()) {
|
|
3133
3277
|
console.error("instances.yml not found");
|
|
3134
3278
|
process.exitCode = 1;
|
|
3135
3279
|
return;
|
|
3136
3280
|
}
|
|
3137
3281
|
|
|
3282
|
+
let instances: Instance[];
|
|
3138
3283
|
try {
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
const instance = instances.find((inst) => inst.name === name);
|
|
3284
|
+
instances = loadInstances(instancesPath);
|
|
3285
|
+
} catch (err) {
|
|
3286
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3287
|
+
console.error(`Error parsing instances.yml: ${message}`);
|
|
3288
|
+
process.exitCode = 1;
|
|
3289
|
+
return;
|
|
3290
|
+
}
|
|
3291
|
+
const instance = instances.find((inst) => inst.name === name);
|
|
3149
3292
|
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3293
|
+
if (!instance) {
|
|
3294
|
+
console.error(`Monitoring target '${name}' not found`);
|
|
3295
|
+
process.exitCode = 1;
|
|
3296
|
+
return;
|
|
3297
|
+
}
|
|
3155
3298
|
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3299
|
+
if (!instance.conn_str) {
|
|
3300
|
+
console.error(`Connection string not found for monitoring target '${name}'`);
|
|
3301
|
+
process.exitCode = 1;
|
|
3302
|
+
return;
|
|
3303
|
+
}
|
|
3161
3304
|
|
|
3162
|
-
|
|
3305
|
+
console.log(`Testing connection to monitoring target '${name}'...`);
|
|
3163
3306
|
|
|
3164
|
-
|
|
3165
|
-
|
|
3307
|
+
warnIfLaxSslmode(instance.conn_str);
|
|
3308
|
+
const client = new Client(buildClientConfig(instance.conn_str, { connectionTimeoutMillis: 10000 }));
|
|
3166
3309
|
|
|
3310
|
+
try {
|
|
3167
3311
|
try {
|
|
3168
3312
|
await client.connect();
|
|
3169
3313
|
const result = await client.query('select version();');
|
|
@@ -3197,17 +3341,17 @@ auth
|
|
|
3197
3341
|
process.exitCode = 1;
|
|
3198
3342
|
return;
|
|
3199
3343
|
}
|
|
3200
|
-
|
|
3344
|
+
|
|
3201
3345
|
// Read existing config to check for defaultProject before updating
|
|
3202
3346
|
const existingConfig = config.readConfig();
|
|
3203
3347
|
const existingProject = existingConfig.defaultProject;
|
|
3204
|
-
|
|
3348
|
+
|
|
3205
3349
|
config.writeConfig({ apiKey: trimmedKey });
|
|
3206
3350
|
// When API key is set directly, only clear orgId (org selection may differ).
|
|
3207
3351
|
// Preserve defaultProject to avoid orphaning historical reports.
|
|
3208
3352
|
// If the new key lacks access to the project, upload will fail with a clear error.
|
|
3209
3353
|
config.deleteConfigKeys(["orgId"]);
|
|
3210
|
-
|
|
3354
|
+
|
|
3211
3355
|
console.log(`API key saved to ${config.getConfigPath()}`);
|
|
3212
3356
|
if (existingProject) {
|
|
3213
3357
|
console.log(`Note: Your default project "${existingProject}" has been preserved.`);
|
|
@@ -3227,8 +3371,8 @@ auth
|
|
|
3227
3371
|
const { apiBaseUrl, uiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
3228
3372
|
|
|
3229
3373
|
if (opts.debug) {
|
|
3230
|
-
console.
|
|
3231
|
-
console.
|
|
3374
|
+
console.error(`Debug: Resolved API base URL: ${apiBaseUrl}`);
|
|
3375
|
+
console.error(`Debug: Resolved UI base URL: ${uiBaseUrl}`);
|
|
3232
3376
|
}
|
|
3233
3377
|
|
|
3234
3378
|
try {
|
|
@@ -3258,8 +3402,8 @@ auth
|
|
|
3258
3402
|
const initUrl = new URL(`${apiBaseUrl}/rpc/oauth_init`);
|
|
3259
3403
|
|
|
3260
3404
|
if (opts.debug) {
|
|
3261
|
-
console.
|
|
3262
|
-
console.
|
|
3405
|
+
console.error(`Debug: Trying to POST to: ${initUrl.toString()}`);
|
|
3406
|
+
console.error(`Debug: Request data: ${initData}`);
|
|
3263
3407
|
}
|
|
3264
3408
|
|
|
3265
3409
|
// Step 2: Initialize OAuth session on backend using fetch
|
|
@@ -3305,7 +3449,7 @@ auth
|
|
|
3305
3449
|
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
3450
|
|
|
3307
3451
|
if (opts.debug) {
|
|
3308
|
-
console.
|
|
3452
|
+
console.error(`Debug: Auth URL: ${authUrl}`);
|
|
3309
3453
|
}
|
|
3310
3454
|
|
|
3311
3455
|
console.log(`\nOpening browser for authentication...`);
|
|
@@ -3391,13 +3535,13 @@ auth
|
|
|
3391
3535
|
const existingOrgId = existingConfig.orgId;
|
|
3392
3536
|
const existingProject = existingConfig.defaultProject;
|
|
3393
3537
|
const orgChanged = existingOrgId && existingOrgId !== orgId;
|
|
3394
|
-
|
|
3538
|
+
|
|
3395
3539
|
config.writeConfig({
|
|
3396
3540
|
apiKey: apiToken,
|
|
3397
3541
|
baseUrl: apiBaseUrl,
|
|
3398
3542
|
orgId: orgId,
|
|
3399
3543
|
});
|
|
3400
|
-
|
|
3544
|
+
|
|
3401
3545
|
// Only clear defaultProject if org actually changed
|
|
3402
3546
|
if (orgChanged && existingProject) {
|
|
3403
3547
|
config.deleteConfigKeys(["defaultProject"]);
|
|
@@ -3515,10 +3659,10 @@ mon
|
|
|
3515
3659
|
|
|
3516
3660
|
try {
|
|
3517
3661
|
// Generate secure password using openssl
|
|
3518
|
-
const { stdout: password } = await
|
|
3519
|
-
"openssl rand -base64 12
|
|
3662
|
+
const { stdout: password } = await execFilePromise(
|
|
3663
|
+
"openssl", ["rand", "-base64", "12"]
|
|
3520
3664
|
);
|
|
3521
|
-
const newPassword = password.trim();
|
|
3665
|
+
const newPassword = password.trim().replace(/\n/g, "");
|
|
3522
3666
|
|
|
3523
3667
|
if (!newPassword) {
|
|
3524
3668
|
console.error("Failed to generate password");
|
|
@@ -3596,6 +3740,19 @@ mon
|
|
|
3596
3740
|
console.log(" URL: http://localhost:3000");
|
|
3597
3741
|
console.log(" Username: monitor");
|
|
3598
3742
|
console.log(` Password: ${password}`);
|
|
3743
|
+
|
|
3744
|
+
// Show VM auth credentials from .env
|
|
3745
|
+
const envFile = path.resolve(projectDir, ".env");
|
|
3746
|
+
if (fs.existsSync(envFile)) {
|
|
3747
|
+
const envContent = fs.readFileSync(envFile, "utf8");
|
|
3748
|
+
const vmUser = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
|
|
3749
|
+
const vmPass = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
|
|
3750
|
+
if (vmUser && vmPass) {
|
|
3751
|
+
console.log("\nVictoriaMetrics credentials:");
|
|
3752
|
+
console.log(` Username: ${stripMatchingQuotes(vmUser[1])}`);
|
|
3753
|
+
console.log(` Password: ${stripMatchingQuotes(vmPass[1])}`);
|
|
3754
|
+
}
|
|
3755
|
+
}
|
|
3599
3756
|
console.log("");
|
|
3600
3757
|
});
|
|
3601
3758
|
|
|
@@ -3723,21 +3880,34 @@ issues
|
|
|
3723
3880
|
.command("post-comment <issueId> <content>")
|
|
3724
3881
|
.description("post a new comment to an issue")
|
|
3725
3882
|
.option("--parent <uuid>", "parent comment id")
|
|
3883
|
+
.option(
|
|
3884
|
+
"--attach <path>",
|
|
3885
|
+
"attach a file (uploads to storage and appends a markdown link; repeatable)",
|
|
3886
|
+
(value: string, previous: string[]) => {
|
|
3887
|
+
previous.push(value);
|
|
3888
|
+
return previous;
|
|
3889
|
+
},
|
|
3890
|
+
[] as string[]
|
|
3891
|
+
)
|
|
3726
3892
|
.option("--debug", "enable debug output")
|
|
3727
3893
|
.option("--json", "output raw JSON")
|
|
3728
|
-
.action(async (issueId: string, content: string, opts: { parent?: string; debug?: boolean; json?: boolean }) => {
|
|
3894
|
+
.action(async (issueId: string, content: string, opts: { parent?: string; attach?: string[]; debug?: boolean; json?: boolean }) => {
|
|
3729
3895
|
// Interpret escape sequences in content (e.g., \n -> newline)
|
|
3730
3896
|
if (opts.debug) {
|
|
3731
3897
|
// eslint-disable-next-line no-console
|
|
3732
|
-
console.
|
|
3898
|
+
console.error(`Debug: Original content: ${JSON.stringify(content)}`);
|
|
3733
3899
|
}
|
|
3734
3900
|
content = interpretEscapes(content);
|
|
3735
3901
|
if (opts.debug) {
|
|
3736
3902
|
// eslint-disable-next-line no-console
|
|
3737
|
-
console.
|
|
3903
|
+
console.error(`Debug: Interpreted content: ${JSON.stringify(content)}`);
|
|
3738
3904
|
}
|
|
3739
3905
|
|
|
3740
|
-
const
|
|
3906
|
+
const attachPaths = Array.isArray(opts.attach) ? opts.attach : [];
|
|
3907
|
+
const spinner = createTtySpinner(
|
|
3908
|
+
process.stdout.isTTY ?? false,
|
|
3909
|
+
attachPaths.length > 0 ? "Uploading attachments..." : "Posting comment..."
|
|
3910
|
+
);
|
|
3741
3911
|
try {
|
|
3742
3912
|
const rootOpts = program.opts<CliOptions>();
|
|
3743
3913
|
const cfg = config.readConfig();
|
|
@@ -3749,13 +3919,25 @@ issues
|
|
|
3749
3919
|
return;
|
|
3750
3920
|
}
|
|
3751
3921
|
|
|
3752
|
-
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
3922
|
+
const { apiBaseUrl, storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
3923
|
+
|
|
3924
|
+
let augmentedContent = content;
|
|
3925
|
+
if (attachPaths.length > 0) {
|
|
3926
|
+
const uploaded = await uploadAttachments({
|
|
3927
|
+
apiKey,
|
|
3928
|
+
storageBaseUrl,
|
|
3929
|
+
attachmentPaths: attachPaths,
|
|
3930
|
+
debug: !!opts.debug,
|
|
3931
|
+
});
|
|
3932
|
+
augmentedContent = appendAttachmentsToContent(content, uploaded);
|
|
3933
|
+
spinner.update("Posting comment...");
|
|
3934
|
+
}
|
|
3753
3935
|
|
|
3754
3936
|
const result = await createIssueComment({
|
|
3755
3937
|
apiKey,
|
|
3756
3938
|
apiBaseUrl,
|
|
3757
3939
|
issueId,
|
|
3758
|
-
content,
|
|
3940
|
+
content: augmentedContent,
|
|
3759
3941
|
parentCommentId: opts.parent,
|
|
3760
3942
|
debug: !!opts.debug,
|
|
3761
3943
|
});
|
|
@@ -3784,9 +3966,18 @@ issues
|
|
|
3784
3966
|
},
|
|
3785
3967
|
[] as string[]
|
|
3786
3968
|
)
|
|
3969
|
+
.option(
|
|
3970
|
+
"--attach <path>",
|
|
3971
|
+
"attach a file (uploads to storage and appends a markdown link to the description; repeatable)",
|
|
3972
|
+
(value: string, previous: string[]) => {
|
|
3973
|
+
previous.push(value);
|
|
3974
|
+
return previous;
|
|
3975
|
+
},
|
|
3976
|
+
[] as string[]
|
|
3977
|
+
)
|
|
3787
3978
|
.option("--debug", "enable debug output")
|
|
3788
3979
|
.option("--json", "output raw JSON")
|
|
3789
|
-
.action(async (rawTitle: string, opts: { orgId?: number; projectId?: number; description?: string; label?: string[]; debug?: boolean; json?: boolean }) => {
|
|
3980
|
+
.action(async (rawTitle: string, opts: { orgId?: number; projectId?: number; description?: string; label?: string[]; attach?: string[]; debug?: boolean; json?: boolean }) => {
|
|
3790
3981
|
const rootOpts = program.opts<CliOptions>();
|
|
3791
3982
|
const cfg = config.readConfig();
|
|
3792
3983
|
const { apiKey } = getConfig(rootOpts);
|
|
@@ -3813,16 +4004,33 @@ issues
|
|
|
3813
4004
|
const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
|
|
3814
4005
|
const labels = Array.isArray(opts.label) && opts.label.length > 0 ? opts.label.map(String) : undefined;
|
|
3815
4006
|
const projectId = typeof opts.projectId === "number" && !Number.isNaN(opts.projectId) ? opts.projectId : undefined;
|
|
4007
|
+
const attachPaths = Array.isArray(opts.attach) ? opts.attach : [];
|
|
3816
4008
|
|
|
3817
|
-
const spinner = createTtySpinner(
|
|
4009
|
+
const spinner = createTtySpinner(
|
|
4010
|
+
process.stdout.isTTY ?? false,
|
|
4011
|
+
attachPaths.length > 0 ? "Uploading attachments..." : "Creating issue..."
|
|
4012
|
+
);
|
|
3818
4013
|
try {
|
|
3819
|
-
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
4014
|
+
const { apiBaseUrl, storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
4015
|
+
|
|
4016
|
+
let augmentedDescription = description;
|
|
4017
|
+
if (attachPaths.length > 0) {
|
|
4018
|
+
const uploaded = await uploadAttachments({
|
|
4019
|
+
apiKey,
|
|
4020
|
+
storageBaseUrl,
|
|
4021
|
+
attachmentPaths: attachPaths,
|
|
4022
|
+
debug: !!opts.debug,
|
|
4023
|
+
});
|
|
4024
|
+
augmentedDescription = appendAttachmentsToContent(description ?? "", uploaded);
|
|
4025
|
+
spinner.update("Creating issue...");
|
|
4026
|
+
}
|
|
4027
|
+
|
|
3820
4028
|
const result = await createIssue({
|
|
3821
4029
|
apiKey,
|
|
3822
4030
|
apiBaseUrl,
|
|
3823
4031
|
title,
|
|
3824
4032
|
orgId,
|
|
3825
|
-
description,
|
|
4033
|
+
description: augmentedDescription,
|
|
3826
4034
|
projectId,
|
|
3827
4035
|
labels,
|
|
3828
4036
|
debug: !!opts.debug,
|
|
@@ -3853,9 +4061,18 @@ issues
|
|
|
3853
4061
|
[] as string[]
|
|
3854
4062
|
)
|
|
3855
4063
|
.option("--clear-labels", "set labels to an empty list")
|
|
4064
|
+
.option(
|
|
4065
|
+
"--attach <path>",
|
|
4066
|
+
"attach a file (uploads and appends a markdown link to --description; if --description is omitted the existing description is fetched and appended to; repeatable)",
|
|
4067
|
+
(value: string, previous: string[]) => {
|
|
4068
|
+
previous.push(value);
|
|
4069
|
+
return previous;
|
|
4070
|
+
},
|
|
4071
|
+
[] as string[]
|
|
4072
|
+
)
|
|
3856
4073
|
.option("--debug", "enable debug output")
|
|
3857
4074
|
.option("--json", "output raw JSON")
|
|
3858
|
-
.action(async (issueId: string, opts: { title?: string; description?: string; status?: string; label?: string[]; clearLabels?: boolean; debug?: boolean; json?: boolean }) => {
|
|
4075
|
+
.action(async (issueId: string, opts: { title?: string; description?: string; status?: string; label?: string[]; clearLabels?: boolean; attach?: string[]; debug?: boolean; json?: boolean }) => {
|
|
3859
4076
|
const rootOpts = program.opts<CliOptions>();
|
|
3860
4077
|
const cfg = config.readConfig();
|
|
3861
4078
|
const { apiKey } = getConfig(rootOpts);
|
|
@@ -3865,10 +4082,10 @@ issues
|
|
|
3865
4082
|
return;
|
|
3866
4083
|
}
|
|
3867
4084
|
|
|
3868
|
-
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
4085
|
+
const { apiBaseUrl, storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
3869
4086
|
|
|
3870
4087
|
const title = opts.title !== undefined ? interpretEscapes(String(opts.title)) : undefined;
|
|
3871
|
-
|
|
4088
|
+
let description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
|
|
3872
4089
|
|
|
3873
4090
|
let status: number | undefined = undefined;
|
|
3874
4091
|
if (opts.status !== undefined) {
|
|
@@ -3898,8 +4115,38 @@ issues
|
|
|
3898
4115
|
labels = opts.label.map(String);
|
|
3899
4116
|
}
|
|
3900
4117
|
|
|
3901
|
-
const
|
|
4118
|
+
const attachPaths = Array.isArray(opts.attach) ? opts.attach : [];
|
|
4119
|
+
const spinner = createTtySpinner(
|
|
4120
|
+
process.stdout.isTTY ?? false,
|
|
4121
|
+
attachPaths.length > 0 ? "Uploading attachments..." : "Updating issue..."
|
|
4122
|
+
);
|
|
3902
4123
|
try {
|
|
4124
|
+
if (attachPaths.length > 0) {
|
|
4125
|
+
// If the caller did not supply a new description, fetch the existing one
|
|
4126
|
+
// and append to it. This makes "add a screenshot to issue X" a one-step
|
|
4127
|
+
// operation rather than forcing the caller to copy-paste the existing
|
|
4128
|
+
// description first. Small race window if someone else updates
|
|
4129
|
+
// concurrently, which is acceptable for an interactive CLI / agent.
|
|
4130
|
+
if (description === undefined) {
|
|
4131
|
+
const existing = await fetchIssue({ apiKey, apiBaseUrl, issueId, debug: !!opts.debug });
|
|
4132
|
+
if (!existing) {
|
|
4133
|
+
spinner.stop();
|
|
4134
|
+
console.error(`Issue not found: ${issueId}`);
|
|
4135
|
+
process.exitCode = 1;
|
|
4136
|
+
return;
|
|
4137
|
+
}
|
|
4138
|
+
description = (existing as { description?: string | null }).description ?? "";
|
|
4139
|
+
}
|
|
4140
|
+
const uploaded = await uploadAttachments({
|
|
4141
|
+
apiKey,
|
|
4142
|
+
storageBaseUrl,
|
|
4143
|
+
attachmentPaths: attachPaths,
|
|
4144
|
+
debug: !!opts.debug,
|
|
4145
|
+
});
|
|
4146
|
+
description = appendAttachmentsToContent(description ?? "", uploaded);
|
|
4147
|
+
spinner.update("Updating issue...");
|
|
4148
|
+
}
|
|
4149
|
+
|
|
3903
4150
|
const result = await updateIssue({
|
|
3904
4151
|
apiKey,
|
|
3905
4152
|
apiBaseUrl,
|
|
@@ -3923,17 +4170,26 @@ issues
|
|
|
3923
4170
|
issues
|
|
3924
4171
|
.command("update-comment <commentId> <content>")
|
|
3925
4172
|
.description("update an existing issue comment")
|
|
4173
|
+
.option(
|
|
4174
|
+
"--attach <path>",
|
|
4175
|
+
"attach a file (uploads and appends a markdown link to <content>; repeatable)",
|
|
4176
|
+
(value: string, previous: string[]) => {
|
|
4177
|
+
previous.push(value);
|
|
4178
|
+
return previous;
|
|
4179
|
+
},
|
|
4180
|
+
[] as string[]
|
|
4181
|
+
)
|
|
3926
4182
|
.option("--debug", "enable debug output")
|
|
3927
4183
|
.option("--json", "output raw JSON")
|
|
3928
|
-
.action(async (commentId: string, content: string, opts: { debug?: boolean; json?: boolean }) => {
|
|
4184
|
+
.action(async (commentId: string, content: string, opts: { attach?: string[]; debug?: boolean; json?: boolean }) => {
|
|
3929
4185
|
if (opts.debug) {
|
|
3930
4186
|
// eslint-disable-next-line no-console
|
|
3931
|
-
console.
|
|
4187
|
+
console.error(`Debug: Original content: ${JSON.stringify(content)}`);
|
|
3932
4188
|
}
|
|
3933
4189
|
content = interpretEscapes(content);
|
|
3934
4190
|
if (opts.debug) {
|
|
3935
4191
|
// eslint-disable-next-line no-console
|
|
3936
|
-
console.
|
|
4192
|
+
console.error(`Debug: Interpreted content: ${JSON.stringify(content)}`);
|
|
3937
4193
|
}
|
|
3938
4194
|
|
|
3939
4195
|
const rootOpts = program.opts<CliOptions>();
|
|
@@ -3945,15 +4201,31 @@ issues
|
|
|
3945
4201
|
return;
|
|
3946
4202
|
}
|
|
3947
4203
|
|
|
3948
|
-
const
|
|
4204
|
+
const attachPaths = Array.isArray(opts.attach) ? opts.attach : [];
|
|
4205
|
+
const spinner = createTtySpinner(
|
|
4206
|
+
process.stdout.isTTY ?? false,
|
|
4207
|
+
attachPaths.length > 0 ? "Uploading attachments..." : "Updating comment..."
|
|
4208
|
+
);
|
|
3949
4209
|
try {
|
|
3950
|
-
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
4210
|
+
const { apiBaseUrl, storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
4211
|
+
|
|
4212
|
+
let augmentedContent = content;
|
|
4213
|
+
if (attachPaths.length > 0) {
|
|
4214
|
+
const uploaded = await uploadAttachments({
|
|
4215
|
+
apiKey,
|
|
4216
|
+
storageBaseUrl,
|
|
4217
|
+
attachmentPaths: attachPaths,
|
|
4218
|
+
debug: !!opts.debug,
|
|
4219
|
+
});
|
|
4220
|
+
augmentedContent = appendAttachmentsToContent(content, uploaded);
|
|
4221
|
+
spinner.update("Updating comment...");
|
|
4222
|
+
}
|
|
3951
4223
|
|
|
3952
4224
|
const result = await updateIssueComment({
|
|
3953
4225
|
apiKey,
|
|
3954
4226
|
apiBaseUrl,
|
|
3955
4227
|
commentId,
|
|
3956
|
-
content,
|
|
4228
|
+
content: augmentedContent,
|
|
3957
4229
|
debug: !!opts.debug,
|
|
3958
4230
|
});
|
|
3959
4231
|
spinner.stop();
|
|
@@ -3966,6 +4238,93 @@ issues
|
|
|
3966
4238
|
}
|
|
3967
4239
|
});
|
|
3968
4240
|
|
|
4241
|
+
// File upload/download (subcommands of issues)
|
|
4242
|
+
const issueFiles = issues.command("files").description("upload and download files for issues");
|
|
4243
|
+
|
|
4244
|
+
issueFiles
|
|
4245
|
+
.command("upload <path>")
|
|
4246
|
+
.description("upload a file to storage and get a markdown link")
|
|
4247
|
+
.option("--debug", "enable debug output")
|
|
4248
|
+
.option("--json", "output raw JSON")
|
|
4249
|
+
.action(async (filePath: string, opts: { debug?: boolean; json?: boolean }) => {
|
|
4250
|
+
const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Uploading file...");
|
|
4251
|
+
try {
|
|
4252
|
+
const rootOpts = program.opts<CliOptions>();
|
|
4253
|
+
const cfg = config.readConfig();
|
|
4254
|
+
const { apiKey } = getConfig(rootOpts);
|
|
4255
|
+
if (!apiKey) {
|
|
4256
|
+
spinner.stop();
|
|
4257
|
+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
4258
|
+
process.exitCode = 1;
|
|
4259
|
+
return;
|
|
4260
|
+
}
|
|
4261
|
+
|
|
4262
|
+
const { storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
4263
|
+
|
|
4264
|
+
const result = await uploadFile({
|
|
4265
|
+
apiKey,
|
|
4266
|
+
storageBaseUrl,
|
|
4267
|
+
filePath,
|
|
4268
|
+
debug: !!opts.debug,
|
|
4269
|
+
});
|
|
4270
|
+
spinner.stop();
|
|
4271
|
+
|
|
4272
|
+
if (opts.json) {
|
|
4273
|
+
printResult(result, true);
|
|
4274
|
+
} else {
|
|
4275
|
+
const md = buildMarkdownLink(result.url, storageBaseUrl, result.metadata.originalName);
|
|
4276
|
+
const displayUrl = result.url.startsWith("/") ? `${storageBaseUrl}${result.url}` : `${storageBaseUrl}/${result.url}`;
|
|
4277
|
+
console.log(`URL: ${displayUrl}`);
|
|
4278
|
+
console.log(`File: ${result.metadata.originalName}`);
|
|
4279
|
+
console.log(`Size: ${result.metadata.size} bytes`);
|
|
4280
|
+
console.log(`Type: ${result.metadata.mimeType}`);
|
|
4281
|
+
console.log(`Markdown: ${md}`);
|
|
4282
|
+
}
|
|
4283
|
+
} catch (err) {
|
|
4284
|
+
spinner.stop();
|
|
4285
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4286
|
+
console.error(message);
|
|
4287
|
+
process.exitCode = 1;
|
|
4288
|
+
}
|
|
4289
|
+
});
|
|
4290
|
+
|
|
4291
|
+
issueFiles
|
|
4292
|
+
.command("download <url>")
|
|
4293
|
+
.description("download a file from storage")
|
|
4294
|
+
.option("-o, --output <path>", "output file path (default: derive from URL)")
|
|
4295
|
+
.option("--debug", "enable debug output")
|
|
4296
|
+
.action(async (fileUrl: string, opts: { output?: string; debug?: boolean }) => {
|
|
4297
|
+
const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Downloading file...");
|
|
4298
|
+
try {
|
|
4299
|
+
const rootOpts = program.opts<CliOptions>();
|
|
4300
|
+
const cfg = config.readConfig();
|
|
4301
|
+
const { apiKey } = getConfig(rootOpts);
|
|
4302
|
+
if (!apiKey) {
|
|
4303
|
+
spinner.stop();
|
|
4304
|
+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
4305
|
+
process.exitCode = 1;
|
|
4306
|
+
return;
|
|
4307
|
+
}
|
|
4308
|
+
|
|
4309
|
+
const { storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
4310
|
+
|
|
4311
|
+
const result = await downloadFile({
|
|
4312
|
+
apiKey,
|
|
4313
|
+
storageBaseUrl,
|
|
4314
|
+
fileUrl,
|
|
4315
|
+
outputPath: opts.output,
|
|
4316
|
+
debug: !!opts.debug,
|
|
4317
|
+
});
|
|
4318
|
+
spinner.stop();
|
|
4319
|
+
console.log(`Saved: ${result.savedTo}`);
|
|
4320
|
+
} catch (err) {
|
|
4321
|
+
spinner.stop();
|
|
4322
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4323
|
+
console.error(message);
|
|
4324
|
+
process.exitCode = 1;
|
|
4325
|
+
}
|
|
4326
|
+
});
|
|
4327
|
+
|
|
3969
4328
|
// Action Items management (subcommands of issues)
|
|
3970
4329
|
issues
|
|
3971
4330
|
.command("action-items <issueId>")
|
|
@@ -4190,6 +4549,228 @@ issues
|
|
|
4190
4549
|
}
|
|
4191
4550
|
});
|
|
4192
4551
|
|
|
4552
|
+
// Reports management
|
|
4553
|
+
const reports = program.command("reports").description("checkup reports management");
|
|
4554
|
+
|
|
4555
|
+
reports
|
|
4556
|
+
.command("list")
|
|
4557
|
+
.description("list checkup reports")
|
|
4558
|
+
.option("--project-id <id>", "filter by project id", (v: string) => parseInt(v, 10))
|
|
4559
|
+
.addOption(new Option("--status <status>", "filter by status (e.g., completed)").hideHelp())
|
|
4560
|
+
.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)); })
|
|
4561
|
+
.option("--before <date>", "show reports created before this date (YYYY-MM-DD, DD.MM.YYYY, etc.)")
|
|
4562
|
+
.option("--all", "fetch all reports (paginated automatically)")
|
|
4563
|
+
.addOption(new Option("--debug", "enable debug output").hideHelp())
|
|
4564
|
+
.option("--json", "output raw JSON")
|
|
4565
|
+
.action(async (opts: { projectId?: number; status?: string; limit?: number; before?: string; all?: boolean; debug?: boolean; json?: boolean }) => {
|
|
4566
|
+
const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching reports...");
|
|
4567
|
+
try {
|
|
4568
|
+
const rootOpts = program.opts<CliOptions>();
|
|
4569
|
+
const cfg = config.readConfig();
|
|
4570
|
+
const { apiKey } = getConfig(rootOpts);
|
|
4571
|
+
if (!apiKey) {
|
|
4572
|
+
spinner.stop();
|
|
4573
|
+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
4574
|
+
process.exitCode = 1;
|
|
4575
|
+
return;
|
|
4576
|
+
}
|
|
4577
|
+
if (opts.all && opts.before) {
|
|
4578
|
+
spinner.stop();
|
|
4579
|
+
console.error("--all and --before cannot be used together");
|
|
4580
|
+
process.exitCode = 1;
|
|
4581
|
+
return;
|
|
4582
|
+
}
|
|
4583
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
4584
|
+
|
|
4585
|
+
let result;
|
|
4586
|
+
if (opts.all) {
|
|
4587
|
+
result = await fetchAllReports({
|
|
4588
|
+
apiKey,
|
|
4589
|
+
apiBaseUrl,
|
|
4590
|
+
projectId: opts.projectId,
|
|
4591
|
+
status: opts.status,
|
|
4592
|
+
limit: opts.limit,
|
|
4593
|
+
debug: !!opts.debug,
|
|
4594
|
+
});
|
|
4595
|
+
} else {
|
|
4596
|
+
result = await fetchReports({
|
|
4597
|
+
apiKey,
|
|
4598
|
+
apiBaseUrl,
|
|
4599
|
+
projectId: opts.projectId,
|
|
4600
|
+
status: opts.status,
|
|
4601
|
+
limit: opts.limit,
|
|
4602
|
+
beforeDate: opts.before ? parseFlexibleDate(opts.before) : undefined,
|
|
4603
|
+
debug: !!opts.debug,
|
|
4604
|
+
});
|
|
4605
|
+
}
|
|
4606
|
+
spinner.stop();
|
|
4607
|
+
printResult(result, opts.json);
|
|
4608
|
+
} catch (err) {
|
|
4609
|
+
spinner.stop();
|
|
4610
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4611
|
+
console.error(message);
|
|
4612
|
+
process.exitCode = 1;
|
|
4613
|
+
}
|
|
4614
|
+
});
|
|
4615
|
+
|
|
4616
|
+
reports
|
|
4617
|
+
.command("files [reportId]")
|
|
4618
|
+
.description("list files of a checkup report (metadata only, no content)")
|
|
4619
|
+
.option("--type <type>", "filter by file type: json, md")
|
|
4620
|
+
.option("--check-id <id>", "filter by check ID (e.g., H002)")
|
|
4621
|
+
.addOption(new Option("--debug", "enable debug output").hideHelp())
|
|
4622
|
+
.option("--json", "output raw JSON")
|
|
4623
|
+
.action(async (reportId: string | undefined, opts: { type?: "json" | "md"; checkId?: string; debug?: boolean; json?: boolean }) => {
|
|
4624
|
+
const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching report files...");
|
|
4625
|
+
try {
|
|
4626
|
+
const rootOpts = program.opts<CliOptions>();
|
|
4627
|
+
const cfg = config.readConfig();
|
|
4628
|
+
const { apiKey } = getConfig(rootOpts);
|
|
4629
|
+
if (!apiKey) {
|
|
4630
|
+
spinner.stop();
|
|
4631
|
+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
4632
|
+
process.exitCode = 1;
|
|
4633
|
+
return;
|
|
4634
|
+
}
|
|
4635
|
+
let numericId: number | undefined;
|
|
4636
|
+
if (reportId !== undefined) {
|
|
4637
|
+
numericId = parseInt(reportId, 10);
|
|
4638
|
+
if (isNaN(numericId)) {
|
|
4639
|
+
spinner.stop();
|
|
4640
|
+
console.error("reportId must be a number");
|
|
4641
|
+
process.exitCode = 1;
|
|
4642
|
+
return;
|
|
4643
|
+
}
|
|
4644
|
+
}
|
|
4645
|
+
if (numericId === undefined && !opts.checkId) {
|
|
4646
|
+
spinner.stop();
|
|
4647
|
+
console.error("Either reportId or --check-id is required");
|
|
4648
|
+
process.exitCode = 1;
|
|
4649
|
+
return;
|
|
4650
|
+
}
|
|
4651
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
4652
|
+
|
|
4653
|
+
const result = await fetchReportFiles({
|
|
4654
|
+
apiKey,
|
|
4655
|
+
apiBaseUrl,
|
|
4656
|
+
reportId: numericId,
|
|
4657
|
+
type: opts.type,
|
|
4658
|
+
checkId: opts.checkId,
|
|
4659
|
+
debug: !!opts.debug,
|
|
4660
|
+
});
|
|
4661
|
+
spinner.stop();
|
|
4662
|
+
printResult(result, opts.json);
|
|
4663
|
+
} catch (err) {
|
|
4664
|
+
spinner.stop();
|
|
4665
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4666
|
+
console.error(message);
|
|
4667
|
+
process.exitCode = 1;
|
|
4668
|
+
}
|
|
4669
|
+
});
|
|
4670
|
+
|
|
4671
|
+
reports
|
|
4672
|
+
.command("data [reportId]")
|
|
4673
|
+
.description("get checkup report file data (includes content)")
|
|
4674
|
+
.option("--type <type>", "filter by file type: json, md")
|
|
4675
|
+
.option("--check-id <id>", "filter by check ID (e.g., H002)")
|
|
4676
|
+
.option("--formatted", "render markdown with ANSI styling (experimental)")
|
|
4677
|
+
.option("-o, --output <dir>", "save files to directory (uses original filenames)")
|
|
4678
|
+
.addOption(new Option("--debug", "enable debug output").hideHelp())
|
|
4679
|
+
.option("--json", "output raw JSON")
|
|
4680
|
+
.action(async (reportId: string | undefined, opts: { type?: "json" | "md"; checkId?: string; formatted?: boolean; output?: string; debug?: boolean; json?: boolean }) => {
|
|
4681
|
+
const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching report data...");
|
|
4682
|
+
try {
|
|
4683
|
+
const rootOpts = program.opts<CliOptions>();
|
|
4684
|
+
const cfg = config.readConfig();
|
|
4685
|
+
const { apiKey } = getConfig(rootOpts);
|
|
4686
|
+
if (!apiKey) {
|
|
4687
|
+
spinner.stop();
|
|
4688
|
+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
4689
|
+
process.exitCode = 1;
|
|
4690
|
+
return;
|
|
4691
|
+
}
|
|
4692
|
+
let numericId: number | undefined;
|
|
4693
|
+
if (reportId !== undefined) {
|
|
4694
|
+
numericId = parseInt(reportId, 10);
|
|
4695
|
+
if (isNaN(numericId)) {
|
|
4696
|
+
spinner.stop();
|
|
4697
|
+
console.error("reportId must be a number");
|
|
4698
|
+
process.exitCode = 1;
|
|
4699
|
+
return;
|
|
4700
|
+
}
|
|
4701
|
+
}
|
|
4702
|
+
if (numericId === undefined && !opts.checkId) {
|
|
4703
|
+
spinner.stop();
|
|
4704
|
+
console.error("Either reportId or --check-id is required");
|
|
4705
|
+
process.exitCode = 1;
|
|
4706
|
+
return;
|
|
4707
|
+
}
|
|
4708
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
4709
|
+
|
|
4710
|
+
// Default to "md" for terminal output (human-readable); --json and --output get all types
|
|
4711
|
+
const effectiveType = opts.type ?? (!opts.json && !opts.output ? "md" as const : undefined);
|
|
4712
|
+
const result = await fetchReportFileData({
|
|
4713
|
+
apiKey,
|
|
4714
|
+
apiBaseUrl,
|
|
4715
|
+
reportId: numericId,
|
|
4716
|
+
type: effectiveType,
|
|
4717
|
+
checkId: opts.checkId,
|
|
4718
|
+
debug: !!opts.debug,
|
|
4719
|
+
});
|
|
4720
|
+
spinner.stop();
|
|
4721
|
+
|
|
4722
|
+
if (opts.output) {
|
|
4723
|
+
const dir = path.resolve(opts.output);
|
|
4724
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
4725
|
+
for (const f of result) {
|
|
4726
|
+
const safeName = path.basename(f.filename);
|
|
4727
|
+
const filePath = path.join(dir, safeName);
|
|
4728
|
+
const content = f.type === "json"
|
|
4729
|
+
? JSON.stringify(tryParseJson(f.data), null, 2)
|
|
4730
|
+
: f.data;
|
|
4731
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
4732
|
+
console.log(filePath);
|
|
4733
|
+
}
|
|
4734
|
+
} else if (opts.json) {
|
|
4735
|
+
const processed = result.map((f) => ({
|
|
4736
|
+
...f,
|
|
4737
|
+
data: f.type === "json" ? tryParseJson(f.data) : f.data,
|
|
4738
|
+
}));
|
|
4739
|
+
printResult(processed, true);
|
|
4740
|
+
} else if (opts.formatted && process.stdout.isTTY) {
|
|
4741
|
+
for (const f of result) {
|
|
4742
|
+
if (result.length > 1) {
|
|
4743
|
+
console.log(`\x1b[1m--- ${f.filename} (${f.check_id}, ${f.type}) ---\x1b[0m`);
|
|
4744
|
+
}
|
|
4745
|
+
if (f.type === "md") {
|
|
4746
|
+
console.log(renderMarkdownForTerminal(f.data));
|
|
4747
|
+
} else if (f.type === "json") {
|
|
4748
|
+
const parsed = tryParseJson(f.data);
|
|
4749
|
+
console.log(typeof parsed === "string" ? parsed : JSON.stringify(parsed, null, 2));
|
|
4750
|
+
} else {
|
|
4751
|
+
console.log(f.data);
|
|
4752
|
+
}
|
|
4753
|
+
}
|
|
4754
|
+
} else {
|
|
4755
|
+
for (const f of result) {
|
|
4756
|
+
if (result.length > 1) {
|
|
4757
|
+
console.log(`--- ${f.filename} (${f.check_id}, ${f.type}) ---`);
|
|
4758
|
+
}
|
|
4759
|
+
console.log(f.data);
|
|
4760
|
+
}
|
|
4761
|
+
}
|
|
4762
|
+
} catch (err) {
|
|
4763
|
+
spinner.stop();
|
|
4764
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4765
|
+
console.error(message);
|
|
4766
|
+
process.exitCode = 1;
|
|
4767
|
+
}
|
|
4768
|
+
});
|
|
4769
|
+
|
|
4770
|
+
function tryParseJson(s: string): unknown {
|
|
4771
|
+
try { return JSON.parse(s); } catch { return s; }
|
|
4772
|
+
}
|
|
4773
|
+
|
|
4193
4774
|
// MCP server
|
|
4194
4775
|
const mcp = program.command("mcp").description("MCP server integration");
|
|
4195
4776
|
|
|
@@ -4247,7 +4828,7 @@ mcp
|
|
|
4247
4828
|
// Get the path to the current pgai executable
|
|
4248
4829
|
let pgaiPath: string;
|
|
4249
4830
|
try {
|
|
4250
|
-
const execPath = await
|
|
4831
|
+
const execPath = await execFilePromise("which", ["pgai"]);
|
|
4251
4832
|
pgaiPath = execPath.stdout.trim();
|
|
4252
4833
|
} catch {
|
|
4253
4834
|
// Fallback to just "pgai" if which fails
|
|
@@ -4259,8 +4840,8 @@ mcp
|
|
|
4259
4840
|
console.log("Installing PostgresAI MCP server for Claude Code...");
|
|
4260
4841
|
|
|
4261
4842
|
try {
|
|
4262
|
-
const { stdout, stderr } = await
|
|
4263
|
-
|
|
4843
|
+
const { stdout, stderr } = await execFilePromise(
|
|
4844
|
+
"claude", ["mcp", "add", "-s", "user", "postgresai", pgaiPath, "mcp", "start"]
|
|
4264
4845
|
);
|
|
4265
4846
|
|
|
4266
4847
|
if (stdout) console.log(stdout);
|
|
@@ -4355,4 +4936,3 @@ mcp
|
|
|
4355
4936
|
program.parseAsync(process.argv).finally(() => {
|
|
4356
4937
|
closeReadline();
|
|
4357
4938
|
});
|
|
4358
|
-
|