postgresai 0.11.0-alpha.1 → 0.11.0-alpha.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 +154 -17
- package/bin/postgres-ai.ts +1006 -0
- package/dist/bin/postgres-ai.d.ts +3 -0
- package/dist/bin/postgres-ai.d.ts.map +1 -0
- package/dist/bin/postgres-ai.js +893 -0
- package/dist/bin/postgres-ai.js.map +1 -0
- package/dist/lib/auth-server.d.ts +31 -0
- package/dist/lib/auth-server.d.ts.map +1 -0
- package/dist/lib/auth-server.js +263 -0
- package/dist/lib/auth-server.js.map +1 -0
- package/dist/lib/config.d.ts +45 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +181 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/pkce.d.ts +32 -0
- package/dist/lib/pkce.d.ts.map +1 -0
- package/dist/lib/pkce.js +101 -0
- package/dist/lib/pkce.js.map +1 -0
- package/dist/package.json +42 -0
- package/lib/auth-server.ts +267 -0
- package/lib/config.ts +161 -0
- package/lib/pkce.ts +79 -0
- package/package.json +17 -7
- package/tsconfig.json +28 -0
- package/bin/postgres-ai.js +0 -360
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import * as pkg from "../package.json";
|
|
5
|
+
import * as config from "../lib/config";
|
|
6
|
+
import * as yaml from "js-yaml";
|
|
7
|
+
import * as fs from "fs";
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
import * as os from "os";
|
|
10
|
+
import { spawn, spawnSync, exec, execFile } from "child_process";
|
|
11
|
+
import { promisify } from "util";
|
|
12
|
+
import * as readline from "readline";
|
|
13
|
+
import * as http from "https";
|
|
14
|
+
import { URL } from "url";
|
|
15
|
+
|
|
16
|
+
const execPromise = promisify(exec);
|
|
17
|
+
const execFilePromise = promisify(execFile);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* CLI configuration options
|
|
21
|
+
*/
|
|
22
|
+
interface CliOptions {
|
|
23
|
+
apiKey?: string;
|
|
24
|
+
apiBaseUrl?: string;
|
|
25
|
+
uiBaseUrl?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Configuration result
|
|
30
|
+
*/
|
|
31
|
+
interface ConfigResult {
|
|
32
|
+
apiKey: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Instance configuration
|
|
37
|
+
*/
|
|
38
|
+
interface Instance {
|
|
39
|
+
name: string;
|
|
40
|
+
conn_str?: string;
|
|
41
|
+
preset_metrics?: string;
|
|
42
|
+
custom_metrics?: any;
|
|
43
|
+
is_enabled?: boolean;
|
|
44
|
+
group?: string;
|
|
45
|
+
custom_tags?: Record<string, any>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Path resolution result
|
|
50
|
+
*/
|
|
51
|
+
interface PathResolution {
|
|
52
|
+
fs: typeof fs;
|
|
53
|
+
path: typeof path;
|
|
54
|
+
projectDir: string;
|
|
55
|
+
composeFile: string;
|
|
56
|
+
instancesFile: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Health check service
|
|
61
|
+
*/
|
|
62
|
+
interface HealthService {
|
|
63
|
+
name: string;
|
|
64
|
+
url: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get configuration from various sources
|
|
69
|
+
* @param opts - Command line options
|
|
70
|
+
* @returns Configuration object
|
|
71
|
+
*/
|
|
72
|
+
function getConfig(opts: CliOptions): ConfigResult {
|
|
73
|
+
// Priority order:
|
|
74
|
+
// 1. Command line option (--api-key)
|
|
75
|
+
// 2. Environment variable (PGAI_API_KEY)
|
|
76
|
+
// 3. User-level config file (~/.config/postgresai/config.json)
|
|
77
|
+
// 4. Legacy project-local config (.pgwatch-config)
|
|
78
|
+
|
|
79
|
+
let apiKey = opts.apiKey || process.env.PGAI_API_KEY || "";
|
|
80
|
+
|
|
81
|
+
// Try config file if not provided via CLI or env
|
|
82
|
+
if (!apiKey) {
|
|
83
|
+
const fileConfig = config.readConfig();
|
|
84
|
+
if (!apiKey) apiKey = fileConfig.apiKey || "";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { apiKey };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const program = new Command();
|
|
91
|
+
|
|
92
|
+
program
|
|
93
|
+
.name("postgres-ai")
|
|
94
|
+
.description("PostgresAI CLI")
|
|
95
|
+
.version(pkg.version)
|
|
96
|
+
.option("--api-key <key>", "API key (overrides PGAI_API_KEY)")
|
|
97
|
+
.option(
|
|
98
|
+
"--api-base-url <url>",
|
|
99
|
+
"API base URL for backend RPC (overrides PGAI_API_BASE_URL)"
|
|
100
|
+
)
|
|
101
|
+
.option(
|
|
102
|
+
"--ui-base-url <url>",
|
|
103
|
+
"UI base URL for browser routes (overrides PGAI_UI_BASE_URL)"
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Stub function for not implemented commands
|
|
108
|
+
*/
|
|
109
|
+
const stub = (name: string) => async (): Promise<void> => {
|
|
110
|
+
// Temporary stubs until Node parity is implemented
|
|
111
|
+
console.error(`${name}: not implemented in Node CLI yet; use bash CLI for now`);
|
|
112
|
+
process.exitCode = 2;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve project paths
|
|
117
|
+
*/
|
|
118
|
+
function resolvePaths(): PathResolution {
|
|
119
|
+
const projectDir = process.cwd();
|
|
120
|
+
const composeFile = path.resolve(projectDir, "docker-compose.yml");
|
|
121
|
+
const instancesFile = path.resolve(projectDir, "instances.yml");
|
|
122
|
+
return { fs, path, projectDir, composeFile, instancesFile };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get docker compose command
|
|
127
|
+
*/
|
|
128
|
+
function getComposeCmd(): string[] | null {
|
|
129
|
+
const tryCmd = (cmd: string, args: string[]): boolean =>
|
|
130
|
+
spawnSync(cmd, args, { stdio: "ignore" }).status === 0;
|
|
131
|
+
if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
|
|
132
|
+
if (tryCmd("docker", ["compose", "version"])) return ["docker", "compose"];
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Run docker compose command
|
|
138
|
+
*/
|
|
139
|
+
async function runCompose(args: string[]): Promise<number> {
|
|
140
|
+
const { composeFile } = resolvePaths();
|
|
141
|
+
const cmd = getComposeCmd();
|
|
142
|
+
if (!cmd) {
|
|
143
|
+
console.error("docker compose not found (need docker-compose or docker compose)");
|
|
144
|
+
process.exitCode = 1;
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
return new Promise<number>((resolve) => {
|
|
148
|
+
const child = spawn(cmd[0], [...cmd.slice(1), "-f", composeFile, ...args], { stdio: "inherit" });
|
|
149
|
+
child.on("close", (code) => resolve(code || 0));
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
program.command("help", { isDefault: true }).description("show help").action(() => {
|
|
154
|
+
program.outputHelp();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Monitoring services management
|
|
158
|
+
const mon = program.command("mon").description("monitoring services management");
|
|
159
|
+
|
|
160
|
+
mon
|
|
161
|
+
.command("quickstart")
|
|
162
|
+
.description("complete setup (generate config, start monitoring services)")
|
|
163
|
+
.option("--demo", "demo mode", false)
|
|
164
|
+
.action(async () => {
|
|
165
|
+
const code1 = await runCompose(["run", "--rm", "sources-generator"]);
|
|
166
|
+
if (code1 !== 0) {
|
|
167
|
+
process.exitCode = code1;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const code2 = await runCompose(["up", "-d"]);
|
|
171
|
+
if (code2 !== 0) process.exitCode = code2;
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
mon
|
|
175
|
+
.command("start")
|
|
176
|
+
.description("start monitoring services")
|
|
177
|
+
.action(async () => {
|
|
178
|
+
const code = await runCompose(["up", "-d"]);
|
|
179
|
+
if (code !== 0) process.exitCode = code;
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
mon
|
|
183
|
+
.command("stop")
|
|
184
|
+
.description("stop monitoring services")
|
|
185
|
+
.action(async () => {
|
|
186
|
+
const code = await runCompose(["down"]);
|
|
187
|
+
if (code !== 0) process.exitCode = code;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
mon
|
|
191
|
+
.command("restart [service]")
|
|
192
|
+
.description("restart all monitoring services or specific service")
|
|
193
|
+
.action(async (service?: string) => {
|
|
194
|
+
const args = ["restart"];
|
|
195
|
+
if (service) args.push(service);
|
|
196
|
+
const code = await runCompose(args);
|
|
197
|
+
if (code !== 0) process.exitCode = code;
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
mon
|
|
201
|
+
.command("status")
|
|
202
|
+
.description("show monitoring services status")
|
|
203
|
+
.action(async () => {
|
|
204
|
+
const code = await runCompose(["ps"]);
|
|
205
|
+
if (code !== 0) process.exitCode = code;
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
mon
|
|
209
|
+
.command("logs [service]")
|
|
210
|
+
.option("-f, --follow", "follow logs", false)
|
|
211
|
+
.option("--tail <lines>", "number of lines to show from the end of logs", "all")
|
|
212
|
+
.description("show logs for all or specific monitoring service")
|
|
213
|
+
.action(async (service: string | undefined, opts: { follow: boolean; tail: string }) => {
|
|
214
|
+
const args: string[] = ["logs"];
|
|
215
|
+
if (opts.follow) args.push("-f");
|
|
216
|
+
if (opts.tail) args.push("--tail", opts.tail);
|
|
217
|
+
if (service) args.push(service);
|
|
218
|
+
const code = await runCompose(args);
|
|
219
|
+
if (code !== 0) process.exitCode = code;
|
|
220
|
+
});
|
|
221
|
+
mon
|
|
222
|
+
.command("health")
|
|
223
|
+
.description("health check for monitoring services")
|
|
224
|
+
.option("--wait <seconds>", "wait time in seconds for services to become healthy", parseInt, 0)
|
|
225
|
+
.action(async (opts: { wait: number }) => {
|
|
226
|
+
const services: HealthService[] = [
|
|
227
|
+
{ name: "Grafana", url: "http://localhost:3000/api/health" },
|
|
228
|
+
{ name: "Prometheus", url: "http://localhost:59090/-/healthy" },
|
|
229
|
+
{ name: "PGWatch (Postgres)", url: "http://localhost:58080/health" },
|
|
230
|
+
{ name: "PGWatch (Prometheus)", url: "http://localhost:58089/health" },
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
const waitTime = opts.wait || 0;
|
|
234
|
+
const maxAttempts = waitTime > 0 ? Math.ceil(waitTime / 5) : 1;
|
|
235
|
+
|
|
236
|
+
console.log("Checking service health...\n");
|
|
237
|
+
|
|
238
|
+
let allHealthy = false;
|
|
239
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
240
|
+
if (attempt > 1) {
|
|
241
|
+
console.log(`Retrying (attempt ${attempt}/${maxAttempts})...\n`);
|
|
242
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
allHealthy = true;
|
|
246
|
+
for (const service of services) {
|
|
247
|
+
try {
|
|
248
|
+
const { stdout } = await execPromise(
|
|
249
|
+
`curl -sf -o /dev/null -w "%{http_code}" ${service.url}`,
|
|
250
|
+
{ timeout: 5000 }
|
|
251
|
+
);
|
|
252
|
+
const code = stdout.trim();
|
|
253
|
+
if (code === "200") {
|
|
254
|
+
console.log(`✓ ${service.name}: healthy`);
|
|
255
|
+
} else {
|
|
256
|
+
console.log(`✗ ${service.name}: unhealthy (HTTP ${code})`);
|
|
257
|
+
allHealthy = false;
|
|
258
|
+
}
|
|
259
|
+
} catch (error) {
|
|
260
|
+
console.log(`✗ ${service.name}: unreachable`);
|
|
261
|
+
allHealthy = false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (allHealthy) {
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
console.log("");
|
|
271
|
+
if (allHealthy) {
|
|
272
|
+
console.log("All services are healthy");
|
|
273
|
+
} else {
|
|
274
|
+
console.log("Some services are unhealthy");
|
|
275
|
+
process.exitCode = 1;
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
mon
|
|
279
|
+
.command("config")
|
|
280
|
+
.description("show monitoring services configuration")
|
|
281
|
+
.action(async () => {
|
|
282
|
+
const { fs, projectDir, composeFile, instancesFile } = resolvePaths();
|
|
283
|
+
console.log(`Project Directory: ${projectDir}`);
|
|
284
|
+
console.log(`Docker Compose File: ${composeFile}`);
|
|
285
|
+
console.log(`Instances File: ${instancesFile}`);
|
|
286
|
+
if (fs.existsSync(instancesFile)) {
|
|
287
|
+
console.log("\nInstances configuration:\n");
|
|
288
|
+
const text = fs.readFileSync(instancesFile, "utf8");
|
|
289
|
+
process.stdout.write(text);
|
|
290
|
+
if (!/\n$/.test(text)) console.log();
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
mon
|
|
294
|
+
.command("update-config")
|
|
295
|
+
.description("apply monitoring services configuration (generate sources)")
|
|
296
|
+
.action(async () => {
|
|
297
|
+
const code = await runCompose(["run", "--rm", "sources-generator"]);
|
|
298
|
+
if (code !== 0) process.exitCode = code;
|
|
299
|
+
});
|
|
300
|
+
mon
|
|
301
|
+
.command("update")
|
|
302
|
+
.description("update monitoring stack")
|
|
303
|
+
.action(async () => {
|
|
304
|
+
console.log("Updating PostgresAI monitoring stack...\n");
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
// Check if we're in a git repo
|
|
308
|
+
const gitDir = path.resolve(process.cwd(), ".git");
|
|
309
|
+
if (!fs.existsSync(gitDir)) {
|
|
310
|
+
console.error("Not a git repository. Cannot update.");
|
|
311
|
+
process.exitCode = 1;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Fetch latest changes
|
|
316
|
+
console.log("Fetching latest changes...");
|
|
317
|
+
await execPromise("git fetch origin");
|
|
318
|
+
|
|
319
|
+
// Check current branch
|
|
320
|
+
const { stdout: branch } = await execPromise("git rev-parse --abbrev-ref HEAD");
|
|
321
|
+
const currentBranch = branch.trim();
|
|
322
|
+
console.log(`Current branch: ${currentBranch}`);
|
|
323
|
+
|
|
324
|
+
// Pull latest changes
|
|
325
|
+
console.log("Pulling latest changes...");
|
|
326
|
+
const { stdout: pullOut } = await execPromise("git pull origin " + currentBranch);
|
|
327
|
+
console.log(pullOut);
|
|
328
|
+
|
|
329
|
+
// Update Docker images
|
|
330
|
+
console.log("\nUpdating Docker images...");
|
|
331
|
+
const code = await runCompose(["pull"]);
|
|
332
|
+
|
|
333
|
+
if (code === 0) {
|
|
334
|
+
console.log("\n✓ Update completed successfully");
|
|
335
|
+
console.log("\nTo apply updates, restart monitoring services:");
|
|
336
|
+
console.log(" postgres-ai mon restart");
|
|
337
|
+
} else {
|
|
338
|
+
console.error("\n✗ Docker image update failed");
|
|
339
|
+
process.exitCode = 1;
|
|
340
|
+
}
|
|
341
|
+
} catch (error) {
|
|
342
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
343
|
+
console.error(`Update failed: ${message}`);
|
|
344
|
+
process.exitCode = 1;
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
mon
|
|
348
|
+
.command("reset [service]")
|
|
349
|
+
.description("reset all or specific monitoring service")
|
|
350
|
+
.action(async (service?: string) => {
|
|
351
|
+
const rl = readline.createInterface({
|
|
352
|
+
input: process.stdin,
|
|
353
|
+
output: process.stdout,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const question = (prompt: string): Promise<string> =>
|
|
357
|
+
new Promise((resolve) => rl.question(prompt, resolve));
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
if (service) {
|
|
361
|
+
// Reset specific service
|
|
362
|
+
console.log(`\nThis will stop '${service}', remove its volume, and restart it.`);
|
|
363
|
+
console.log("All data for this service will be lost!\n");
|
|
364
|
+
|
|
365
|
+
const answer = await question("Continue? (y/N): ");
|
|
366
|
+
if (answer.toLowerCase() !== "y") {
|
|
367
|
+
console.log("Cancelled");
|
|
368
|
+
rl.close();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
console.log(`\nStopping ${service}...`);
|
|
373
|
+
await runCompose(["stop", service]);
|
|
374
|
+
|
|
375
|
+
console.log(`Removing volume for ${service}...`);
|
|
376
|
+
await runCompose(["rm", "-f", "-v", service]);
|
|
377
|
+
|
|
378
|
+
console.log(`Restarting ${service}...`);
|
|
379
|
+
const code = await runCompose(["up", "-d", service]);
|
|
380
|
+
|
|
381
|
+
if (code === 0) {
|
|
382
|
+
console.log(`\n✓ Service '${service}' has been reset`);
|
|
383
|
+
} else {
|
|
384
|
+
console.error(`\n✗ Failed to restart '${service}'`);
|
|
385
|
+
process.exitCode = 1;
|
|
386
|
+
}
|
|
387
|
+
} else {
|
|
388
|
+
// Reset all services
|
|
389
|
+
console.log("\nThis will stop all services and remove all data!");
|
|
390
|
+
console.log("Volumes, networks, and containers will be deleted.\n");
|
|
391
|
+
|
|
392
|
+
const answer = await question("Continue? (y/N): ");
|
|
393
|
+
if (answer.toLowerCase() !== "y") {
|
|
394
|
+
console.log("Cancelled");
|
|
395
|
+
rl.close();
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
console.log("\nStopping services and removing data...");
|
|
400
|
+
const downCode = await runCompose(["down", "-v"]);
|
|
401
|
+
|
|
402
|
+
if (downCode === 0) {
|
|
403
|
+
console.log("✓ Environment reset completed - all containers and data removed");
|
|
404
|
+
} else {
|
|
405
|
+
console.error("✗ Reset failed");
|
|
406
|
+
process.exitCode = 1;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
rl.close();
|
|
411
|
+
} catch (error) {
|
|
412
|
+
rl.close();
|
|
413
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
414
|
+
console.error(`Reset failed: ${message}`);
|
|
415
|
+
process.exitCode = 1;
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
mon
|
|
419
|
+
.command("clean")
|
|
420
|
+
.description("cleanup monitoring services artifacts")
|
|
421
|
+
.action(async () => {
|
|
422
|
+
console.log("Cleaning up Docker resources...\n");
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
// Remove stopped containers
|
|
426
|
+
const { stdout: containers } = await execFilePromise("docker", ["ps", "-aq", "--filter", "status=exited"]);
|
|
427
|
+
if (containers.trim()) {
|
|
428
|
+
const containerIds = containers.trim().split('\n');
|
|
429
|
+
await execFilePromise("docker", ["rm", ...containerIds]);
|
|
430
|
+
console.log("✓ Removed stopped containers");
|
|
431
|
+
} else {
|
|
432
|
+
console.log("✓ No stopped containers to remove");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Remove unused volumes
|
|
436
|
+
await execFilePromise("docker", ["volume", "prune", "-f"]);
|
|
437
|
+
console.log("✓ Removed unused volumes");
|
|
438
|
+
|
|
439
|
+
// Remove unused networks
|
|
440
|
+
await execFilePromise("docker", ["network", "prune", "-f"]);
|
|
441
|
+
console.log("✓ Removed unused networks");
|
|
442
|
+
|
|
443
|
+
// Remove dangling images
|
|
444
|
+
await execFilePromise("docker", ["image", "prune", "-f"]);
|
|
445
|
+
console.log("✓ Removed dangling images");
|
|
446
|
+
|
|
447
|
+
console.log("\nCleanup completed");
|
|
448
|
+
} catch (error) {
|
|
449
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
450
|
+
console.error(`Error during cleanup: ${message}`);
|
|
451
|
+
process.exitCode = 1;
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
mon
|
|
455
|
+
.command("shell <service>")
|
|
456
|
+
.description("open shell to monitoring service")
|
|
457
|
+
.action(async (service: string) => {
|
|
458
|
+
const code = await runCompose(["exec", service, "/bin/sh"]);
|
|
459
|
+
if (code !== 0) process.exitCode = code;
|
|
460
|
+
});
|
|
461
|
+
mon
|
|
462
|
+
.command("check")
|
|
463
|
+
.description("monitoring services system readiness check")
|
|
464
|
+
.action(async () => {
|
|
465
|
+
const code = await runCompose(["ps"]);
|
|
466
|
+
if (code !== 0) process.exitCode = code;
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// Monitoring targets (databases to monitor)
|
|
470
|
+
const targets = mon.command("targets").description("manage databases to monitor");
|
|
471
|
+
|
|
472
|
+
targets
|
|
473
|
+
.command("list")
|
|
474
|
+
.description("list monitoring target databases")
|
|
475
|
+
.action(async () => {
|
|
476
|
+
const instancesPath = path.resolve(process.cwd(), "instances.yml");
|
|
477
|
+
if (!fs.existsSync(instancesPath)) {
|
|
478
|
+
console.error(`instances.yml not found in ${process.cwd()}`);
|
|
479
|
+
process.exitCode = 1;
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
const content = fs.readFileSync(instancesPath, "utf8");
|
|
485
|
+
const instances = yaml.load(content) as Instance[] | null;
|
|
486
|
+
|
|
487
|
+
if (!instances || !Array.isArray(instances) || instances.length === 0) {
|
|
488
|
+
console.log("No monitoring targets configured");
|
|
489
|
+
console.log("");
|
|
490
|
+
console.log("To add a monitoring target:");
|
|
491
|
+
console.log(" postgres-ai mon targets add <connection-string> <name>");
|
|
492
|
+
console.log("");
|
|
493
|
+
console.log("Example:");
|
|
494
|
+
console.log(" postgres-ai mon targets add 'postgresql://user:pass@host:5432/db' my-db");
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Filter out demo placeholder
|
|
499
|
+
const filtered = instances.filter((inst) => inst.name && inst.name !== "target-database");
|
|
500
|
+
|
|
501
|
+
if (filtered.length === 0) {
|
|
502
|
+
console.log("No monitoring targets configured");
|
|
503
|
+
console.log("");
|
|
504
|
+
console.log("To add a monitoring target:");
|
|
505
|
+
console.log(" postgres-ai mon targets add <connection-string> <name>");
|
|
506
|
+
console.log("");
|
|
507
|
+
console.log("Example:");
|
|
508
|
+
console.log(" postgres-ai mon targets add 'postgresql://user:pass@host:5432/db' my-db");
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
for (const inst of filtered) {
|
|
513
|
+
console.log(`Target: ${inst.name}`);
|
|
514
|
+
}
|
|
515
|
+
} catch (err) {
|
|
516
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
517
|
+
console.error(`Error parsing instances.yml: ${message}`);
|
|
518
|
+
process.exitCode = 1;
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
targets
|
|
522
|
+
.command("add [connStr] [name]")
|
|
523
|
+
.description("add monitoring target database")
|
|
524
|
+
.action(async (connStr?: string, name?: string) => {
|
|
525
|
+
const file = path.resolve(process.cwd(), "instances.yml");
|
|
526
|
+
if (!connStr) {
|
|
527
|
+
console.error("Connection string required: postgresql://user:pass@host:port/db");
|
|
528
|
+
process.exitCode = 1;
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
|
|
532
|
+
if (!m) {
|
|
533
|
+
console.error("Invalid connection string format");
|
|
534
|
+
process.exitCode = 1;
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
const host = m[3];
|
|
538
|
+
const db = m[5];
|
|
539
|
+
const instanceName = name && name.trim() ? name.trim() : `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
|
|
540
|
+
|
|
541
|
+
// Check if instance already exists
|
|
542
|
+
try {
|
|
543
|
+
if (fs.existsSync(file)) {
|
|
544
|
+
const content = fs.readFileSync(file, "utf8");
|
|
545
|
+
const instances = yaml.load(content) as Instance[] | null || [];
|
|
546
|
+
if (Array.isArray(instances)) {
|
|
547
|
+
const exists = instances.some((inst) => inst.name === instanceName);
|
|
548
|
+
if (exists) {
|
|
549
|
+
console.error(`Monitoring target '${instanceName}' already exists`);
|
|
550
|
+
process.exitCode = 1;
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
} catch (err) {
|
|
556
|
+
// If YAML parsing fails, fall back to simple check
|
|
557
|
+
const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
558
|
+
if (new RegExp(`^- name: ${instanceName}$`, "m").test(content)) {
|
|
559
|
+
console.error(`Monitoring target '${instanceName}' already exists`);
|
|
560
|
+
process.exitCode = 1;
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Add new instance
|
|
566
|
+
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`;
|
|
567
|
+
const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
568
|
+
fs.appendFileSync(file, (content && !/\n$/.test(content) ? "\n" : "") + body, "utf8");
|
|
569
|
+
console.log(`Monitoring target '${instanceName}' added`);
|
|
570
|
+
});
|
|
571
|
+
targets
|
|
572
|
+
.command("remove <name>")
|
|
573
|
+
.description("remove monitoring target database")
|
|
574
|
+
.action(async (name: string) => {
|
|
575
|
+
const file = path.resolve(process.cwd(), "instances.yml");
|
|
576
|
+
if (!fs.existsSync(file)) {
|
|
577
|
+
console.error("instances.yml not found");
|
|
578
|
+
process.exitCode = 1;
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
try {
|
|
583
|
+
const content = fs.readFileSync(file, "utf8");
|
|
584
|
+
const instances = yaml.load(content) as Instance[] | null;
|
|
585
|
+
|
|
586
|
+
if (!instances || !Array.isArray(instances)) {
|
|
587
|
+
console.error("Invalid instances.yml format");
|
|
588
|
+
process.exitCode = 1;
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const filtered = instances.filter((inst) => inst.name !== name);
|
|
593
|
+
|
|
594
|
+
if (filtered.length === instances.length) {
|
|
595
|
+
console.error(`Monitoring target '${name}' not found`);
|
|
596
|
+
process.exitCode = 1;
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
fs.writeFileSync(file, yaml.dump(filtered), "utf8");
|
|
601
|
+
console.log(`Monitoring target '${name}' removed`);
|
|
602
|
+
} catch (err) {
|
|
603
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
604
|
+
console.error(`Error processing instances.yml: ${message}`);
|
|
605
|
+
process.exitCode = 1;
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
targets
|
|
609
|
+
.command("test <name>")
|
|
610
|
+
.description("test monitoring target database connectivity")
|
|
611
|
+
.action(async (name: string) => {
|
|
612
|
+
const instancesPath = path.resolve(process.cwd(), "instances.yml");
|
|
613
|
+
if (!fs.existsSync(instancesPath)) {
|
|
614
|
+
console.error("instances.yml not found");
|
|
615
|
+
process.exitCode = 1;
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
try {
|
|
620
|
+
const content = fs.readFileSync(instancesPath, "utf8");
|
|
621
|
+
const instances = yaml.load(content) as Instance[] | null;
|
|
622
|
+
|
|
623
|
+
if (!instances || !Array.isArray(instances)) {
|
|
624
|
+
console.error("Invalid instances.yml format");
|
|
625
|
+
process.exitCode = 1;
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const instance = instances.find((inst) => inst.name === name);
|
|
630
|
+
|
|
631
|
+
if (!instance) {
|
|
632
|
+
console.error(`Monitoring target '${name}' not found`);
|
|
633
|
+
process.exitCode = 1;
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (!instance.conn_str) {
|
|
638
|
+
console.error(`Connection string not found for monitoring target '${name}'`);
|
|
639
|
+
process.exitCode = 1;
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
console.log(`Testing connection to monitoring target '${name}'...`);
|
|
644
|
+
|
|
645
|
+
const { stdout, stderr } = await execFilePromise(
|
|
646
|
+
"psql",
|
|
647
|
+
[instance.conn_str, "-c", "SELECT version();", "--no-psqlrc"],
|
|
648
|
+
{ timeout: 10000, env: { ...process.env, PAGER: 'cat' } }
|
|
649
|
+
);
|
|
650
|
+
console.log(`✓ Connection successful`);
|
|
651
|
+
console.log(stdout.trim());
|
|
652
|
+
} catch (error) {
|
|
653
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
654
|
+
console.error(`✗ Connection failed: ${message}`);
|
|
655
|
+
process.exitCode = 1;
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// Authentication and API key management
|
|
660
|
+
program
|
|
661
|
+
.command("auth")
|
|
662
|
+
.description("authenticate via browser and obtain API key")
|
|
663
|
+
.option("--port <port>", "local callback server port (default: random)", parseInt)
|
|
664
|
+
.option("--debug", "enable debug output")
|
|
665
|
+
.action(async (opts: { port?: number; debug?: boolean }) => {
|
|
666
|
+
const pkce = require("../lib/pkce");
|
|
667
|
+
const authServer = require("../lib/auth-server");
|
|
668
|
+
|
|
669
|
+
console.log("Starting authentication flow...\n");
|
|
670
|
+
|
|
671
|
+
// Generate PKCE parameters
|
|
672
|
+
const params = pkce.generatePKCEParams();
|
|
673
|
+
|
|
674
|
+
const rootOpts = program.opts<CliOptions>();
|
|
675
|
+
|
|
676
|
+
const apiBaseUrl = (rootOpts.apiBaseUrl || process.env.PGAI_API_BASE_URL || "https://postgres.ai/api/general/").replace(/\/$/, "");
|
|
677
|
+
const uiBaseUrl = (rootOpts.uiBaseUrl || process.env.PGAI_UI_BASE_URL || "https://console.postgres.ai").replace(/\/$/, "");
|
|
678
|
+
|
|
679
|
+
if (opts.debug) {
|
|
680
|
+
console.log(`Debug: Resolved API base URL: ${apiBaseUrl}`);
|
|
681
|
+
console.log(`Debug: Resolved UI base URL: ${uiBaseUrl}`);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
try {
|
|
685
|
+
// Step 1: Start local callback server FIRST to get actual port
|
|
686
|
+
console.log("Starting local callback server...");
|
|
687
|
+
const requestedPort = opts.port || 0; // 0 = OS assigns available port
|
|
688
|
+
const callbackServer = authServer.createCallbackServer(requestedPort, params.state, 300000);
|
|
689
|
+
|
|
690
|
+
// Wait a bit for server to start and get port
|
|
691
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
692
|
+
const actualPort = callbackServer.getPort();
|
|
693
|
+
const redirectUri = `http://localhost:${actualPort}/callback`;
|
|
694
|
+
|
|
695
|
+
console.log(`Callback server listening on port ${actualPort}`);
|
|
696
|
+
|
|
697
|
+
// Step 2: Initialize OAuth session on backend
|
|
698
|
+
console.log("Initializing authentication session...");
|
|
699
|
+
const initData = JSON.stringify({
|
|
700
|
+
client_type: "cli",
|
|
701
|
+
state: params.state,
|
|
702
|
+
code_challenge: params.codeChallenge,
|
|
703
|
+
code_challenge_method: params.codeChallengeMethod,
|
|
704
|
+
redirect_uri: redirectUri,
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
// Build init URL by appending to the API base path (keep /api/general)
|
|
708
|
+
const initUrl = new URL(`${apiBaseUrl}/rpc/oauth_init`);
|
|
709
|
+
|
|
710
|
+
if (opts.debug) {
|
|
711
|
+
console.log(`Debug: Trying to POST to: ${initUrl.toString()}`);
|
|
712
|
+
console.log(`Debug: Request data: ${initData}`);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const initReq = http.request(
|
|
716
|
+
initUrl,
|
|
717
|
+
{
|
|
718
|
+
method: "POST",
|
|
719
|
+
headers: {
|
|
720
|
+
"Content-Type": "application/json",
|
|
721
|
+
"Content-Length": Buffer.byteLength(initData),
|
|
722
|
+
},
|
|
723
|
+
},
|
|
724
|
+
(res) => {
|
|
725
|
+
let data = "";
|
|
726
|
+
res.on("data", (chunk) => (data += chunk));
|
|
727
|
+
res.on("end", async () => {
|
|
728
|
+
if (res.statusCode !== 200) {
|
|
729
|
+
console.error(`Failed to initialize auth session: ${res.statusCode}`);
|
|
730
|
+
console.error(data);
|
|
731
|
+
callbackServer.server.close();
|
|
732
|
+
process.exitCode = 1;
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Step 3: Open browser
|
|
737
|
+
const authUrl = `${uiBaseUrl}/cli/auth?state=${encodeURIComponent(params.state)}&code_challenge=${encodeURIComponent(params.codeChallenge)}&code_challenge_method=S256&redirect_uri=${encodeURIComponent(redirectUri)}`;
|
|
738
|
+
|
|
739
|
+
if (opts.debug) {
|
|
740
|
+
console.log(`Debug: Auth URL: ${authUrl}`);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
console.log(`\nOpening browser for authentication...`);
|
|
744
|
+
console.log(`If browser does not open automatically, visit:\n${authUrl}\n`);
|
|
745
|
+
|
|
746
|
+
// Open browser (cross-platform)
|
|
747
|
+
const openCommand = process.platform === "darwin" ? "open" :
|
|
748
|
+
process.platform === "win32" ? "start" :
|
|
749
|
+
"xdg-open";
|
|
750
|
+
spawn(openCommand, [authUrl], { detached: true, stdio: "ignore" }).unref();
|
|
751
|
+
|
|
752
|
+
// Step 4: Wait for callback
|
|
753
|
+
console.log("Waiting for authorization...");
|
|
754
|
+
try {
|
|
755
|
+
const { code } = await callbackServer.promise;
|
|
756
|
+
|
|
757
|
+
// Step 5: Exchange code for token
|
|
758
|
+
console.log("\nExchanging authorization code for API token...");
|
|
759
|
+
const exchangeData = JSON.stringify({
|
|
760
|
+
authorization_code: code,
|
|
761
|
+
code_verifier: params.codeVerifier,
|
|
762
|
+
state: params.state,
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
const exchangeUrl = new URL(`${apiBaseUrl}/rpc/oauth_token_exchange`);
|
|
766
|
+
const exchangeReq = http.request(
|
|
767
|
+
exchangeUrl,
|
|
768
|
+
{
|
|
769
|
+
method: "POST",
|
|
770
|
+
headers: {
|
|
771
|
+
"Content-Type": "application/json",
|
|
772
|
+
"Content-Length": Buffer.byteLength(exchangeData),
|
|
773
|
+
},
|
|
774
|
+
},
|
|
775
|
+
(exchangeRes) => {
|
|
776
|
+
let exchangeData = "";
|
|
777
|
+
exchangeRes.on("data", (chunk) => (exchangeData += chunk));
|
|
778
|
+
exchangeRes.on("end", () => {
|
|
779
|
+
if (exchangeRes.statusCode !== 200) {
|
|
780
|
+
console.error(`Failed to exchange code for token: ${exchangeRes.statusCode}`);
|
|
781
|
+
console.error(exchangeData);
|
|
782
|
+
process.exitCode = 1;
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
try {
|
|
787
|
+
const result = JSON.parse(exchangeData);
|
|
788
|
+
const apiToken = result.api_token;
|
|
789
|
+
const orgId = result.org_id;
|
|
790
|
+
|
|
791
|
+
// Step 6: Save token to config
|
|
792
|
+
config.writeConfig({
|
|
793
|
+
apiKey: apiToken,
|
|
794
|
+
baseUrl: apiBaseUrl,
|
|
795
|
+
orgId: orgId,
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
console.log("\nAuthentication successful!");
|
|
799
|
+
console.log(`API key saved to: ${config.getConfigPath()}`);
|
|
800
|
+
console.log(`Organization ID: ${orgId}`);
|
|
801
|
+
console.log(`\nYou can now use the CLI without specifying an API key.`);
|
|
802
|
+
} catch (err) {
|
|
803
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
804
|
+
console.error(`Failed to parse response: ${message}`);
|
|
805
|
+
process.exitCode = 1;
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
);
|
|
810
|
+
|
|
811
|
+
exchangeReq.on("error", (err: Error) => {
|
|
812
|
+
console.error(`Exchange request failed: ${err.message}`);
|
|
813
|
+
process.exitCode = 1;
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
exchangeReq.write(exchangeData);
|
|
817
|
+
exchangeReq.end();
|
|
818
|
+
|
|
819
|
+
} catch (err) {
|
|
820
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
821
|
+
console.error(`\nAuthentication failed: ${message}`);
|
|
822
|
+
process.exitCode = 1;
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
initReq.on("error", (err: Error) => {
|
|
829
|
+
console.error(`Failed to connect to API: ${err.message}`);
|
|
830
|
+
callbackServer.server.close();
|
|
831
|
+
process.exitCode = 1;
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
initReq.write(initData);
|
|
835
|
+
initReq.end();
|
|
836
|
+
|
|
837
|
+
} catch (err) {
|
|
838
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
839
|
+
console.error(`Authentication error: ${message}`);
|
|
840
|
+
process.exitCode = 1;
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
program
|
|
845
|
+
.command("add-key <apiKey>")
|
|
846
|
+
.description("store API key")
|
|
847
|
+
.action(async (apiKey: string) => {
|
|
848
|
+
config.writeConfig({ apiKey });
|
|
849
|
+
console.log(`API key saved to ${config.getConfigPath()}`);
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
program
|
|
853
|
+
.command("show-key")
|
|
854
|
+
.description("show API key (masked)")
|
|
855
|
+
.action(async () => {
|
|
856
|
+
const cfg = config.readConfig();
|
|
857
|
+
if (!cfg.apiKey) {
|
|
858
|
+
console.log("No API key configured");
|
|
859
|
+
console.log(`\nTo authenticate, run: pgai auth`);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
const mask = (k: string): string => {
|
|
863
|
+
if (k.length <= 8) return "****";
|
|
864
|
+
if (k.length <= 16) return `${k.slice(0, 4)}${"*".repeat(k.length - 8)}${k.slice(-4)}`;
|
|
865
|
+
// For longer keys, show more of the beginning to help identify them
|
|
866
|
+
return `${k.slice(0, Math.min(12, k.length - 8))}${"*".repeat(Math.max(4, k.length - 16))}${k.slice(-4)}`;
|
|
867
|
+
};
|
|
868
|
+
console.log(`Current API key: ${mask(cfg.apiKey)}`);
|
|
869
|
+
if (cfg.orgId) {
|
|
870
|
+
console.log(`Organization ID: ${cfg.orgId}`);
|
|
871
|
+
}
|
|
872
|
+
console.log(`Config location: ${config.getConfigPath()}`);
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
program
|
|
876
|
+
.command("remove-key")
|
|
877
|
+
.description("remove API key")
|
|
878
|
+
.action(async () => {
|
|
879
|
+
// Check both new config and legacy config
|
|
880
|
+
const newConfigPath = config.getConfigPath();
|
|
881
|
+
const hasNewConfig = fs.existsSync(newConfigPath);
|
|
882
|
+
const legacyPath = path.resolve(process.cwd(), ".pgwatch-config");
|
|
883
|
+
const hasLegacyConfig = fs.existsSync(legacyPath) && fs.statSync(legacyPath).isFile();
|
|
884
|
+
|
|
885
|
+
if (!hasNewConfig && !hasLegacyConfig) {
|
|
886
|
+
console.log("No API key configured");
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Remove from new config
|
|
891
|
+
if (hasNewConfig) {
|
|
892
|
+
config.deleteConfigKeys(["apiKey", "orgId"]);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Remove from legacy config
|
|
896
|
+
if (hasLegacyConfig) {
|
|
897
|
+
try {
|
|
898
|
+
const content = fs.readFileSync(legacyPath, "utf8");
|
|
899
|
+
const filtered = content
|
|
900
|
+
.split(/\r?\n/)
|
|
901
|
+
.filter((l) => !/^api_key=/.test(l))
|
|
902
|
+
.join("\n")
|
|
903
|
+
.replace(/\n+$/g, "\n");
|
|
904
|
+
fs.writeFileSync(legacyPath, filtered, "utf8");
|
|
905
|
+
} catch (err) {
|
|
906
|
+
// If we can't read/write the legacy config, just skip it
|
|
907
|
+
console.warn(`Warning: Could not update legacy config: ${err instanceof Error ? err.message : String(err)}`);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
console.log("API key removed");
|
|
912
|
+
console.log(`\nTo authenticate again, run: pgai auth`);
|
|
913
|
+
});
|
|
914
|
+
mon
|
|
915
|
+
.command("generate-grafana-password")
|
|
916
|
+
.description("generate Grafana password for monitoring services")
|
|
917
|
+
.action(async () => {
|
|
918
|
+
const cfgPath = path.resolve(process.cwd(), ".pgwatch-config");
|
|
919
|
+
|
|
920
|
+
try {
|
|
921
|
+
// Generate secure password using openssl
|
|
922
|
+
const { stdout: password } = await execPromise(
|
|
923
|
+
"openssl rand -base64 12 | tr -d '\n'"
|
|
924
|
+
);
|
|
925
|
+
const newPassword = password.trim();
|
|
926
|
+
|
|
927
|
+
if (!newPassword) {
|
|
928
|
+
console.error("Failed to generate password");
|
|
929
|
+
process.exitCode = 1;
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Read existing config
|
|
934
|
+
let configContent = "";
|
|
935
|
+
if (fs.existsSync(cfgPath)) {
|
|
936
|
+
const stats = fs.statSync(cfgPath);
|
|
937
|
+
if (stats.isDirectory()) {
|
|
938
|
+
console.error(".pgwatch-config is a directory, expected a file. Skipping read.");
|
|
939
|
+
} else {
|
|
940
|
+
configContent = fs.readFileSync(cfgPath, "utf8");
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Update or add grafana_password
|
|
945
|
+
const lines = configContent.split(/\r?\n/).filter((l) => !/^grafana_password=/.test(l));
|
|
946
|
+
lines.push(`grafana_password=${newPassword}`);
|
|
947
|
+
|
|
948
|
+
// Write back
|
|
949
|
+
fs.writeFileSync(cfgPath, lines.filter(Boolean).join("\n") + "\n", "utf8");
|
|
950
|
+
|
|
951
|
+
console.log("✓ New Grafana password generated and saved");
|
|
952
|
+
console.log("\nNew credentials:");
|
|
953
|
+
console.log(" URL: http://localhost:3000");
|
|
954
|
+
console.log(" Username: monitor");
|
|
955
|
+
console.log(` Password: ${newPassword}`);
|
|
956
|
+
console.log("\nRestart Grafana to apply:");
|
|
957
|
+
console.log(" postgres-ai mon restart grafana");
|
|
958
|
+
} catch (error) {
|
|
959
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
960
|
+
console.error(`Failed to generate password: ${message}`);
|
|
961
|
+
console.error("\nNote: This command requires 'openssl' to be installed");
|
|
962
|
+
process.exitCode = 1;
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
mon
|
|
966
|
+
.command("show-grafana-credentials")
|
|
967
|
+
.description("show Grafana credentials for monitoring services")
|
|
968
|
+
.action(async () => {
|
|
969
|
+
const cfgPath = path.resolve(process.cwd(), ".pgwatch-config");
|
|
970
|
+
if (!fs.existsSync(cfgPath)) {
|
|
971
|
+
console.error("Configuration file not found. Run 'postgres-ai mon quickstart' first.");
|
|
972
|
+
process.exitCode = 1;
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const stats = fs.statSync(cfgPath);
|
|
977
|
+
if (stats.isDirectory()) {
|
|
978
|
+
console.error(".pgwatch-config is a directory, expected a file. Cannot read credentials.");
|
|
979
|
+
process.exitCode = 1;
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const content = fs.readFileSync(cfgPath, "utf8");
|
|
984
|
+
const lines = content.split(/\r?\n/);
|
|
985
|
+
let password = "";
|
|
986
|
+
for (const line of lines) {
|
|
987
|
+
const m = line.match(/^grafana_password=(.+)$/);
|
|
988
|
+
if (m) {
|
|
989
|
+
password = m[1].trim();
|
|
990
|
+
break;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
if (!password) {
|
|
994
|
+
console.error("Grafana password not found in configuration");
|
|
995
|
+
process.exitCode = 1;
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
console.log("\nGrafana credentials:");
|
|
999
|
+
console.log(" URL: http://localhost:3000");
|
|
1000
|
+
console.log(" Username: monitor");
|
|
1001
|
+
console.log(` Password: ${password}`);
|
|
1002
|
+
console.log("");
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
program.parseAsync(process.argv);
|
|
1006
|
+
|