postgresai 0.15.0-dev.6 → 0.15.0-rc.0
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 +119 -71
- package/bun.lock +4 -4
- package/dist/bin/postgres-ai.js +867 -232
- package/instances.demo.yml +14 -0
- package/lib/checkup-api.ts +25 -6
- package/lib/checkup.ts +225 -0
- package/lib/init.ts +195 -3
- package/lib/metrics-loader.ts +3 -1
- package/lib/supabase.ts +8 -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 +1288 -2
- package/test/config-consistency.test.ts +321 -5
- package/test/init.integration.test.ts +27 -28
- package/test/init.test.ts +528 -8
- package/test/monitoring.test.ts +2 -2
- package/test/permission-check-sql.test.ts +116 -0
- package/test/schema-validation.test.ts +81 -0
- package/test/test-utils.ts +51 -2
- package/test/upgrade.test.ts +422 -0
package/README.md
CHANGED
|
@@ -173,7 +173,7 @@ postgresai mon local-install --api-key your_key --db-url postgresql://user:pass@
|
|
|
173
173
|
This will:
|
|
174
174
|
- Configure API key for automated report uploads (if provided)
|
|
175
175
|
- Add PostgreSQL instance to monitor (if provided)
|
|
176
|
-
- Generate secure Grafana
|
|
176
|
+
- Generate secure Grafana and replication passwords
|
|
177
177
|
- Start all monitoring services
|
|
178
178
|
- Open Grafana at http://localhost:3000
|
|
179
179
|
|
|
@@ -205,6 +205,8 @@ postgresai mon health [--wait <sec>] # Check monitoring services health
|
|
|
205
205
|
- `--db-url <url>` - PostgreSQL connection URL to monitor (format: `postgresql://user:pass@host:port/db`)
|
|
206
206
|
- `-y, --yes` - Accept all defaults and skip interactive prompts
|
|
207
207
|
|
|
208
|
+
`local-install` writes `.env` in the monitoring directory. It preserves existing `REPLICATOR_PASSWORD` and `VM_AUTH_*` values or generates new random ones when missing; `VM_AUTH_USERNAME` defaults to `vmauth` when absent. The replication password is used by the demo PostgreSQL standby replication user, and the VM auth credentials are required before Docker Compose can provision Grafana datasources. If you run `docker compose` directly or maintain `.env` yourself, set both VM auth values before upgrading. For rotation, run `VM_AUTH_PASSWORD="$(openssl rand -base64 18)" ./scripts/rotate-vm-auth.sh` from the monitoring directory so `.env`, `sink-prometheus`, and `grafana` update together.
|
|
209
|
+
|
|
208
210
|
#### Monitoring target databases (`mon targets` subgroup)
|
|
209
211
|
```bash
|
|
210
212
|
postgresai mon targets list # List databases to monitor
|
package/bin/postgres-ai.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, create
|
|
|
15
15
|
import { fetchReports, fetchAllReports, fetchReportFiles, fetchReportFileData, renderMarkdownForTerminal, parseFlexibleDate } from "../lib/reports";
|
|
16
16
|
import { resolveBaseUrls } from "../lib/util";
|
|
17
17
|
import { uploadFile, downloadFile, buildMarkdownLink } from "../lib/storage";
|
|
18
|
-
import { applyInitPlan, applyUninitPlan, buildInitPlan, buildUninitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, KNOWN_PROVIDERS, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, validateProvider, verifyInitSetup } from "../lib/init";
|
|
18
|
+
import { applyInitPlan, applyUninitPlan, buildInitPlan, buildUninitPlan, checkCurrentUserPermissions, connectWithSslFallback, DEFAULT_MONITORING_USER, formatPermissionCheckMessages, KNOWN_PROVIDERS, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, validateProvider, verifyInitSetup } from "../lib/init";
|
|
19
19
|
import { SupabaseClient, resolveSupabaseConfig, extractProjectRefFromUrl, applyInitPlanViaSupabase, verifyInitSetupViaSupabase, fetchPoolerDatabaseUrl, type PgCompatibleError } from "../lib/supabase";
|
|
20
20
|
import * as pkce from "../lib/pkce";
|
|
21
21
|
import * as authServer from "../lib/auth-server";
|
|
@@ -54,21 +54,16 @@ function closeReadline() {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
reject(err);
|
|
65
|
-
} else {
|
|
66
|
-
resolve({ stdout, stderr });
|
|
67
|
-
}
|
|
68
|
-
});
|
|
69
|
-
});
|
|
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;
|
|
70
64
|
}
|
|
71
65
|
|
|
66
|
+
// Helper functions for spawning processes - use Node.js child_process for compatibility
|
|
72
67
|
async function execFilePromise(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
|
73
68
|
return new Promise((resolve, reject) => {
|
|
74
69
|
childProcess.execFile(file, args, (error, stdout, stderr) => {
|
|
@@ -506,7 +501,7 @@ async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
|
|
|
506
501
|
|
|
507
502
|
// Ensure instances.yml exists as a FILE (avoid Docker creating a directory).
|
|
508
503
|
// Docker bind-mounts create missing paths as directories; replace if so.
|
|
509
|
-
if (fs.existsSync(instancesFile) && fs.
|
|
504
|
+
if (fs.existsSync(instancesFile) && fs.lstatSync(instancesFile).isDirectory()) {
|
|
510
505
|
fs.rmSync(instancesFile, { recursive: true, force: true });
|
|
511
506
|
}
|
|
512
507
|
if (!fs.existsSync(instancesFile)) {
|
|
@@ -1831,6 +1826,24 @@ program
|
|
|
1831
1826
|
const connResult = await connectWithSslFallback(Client, adminConn);
|
|
1832
1827
|
client = connResult.client as Client;
|
|
1833
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
|
+
|
|
1834
1847
|
// Generate reports
|
|
1835
1848
|
let reports: Record<string, any>;
|
|
1836
1849
|
if (checkId === "ALL") {
|
|
@@ -2250,11 +2263,11 @@ async function runCompose(args: string[], grafanaPassword?: string): Promise<num
|
|
|
2250
2263
|
const envContent = fs.readFileSync(envFilePath, "utf8");
|
|
2251
2264
|
if (!env.VM_AUTH_USERNAME) {
|
|
2252
2265
|
const m = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
|
|
2253
|
-
if (m) env.VM_AUTH_USERNAME = m[1]
|
|
2266
|
+
if (m) env.VM_AUTH_USERNAME = stripMatchingQuotes(m[1]);
|
|
2254
2267
|
}
|
|
2255
2268
|
if (!env.VM_AUTH_PASSWORD) {
|
|
2256
2269
|
const m = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
|
|
2257
|
-
if (m) env.VM_AUTH_PASSWORD = m[1]
|
|
2270
|
+
if (m) env.VM_AUTH_PASSWORD = stripMatchingQuotes(m[1]);
|
|
2258
2271
|
}
|
|
2259
2272
|
} catch (err) {
|
|
2260
2273
|
if (process.env.DEBUG) {
|
|
@@ -2320,10 +2333,13 @@ mon
|
|
|
2320
2333
|
// Update .env with custom tag if provided
|
|
2321
2334
|
const envFile = path.resolve(projectDir, ".env");
|
|
2322
2335
|
|
|
2323
|
-
// Build .env content, preserving important existing values
|
|
2324
|
-
// 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.
|
|
2325
2338
|
let existingRegistry: string | null = null;
|
|
2326
2339
|
let existingPassword: string | null = null;
|
|
2340
|
+
let existingReplicatorPassword: string | null = null;
|
|
2341
|
+
let existingVmAuthUsername: string | null = null;
|
|
2342
|
+
let existingVmAuthPassword: string | null = null;
|
|
2327
2343
|
|
|
2328
2344
|
if (fs.existsSync(envFile)) {
|
|
2329
2345
|
const existingEnv = fs.readFileSync(envFile, "utf8");
|
|
@@ -2332,6 +2348,12 @@ mon
|
|
|
2332
2348
|
if (registryMatch) existingRegistry = registryMatch[1].trim();
|
|
2333
2349
|
const pwdMatch = existingEnv.match(/^GF_SECURITY_ADMIN_PASSWORD=(.+)$/m);
|
|
2334
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]);
|
|
2335
2357
|
}
|
|
2336
2358
|
|
|
2337
2359
|
// Priority: CLI --tag flag > package version
|
|
@@ -2347,6 +2369,11 @@ mon
|
|
|
2347
2369
|
if (existingPassword) {
|
|
2348
2370
|
envLines.push(`GF_SECURITY_ADMIN_PASSWORD=${existingPassword}`);
|
|
2349
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")}`);
|
|
2350
2377
|
fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
|
|
2351
2378
|
|
|
2352
2379
|
if (opts.tag) {
|
|
@@ -2466,16 +2493,20 @@ mon
|
|
|
2466
2493
|
|
|
2467
2494
|
// Test connection
|
|
2468
2495
|
console.log("Testing connection to the added instance...");
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
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
|
+
}
|
|
2479
2510
|
}
|
|
2480
2511
|
} else if (opts.yes) {
|
|
2481
2512
|
// Auto-yes mode without database URL - skip database setup
|
|
@@ -2510,16 +2541,20 @@ mon
|
|
|
2510
2541
|
|
|
2511
2542
|
// Test connection
|
|
2512
2543
|
console.log("Testing connection to the added instance...");
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
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
|
+
}
|
|
2523
2558
|
}
|
|
2524
2559
|
}
|
|
2525
2560
|
} else {
|
|
@@ -2530,10 +2565,21 @@ mon
|
|
|
2530
2565
|
}
|
|
2531
2566
|
}
|
|
2532
2567
|
} else {
|
|
2533
|
-
// Demo mode:
|
|
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
|
|
2534
2582
|
console.log("Step 2: Demo mode enabled - using included demo PostgreSQL database");
|
|
2535
|
-
// Use import.meta.url instead of __dirname — bundlers bake in __dirname at build time.
|
|
2536
|
-
// Check multiple candidate paths (npm package vs repo dev layout).
|
|
2537
2583
|
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
2538
2584
|
const demoCandidates = [
|
|
2539
2585
|
path.resolve(currentDir, "..", "..", "instances.demo.yml"), // npm: dist/bin -> package root
|
|
@@ -2541,14 +2587,15 @@ mon
|
|
|
2541
2587
|
];
|
|
2542
2588
|
const demoSrc = demoCandidates.find(p => fs.existsSync(p));
|
|
2543
2589
|
if (demoSrc) {
|
|
2544
|
-
// Remove directory artifact left by Docker bind-mounts
|
|
2545
|
-
if (fs.existsSync(instancesPath) && fs.
|
|
2590
|
+
// Remove directory artifact left by Docker bind-mounts before copying
|
|
2591
|
+
if (fs.existsSync(instancesPath) && fs.lstatSync(instancesPath).isDirectory()) {
|
|
2546
2592
|
fs.rmSync(instancesPath, { recursive: true, force: true });
|
|
2547
2593
|
}
|
|
2548
2594
|
fs.copyFileSync(demoSrc, instancesPath);
|
|
2549
2595
|
console.log("✓ Demo monitoring target configured\n");
|
|
2550
2596
|
} else {
|
|
2551
|
-
console.error(
|
|
2597
|
+
console.error(`Error: instances.demo.yml not found — cannot configure demo target.\nSearched: ${demoCandidates.join(", ")}\n`);
|
|
2598
|
+
process.exit(1);
|
|
2552
2599
|
}
|
|
2553
2600
|
}
|
|
2554
2601
|
|
|
@@ -2582,8 +2629,8 @@ mon
|
|
|
2582
2629
|
|
|
2583
2630
|
if (!grafanaPassword) {
|
|
2584
2631
|
console.log("Generating secure Grafana password...");
|
|
2585
|
-
const { stdout: password } = await
|
|
2586
|
-
grafanaPassword = password.trim();
|
|
2632
|
+
const { stdout: password } = await execFilePromise("openssl", ["rand", "-base64", "12"]);
|
|
2633
|
+
grafanaPassword = password.trim().replace(/\n/g, "");
|
|
2587
2634
|
|
|
2588
2635
|
let configContent = "";
|
|
2589
2636
|
if (fs.existsSync(cfgPath)) {
|
|
@@ -2614,16 +2661,16 @@ mon
|
|
|
2614
2661
|
const envContent = fs.readFileSync(envFile, "utf8");
|
|
2615
2662
|
const userMatch = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
|
|
2616
2663
|
const passMatch = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
|
|
2617
|
-
if (userMatch) vmAuthUsername = userMatch[1]
|
|
2618
|
-
if (passMatch) vmAuthPassword = passMatch[1]
|
|
2664
|
+
if (userMatch) vmAuthUsername = stripMatchingQuotes(userMatch[1]);
|
|
2665
|
+
if (passMatch) vmAuthPassword = stripMatchingQuotes(passMatch[1]);
|
|
2619
2666
|
}
|
|
2620
2667
|
|
|
2621
2668
|
if (!vmAuthUsername || !vmAuthPassword) {
|
|
2622
2669
|
console.log("Generating VictoriaMetrics auth credentials...");
|
|
2623
2670
|
vmAuthUsername = vmAuthUsername || "vmauth";
|
|
2624
2671
|
if (!vmAuthPassword) {
|
|
2625
|
-
const { stdout: vmPass } = await
|
|
2626
|
-
vmAuthPassword = vmPass.trim();
|
|
2672
|
+
const { stdout: vmPass } = await execFilePromise("openssl", ["rand", "-base64", "12"]);
|
|
2673
|
+
vmAuthPassword = vmPass.trim().replace(/\n/g, "");
|
|
2627
2674
|
}
|
|
2628
2675
|
|
|
2629
2676
|
// Update .env file with VM auth credentials
|
|
@@ -2904,7 +2951,7 @@ mon
|
|
|
2904
2951
|
console.log(`Project Directory: ${projectDir}`);
|
|
2905
2952
|
console.log(`Docker Compose File: ${composeFile}`);
|
|
2906
2953
|
console.log(`Instances File: ${instancesFile}`);
|
|
2907
|
-
if (fs.existsSync(instancesFile) && !fs.
|
|
2954
|
+
if (fs.existsSync(instancesFile) && !fs.lstatSync(instancesFile).isDirectory()) {
|
|
2908
2955
|
console.log("\nInstances configuration:\n");
|
|
2909
2956
|
const text = fs.readFileSync(instancesFile, "utf8");
|
|
2910
2957
|
process.stdout.write(text);
|
|
@@ -2935,16 +2982,16 @@ mon
|
|
|
2935
2982
|
|
|
2936
2983
|
// Fetch latest changes
|
|
2937
2984
|
console.log("Fetching latest changes...");
|
|
2938
|
-
await
|
|
2985
|
+
await execFilePromise("git", ["fetch", "origin"]);
|
|
2939
2986
|
|
|
2940
2987
|
// Check current branch
|
|
2941
|
-
const { stdout: branch } = await
|
|
2988
|
+
const { stdout: branch } = await execFilePromise("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
2942
2989
|
const currentBranch = branch.trim();
|
|
2943
2990
|
console.log(`Current branch: ${currentBranch}`);
|
|
2944
2991
|
|
|
2945
2992
|
// Pull latest changes
|
|
2946
2993
|
console.log("Pulling latest changes...");
|
|
2947
|
-
const { stdout: pullOut } = await
|
|
2994
|
+
const { stdout: pullOut } = await execFilePromise("git", ["pull", "origin", currentBranch]);
|
|
2948
2995
|
console.log(pullOut);
|
|
2949
2996
|
|
|
2950
2997
|
// Update Docker images
|
|
@@ -3120,7 +3167,7 @@ targets
|
|
|
3120
3167
|
.description("list monitoring target databases")
|
|
3121
3168
|
.action(async () => {
|
|
3122
3169
|
const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
|
|
3123
|
-
if (!fs.existsSync(instancesPath) || fs.
|
|
3170
|
+
if (!fs.existsSync(instancesPath) || fs.lstatSync(instancesPath).isDirectory()) {
|
|
3124
3171
|
console.error(`instances.yml not found in ${projectDir}`);
|
|
3125
3172
|
process.exitCode = 1;
|
|
3126
3173
|
return;
|
|
@@ -3186,7 +3233,7 @@ targets
|
|
|
3186
3233
|
|
|
3187
3234
|
// Check if instance already exists
|
|
3188
3235
|
try {
|
|
3189
|
-
if (fs.existsSync(file) && !fs.
|
|
3236
|
+
if (fs.existsSync(file) && !fs.lstatSync(file).isDirectory()) {
|
|
3190
3237
|
const content = fs.readFileSync(file, "utf8");
|
|
3191
3238
|
const instances = yaml.load(content) as Instance[] | null || [];
|
|
3192
3239
|
if (Array.isArray(instances)) {
|
|
@@ -3200,9 +3247,10 @@ targets
|
|
|
3200
3247
|
}
|
|
3201
3248
|
} catch (err) {
|
|
3202
3249
|
// If YAML parsing fails, fall back to simple check
|
|
3203
|
-
const isFile = fs.existsSync(file) && !fs.
|
|
3250
|
+
const isFile = fs.existsSync(file) && !fs.lstatSync(file).isDirectory();
|
|
3204
3251
|
const content = isFile ? fs.readFileSync(file, "utf8") : "";
|
|
3205
|
-
|
|
3252
|
+
const escapedName = instanceName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3253
|
+
if (new RegExp(`^- name: ${escapedName}$`, "m").test(content)) {
|
|
3206
3254
|
console.error(`Monitoring target '${instanceName}' already exists`);
|
|
3207
3255
|
process.exitCode = 1;
|
|
3208
3256
|
return;
|
|
@@ -3210,7 +3258,7 @@ targets
|
|
|
3210
3258
|
}
|
|
3211
3259
|
|
|
3212
3260
|
// Add new instance — if instances.yml is a directory (Docker artifact), replace it with a file
|
|
3213
|
-
if (fs.existsSync(file) && fs.
|
|
3261
|
+
if (fs.existsSync(file) && fs.lstatSync(file).isDirectory()) {
|
|
3214
3262
|
fs.rmSync(file, { recursive: true, force: true });
|
|
3215
3263
|
}
|
|
3216
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`;
|
|
@@ -3223,7 +3271,7 @@ targets
|
|
|
3223
3271
|
.description("remove monitoring target database")
|
|
3224
3272
|
.action(async (name: string) => {
|
|
3225
3273
|
const { instancesFile: file } = await resolveOrInitPaths();
|
|
3226
|
-
if (!fs.existsSync(file) || fs.
|
|
3274
|
+
if (!fs.existsSync(file) || fs.lstatSync(file).isDirectory()) {
|
|
3227
3275
|
console.error("instances.yml not found");
|
|
3228
3276
|
process.exitCode = 1;
|
|
3229
3277
|
return;
|
|
@@ -3260,7 +3308,7 @@ targets
|
|
|
3260
3308
|
.description("test monitoring target database connectivity")
|
|
3261
3309
|
.action(async (name: string) => {
|
|
3262
3310
|
const { instancesFile: instancesPath } = await resolveOrInitPaths();
|
|
3263
|
-
if (!fs.existsSync(instancesPath) || fs.
|
|
3311
|
+
if (!fs.existsSync(instancesPath) || fs.lstatSync(instancesPath).isDirectory()) {
|
|
3264
3312
|
console.error("instances.yml not found");
|
|
3265
3313
|
process.exitCode = 1;
|
|
3266
3314
|
return;
|
|
@@ -3293,7 +3341,7 @@ targets
|
|
|
3293
3341
|
console.log(`Testing connection to monitoring target '${name}'...`);
|
|
3294
3342
|
|
|
3295
3343
|
// Use native pg client instead of requiring psql to be installed
|
|
3296
|
-
const client = new Client({ connectionString: instance.conn_str });
|
|
3344
|
+
const client = new Client({ connectionString: instance.conn_str, connectionTimeoutMillis: 10000 });
|
|
3297
3345
|
|
|
3298
3346
|
try {
|
|
3299
3347
|
await client.connect();
|
|
@@ -3646,10 +3694,10 @@ mon
|
|
|
3646
3694
|
|
|
3647
3695
|
try {
|
|
3648
3696
|
// Generate secure password using openssl
|
|
3649
|
-
const { stdout: password } = await
|
|
3650
|
-
"openssl rand -base64 12
|
|
3697
|
+
const { stdout: password } = await execFilePromise(
|
|
3698
|
+
"openssl", ["rand", "-base64", "12"]
|
|
3651
3699
|
);
|
|
3652
|
-
const newPassword = password.trim();
|
|
3700
|
+
const newPassword = password.trim().replace(/\n/g, "");
|
|
3653
3701
|
|
|
3654
3702
|
if (!newPassword) {
|
|
3655
3703
|
console.error("Failed to generate password");
|
|
@@ -3736,8 +3784,8 @@ mon
|
|
|
3736
3784
|
const vmPass = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
|
|
3737
3785
|
if (vmUser && vmPass) {
|
|
3738
3786
|
console.log("\nVictoriaMetrics credentials:");
|
|
3739
|
-
console.log(` Username: ${vmUser[1]
|
|
3740
|
-
console.log(` Password: ${vmPass[1]
|
|
3787
|
+
console.log(` Username: ${stripMatchingQuotes(vmUser[1])}`);
|
|
3788
|
+
console.log(` Password: ${stripMatchingQuotes(vmPass[1])}`);
|
|
3741
3789
|
}
|
|
3742
3790
|
}
|
|
3743
3791
|
console.log("");
|
|
@@ -4700,7 +4748,7 @@ mcp
|
|
|
4700
4748
|
// Get the path to the current pgai executable
|
|
4701
4749
|
let pgaiPath: string;
|
|
4702
4750
|
try {
|
|
4703
|
-
const execPath = await
|
|
4751
|
+
const execPath = await execFilePromise("which", ["pgai"]);
|
|
4704
4752
|
pgaiPath = execPath.stdout.trim();
|
|
4705
4753
|
} catch {
|
|
4706
4754
|
// Fallback to just "pgai" if which fails
|
|
@@ -4712,8 +4760,8 @@ mcp
|
|
|
4712
4760
|
console.log("Installing PostgresAI MCP server for Claude Code...");
|
|
4713
4761
|
|
|
4714
4762
|
try {
|
|
4715
|
-
const { stdout, stderr } = await
|
|
4716
|
-
|
|
4763
|
+
const { stdout, stderr } = await execFilePromise(
|
|
4764
|
+
"claude", ["mcp", "add", "-s", "user", "postgresai", pgaiPath, "mcp", "start"]
|
|
4717
4765
|
);
|
|
4718
4766
|
|
|
4719
4767
|
if (stdout) console.log(stdout);
|
package/bun.lock
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"name": "postgresai",
|
|
7
7
|
"dependencies": {
|
|
8
8
|
"@modelcontextprotocol/sdk": "^1.20.2",
|
|
9
|
-
"commander": "^
|
|
9
|
+
"commander": "^14.0.3",
|
|
10
10
|
"js-yaml": "^4.1.0",
|
|
11
11
|
"pg": "^8.16.3",
|
|
12
12
|
},
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"@types/pg": "^8.15.6",
|
|
17
17
|
"ajv": "^8.17.1",
|
|
18
18
|
"ajv-formats": "^3.0.1",
|
|
19
|
-
"typescript": "^
|
|
19
|
+
"typescript": "^6.0.2",
|
|
20
20
|
},
|
|
21
21
|
},
|
|
22
22
|
},
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
|
|
52
52
|
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
|
53
53
|
|
|
54
|
-
"commander": ["commander@
|
|
54
|
+
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
|
55
55
|
|
|
56
56
|
"content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="],
|
|
57
57
|
|
|
@@ -233,7 +233,7 @@
|
|
|
233
233
|
|
|
234
234
|
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
|
235
235
|
|
|
236
|
-
"typescript": ["typescript@
|
|
236
|
+
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
|
237
237
|
|
|
238
238
|
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
|
239
239
|
|