postgresai 0.14.0-beta.2 → 0.14.0-beta.4
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 +53 -45
- package/bin/postgres-ai.ts +953 -353
- package/bun.lock +258 -0
- package/bunfig.toml +11 -0
- package/dist/bin/postgres-ai.js +27868 -1781
- package/lib/auth-server.ts +124 -106
- package/lib/checkup-api.ts +386 -0
- package/lib/checkup.ts +1327 -0
- package/lib/config.ts +3 -0
- package/lib/init.ts +283 -158
- package/lib/issues.ts +86 -195
- package/lib/mcp-server.ts +6 -17
- package/lib/metrics-embedded.ts +79 -0
- package/lib/metrics-loader.ts +127 -0
- package/lib/util.ts +61 -0
- package/package.json +18 -10
- package/packages/postgres-ai/README.md +26 -0
- package/packages/postgres-ai/bin/postgres-ai.js +27 -0
- package/packages/postgres-ai/package.json +27 -0
- package/scripts/embed-metrics.ts +154 -0
- package/sql/02.permissions.sql +9 -5
- package/sql/05.helpers.sql +415 -0
- package/test/checkup.integration.test.ts +273 -0
- package/test/checkup.test.ts +890 -0
- package/test/init.integration.test.ts +399 -0
- package/test/init.test.ts +345 -0
- package/test/schema-validation.test.ts +81 -0
- package/test/test-utils.ts +122 -0
- package/tsconfig.json +12 -20
- package/dist/bin/postgres-ai.d.ts +0 -3
- package/dist/bin/postgres-ai.d.ts.map +0 -1
- package/dist/bin/postgres-ai.js.map +0 -1
- package/dist/lib/auth-server.d.ts +0 -31
- package/dist/lib/auth-server.d.ts.map +0 -1
- package/dist/lib/auth-server.js +0 -263
- package/dist/lib/auth-server.js.map +0 -1
- package/dist/lib/config.d.ts +0 -45
- package/dist/lib/config.d.ts.map +0 -1
- package/dist/lib/config.js +0 -181
- package/dist/lib/config.js.map +0 -1
- package/dist/lib/init.d.ts +0 -77
- package/dist/lib/init.d.ts.map +0 -1
- package/dist/lib/init.js +0 -550
- package/dist/lib/init.js.map +0 -1
- package/dist/lib/issues.d.ts +0 -75
- package/dist/lib/issues.d.ts.map +0 -1
- package/dist/lib/issues.js +0 -336
- package/dist/lib/issues.js.map +0 -1
- package/dist/lib/mcp-server.d.ts +0 -9
- package/dist/lib/mcp-server.d.ts.map +0 -1
- package/dist/lib/mcp-server.js +0 -168
- package/dist/lib/mcp-server.js.map +0 -1
- package/dist/lib/pkce.d.ts +0 -32
- package/dist/lib/pkce.d.ts.map +0 -1
- package/dist/lib/pkce.js +0 -101
- package/dist/lib/pkce.js.map +0 -1
- package/dist/lib/util.d.ts +0 -27
- package/dist/lib/util.d.ts.map +0 -1
- package/dist/lib/util.js +0 -46
- package/dist/lib/util.js.map +0 -1
- package/dist/package.json +0 -46
- package/test/init.integration.test.cjs +0 -382
- package/test/init.test.cjs +0 -323
package/bin/postgres-ai.ts
CHANGED
|
@@ -1,25 +1,365 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
import { Command } from "commander";
|
|
4
|
-
import
|
|
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
|
|
11
|
-
import { promisify } from "util";
|
|
12
|
-
import * as readline from "readline";
|
|
13
|
-
import * as http from "https";
|
|
14
|
-
import { URL } from "url";
|
|
10
|
+
import * as crypto from "node:crypto";
|
|
15
11
|
import { Client } from "pg";
|
|
16
12
|
import { startMcpServer } from "../lib/mcp-server";
|
|
17
13
|
import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue } from "../lib/issues";
|
|
18
14
|
import { resolveBaseUrls } from "../lib/util";
|
|
19
|
-
import { applyInitPlan, buildInitPlan, DEFAULT_MONITORING_USER, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, verifyInitSetup } from "../lib/init";
|
|
15
|
+
import { applyInitPlan, buildInitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, verifyInitSetup } from "../lib/init";
|
|
16
|
+
import * as pkce from "../lib/pkce";
|
|
17
|
+
import * as authServer from "../lib/auth-server";
|
|
18
|
+
import { maskSecret } from "../lib/util";
|
|
19
|
+
import { createInterface } from "readline";
|
|
20
|
+
import * as childProcess from "child_process";
|
|
21
|
+
import { REPORT_GENERATORS, CHECK_INFO, generateAllReports } from "../lib/checkup";
|
|
22
|
+
import { createCheckupReport, uploadCheckupReportJson, RpcError, formatRpcErrorForDisplay, withRetry } from "../lib/checkup-api";
|
|
23
|
+
|
|
24
|
+
// Singleton readline interface for stdin prompts
|
|
25
|
+
let rl: ReturnType<typeof createInterface> | null = null;
|
|
26
|
+
function getReadline() {
|
|
27
|
+
if (!rl) {
|
|
28
|
+
rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
29
|
+
}
|
|
30
|
+
return rl;
|
|
31
|
+
}
|
|
32
|
+
function closeReadline() {
|
|
33
|
+
if (rl) {
|
|
34
|
+
rl.close();
|
|
35
|
+
rl = null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Helper functions for spawning processes - use Node.js child_process for compatibility
|
|
40
|
+
async function execPromise(command: string): Promise<{ stdout: string; stderr: string }> {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
childProcess.exec(command, (error, stdout, stderr) => {
|
|
43
|
+
if (error) {
|
|
44
|
+
const err = error as Error & { code: number };
|
|
45
|
+
err.code = error.code ?? 1;
|
|
46
|
+
reject(err);
|
|
47
|
+
} else {
|
|
48
|
+
resolve({ stdout, stderr });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function execFilePromise(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
childProcess.execFile(file, args, (error, stdout, stderr) => {
|
|
57
|
+
if (error) {
|
|
58
|
+
const err = error as Error & { code: number };
|
|
59
|
+
err.code = error.code ?? 1;
|
|
60
|
+
reject(err);
|
|
61
|
+
} else {
|
|
62
|
+
resolve({ stdout, stderr });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function spawnSync(cmd: string, args: string[], options?: { stdio?: "pipe" | "ignore" | "inherit"; encoding?: string; env?: Record<string, string | undefined>; cwd?: string }): { status: number | null; stdout: string; stderr: string } {
|
|
69
|
+
const result = childProcess.spawnSync(cmd, args, {
|
|
70
|
+
stdio: options?.stdio === "inherit" ? "inherit" : "pipe",
|
|
71
|
+
env: options?.env as NodeJS.ProcessEnv,
|
|
72
|
+
cwd: options?.cwd,
|
|
73
|
+
encoding: "utf8",
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
status: result.status,
|
|
77
|
+
stdout: typeof result.stdout === "string" ? result.stdout : "",
|
|
78
|
+
stderr: typeof result.stderr === "string" ? result.stderr : "",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function spawn(cmd: string, args: string[], options?: { stdio?: "pipe" | "ignore" | "inherit"; env?: Record<string, string | undefined>; cwd?: string; detached?: boolean }): { on: (event: string, cb: (code: number | null, signal?: string) => void) => void; unref: () => void; pid?: number } {
|
|
83
|
+
const proc = childProcess.spawn(cmd, args, {
|
|
84
|
+
stdio: options?.stdio ?? "pipe",
|
|
85
|
+
env: options?.env as NodeJS.ProcessEnv,
|
|
86
|
+
cwd: options?.cwd,
|
|
87
|
+
detached: options?.detached,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
on(event: string, cb: (code: number | null, signal?: string) => void) {
|
|
92
|
+
if (event === "close" || event === "exit") {
|
|
93
|
+
proc.on(event, (code, signal) => cb(code, signal ?? undefined));
|
|
94
|
+
} else if (event === "error") {
|
|
95
|
+
proc.on("error", (err) => cb(null, String(err)));
|
|
96
|
+
}
|
|
97
|
+
return this;
|
|
98
|
+
},
|
|
99
|
+
unref() {
|
|
100
|
+
proc.unref();
|
|
101
|
+
},
|
|
102
|
+
pid: proc.pid,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Simple readline-like interface for prompts using Bun
|
|
107
|
+
async function question(prompt: string): Promise<string> {
|
|
108
|
+
return new Promise((resolve) => {
|
|
109
|
+
getReadline().question(prompt, (answer) => {
|
|
110
|
+
resolve(answer);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function expandHomePath(p: string): string {
|
|
116
|
+
const s = (p || "").trim();
|
|
117
|
+
if (!s) return s;
|
|
118
|
+
if (s === "~") return os.homedir();
|
|
119
|
+
if (s.startsWith("~/") || s.startsWith("~\\")) {
|
|
120
|
+
return path.join(os.homedir(), s.slice(2));
|
|
121
|
+
}
|
|
122
|
+
return s;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function createTtySpinner(
|
|
126
|
+
enabled: boolean,
|
|
127
|
+
initialText: string
|
|
128
|
+
): { update: (text: string) => void; stop: (finalText?: string) => void } {
|
|
129
|
+
if (!enabled) {
|
|
130
|
+
return {
|
|
131
|
+
update: () => {},
|
|
132
|
+
stop: () => {},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const frames = ["|", "/", "-", "\\"];
|
|
137
|
+
const startTs = Date.now();
|
|
138
|
+
let text = initialText;
|
|
139
|
+
let frameIdx = 0;
|
|
140
|
+
let stopped = false;
|
|
141
|
+
|
|
142
|
+
const render = (): void => {
|
|
143
|
+
if (stopped) return;
|
|
144
|
+
const elapsedSec = ((Date.now() - startTs) / 1000).toFixed(1);
|
|
145
|
+
const frame = frames[frameIdx % frames.length] ?? frames[0] ?? "⠿";
|
|
146
|
+
frameIdx += 1;
|
|
147
|
+
process.stdout.write(`\r\x1b[2K${frame} ${text} (${elapsedSec}s)`);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const timer = setInterval(render, 120);
|
|
151
|
+
render(); // immediate feedback
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
update: (t: string) => {
|
|
155
|
+
text = t;
|
|
156
|
+
render();
|
|
157
|
+
},
|
|
158
|
+
stop: (finalText?: string) => {
|
|
159
|
+
if (stopped) return;
|
|
160
|
+
// Set flag first so any queued render() calls exit early.
|
|
161
|
+
// JavaScript is single-threaded, so this is safe: queued callbacks
|
|
162
|
+
// run after stop() returns and will see stopped=true immediately.
|
|
163
|
+
stopped = true;
|
|
164
|
+
clearInterval(timer);
|
|
165
|
+
process.stdout.write("\r\x1b[2K");
|
|
166
|
+
if (finalText && finalText.trim()) {
|
|
167
|
+
process.stdout.write(finalText);
|
|
168
|
+
}
|
|
169
|
+
process.stdout.write("\n");
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
20
173
|
|
|
21
|
-
|
|
22
|
-
|
|
174
|
+
// ============================================================================
|
|
175
|
+
// Checkup command helpers
|
|
176
|
+
// ============================================================================
|
|
177
|
+
|
|
178
|
+
interface CheckupOptions {
|
|
179
|
+
checkId: string;
|
|
180
|
+
nodeName: string;
|
|
181
|
+
output?: string;
|
|
182
|
+
upload?: boolean;
|
|
183
|
+
project?: string;
|
|
184
|
+
json?: boolean;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface UploadConfig {
|
|
188
|
+
apiKey: string;
|
|
189
|
+
apiBaseUrl: string;
|
|
190
|
+
project: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
interface UploadSummary {
|
|
194
|
+
project: string;
|
|
195
|
+
reportId: number;
|
|
196
|
+
uploaded: Array<{ checkId: string; filename: string; chunkId: number }>;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Prepare and validate output directory for checkup reports.
|
|
201
|
+
* @returns Output path if valid, null if should exit with error
|
|
202
|
+
*/
|
|
203
|
+
function prepareOutputDirectory(outputOpt: string | undefined): string | null | undefined {
|
|
204
|
+
if (!outputOpt) return undefined;
|
|
205
|
+
|
|
206
|
+
const outputDir = expandHomePath(outputOpt);
|
|
207
|
+
const outputPath = path.isAbsolute(outputDir) ? outputDir : path.resolve(process.cwd(), outputDir);
|
|
208
|
+
|
|
209
|
+
if (!fs.existsSync(outputPath)) {
|
|
210
|
+
try {
|
|
211
|
+
fs.mkdirSync(outputPath, { recursive: true });
|
|
212
|
+
} catch (e) {
|
|
213
|
+
const errAny = e as any;
|
|
214
|
+
const code = typeof errAny?.code === "string" ? errAny.code : "";
|
|
215
|
+
const msg = errAny instanceof Error ? errAny.message : String(errAny);
|
|
216
|
+
if (code === "EACCES" || code === "EPERM" || code === "ENOENT") {
|
|
217
|
+
console.error(`Error: Failed to create output directory: ${outputPath}`);
|
|
218
|
+
console.error(`Reason: ${msg}`);
|
|
219
|
+
console.error("Tip: choose a writable path, e.g. --output ./reports or --output ~/reports");
|
|
220
|
+
return null; // Signal to exit
|
|
221
|
+
}
|
|
222
|
+
throw e;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return outputPath;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Prepare upload configuration for checkup reports.
|
|
230
|
+
* @returns Upload config if valid, null if should exit, undefined if upload not needed
|
|
231
|
+
*/
|
|
232
|
+
function prepareUploadConfig(
|
|
233
|
+
opts: CheckupOptions,
|
|
234
|
+
rootOpts: CliOptions,
|
|
235
|
+
shouldUpload: boolean,
|
|
236
|
+
uploadExplicitlyRequested: boolean
|
|
237
|
+
): { config: UploadConfig; projectWasGenerated: boolean } | null | undefined {
|
|
238
|
+
if (!shouldUpload) return undefined;
|
|
239
|
+
|
|
240
|
+
const { apiKey } = getConfig(rootOpts);
|
|
241
|
+
if (!apiKey) {
|
|
242
|
+
if (uploadExplicitlyRequested) {
|
|
243
|
+
console.error("Error: API key is required for upload");
|
|
244
|
+
console.error("Tip: run 'postgresai auth' or pass --api-key / set PGAI_API_KEY");
|
|
245
|
+
return null; // Signal to exit
|
|
246
|
+
}
|
|
247
|
+
return undefined; // Skip upload silently
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const cfg = config.readConfig();
|
|
251
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
252
|
+
let project = ((opts.project || cfg.defaultProject) || "").trim();
|
|
253
|
+
let projectWasGenerated = false;
|
|
254
|
+
|
|
255
|
+
if (!project) {
|
|
256
|
+
project = `project_${crypto.randomBytes(6).toString("hex")}`;
|
|
257
|
+
projectWasGenerated = true;
|
|
258
|
+
try {
|
|
259
|
+
config.writeConfig({ defaultProject: project });
|
|
260
|
+
} catch (e) {
|
|
261
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
262
|
+
console.error(`Warning: Failed to save generated default project: ${message}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
config: { apiKey, apiBaseUrl, project },
|
|
268
|
+
projectWasGenerated,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Upload checkup reports to PostgresAI API.
|
|
274
|
+
*/
|
|
275
|
+
async function uploadCheckupReports(
|
|
276
|
+
uploadCfg: UploadConfig,
|
|
277
|
+
reports: Record<string, any>,
|
|
278
|
+
spinner: ReturnType<typeof createTtySpinner>,
|
|
279
|
+
logUpload: (msg: string) => void
|
|
280
|
+
): Promise<UploadSummary> {
|
|
281
|
+
spinner.update("Creating remote checkup report");
|
|
282
|
+
const created = await withRetry(
|
|
283
|
+
() => createCheckupReport({
|
|
284
|
+
apiKey: uploadCfg.apiKey,
|
|
285
|
+
apiBaseUrl: uploadCfg.apiBaseUrl,
|
|
286
|
+
project: uploadCfg.project,
|
|
287
|
+
}),
|
|
288
|
+
{ maxAttempts: 3 },
|
|
289
|
+
(attempt, err, delayMs) => {
|
|
290
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
291
|
+
logUpload(`[Retry ${attempt}/3] createCheckupReport failed: ${errMsg}, retrying in ${delayMs}ms...`);
|
|
292
|
+
}
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const reportId = created.reportId;
|
|
296
|
+
logUpload(`Created remote checkup report: ${reportId}`);
|
|
297
|
+
|
|
298
|
+
const uploaded: Array<{ checkId: string; filename: string; chunkId: number }> = [];
|
|
299
|
+
for (const [checkId, report] of Object.entries(reports)) {
|
|
300
|
+
spinner.update(`Uploading ${checkId}.json`);
|
|
301
|
+
const jsonText = JSON.stringify(report, null, 2);
|
|
302
|
+
const r = await withRetry(
|
|
303
|
+
() => uploadCheckupReportJson({
|
|
304
|
+
apiKey: uploadCfg.apiKey,
|
|
305
|
+
apiBaseUrl: uploadCfg.apiBaseUrl,
|
|
306
|
+
reportId,
|
|
307
|
+
filename: `${checkId}.json`,
|
|
308
|
+
checkId,
|
|
309
|
+
jsonText,
|
|
310
|
+
}),
|
|
311
|
+
{ maxAttempts: 3 },
|
|
312
|
+
(attempt, err, delayMs) => {
|
|
313
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
314
|
+
logUpload(`[Retry ${attempt}/3] Upload ${checkId}.json failed: ${errMsg}, retrying in ${delayMs}ms...`);
|
|
315
|
+
}
|
|
316
|
+
);
|
|
317
|
+
uploaded.push({ checkId, filename: `${checkId}.json`, chunkId: r.reportChunkId });
|
|
318
|
+
}
|
|
319
|
+
logUpload("Upload completed");
|
|
320
|
+
|
|
321
|
+
return { project: uploadCfg.project, reportId, uploaded };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Write checkup reports to files.
|
|
326
|
+
*/
|
|
327
|
+
function writeReportFiles(reports: Record<string, any>, outputPath: string): void {
|
|
328
|
+
for (const [checkId, report] of Object.entries(reports)) {
|
|
329
|
+
const filePath = path.join(outputPath, `${checkId}.json`);
|
|
330
|
+
fs.writeFileSync(filePath, JSON.stringify(report, null, 2), "utf8");
|
|
331
|
+
console.log(`✓ ${checkId}: ${filePath}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Print upload summary to console.
|
|
337
|
+
*/
|
|
338
|
+
function printUploadSummary(
|
|
339
|
+
summary: UploadSummary,
|
|
340
|
+
projectWasGenerated: boolean,
|
|
341
|
+
useStderr: boolean
|
|
342
|
+
): void {
|
|
343
|
+
const out = useStderr ? console.error : console.log;
|
|
344
|
+
out("\nCheckup report uploaded");
|
|
345
|
+
out("======================\n");
|
|
346
|
+
if (projectWasGenerated) {
|
|
347
|
+
out(`Project: ${summary.project} (generated and saved as default)`);
|
|
348
|
+
} else {
|
|
349
|
+
out(`Project: ${summary.project}`);
|
|
350
|
+
}
|
|
351
|
+
out(`Report ID: ${summary.reportId}`);
|
|
352
|
+
out("View in Console: console.postgres.ai → Support → checkup reports");
|
|
353
|
+
out("");
|
|
354
|
+
out("Files:");
|
|
355
|
+
for (const item of summary.uploaded) {
|
|
356
|
+
out(`- ${item.checkId}: ${item.filename}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ============================================================================
|
|
361
|
+
// CLI configuration
|
|
362
|
+
// ============================================================================
|
|
23
363
|
|
|
24
364
|
/**
|
|
25
365
|
* CLI configuration options
|
|
@@ -61,6 +401,86 @@ interface PathResolution {
|
|
|
61
401
|
instancesFile: string;
|
|
62
402
|
}
|
|
63
403
|
|
|
404
|
+
function getDefaultMonitoringProjectDir(): string {
|
|
405
|
+
const override = process.env.PGAI_PROJECT_DIR;
|
|
406
|
+
if (override && override.trim()) return override.trim();
|
|
407
|
+
// Keep monitoring project next to user-level config (~/.config/postgresai)
|
|
408
|
+
return path.join(config.getConfigDir(), "monitoring");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function downloadText(url: string): Promise<string> {
|
|
412
|
+
const controller = new AbortController();
|
|
413
|
+
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
414
|
+
try {
|
|
415
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
416
|
+
if (!response.ok) {
|
|
417
|
+
throw new Error(`HTTP ${response.status} for ${url}`);
|
|
418
|
+
}
|
|
419
|
+
return await response.text();
|
|
420
|
+
} finally {
|
|
421
|
+
clearTimeout(timeout);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
|
|
426
|
+
const projectDir = getDefaultMonitoringProjectDir();
|
|
427
|
+
const composeFile = path.resolve(projectDir, "docker-compose.yml");
|
|
428
|
+
const instancesFile = path.resolve(projectDir, "instances.yml");
|
|
429
|
+
|
|
430
|
+
if (!fs.existsSync(projectDir)) {
|
|
431
|
+
fs.mkdirSync(projectDir, { recursive: true, mode: 0o700 });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (!fs.existsSync(composeFile)) {
|
|
435
|
+
const refs = [
|
|
436
|
+
process.env.PGAI_PROJECT_REF,
|
|
437
|
+
pkg.version,
|
|
438
|
+
`v${pkg.version}`,
|
|
439
|
+
"main",
|
|
440
|
+
].filter((v): v is string => Boolean(v && v.trim()));
|
|
441
|
+
|
|
442
|
+
let lastErr: unknown;
|
|
443
|
+
for (const ref of refs) {
|
|
444
|
+
const url = `https://gitlab.com/postgres-ai/postgres_ai/-/raw/${encodeURIComponent(ref)}/docker-compose.yml`;
|
|
445
|
+
try {
|
|
446
|
+
const text = await downloadText(url);
|
|
447
|
+
fs.writeFileSync(composeFile, text, { encoding: "utf8", mode: 0o600 });
|
|
448
|
+
break;
|
|
449
|
+
} catch (err) {
|
|
450
|
+
lastErr = err;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (!fs.existsSync(composeFile)) {
|
|
455
|
+
const msg = lastErr instanceof Error ? lastErr.message : String(lastErr);
|
|
456
|
+
throw new Error(`Failed to bootstrap docker-compose.yml: ${msg}`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Ensure instances.yml exists as a FILE (avoid Docker creating a directory)
|
|
461
|
+
if (!fs.existsSync(instancesFile)) {
|
|
462
|
+
const header =
|
|
463
|
+
"# PostgreSQL instances to monitor\n" +
|
|
464
|
+
"# Add your instances using: pgai mon targets add <connection-string> <name>\n\n";
|
|
465
|
+
fs.writeFileSync(instancesFile, header, { encoding: "utf8", mode: 0o600 });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Ensure .pgwatch-config exists as a FILE for reporter (may remain empty)
|
|
469
|
+
const pgwatchConfig = path.resolve(projectDir, ".pgwatch-config");
|
|
470
|
+
if (!fs.existsSync(pgwatchConfig)) {
|
|
471
|
+
fs.writeFileSync(pgwatchConfig, "", { encoding: "utf8", mode: 0o600 });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Ensure .env exists and has PGAI_TAG (compose requires it)
|
|
475
|
+
const envFile = path.resolve(projectDir, ".env");
|
|
476
|
+
if (!fs.existsSync(envFile)) {
|
|
477
|
+
const envText = `PGAI_TAG=${pkg.version}\n# PGAI_REGISTRY=registry.gitlab.com/postgres-ai/postgres_ai\n`;
|
|
478
|
+
fs.writeFileSync(envFile, envText, { encoding: "utf8", mode: 0o600 });
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return { fs, path, projectDir, composeFile, instancesFile };
|
|
482
|
+
}
|
|
483
|
+
|
|
64
484
|
/**
|
|
65
485
|
* Get configuration from various sources
|
|
66
486
|
* @param opts - Command line options
|
|
@@ -119,8 +539,22 @@ program
|
|
|
119
539
|
);
|
|
120
540
|
|
|
121
541
|
program
|
|
122
|
-
.command("
|
|
123
|
-
.description("
|
|
542
|
+
.command("set-default-project <project>")
|
|
543
|
+
.description("store default project for checkup uploads")
|
|
544
|
+
.action(async (project: string) => {
|
|
545
|
+
const value = (project || "").trim();
|
|
546
|
+
if (!value) {
|
|
547
|
+
console.error("Error: project is required");
|
|
548
|
+
process.exitCode = 1;
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
config.writeConfig({ defaultProject: value });
|
|
552
|
+
console.log(`Default project saved: ${value}`);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
program
|
|
556
|
+
.command("prepare-db [conn]")
|
|
557
|
+
.description("prepare database for monitoring: create monitoring user, required view(s), and grant permissions (idempotent)")
|
|
124
558
|
.option("--db-url <url>", "PostgreSQL connection URL (admin) to run the setup against (deprecated; pass it as positional arg)")
|
|
125
559
|
.option("-h, --host <host>", "PostgreSQL host (psql-like)")
|
|
126
560
|
.option("-p, --port <port>", "PostgreSQL port (psql-like)")
|
|
@@ -133,16 +567,15 @@ program
|
|
|
133
567
|
.option("--verify", "Verify that monitoring role/permissions are in place (no changes)", false)
|
|
134
568
|
.option("--reset-password", "Reset monitoring role password only (no other changes)", false)
|
|
135
569
|
.option("--print-sql", "Print SQL plan and exit (no changes applied)", false)
|
|
136
|
-
.option("--show-secrets", "When printing SQL, do not redact secrets (DANGEROUS)", false)
|
|
137
570
|
.option("--print-password", "Print generated monitoring password (DANGEROUS in CI logs)", false)
|
|
138
571
|
.addHelpText(
|
|
139
572
|
"after",
|
|
140
573
|
[
|
|
141
574
|
"",
|
|
142
575
|
"Examples:",
|
|
143
|
-
" postgresai
|
|
144
|
-
" postgresai
|
|
145
|
-
" postgresai
|
|
576
|
+
" postgresai prepare-db postgresql://admin@host:5432/dbname",
|
|
577
|
+
" postgresai prepare-db \"dbname=dbname host=host user=admin\"",
|
|
578
|
+
" postgresai prepare-db -h host -p 5432 -U admin -d dbname",
|
|
146
579
|
"",
|
|
147
580
|
"Admin password:",
|
|
148
581
|
" --admin-password <password> or PGPASSWORD=... (libpq standard)",
|
|
@@ -152,22 +585,28 @@ program
|
|
|
152
585
|
" If auto-generated, it is printed only on TTY by default.",
|
|
153
586
|
" To print it in non-interactive mode: --print-password",
|
|
154
587
|
"",
|
|
588
|
+
"SSL connection (sslmode=prefer behavior):",
|
|
589
|
+
" Tries SSL first, falls back to non-SSL if server doesn't support it.",
|
|
590
|
+
" To force SSL: PGSSLMODE=require or ?sslmode=require in URL",
|
|
591
|
+
" To disable SSL: PGSSLMODE=disable or ?sslmode=disable in URL",
|
|
592
|
+
"",
|
|
155
593
|
"Environment variables (libpq standard):",
|
|
156
594
|
" PGHOST, PGPORT, PGUSER, PGDATABASE — connection defaults",
|
|
157
595
|
" PGPASSWORD — admin password",
|
|
596
|
+
" PGSSLMODE — SSL mode (disable, require, verify-full)",
|
|
158
597
|
" PGAI_MON_PASSWORD — monitoring password",
|
|
159
598
|
"",
|
|
160
599
|
"Inspect SQL without applying changes:",
|
|
161
|
-
" postgresai
|
|
600
|
+
" postgresai prepare-db <conn> --print-sql",
|
|
162
601
|
"",
|
|
163
602
|
"Verify setup (no changes):",
|
|
164
|
-
" postgresai
|
|
603
|
+
" postgresai prepare-db <conn> --verify",
|
|
165
604
|
"",
|
|
166
605
|
"Reset monitoring password only:",
|
|
167
|
-
" postgresai
|
|
606
|
+
" postgresai prepare-db <conn> --reset-password --password '...'",
|
|
168
607
|
"",
|
|
169
608
|
"Offline SQL plan (no DB connection):",
|
|
170
|
-
" postgresai
|
|
609
|
+
" postgresai prepare-db --print-sql",
|
|
171
610
|
].join("\n")
|
|
172
611
|
)
|
|
173
612
|
.action(async (conn: string | undefined, opts: {
|
|
@@ -183,7 +622,6 @@ program
|
|
|
183
622
|
verify?: boolean;
|
|
184
623
|
resetPassword?: boolean;
|
|
185
624
|
printSql?: boolean;
|
|
186
|
-
showSecrets?: boolean;
|
|
187
625
|
printPassword?: boolean;
|
|
188
626
|
}, cmd: Command) => {
|
|
189
627
|
if (opts.verify && opts.resetPassword) {
|
|
@@ -198,23 +636,19 @@ program
|
|
|
198
636
|
}
|
|
199
637
|
|
|
200
638
|
const shouldPrintSql = !!opts.printSql;
|
|
201
|
-
const
|
|
202
|
-
const redactPasswords = (sql: string): string => {
|
|
203
|
-
if (!shouldRedactSecrets) return sql;
|
|
204
|
-
// Replace PASSWORD '<literal>' (handles doubled quotes inside).
|
|
205
|
-
return redactPasswordsInSql(sql);
|
|
206
|
-
};
|
|
639
|
+
const redactPasswords = (sql: string): string => redactPasswordsInSql(sql);
|
|
207
640
|
|
|
208
641
|
// Offline mode: allow printing SQL without providing/using an admin connection.
|
|
209
|
-
// Useful for audits/reviews; caller can provide -d/PGDATABASE
|
|
642
|
+
// Useful for audits/reviews; caller can provide -d/PGDATABASE.
|
|
210
643
|
if (!conn && !opts.dbUrl && !opts.host && !opts.port && !opts.username && !opts.adminPassword) {
|
|
211
644
|
if (shouldPrintSql) {
|
|
212
645
|
const database = (opts.dbname ?? process.env.PGDATABASE ?? "postgres").trim();
|
|
213
646
|
const includeOptionalPermissions = !opts.skipOptionalPermissions;
|
|
214
647
|
|
|
215
|
-
// Use explicit password/env if provided; otherwise use a placeholder
|
|
648
|
+
// Use explicit password/env if provided; otherwise use a placeholder.
|
|
649
|
+
// Printed SQL always redacts secrets.
|
|
216
650
|
const monPassword =
|
|
217
|
-
(opts.password ?? process.env.PGAI_MON_PASSWORD ?? "
|
|
651
|
+
(opts.password ?? process.env.PGAI_MON_PASSWORD ?? "<redacted>").toString();
|
|
218
652
|
|
|
219
653
|
const plan = await buildInitPlan({
|
|
220
654
|
database,
|
|
@@ -232,9 +666,7 @@ program
|
|
|
232
666
|
console.log(redactPasswords(step.sql));
|
|
233
667
|
}
|
|
234
668
|
console.log("\n--- end SQL plan ---\n");
|
|
235
|
-
|
|
236
|
-
console.log("Note: passwords are redacted in the printed SQL (use --show-secrets to print them).");
|
|
237
|
-
}
|
|
669
|
+
console.log("Note: passwords are redacted in the printed SQL output.");
|
|
238
670
|
return;
|
|
239
671
|
}
|
|
240
672
|
}
|
|
@@ -254,7 +686,7 @@ program
|
|
|
254
686
|
});
|
|
255
687
|
} catch (e) {
|
|
256
688
|
const msg = e instanceof Error ? e.message : String(e);
|
|
257
|
-
console.error(`Error:
|
|
689
|
+
console.error(`Error: prepare-db: ${msg}`);
|
|
258
690
|
// When connection details are missing, show full init help (options + examples).
|
|
259
691
|
if (typeof msg === "string" && msg.startsWith("Connection is required.")) {
|
|
260
692
|
console.error("");
|
|
@@ -273,8 +705,8 @@ program
|
|
|
273
705
|
// Use native pg client instead of requiring psql to be installed
|
|
274
706
|
let client: Client | undefined;
|
|
275
707
|
try {
|
|
276
|
-
|
|
277
|
-
|
|
708
|
+
const connResult = await connectWithSslFallback(Client, adminConn);
|
|
709
|
+
client = connResult.client;
|
|
278
710
|
|
|
279
711
|
const dbRes = await client.query("select current_database() as db");
|
|
280
712
|
const database = dbRes.rows?.[0]?.db;
|
|
@@ -290,14 +722,14 @@ program
|
|
|
290
722
|
includeOptionalPermissions,
|
|
291
723
|
});
|
|
292
724
|
if (v.ok) {
|
|
293
|
-
console.log("✓
|
|
725
|
+
console.log("✓ prepare-db verify: OK");
|
|
294
726
|
if (v.missingOptional.length > 0) {
|
|
295
727
|
console.log("⚠ Optional items missing:");
|
|
296
728
|
for (const m of v.missingOptional) console.log(`- ${m}`);
|
|
297
729
|
}
|
|
298
730
|
return;
|
|
299
731
|
}
|
|
300
|
-
console.error("✗
|
|
732
|
+
console.error("✗ prepare-db verify failed: missing required items");
|
|
301
733
|
for (const m of v.missingRequired) console.error(`- ${m}`);
|
|
302
734
|
if (v.missingOptional.length > 0) {
|
|
303
735
|
console.error("Optional items missing:");
|
|
@@ -367,15 +799,13 @@ program
|
|
|
367
799
|
console.log(redactPasswords(step.sql));
|
|
368
800
|
}
|
|
369
801
|
console.log("\n--- end SQL plan ---\n");
|
|
370
|
-
|
|
371
|
-
console.log("Note: passwords are redacted in the printed SQL (use --show-secrets to print them).");
|
|
372
|
-
}
|
|
802
|
+
console.log("Note: passwords are redacted in the printed SQL output.");
|
|
373
803
|
return;
|
|
374
804
|
}
|
|
375
805
|
|
|
376
806
|
const { applied, skippedOptional } = await applyInitPlan({ client, plan: effectivePlan });
|
|
377
807
|
|
|
378
|
-
console.log(opts.resetPassword ? "✓
|
|
808
|
+
console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
|
|
379
809
|
if (skippedOptional.length > 0) {
|
|
380
810
|
console.log("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
|
|
381
811
|
for (const s of skippedOptional) console.log(`- ${s}`);
|
|
@@ -397,7 +827,7 @@ program
|
|
|
397
827
|
if (!message || message === "[object Object]") {
|
|
398
828
|
message = "Unknown error";
|
|
399
829
|
}
|
|
400
|
-
console.error(`Error:
|
|
830
|
+
console.error(`Error: prepare-db: ${message}`);
|
|
401
831
|
// If this was a plan step failure, surface the step name explicitly to help users diagnose quickly.
|
|
402
832
|
const stepMatch =
|
|
403
833
|
typeof message === "string" ? message.match(/Failed at step "([^"]+)":/i) : null;
|
|
@@ -449,6 +879,143 @@ program
|
|
|
449
879
|
}
|
|
450
880
|
});
|
|
451
881
|
|
|
882
|
+
program
|
|
883
|
+
.command("checkup [conn]")
|
|
884
|
+
.description("generate health check reports directly from PostgreSQL (express mode)")
|
|
885
|
+
.option("--check-id <id>", `specific check to run: ${Object.keys(CHECK_INFO).join(", ")}, or ALL`, "ALL")
|
|
886
|
+
.option("--node-name <name>", "node name for reports", "node-01")
|
|
887
|
+
.option("--output <path>", "output directory for JSON files")
|
|
888
|
+
.option("--[no-]upload", "upload JSON results to PostgresAI (default: enabled; requires API key)", undefined)
|
|
889
|
+
.option(
|
|
890
|
+
"--project <project>",
|
|
891
|
+
"project name or ID for remote upload (used with --upload; defaults to config defaultProject; auto-generated on first run)"
|
|
892
|
+
)
|
|
893
|
+
.option("--json", "output JSON to stdout (implies --no-upload)")
|
|
894
|
+
.addHelpText(
|
|
895
|
+
"after",
|
|
896
|
+
[
|
|
897
|
+
"",
|
|
898
|
+
"Available checks:",
|
|
899
|
+
...Object.entries(CHECK_INFO).map(([id, title]) => ` ${id}: ${title}`),
|
|
900
|
+
"",
|
|
901
|
+
"Examples:",
|
|
902
|
+
" postgresai checkup postgresql://user:pass@host:5432/db",
|
|
903
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --check-id A003",
|
|
904
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --output ./reports",
|
|
905
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --project my_project",
|
|
906
|
+
" postgresai set-default-project my_project",
|
|
907
|
+
" postgresai checkup postgresql://user:pass@host:5432/db",
|
|
908
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --no-upload --json",
|
|
909
|
+
].join("\n")
|
|
910
|
+
)
|
|
911
|
+
.action(async (conn: string | undefined, opts: CheckupOptions, cmd: Command) => {
|
|
912
|
+
if (!conn) {
|
|
913
|
+
cmd.outputHelp();
|
|
914
|
+
process.exitCode = 1;
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const shouldPrintJson = !!opts.json;
|
|
919
|
+
const uploadExplicitlyRequested = opts.upload === true;
|
|
920
|
+
const uploadExplicitlyDisabled = opts.upload === false || shouldPrintJson;
|
|
921
|
+
let shouldUpload = !uploadExplicitlyDisabled;
|
|
922
|
+
|
|
923
|
+
// Preflight: validate/create output directory BEFORE connecting / running checks.
|
|
924
|
+
const outputPath = prepareOutputDirectory(opts.output);
|
|
925
|
+
if (outputPath === null) {
|
|
926
|
+
process.exitCode = 1;
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Preflight: validate upload flags/credentials BEFORE connecting / running checks.
|
|
931
|
+
const rootOpts = program.opts() as CliOptions;
|
|
932
|
+
const uploadResult = prepareUploadConfig(opts, rootOpts, shouldUpload, uploadExplicitlyRequested);
|
|
933
|
+
if (uploadResult === null) {
|
|
934
|
+
process.exitCode = 1;
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
const uploadCfg = uploadResult?.config;
|
|
938
|
+
const projectWasGenerated = uploadResult?.projectWasGenerated ?? false;
|
|
939
|
+
shouldUpload = !!uploadCfg;
|
|
940
|
+
|
|
941
|
+
// Connect and run checks
|
|
942
|
+
const adminConn = resolveAdminConnection({
|
|
943
|
+
conn,
|
|
944
|
+
envPassword: process.env.PGPASSWORD,
|
|
945
|
+
});
|
|
946
|
+
let client: Client | undefined;
|
|
947
|
+
const spinnerEnabled = !!process.stdout.isTTY && shouldUpload;
|
|
948
|
+
const spinner = createTtySpinner(spinnerEnabled, "Connecting to Postgres");
|
|
949
|
+
|
|
950
|
+
try {
|
|
951
|
+
spinner.update("Connecting to Postgres");
|
|
952
|
+
const connResult = await connectWithSslFallback(Client, adminConn);
|
|
953
|
+
client = connResult.client as Client;
|
|
954
|
+
|
|
955
|
+
// Generate reports
|
|
956
|
+
let reports: Record<string, any>;
|
|
957
|
+
if (opts.checkId === "ALL") {
|
|
958
|
+
reports = await generateAllReports(client, opts.nodeName, (p) => {
|
|
959
|
+
spinner.update(`Running ${p.checkId}: ${p.checkTitle} (${p.index}/${p.total})`);
|
|
960
|
+
});
|
|
961
|
+
} else {
|
|
962
|
+
const checkId = opts.checkId.toUpperCase();
|
|
963
|
+
const generator = REPORT_GENERATORS[checkId];
|
|
964
|
+
if (!generator) {
|
|
965
|
+
spinner.stop();
|
|
966
|
+
console.error(`Unknown check ID: ${opts.checkId}`);
|
|
967
|
+
console.error(`Available: ${Object.keys(CHECK_INFO).join(", ")}, ALL`);
|
|
968
|
+
process.exitCode = 1;
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
spinner.update(`Running ${checkId}: ${CHECK_INFO[checkId] || checkId}`);
|
|
972
|
+
reports = { [checkId]: await generator(client, opts.nodeName) };
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// Upload to PostgresAI API (if configured)
|
|
976
|
+
let uploadSummary: UploadSummary | undefined;
|
|
977
|
+
if (uploadCfg) {
|
|
978
|
+
const logUpload = (msg: string): void => {
|
|
979
|
+
(shouldPrintJson ? console.error : console.log)(msg);
|
|
980
|
+
};
|
|
981
|
+
uploadSummary = await uploadCheckupReports(uploadCfg, reports, spinner, logUpload);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
spinner.stop();
|
|
985
|
+
|
|
986
|
+
// Write to files (if output path specified)
|
|
987
|
+
if (outputPath) {
|
|
988
|
+
writeReportFiles(reports, outputPath);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Print upload summary
|
|
992
|
+
if (uploadSummary) {
|
|
993
|
+
printUploadSummary(uploadSummary, projectWasGenerated, shouldPrintJson);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Output JSON to stdout
|
|
997
|
+
if (shouldPrintJson || (!shouldUpload && !opts.output)) {
|
|
998
|
+
console.log(JSON.stringify(reports, null, 2));
|
|
999
|
+
}
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
if (error instanceof RpcError) {
|
|
1002
|
+
for (const line of formatRpcErrorForDisplay(error)) {
|
|
1003
|
+
console.error(line);
|
|
1004
|
+
}
|
|
1005
|
+
} else {
|
|
1006
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1007
|
+
console.error(`Error: ${message}`);
|
|
1008
|
+
}
|
|
1009
|
+
process.exitCode = 1;
|
|
1010
|
+
} finally {
|
|
1011
|
+
// Always stop spinner to prevent interval leak (idempotent - safe to call multiple times)
|
|
1012
|
+
spinner.stop();
|
|
1013
|
+
if (client) {
|
|
1014
|
+
await client.end();
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
});
|
|
1018
|
+
|
|
452
1019
|
/**
|
|
453
1020
|
* Stub function for not implemented commands
|
|
454
1021
|
*/
|
|
@@ -482,6 +1049,14 @@ function resolvePaths(): PathResolution {
|
|
|
482
1049
|
);
|
|
483
1050
|
}
|
|
484
1051
|
|
|
1052
|
+
async function resolveOrInitPaths(): Promise<PathResolution> {
|
|
1053
|
+
try {
|
|
1054
|
+
return resolvePaths();
|
|
1055
|
+
} catch {
|
|
1056
|
+
return ensureDefaultMonitoringProject();
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
485
1060
|
/**
|
|
486
1061
|
* Check if Docker daemon is running
|
|
487
1062
|
*/
|
|
@@ -533,7 +1108,7 @@ async function runCompose(args: string[]): Promise<number> {
|
|
|
533
1108
|
let composeFile: string;
|
|
534
1109
|
let projectDir: string;
|
|
535
1110
|
try {
|
|
536
|
-
({ composeFile, projectDir } =
|
|
1111
|
+
({ composeFile, projectDir } = await resolveOrInitPaths());
|
|
537
1112
|
} catch (error) {
|
|
538
1113
|
const message = error instanceof Error ? error.message : String(error);
|
|
539
1114
|
console.error(message);
|
|
@@ -576,7 +1151,8 @@ async function runCompose(args: string[]): Promise<number> {
|
|
|
576
1151
|
return new Promise<number>((resolve) => {
|
|
577
1152
|
const child = spawn(cmd[0], [...cmd.slice(1), "-f", composeFile, ...args], {
|
|
578
1153
|
stdio: "inherit",
|
|
579
|
-
env: env
|
|
1154
|
+
env: env,
|
|
1155
|
+
cwd: projectDir
|
|
580
1156
|
});
|
|
581
1157
|
child.on("close", (code) => resolve(code || 0));
|
|
582
1158
|
});
|
|
@@ -590,18 +1166,43 @@ program.command("help", { isDefault: true }).description("show help").action(()
|
|
|
590
1166
|
const mon = program.command("mon").description("monitoring services management");
|
|
591
1167
|
|
|
592
1168
|
mon
|
|
593
|
-
.command("
|
|
594
|
-
.description("
|
|
1169
|
+
.command("local-install")
|
|
1170
|
+
.description("install local monitoring stack (generate config, start services)")
|
|
595
1171
|
.option("--demo", "demo mode with sample database", false)
|
|
596
1172
|
.option("--api-key <key>", "Postgres AI API key for automated report uploads")
|
|
597
1173
|
.option("--db-url <url>", "PostgreSQL connection URL to monitor")
|
|
1174
|
+
.option("--tag <tag>", "Docker image tag to use (e.g., 0.14.0, 0.14.0-dev.33)")
|
|
598
1175
|
.option("-y, --yes", "accept all defaults and skip interactive prompts", false)
|
|
599
|
-
.action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; yes: boolean }) => {
|
|
1176
|
+
.action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; tag?: string; yes: boolean }) => {
|
|
600
1177
|
console.log("\n=================================");
|
|
601
|
-
console.log(" PostgresAI
|
|
1178
|
+
console.log(" PostgresAI monitoring local install");
|
|
602
1179
|
console.log("=================================\n");
|
|
603
1180
|
console.log("This will install, configure, and start the monitoring system\n");
|
|
604
1181
|
|
|
1182
|
+
// Ensure we have a project directory with docker-compose.yml even if running from elsewhere
|
|
1183
|
+
const { projectDir } = await resolveOrInitPaths();
|
|
1184
|
+
console.log(`Project directory: ${projectDir}\n`);
|
|
1185
|
+
|
|
1186
|
+
// Update .env with custom tag if provided
|
|
1187
|
+
const envFile = path.resolve(projectDir, ".env");
|
|
1188
|
+
const imageTag = opts.tag || pkg.version;
|
|
1189
|
+
|
|
1190
|
+
// Build .env content
|
|
1191
|
+
const envLines: string[] = [`PGAI_TAG=${imageTag}`];
|
|
1192
|
+
// Preserve GF_SECURITY_ADMIN_PASSWORD if it exists
|
|
1193
|
+
if (fs.existsSync(envFile)) {
|
|
1194
|
+
const existingEnv = fs.readFileSync(envFile, "utf8");
|
|
1195
|
+
const pwdMatch = existingEnv.match(/^GF_SECURITY_ADMIN_PASSWORD=(.+)$/m);
|
|
1196
|
+
if (pwdMatch) {
|
|
1197
|
+
envLines.push(`GF_SECURITY_ADMIN_PASSWORD=${pwdMatch[1]}`);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
|
|
1201
|
+
|
|
1202
|
+
if (opts.tag) {
|
|
1203
|
+
console.log(`Using image tag: ${imageTag}\n`);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
605
1206
|
// Validate conflicting options
|
|
606
1207
|
if (opts.demo && opts.dbUrl) {
|
|
607
1208
|
console.log("⚠ Both --demo and --db-url provided. Demo mode includes its own database.");
|
|
@@ -612,8 +1213,8 @@ mon
|
|
|
612
1213
|
if (opts.demo && opts.apiKey) {
|
|
613
1214
|
console.error("✗ Cannot use --api-key with --demo mode");
|
|
614
1215
|
console.error("✗ Demo mode is for testing only and does not support API key integration");
|
|
615
|
-
console.error("\nUse demo mode without API key: postgres-ai mon
|
|
616
|
-
console.error("Or use production mode with API key: postgres-ai mon
|
|
1216
|
+
console.error("\nUse demo mode without API key: postgres-ai mon local-install --demo");
|
|
1217
|
+
console.error("Or use production mode with API key: postgres-ai mon local-install --api-key=your_key");
|
|
617
1218
|
process.exitCode = 1;
|
|
618
1219
|
return;
|
|
619
1220
|
}
|
|
@@ -634,6 +1235,11 @@ mon
|
|
|
634
1235
|
if (opts.apiKey) {
|
|
635
1236
|
console.log("Using API key provided via --api-key parameter");
|
|
636
1237
|
config.writeConfig({ apiKey: opts.apiKey });
|
|
1238
|
+
// Keep reporter compatibility (docker-compose mounts .pgwatch-config)
|
|
1239
|
+
fs.writeFileSync(path.resolve(projectDir, ".pgwatch-config"), `api_key=${opts.apiKey}\n`, {
|
|
1240
|
+
encoding: "utf8",
|
|
1241
|
+
mode: 0o600
|
|
1242
|
+
});
|
|
637
1243
|
console.log("✓ API key saved\n");
|
|
638
1244
|
} else if (opts.yes) {
|
|
639
1245
|
// Auto-yes mode without API key - skip API key setup
|
|
@@ -641,43 +1247,36 @@ mon
|
|
|
641
1247
|
console.log("⚠ Reports will be generated locally only");
|
|
642
1248
|
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
643
1249
|
} else {
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
config.writeConfig({ apiKey: trimmedKey });
|
|
663
|
-
console.log("✓ API key saved\n");
|
|
664
|
-
break;
|
|
665
|
-
}
|
|
1250
|
+
const answer = await question("Do you have a Postgres AI API key? (Y/n): ");
|
|
1251
|
+
const proceedWithApiKey = !answer || answer.toLowerCase() === "y";
|
|
1252
|
+
|
|
1253
|
+
if (proceedWithApiKey) {
|
|
1254
|
+
while (true) {
|
|
1255
|
+
const inputApiKey = await question("Enter your Postgres AI API key: ");
|
|
1256
|
+
const trimmedKey = inputApiKey.trim();
|
|
1257
|
+
|
|
1258
|
+
if (trimmedKey) {
|
|
1259
|
+
config.writeConfig({ apiKey: trimmedKey });
|
|
1260
|
+
// Keep reporter compatibility (docker-compose mounts .pgwatch-config)
|
|
1261
|
+
fs.writeFileSync(path.resolve(projectDir, ".pgwatch-config"), `api_key=${trimmedKey}\n`, {
|
|
1262
|
+
encoding: "utf8",
|
|
1263
|
+
mode: 0o600
|
|
1264
|
+
});
|
|
1265
|
+
console.log("✓ API key saved\n");
|
|
1266
|
+
break;
|
|
1267
|
+
}
|
|
666
1268
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
}
|
|
1269
|
+
console.log("⚠ API key cannot be empty");
|
|
1270
|
+
const retry = await question("Try again or skip API key setup, retry? (Y/n): ");
|
|
1271
|
+
if (retry.toLowerCase() === "n") {
|
|
1272
|
+
console.log("⚠ Skipping API key setup - reports will be generated locally only");
|
|
1273
|
+
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
1274
|
+
break;
|
|
674
1275
|
}
|
|
675
|
-
} else {
|
|
676
|
-
console.log("⚠ Skipping API key setup - reports will be generated locally only");
|
|
677
|
-
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
678
1276
|
}
|
|
679
|
-
}
|
|
680
|
-
|
|
1277
|
+
} else {
|
|
1278
|
+
console.log("⚠ Skipping API key setup - reports will be generated locally only");
|
|
1279
|
+
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
681
1280
|
}
|
|
682
1281
|
}
|
|
683
1282
|
} else {
|
|
@@ -690,9 +1289,11 @@ mon
|
|
|
690
1289
|
console.log("Step 2: Add PostgreSQL Instance to Monitor\n");
|
|
691
1290
|
|
|
692
1291
|
// Clear instances.yml in production mode (start fresh)
|
|
693
|
-
const instancesPath =
|
|
1292
|
+
const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
|
|
694
1293
|
const emptyInstancesContent = "# PostgreSQL instances to monitor\n# Add your instances using: postgres-ai mon targets add\n\n";
|
|
695
1294
|
fs.writeFileSync(instancesPath, emptyInstancesContent, "utf8");
|
|
1295
|
+
console.log(`Instances file: ${instancesPath}`);
|
|
1296
|
+
console.log(`Project directory: ${projectDir}\n`);
|
|
696
1297
|
|
|
697
1298
|
if (opts.dbUrl) {
|
|
698
1299
|
console.log("Using database URL provided via --db-url parameter");
|
|
@@ -721,7 +1322,6 @@ mon
|
|
|
721
1322
|
// Test connection
|
|
722
1323
|
console.log("Testing connection to the added instance...");
|
|
723
1324
|
try {
|
|
724
|
-
const { Client } = require("pg");
|
|
725
1325
|
const client = new Client({ connectionString: connStr });
|
|
726
1326
|
await client.connect();
|
|
727
1327
|
const result = await client.query("select version();");
|
|
@@ -738,63 +1338,50 @@ mon
|
|
|
738
1338
|
console.log("⚠ No PostgreSQL instance added");
|
|
739
1339
|
console.log("You can add one later with: postgres-ai mon targets add\n");
|
|
740
1340
|
} else {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
console.log(" 2. Press Enter to skip for now\n");
|
|
758
|
-
|
|
759
|
-
const connStr = await question("Enter connection string (or press Enter to skip): ");
|
|
760
|
-
|
|
761
|
-
if (connStr.trim()) {
|
|
762
|
-
const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
|
|
763
|
-
if (!m) {
|
|
764
|
-
console.error("✗ Invalid connection string format");
|
|
765
|
-
console.log("⚠ Continuing without adding instance\n");
|
|
766
|
-
} else {
|
|
767
|
-
const host = m[3];
|
|
768
|
-
const db = m[5];
|
|
769
|
-
const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
|
|
770
|
-
|
|
771
|
-
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`;
|
|
772
|
-
fs.appendFileSync(instancesPath, body, "utf8");
|
|
773
|
-
console.log(`✓ Monitoring target '${instanceName}' added\n`);
|
|
774
|
-
|
|
775
|
-
// Test connection
|
|
776
|
-
console.log("Testing connection to the added instance...");
|
|
777
|
-
try {
|
|
778
|
-
const { Client } = require("pg");
|
|
779
|
-
const client = new Client({ connectionString: connStr });
|
|
780
|
-
await client.connect();
|
|
781
|
-
const result = await client.query("select version();");
|
|
782
|
-
console.log("✓ Connection successful");
|
|
783
|
-
console.log(`${result.rows[0].version}\n`);
|
|
784
|
-
await client.end();
|
|
785
|
-
} catch (error) {
|
|
786
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
787
|
-
console.error(`✗ Connection failed: ${message}\n`);
|
|
788
|
-
}
|
|
789
|
-
}
|
|
1341
|
+
console.log("You need to add at least one PostgreSQL instance to monitor");
|
|
1342
|
+
const answer = await question("Do you want to add a PostgreSQL instance now? (Y/n): ");
|
|
1343
|
+
const proceedWithInstance = !answer || answer.toLowerCase() === "y";
|
|
1344
|
+
|
|
1345
|
+
if (proceedWithInstance) {
|
|
1346
|
+
console.log("\nYou can provide either:");
|
|
1347
|
+
console.log(" 1. A full connection string: postgresql://user:pass@host:port/database");
|
|
1348
|
+
console.log(" 2. Press Enter to skip for now\n");
|
|
1349
|
+
|
|
1350
|
+
const connStr = await question("Enter connection string (or press Enter to skip): ");
|
|
1351
|
+
|
|
1352
|
+
if (connStr.trim()) {
|
|
1353
|
+
const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
|
|
1354
|
+
if (!m) {
|
|
1355
|
+
console.error("✗ Invalid connection string format");
|
|
1356
|
+
console.log("⚠ Continuing without adding instance\n");
|
|
790
1357
|
} else {
|
|
791
|
-
|
|
1358
|
+
const host = m[3];
|
|
1359
|
+
const db = m[5];
|
|
1360
|
+
const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
|
|
1361
|
+
|
|
1362
|
+
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`;
|
|
1363
|
+
fs.appendFileSync(instancesPath, body, "utf8");
|
|
1364
|
+
console.log(`✓ Monitoring target '${instanceName}' added\n`);
|
|
1365
|
+
|
|
1366
|
+
// Test connection
|
|
1367
|
+
console.log("Testing connection to the added instance...");
|
|
1368
|
+
try {
|
|
1369
|
+
const client = new Client({ connectionString: connStr });
|
|
1370
|
+
await client.connect();
|
|
1371
|
+
const result = await client.query("select version();");
|
|
1372
|
+
console.log("✓ Connection successful");
|
|
1373
|
+
console.log(`${result.rows[0].version}\n`);
|
|
1374
|
+
await client.end();
|
|
1375
|
+
} catch (error) {
|
|
1376
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1377
|
+
console.error(`✗ Connection failed: ${message}\n`);
|
|
1378
|
+
}
|
|
792
1379
|
}
|
|
793
1380
|
} else {
|
|
794
1381
|
console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
|
|
795
1382
|
}
|
|
796
|
-
}
|
|
797
|
-
|
|
1383
|
+
} else {
|
|
1384
|
+
console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
|
|
798
1385
|
}
|
|
799
1386
|
}
|
|
800
1387
|
} else {
|
|
@@ -812,7 +1399,7 @@ mon
|
|
|
812
1399
|
|
|
813
1400
|
// Step 4: Ensure Grafana password is configured
|
|
814
1401
|
console.log(opts.demo ? "Step 4: Configuring Grafana security..." : "Step 4: Configuring Grafana security...");
|
|
815
|
-
const cfgPath = path.resolve(
|
|
1402
|
+
const cfgPath = path.resolve(projectDir, ".pgwatch-config");
|
|
816
1403
|
let grafanaPassword = "";
|
|
817
1404
|
|
|
818
1405
|
try {
|
|
@@ -863,7 +1450,7 @@ mon
|
|
|
863
1450
|
|
|
864
1451
|
// Final summary
|
|
865
1452
|
console.log("=================================");
|
|
866
|
-
console.log("
|
|
1453
|
+
console.log(" Local install completed!");
|
|
867
1454
|
console.log("=================================\n");
|
|
868
1455
|
|
|
869
1456
|
console.log("What's running:");
|
|
@@ -980,17 +1567,17 @@ mon
|
|
|
980
1567
|
allHealthy = true;
|
|
981
1568
|
for (const service of services) {
|
|
982
1569
|
try {
|
|
983
|
-
const
|
|
984
|
-
const status =
|
|
985
|
-
encoding: 'utf8',
|
|
986
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
987
|
-
}).trim();
|
|
1570
|
+
const result = spawnSync("docker", ["inspect", "-f", "{{.State.Status}}", service.container], { stdio: "pipe" });
|
|
1571
|
+
const status = result.stdout.trim();
|
|
988
1572
|
|
|
989
|
-
if (status === 'running') {
|
|
1573
|
+
if (result.status === 0 && status === 'running') {
|
|
990
1574
|
console.log(`✓ ${service.name}: healthy`);
|
|
991
|
-
} else {
|
|
1575
|
+
} else if (result.status === 0) {
|
|
992
1576
|
console.log(`✗ ${service.name}: unhealthy (status: ${status})`);
|
|
993
1577
|
allHealthy = false;
|
|
1578
|
+
} else {
|
|
1579
|
+
console.log(`✗ ${service.name}: unreachable`);
|
|
1580
|
+
allHealthy = false;
|
|
994
1581
|
}
|
|
995
1582
|
} catch (error) {
|
|
996
1583
|
console.log(`✗ ${service.name}: unreachable`);
|
|
@@ -1019,7 +1606,7 @@ mon
|
|
|
1019
1606
|
let composeFile: string;
|
|
1020
1607
|
let instancesFile: string;
|
|
1021
1608
|
try {
|
|
1022
|
-
({ projectDir, composeFile, instancesFile } =
|
|
1609
|
+
({ projectDir, composeFile, instancesFile } = await resolveOrInitPaths());
|
|
1023
1610
|
} catch (error) {
|
|
1024
1611
|
const message = error instanceof Error ? error.message : String(error);
|
|
1025
1612
|
console.error(message);
|
|
@@ -1094,14 +1681,6 @@ mon
|
|
|
1094
1681
|
.command("reset [service]")
|
|
1095
1682
|
.description("reset all or specific monitoring service")
|
|
1096
1683
|
.action(async (service?: string) => {
|
|
1097
|
-
const rl = readline.createInterface({
|
|
1098
|
-
input: process.stdin,
|
|
1099
|
-
output: process.stdout,
|
|
1100
|
-
});
|
|
1101
|
-
|
|
1102
|
-
const question = (prompt: string): Promise<string> =>
|
|
1103
|
-
new Promise((resolve) => rl.question(prompt, resolve));
|
|
1104
|
-
|
|
1105
1684
|
try {
|
|
1106
1685
|
if (service) {
|
|
1107
1686
|
// Reset specific service
|
|
@@ -1111,7 +1690,6 @@ mon
|
|
|
1111
1690
|
const answer = await question("Continue? (y/N): ");
|
|
1112
1691
|
if (answer.toLowerCase() !== "y") {
|
|
1113
1692
|
console.log("Cancelled");
|
|
1114
|
-
rl.close();
|
|
1115
1693
|
return;
|
|
1116
1694
|
}
|
|
1117
1695
|
|
|
@@ -1138,7 +1716,6 @@ mon
|
|
|
1138
1716
|
const answer = await question("Continue? (y/N): ");
|
|
1139
1717
|
if (answer.toLowerCase() !== "y") {
|
|
1140
1718
|
console.log("Cancelled");
|
|
1141
|
-
rl.close();
|
|
1142
1719
|
return;
|
|
1143
1720
|
}
|
|
1144
1721
|
|
|
@@ -1152,10 +1729,7 @@ mon
|
|
|
1152
1729
|
process.exitCode = 1;
|
|
1153
1730
|
}
|
|
1154
1731
|
}
|
|
1155
|
-
|
|
1156
|
-
rl.close();
|
|
1157
1732
|
} catch (error) {
|
|
1158
|
-
rl.close();
|
|
1159
1733
|
const message = error instanceof Error ? error.message : String(error);
|
|
1160
1734
|
console.error(`Reset failed: ${message}`);
|
|
1161
1735
|
process.exitCode = 1;
|
|
@@ -1219,9 +1793,9 @@ targets
|
|
|
1219
1793
|
.command("list")
|
|
1220
1794
|
.description("list monitoring target databases")
|
|
1221
1795
|
.action(async () => {
|
|
1222
|
-
const instancesPath =
|
|
1796
|
+
const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
|
|
1223
1797
|
if (!fs.existsSync(instancesPath)) {
|
|
1224
|
-
console.error(`instances.yml not found in ${
|
|
1798
|
+
console.error(`instances.yml not found in ${projectDir}`);
|
|
1225
1799
|
process.exitCode = 1;
|
|
1226
1800
|
return;
|
|
1227
1801
|
}
|
|
@@ -1268,7 +1842,7 @@ targets
|
|
|
1268
1842
|
.command("add [connStr] [name]")
|
|
1269
1843
|
.description("add monitoring target database")
|
|
1270
1844
|
.action(async (connStr?: string, name?: string) => {
|
|
1271
|
-
const file =
|
|
1845
|
+
const { instancesFile: file } = await resolveOrInitPaths();
|
|
1272
1846
|
if (!connStr) {
|
|
1273
1847
|
console.error("Connection string required: postgresql://user:pass@host:port/db");
|
|
1274
1848
|
process.exitCode = 1;
|
|
@@ -1318,7 +1892,7 @@ targets
|
|
|
1318
1892
|
.command("remove <name>")
|
|
1319
1893
|
.description("remove monitoring target database")
|
|
1320
1894
|
.action(async (name: string) => {
|
|
1321
|
-
const file =
|
|
1895
|
+
const { instancesFile: file } = await resolveOrInitPaths();
|
|
1322
1896
|
if (!fs.existsSync(file)) {
|
|
1323
1897
|
console.error("instances.yml not found");
|
|
1324
1898
|
process.exitCode = 1;
|
|
@@ -1355,7 +1929,7 @@ targets
|
|
|
1355
1929
|
.command("test <name>")
|
|
1356
1930
|
.description("test monitoring target database connectivity")
|
|
1357
1931
|
.action(async (name: string) => {
|
|
1358
|
-
const instancesPath =
|
|
1932
|
+
const { instancesFile: instancesPath } = await resolveOrInitPaths();
|
|
1359
1933
|
if (!fs.existsSync(instancesPath)) {
|
|
1360
1934
|
console.error("instances.yml not found");
|
|
1361
1935
|
process.exitCode = 1;
|
|
@@ -1389,7 +1963,6 @@ targets
|
|
|
1389
1963
|
console.log(`Testing connection to monitoring target '${name}'...`);
|
|
1390
1964
|
|
|
1391
1965
|
// Use native pg client instead of requiring psql to be installed
|
|
1392
|
-
const { Client } = require('pg');
|
|
1393
1966
|
const client = new Client({ connectionString: instance.conn_str });
|
|
1394
1967
|
|
|
1395
1968
|
try {
|
|
@@ -1408,15 +1981,43 @@ targets
|
|
|
1408
1981
|
});
|
|
1409
1982
|
|
|
1410
1983
|
// Authentication and API key management
|
|
1411
|
-
program
|
|
1412
|
-
|
|
1413
|
-
|
|
1984
|
+
const auth = program.command("auth").description("authentication and API key management");
|
|
1985
|
+
|
|
1986
|
+
auth
|
|
1987
|
+
.command("login", { isDefault: true })
|
|
1988
|
+
.description("authenticate via browser (OAuth) or store API key directly")
|
|
1989
|
+
.option("--set-key <key>", "store API key directly without OAuth flow")
|
|
1414
1990
|
.option("--port <port>", "local callback server port (default: random)", parseInt)
|
|
1415
1991
|
.option("--debug", "enable debug output")
|
|
1416
|
-
.action(async (opts: { port?: number; debug?: boolean }) => {
|
|
1417
|
-
|
|
1418
|
-
|
|
1992
|
+
.action(async (opts: { setKey?: string; port?: number; debug?: boolean }) => {
|
|
1993
|
+
// If --set-key is provided, store it directly without OAuth
|
|
1994
|
+
if (opts.setKey) {
|
|
1995
|
+
const trimmedKey = opts.setKey.trim();
|
|
1996
|
+
if (!trimmedKey) {
|
|
1997
|
+
console.error("Error: API key cannot be empty");
|
|
1998
|
+
process.exitCode = 1;
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
// Read existing config to check for defaultProject before updating
|
|
2003
|
+
const existingConfig = config.readConfig();
|
|
2004
|
+
const existingProject = existingConfig.defaultProject;
|
|
2005
|
+
|
|
2006
|
+
config.writeConfig({ apiKey: trimmedKey });
|
|
2007
|
+
// When API key is set directly, only clear orgId (org selection may differ).
|
|
2008
|
+
// Preserve defaultProject to avoid orphaning historical reports.
|
|
2009
|
+
// If the new key lacks access to the project, upload will fail with a clear error.
|
|
2010
|
+
config.deleteConfigKeys(["orgId"]);
|
|
2011
|
+
|
|
2012
|
+
console.log(`API key saved to ${config.getConfigPath()}`);
|
|
2013
|
+
if (existingProject) {
|
|
2014
|
+
console.log(`Note: Your default project "${existingProject}" has been preserved.`);
|
|
2015
|
+
console.log(` If this key belongs to a different account, use --project to specify a new one.`);
|
|
2016
|
+
}
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
1419
2019
|
|
|
2020
|
+
// Otherwise, proceed with OAuth flow
|
|
1420
2021
|
console.log("Starting authentication flow...\n");
|
|
1421
2022
|
|
|
1422
2023
|
// Generate PKCE parameters
|
|
@@ -1437,10 +2038,10 @@ program
|
|
|
1437
2038
|
const requestedPort = opts.port || 0; // 0 = OS assigns available port
|
|
1438
2039
|
const callbackServer = authServer.createCallbackServer(requestedPort, params.state, 120000); // 2 minute timeout
|
|
1439
2040
|
|
|
1440
|
-
// Wait
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
const redirectUri = `http://
|
|
2041
|
+
// Wait for server to start and get the actual port
|
|
2042
|
+
const actualPort = await callbackServer.ready;
|
|
2043
|
+
// Use 127.0.0.1 to match the server bind address (avoids IPv6 issues on some hosts)
|
|
2044
|
+
const redirectUri = `http://127.0.0.1:${actualPort}/callback`;
|
|
1444
2045
|
|
|
1445
2046
|
console.log(`Callback server listening on port ${actualPort}`);
|
|
1446
2047
|
|
|
@@ -1462,173 +2063,179 @@ program
|
|
|
1462
2063
|
console.log(`Debug: Request data: ${initData}`);
|
|
1463
2064
|
}
|
|
1464
2065
|
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
2066
|
+
// Step 2: Initialize OAuth session on backend using fetch
|
|
2067
|
+
let initResponse: Response;
|
|
2068
|
+
try {
|
|
2069
|
+
initResponse = await fetch(initUrl.toString(), {
|
|
1468
2070
|
method: "POST",
|
|
1469
2071
|
headers: {
|
|
1470
2072
|
"Content-Type": "application/json",
|
|
1471
|
-
"Content-Length": Buffer.byteLength(initData),
|
|
1472
2073
|
},
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
if (data.trim().startsWith("<!") || data.trim().startsWith("<html")) {
|
|
1483
|
-
console.error("Error: Received HTML response instead of JSON. This usually means:");
|
|
1484
|
-
console.error(" 1. The API endpoint URL is incorrect");
|
|
1485
|
-
console.error(" 2. The endpoint does not exist (404)");
|
|
1486
|
-
console.error(`\nAPI URL attempted: ${initUrl.toString()}`);
|
|
1487
|
-
console.error("\nPlease verify the --api-base-url parameter.");
|
|
1488
|
-
} else {
|
|
1489
|
-
console.error(data);
|
|
1490
|
-
}
|
|
2074
|
+
body: initData,
|
|
2075
|
+
});
|
|
2076
|
+
} catch (err) {
|
|
2077
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2078
|
+
console.error(`Failed to connect to API: ${message}`);
|
|
2079
|
+
callbackServer.server.stop();
|
|
2080
|
+
process.exit(1);
|
|
2081
|
+
return;
|
|
2082
|
+
}
|
|
1491
2083
|
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
2084
|
+
if (!initResponse.ok) {
|
|
2085
|
+
const data = await initResponse.text();
|
|
2086
|
+
console.error(`Failed to initialize auth session: ${initResponse.status}`);
|
|
2087
|
+
|
|
2088
|
+
// Check if response is HTML (common for 404 pages)
|
|
2089
|
+
if (data.trim().startsWith("<!") || data.trim().startsWith("<html")) {
|
|
2090
|
+
console.error("Error: Received HTML response instead of JSON. This usually means:");
|
|
2091
|
+
console.error(" 1. The API endpoint URL is incorrect");
|
|
2092
|
+
console.error(" 2. The endpoint does not exist (404)");
|
|
2093
|
+
console.error(`\nAPI URL attempted: ${initUrl.toString()}`);
|
|
2094
|
+
console.error("\nPlease verify the --api-base-url parameter.");
|
|
2095
|
+
} else {
|
|
2096
|
+
console.error(data);
|
|
2097
|
+
}
|
|
1495
2098
|
|
|
1496
|
-
|
|
1497
|
-
|
|
2099
|
+
callbackServer.server.stop();
|
|
2100
|
+
process.exit(1);
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
1498
2103
|
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
}
|
|
2104
|
+
// Step 3: Open browser
|
|
2105
|
+
const authUrl = `${uiBaseUrl}/cli/auth?state=${encodeURIComponent(params.state)}&code_challenge=${encodeURIComponent(params.codeChallenge)}&code_challenge_method=S256&redirect_uri=${encodeURIComponent(redirectUri)}`;
|
|
1502
2106
|
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
// Open browser (cross-platform)
|
|
1507
|
-
const openCommand = process.platform === "darwin" ? "open" :
|
|
1508
|
-
process.platform === "win32" ? "start" :
|
|
1509
|
-
"xdg-open";
|
|
1510
|
-
spawn(openCommand, [authUrl], { detached: true, stdio: "ignore" }).unref();
|
|
1511
|
-
|
|
1512
|
-
// Step 4: Wait for callback
|
|
1513
|
-
console.log("Waiting for authorization...");
|
|
1514
|
-
console.log("(Press Ctrl+C to cancel)\n");
|
|
1515
|
-
|
|
1516
|
-
// Handle Ctrl+C gracefully
|
|
1517
|
-
const cancelHandler = () => {
|
|
1518
|
-
console.log("\n\nAuthentication cancelled by user.");
|
|
1519
|
-
callbackServer.server.close();
|
|
1520
|
-
process.exit(130); // Standard exit code for SIGINT
|
|
1521
|
-
};
|
|
1522
|
-
process.on("SIGINT", cancelHandler);
|
|
1523
|
-
|
|
1524
|
-
try {
|
|
1525
|
-
const { code } = await callbackServer.promise;
|
|
1526
|
-
|
|
1527
|
-
// Remove the cancel handler after successful auth
|
|
1528
|
-
process.off("SIGINT", cancelHandler);
|
|
1529
|
-
|
|
1530
|
-
// Step 5: Exchange code for token
|
|
1531
|
-
console.log("\nExchanging authorization code for API token...");
|
|
1532
|
-
const exchangeData = JSON.stringify({
|
|
1533
|
-
authorization_code: code,
|
|
1534
|
-
code_verifier: params.codeVerifier,
|
|
1535
|
-
state: params.state,
|
|
1536
|
-
});
|
|
1537
|
-
const exchangeUrl = new URL(`${apiBaseUrl}/rpc/oauth_token_exchange`);
|
|
1538
|
-
const exchangeReq = http.request(
|
|
1539
|
-
exchangeUrl,
|
|
1540
|
-
{
|
|
1541
|
-
method: "POST",
|
|
1542
|
-
headers: {
|
|
1543
|
-
"Content-Type": "application/json",
|
|
1544
|
-
"Content-Length": Buffer.byteLength(exchangeData),
|
|
1545
|
-
},
|
|
1546
|
-
},
|
|
1547
|
-
(exchangeRes) => {
|
|
1548
|
-
let exchangeBody = "";
|
|
1549
|
-
exchangeRes.on("data", (chunk) => (exchangeBody += chunk));
|
|
1550
|
-
exchangeRes.on("end", () => {
|
|
1551
|
-
if (exchangeRes.statusCode !== 200) {
|
|
1552
|
-
console.error(`Failed to exchange code for token: ${exchangeRes.statusCode}`);
|
|
1553
|
-
|
|
1554
|
-
// Check if response is HTML (common for 404 pages)
|
|
1555
|
-
if (exchangeBody.trim().startsWith("<!") || exchangeBody.trim().startsWith("<html")) {
|
|
1556
|
-
console.error("Error: Received HTML response instead of JSON. This usually means:");
|
|
1557
|
-
console.error(" 1. The API endpoint URL is incorrect");
|
|
1558
|
-
console.error(" 2. The endpoint does not exist (404)");
|
|
1559
|
-
console.error(`\nAPI URL attempted: ${exchangeUrl.toString()}`);
|
|
1560
|
-
console.error("\nPlease verify the --api-base-url parameter.");
|
|
1561
|
-
} else {
|
|
1562
|
-
console.error(exchangeBody);
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
process.exit(1);
|
|
1566
|
-
return;
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
try {
|
|
1570
|
-
const result = JSON.parse(exchangeBody);
|
|
1571
|
-
const apiToken = result.api_token || result?.[0]?.result?.api_token; // There is a bug with PostgREST Caching that may return an array, not single object, it's a workaround to support both cases.
|
|
1572
|
-
const orgId = result.org_id || result?.[0]?.result?.org_id; // There is a bug with PostgREST Caching that may return an array, not single object, it's a workaround to support both cases.
|
|
1573
|
-
|
|
1574
|
-
// Step 6: Save token to config
|
|
1575
|
-
config.writeConfig({
|
|
1576
|
-
apiKey: apiToken,
|
|
1577
|
-
baseUrl: apiBaseUrl,
|
|
1578
|
-
orgId: orgId,
|
|
1579
|
-
});
|
|
1580
|
-
|
|
1581
|
-
console.log("\nAuthentication successful!");
|
|
1582
|
-
console.log(`API key saved to: ${config.getConfigPath()}`);
|
|
1583
|
-
console.log(`Organization ID: ${orgId}`);
|
|
1584
|
-
console.log(`\nYou can now use the CLI without specifying an API key.`);
|
|
1585
|
-
process.exit(0);
|
|
1586
|
-
} catch (err) {
|
|
1587
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1588
|
-
console.error(`Failed to parse response: ${message}`);
|
|
1589
|
-
process.exit(1);
|
|
1590
|
-
}
|
|
1591
|
-
});
|
|
1592
|
-
}
|
|
1593
|
-
);
|
|
1594
|
-
|
|
1595
|
-
exchangeReq.on("error", (err: Error) => {
|
|
1596
|
-
console.error(`Exchange request failed: ${err.message}`);
|
|
1597
|
-
process.exit(1);
|
|
1598
|
-
});
|
|
2107
|
+
if (opts.debug) {
|
|
2108
|
+
console.log(`Debug: Auth URL: ${authUrl}`);
|
|
2109
|
+
}
|
|
1599
2110
|
|
|
1600
|
-
|
|
1601
|
-
|
|
2111
|
+
console.log(`\nOpening browser for authentication...`);
|
|
2112
|
+
console.log(`If browser does not open automatically, visit:\n${authUrl}\n`);
|
|
1602
2113
|
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
2114
|
+
// Open browser (cross-platform)
|
|
2115
|
+
const openCommand = process.platform === "darwin" ? "open" :
|
|
2116
|
+
process.platform === "win32" ? "start" :
|
|
2117
|
+
"xdg-open";
|
|
2118
|
+
spawn(openCommand, [authUrl], { detached: true, stdio: "ignore" }).unref();
|
|
1606
2119
|
|
|
1607
|
-
|
|
2120
|
+
// Step 4: Wait for callback
|
|
2121
|
+
console.log("Waiting for authorization...");
|
|
2122
|
+
console.log("(Press Ctrl+C to cancel)\n");
|
|
1608
2123
|
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
}
|
|
2124
|
+
// Handle Ctrl+C gracefully
|
|
2125
|
+
const cancelHandler = () => {
|
|
2126
|
+
console.log("\n\nAuthentication cancelled by user.");
|
|
2127
|
+
callbackServer.server.stop();
|
|
2128
|
+
process.exit(130); // Standard exit code for SIGINT
|
|
2129
|
+
};
|
|
2130
|
+
process.on("SIGINT", cancelHandler);
|
|
1617
2131
|
|
|
1618
|
-
|
|
1619
|
-
|
|
2132
|
+
try {
|
|
2133
|
+
const { code } = await callbackServer.promise;
|
|
2134
|
+
|
|
2135
|
+
// Remove the cancel handler after successful auth
|
|
2136
|
+
process.off("SIGINT", cancelHandler);
|
|
2137
|
+
|
|
2138
|
+
// Step 5: Exchange code for token using fetch
|
|
2139
|
+
console.log("\nExchanging authorization code for API token...");
|
|
2140
|
+
const exchangeData = JSON.stringify({
|
|
2141
|
+
authorization_code: code,
|
|
2142
|
+
code_verifier: params.codeVerifier,
|
|
2143
|
+
state: params.state,
|
|
2144
|
+
});
|
|
2145
|
+
const exchangeUrl = new URL(`${apiBaseUrl}/rpc/oauth_token_exchange`);
|
|
2146
|
+
|
|
2147
|
+
let exchangeResponse: Response;
|
|
2148
|
+
try {
|
|
2149
|
+
exchangeResponse = await fetch(exchangeUrl.toString(), {
|
|
2150
|
+
method: "POST",
|
|
2151
|
+
headers: {
|
|
2152
|
+
"Content-Type": "application/json",
|
|
2153
|
+
},
|
|
2154
|
+
body: exchangeData,
|
|
1620
2155
|
});
|
|
2156
|
+
} catch (err) {
|
|
2157
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2158
|
+
console.error(`Exchange request failed: ${message}`);
|
|
2159
|
+
process.exit(1);
|
|
2160
|
+
return;
|
|
1621
2161
|
}
|
|
1622
|
-
);
|
|
1623
2162
|
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
2163
|
+
const exchangeBody = await exchangeResponse.text();
|
|
2164
|
+
|
|
2165
|
+
if (!exchangeResponse.ok) {
|
|
2166
|
+
console.error(`Failed to exchange code for token: ${exchangeResponse.status}`);
|
|
2167
|
+
|
|
2168
|
+
// Check if response is HTML (common for 404 pages)
|
|
2169
|
+
if (exchangeBody.trim().startsWith("<!") || exchangeBody.trim().startsWith("<html")) {
|
|
2170
|
+
console.error("Error: Received HTML response instead of JSON. This usually means:");
|
|
2171
|
+
console.error(" 1. The API endpoint URL is incorrect");
|
|
2172
|
+
console.error(" 2. The endpoint does not exist (404)");
|
|
2173
|
+
console.error(`\nAPI URL attempted: ${exchangeUrl.toString()}`);
|
|
2174
|
+
console.error("\nPlease verify the --api-base-url parameter.");
|
|
2175
|
+
} else {
|
|
2176
|
+
console.error(exchangeBody);
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
process.exit(1);
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
try {
|
|
2184
|
+
const result = JSON.parse(exchangeBody);
|
|
2185
|
+
const apiToken = result.api_token || result?.[0]?.result?.api_token; // There is a bug with PostgREST Caching that may return an array, not single object, it's a workaround to support both cases.
|
|
2186
|
+
const orgId = result.org_id || result?.[0]?.result?.org_id; // There is a bug with PostgREST Caching that may return an array, not single object, it's a workaround to support both cases.
|
|
2187
|
+
|
|
2188
|
+
// Step 6: Save token to config
|
|
2189
|
+
// Check if org changed to decide whether to preserve defaultProject
|
|
2190
|
+
const existingConfig = config.readConfig();
|
|
2191
|
+
const existingOrgId = existingConfig.orgId;
|
|
2192
|
+
const existingProject = existingConfig.defaultProject;
|
|
2193
|
+
const orgChanged = existingOrgId && existingOrgId !== orgId;
|
|
2194
|
+
|
|
2195
|
+
config.writeConfig({
|
|
2196
|
+
apiKey: apiToken,
|
|
2197
|
+
baseUrl: apiBaseUrl,
|
|
2198
|
+
orgId: orgId,
|
|
2199
|
+
});
|
|
2200
|
+
|
|
2201
|
+
// Only clear defaultProject if org actually changed
|
|
2202
|
+
if (orgChanged && existingProject) {
|
|
2203
|
+
config.deleteConfigKeys(["defaultProject"]);
|
|
2204
|
+
console.log(`\nNote: Organization changed (${existingOrgId} → ${orgId}).`);
|
|
2205
|
+
console.log(` Default project "${existingProject}" has been cleared.`);
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
console.log("\nAuthentication successful!");
|
|
2209
|
+
console.log(`API key saved to: ${config.getConfigPath()}`);
|
|
2210
|
+
console.log(`Organization ID: ${orgId}`);
|
|
2211
|
+
if (!orgChanged && existingProject) {
|
|
2212
|
+
console.log(`Default project: ${existingProject} (preserved)`);
|
|
2213
|
+
}
|
|
2214
|
+
console.log(`\nYou can now use the CLI without specifying an API key.`);
|
|
2215
|
+
process.exit(0);
|
|
2216
|
+
} catch (err) {
|
|
2217
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2218
|
+
console.error(`Failed to parse response: ${message}`);
|
|
2219
|
+
process.exit(1);
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
} catch (err) {
|
|
2223
|
+
// Remove the cancel handler in error case too
|
|
2224
|
+
process.off("SIGINT", cancelHandler);
|
|
1629
2225
|
|
|
1630
|
-
|
|
1631
|
-
|
|
2226
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2227
|
+
|
|
2228
|
+
// Provide more helpful error messages
|
|
2229
|
+
if (message.includes("timeout")) {
|
|
2230
|
+
console.error(`\nAuthentication timed out.`);
|
|
2231
|
+
console.error(`This usually means you closed the browser window without completing authentication.`);
|
|
2232
|
+
console.error(`Please try again and complete the authentication flow.`);
|
|
2233
|
+
} else {
|
|
2234
|
+
console.error(`\nAuthentication failed: ${message}`);
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
process.exit(1);
|
|
2238
|
+
}
|
|
1632
2239
|
|
|
1633
2240
|
} catch (err) {
|
|
1634
2241
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1637,15 +2244,7 @@ program
|
|
|
1637
2244
|
}
|
|
1638
2245
|
});
|
|
1639
2246
|
|
|
1640
|
-
|
|
1641
|
-
.command("add-key <apiKey>")
|
|
1642
|
-
.description("store API key")
|
|
1643
|
-
.action(async (apiKey: string) => {
|
|
1644
|
-
config.writeConfig({ apiKey });
|
|
1645
|
-
console.log(`API key saved to ${config.getConfigPath()}`);
|
|
1646
|
-
});
|
|
1647
|
-
|
|
1648
|
-
program
|
|
2247
|
+
auth
|
|
1649
2248
|
.command("show-key")
|
|
1650
2249
|
.description("show API key (masked)")
|
|
1651
2250
|
.action(async () => {
|
|
@@ -1655,7 +2254,6 @@ program
|
|
|
1655
2254
|
console.log(`\nTo authenticate, run: pgai auth`);
|
|
1656
2255
|
return;
|
|
1657
2256
|
}
|
|
1658
|
-
const { maskSecret } = require("../lib/util");
|
|
1659
2257
|
console.log(`Current API key: ${maskSecret(cfg.apiKey)}`);
|
|
1660
2258
|
if (cfg.orgId) {
|
|
1661
2259
|
console.log(`Organization ID: ${cfg.orgId}`);
|
|
@@ -1663,14 +2261,20 @@ program
|
|
|
1663
2261
|
console.log(`Config location: ${config.getConfigPath()}`);
|
|
1664
2262
|
});
|
|
1665
2263
|
|
|
1666
|
-
|
|
2264
|
+
auth
|
|
1667
2265
|
.command("remove-key")
|
|
1668
2266
|
.description("remove API key")
|
|
1669
2267
|
.action(async () => {
|
|
1670
2268
|
// Check both new config and legacy config
|
|
1671
2269
|
const newConfigPath = config.getConfigPath();
|
|
1672
2270
|
const hasNewConfig = fs.existsSync(newConfigPath);
|
|
1673
|
-
|
|
2271
|
+
let legacyPath: string;
|
|
2272
|
+
try {
|
|
2273
|
+
const { projectDir } = await resolveOrInitPaths();
|
|
2274
|
+
legacyPath = path.resolve(projectDir, ".pgwatch-config");
|
|
2275
|
+
} catch {
|
|
2276
|
+
legacyPath = path.resolve(process.cwd(), ".pgwatch-config");
|
|
2277
|
+
}
|
|
1674
2278
|
const hasLegacyConfig = fs.existsSync(legacyPath) && fs.statSync(legacyPath).isFile();
|
|
1675
2279
|
|
|
1676
2280
|
if (!hasNewConfig && !hasLegacyConfig) {
|
|
@@ -1706,7 +2310,8 @@ mon
|
|
|
1706
2310
|
.command("generate-grafana-password")
|
|
1707
2311
|
.description("generate Grafana password for monitoring services")
|
|
1708
2312
|
.action(async () => {
|
|
1709
|
-
const
|
|
2313
|
+
const { projectDir } = await resolveOrInitPaths();
|
|
2314
|
+
const cfgPath = path.resolve(projectDir, ".pgwatch-config");
|
|
1710
2315
|
|
|
1711
2316
|
try {
|
|
1712
2317
|
// Generate secure password using openssl
|
|
@@ -1757,9 +2362,10 @@ mon
|
|
|
1757
2362
|
.command("show-grafana-credentials")
|
|
1758
2363
|
.description("show Grafana credentials for monitoring services")
|
|
1759
2364
|
.action(async () => {
|
|
1760
|
-
const
|
|
2365
|
+
const { projectDir } = await resolveOrInitPaths();
|
|
2366
|
+
const cfgPath = path.resolve(projectDir, ".pgwatch-config");
|
|
1761
2367
|
if (!fs.existsSync(cfgPath)) {
|
|
1762
|
-
console.error("Configuration file not found. Run 'postgres-ai mon
|
|
2368
|
+
console.error("Configuration file not found. Run 'postgres-ai mon local-install' first.");
|
|
1763
2369
|
process.exitCode = 1;
|
|
1764
2370
|
return;
|
|
1765
2371
|
}
|
|
@@ -1957,15 +2563,7 @@ mcp
|
|
|
1957
2563
|
console.log(" 4. Codex");
|
|
1958
2564
|
console.log("");
|
|
1959
2565
|
|
|
1960
|
-
const
|
|
1961
|
-
input: process.stdin,
|
|
1962
|
-
output: process.stdout
|
|
1963
|
-
});
|
|
1964
|
-
|
|
1965
|
-
const answer = await new Promise<string>((resolve) => {
|
|
1966
|
-
rl.question("Select your AI coding tool (1-4): ", resolve);
|
|
1967
|
-
});
|
|
1968
|
-
rl.close();
|
|
2566
|
+
const answer = await question("Select your AI coding tool (1-4): ");
|
|
1969
2567
|
|
|
1970
2568
|
const choices: Record<string, string> = {
|
|
1971
2569
|
"1": "cursor",
|
|
@@ -2100,5 +2698,7 @@ mcp
|
|
|
2100
2698
|
}
|
|
2101
2699
|
});
|
|
2102
2700
|
|
|
2103
|
-
program.parseAsync(process.argv)
|
|
2701
|
+
program.parseAsync(process.argv).finally(() => {
|
|
2702
|
+
closeReadline();
|
|
2703
|
+
});
|
|
2104
2704
|
|