postgresai 0.14.0-dev.7 → 0.14.0-dev.70
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 +161 -61
- package/bin/postgres-ai.ts +1957 -404
- package/bun.lock +258 -0
- package/bunfig.toml +20 -0
- package/dist/bin/postgres-ai.js +29351 -1576
- package/dist/sql/01.role.sql +16 -0
- package/dist/sql/02.permissions.sql +37 -0
- package/dist/sql/03.optional_rds.sql +6 -0
- package/dist/sql/04.optional_self_managed.sql +8 -0
- package/dist/sql/05.helpers.sql +439 -0
- package/dist/sql/sql/01.role.sql +16 -0
- package/dist/sql/sql/02.permissions.sql +37 -0
- package/dist/sql/sql/03.optional_rds.sql +6 -0
- package/dist/sql/sql/04.optional_self_managed.sql +8 -0
- package/dist/sql/sql/05.helpers.sql +439 -0
- package/lib/auth-server.ts +124 -106
- package/lib/checkup-api.ts +386 -0
- package/lib/checkup.ts +1396 -0
- package/lib/config.ts +6 -3
- package/lib/init.ts +512 -156
- package/lib/issues.ts +400 -191
- package/lib/mcp-server.ts +213 -90
- package/lib/metrics-embedded.ts +79 -0
- package/lib/metrics-loader.ts +127 -0
- package/lib/supabase.ts +769 -0
- package/lib/util.ts +61 -0
- package/package.json +20 -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/01.role.sql +16 -0
- package/sql/02.permissions.sql +37 -0
- package/sql/03.optional_rds.sql +6 -0
- package/sql/04.optional_self_managed.sql +8 -0
- package/sql/05.helpers.sql +439 -0
- package/test/auth.test.ts +258 -0
- package/test/checkup.integration.test.ts +321 -0
- package/test/checkup.test.ts +1117 -0
- package/test/init.integration.test.ts +500 -0
- package/test/init.test.ts +527 -0
- package/test/issues.cli.test.ts +314 -0
- package/test/issues.test.ts +456 -0
- package/test/mcp-server.test.ts +988 -0
- package/test/schema-validation.test.ts +81 -0
- package/test/supabase.test.ts +568 -0
- package/test/test-utils.ts +128 -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 -61
- package/dist/lib/init.d.ts.map +0 -1
- package/dist/lib/init.js +0 -359
- 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 -269
- package/test/init.test.cjs +0 -69
package/bin/postgres-ai.ts
CHANGED
|
@@ -1,24 +1,366 @@
|
|
|
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 {
|
|
12
|
-
import * as readline from "readline";
|
|
13
|
-
import * as http from "https";
|
|
14
|
-
import { URL } from "url";
|
|
10
|
+
import * as crypto from "node:crypto";
|
|
11
|
+
import { Client } from "pg";
|
|
15
12
|
import { startMcpServer } from "../lib/mcp-server";
|
|
16
|
-
import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue } from "../lib/issues";
|
|
13
|
+
import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment } from "../lib/issues";
|
|
17
14
|
import { resolveBaseUrls } from "../lib/util";
|
|
18
|
-
import { applyInitPlan, buildInitPlan, resolveAdminConnection, resolveMonitoringPassword } from "../lib/init";
|
|
15
|
+
import { applyInitPlan, buildInitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, verifyInitSetup } from "../lib/init";
|
|
16
|
+
import { SupabaseClient, resolveSupabaseConfig, extractProjectRefFromUrl, applyInitPlanViaSupabase, verifyInitSetupViaSupabase, type PgCompatibleError } from "../lib/supabase";
|
|
17
|
+
import * as pkce from "../lib/pkce";
|
|
18
|
+
import * as authServer from "../lib/auth-server";
|
|
19
|
+
import { maskSecret } from "../lib/util";
|
|
20
|
+
import { createInterface } from "readline";
|
|
21
|
+
import * as childProcess from "child_process";
|
|
22
|
+
import { REPORT_GENERATORS, CHECK_INFO, generateAllReports } from "../lib/checkup";
|
|
23
|
+
import { createCheckupReport, uploadCheckupReportJson, RpcError, formatRpcErrorForDisplay, withRetry } from "../lib/checkup-api";
|
|
24
|
+
|
|
25
|
+
// Singleton readline interface for stdin prompts
|
|
26
|
+
let rl: ReturnType<typeof createInterface> | null = null;
|
|
27
|
+
function getReadline() {
|
|
28
|
+
if (!rl) {
|
|
29
|
+
rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
30
|
+
}
|
|
31
|
+
return rl;
|
|
32
|
+
}
|
|
33
|
+
function closeReadline() {
|
|
34
|
+
if (rl) {
|
|
35
|
+
rl.close();
|
|
36
|
+
rl = null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Helper functions for spawning processes - use Node.js child_process for compatibility
|
|
41
|
+
async function execPromise(command: string): Promise<{ stdout: string; stderr: string }> {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
childProcess.exec(command, (error, stdout, stderr) => {
|
|
44
|
+
if (error) {
|
|
45
|
+
const err = error as Error & { code: number };
|
|
46
|
+
err.code = typeof error.code === "number" ? error.code : 1;
|
|
47
|
+
reject(err);
|
|
48
|
+
} else {
|
|
49
|
+
resolve({ stdout, stderr });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function execFilePromise(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
childProcess.execFile(file, args, (error, stdout, stderr) => {
|
|
58
|
+
if (error) {
|
|
59
|
+
const err = error as Error & { code: number };
|
|
60
|
+
err.code = typeof error.code === "number" ? error.code : 1;
|
|
61
|
+
reject(err);
|
|
62
|
+
} else {
|
|
63
|
+
resolve({ stdout, stderr });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
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 } {
|
|
70
|
+
const result = childProcess.spawnSync(cmd, args, {
|
|
71
|
+
stdio: options?.stdio === "inherit" ? "inherit" : "pipe",
|
|
72
|
+
env: options?.env as NodeJS.ProcessEnv,
|
|
73
|
+
cwd: options?.cwd,
|
|
74
|
+
encoding: "utf8",
|
|
75
|
+
});
|
|
76
|
+
return {
|
|
77
|
+
status: result.status,
|
|
78
|
+
stdout: typeof result.stdout === "string" ? result.stdout : "",
|
|
79
|
+
stderr: typeof result.stderr === "string" ? result.stderr : "",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
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 } {
|
|
84
|
+
const proc = childProcess.spawn(cmd, args, {
|
|
85
|
+
stdio: options?.stdio ?? "pipe",
|
|
86
|
+
env: options?.env as NodeJS.ProcessEnv,
|
|
87
|
+
cwd: options?.cwd,
|
|
88
|
+
detached: options?.detached,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
on(event: string, cb: (code: number | null, signal?: string) => void) {
|
|
93
|
+
if (event === "close" || event === "exit") {
|
|
94
|
+
proc.on(event, (code, signal) => cb(code, signal ?? undefined));
|
|
95
|
+
} else if (event === "error") {
|
|
96
|
+
proc.on("error", (err) => cb(null, String(err)));
|
|
97
|
+
}
|
|
98
|
+
return this;
|
|
99
|
+
},
|
|
100
|
+
unref() {
|
|
101
|
+
proc.unref();
|
|
102
|
+
},
|
|
103
|
+
pid: proc.pid,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Simple readline-like interface for prompts using Bun
|
|
108
|
+
async function question(prompt: string): Promise<string> {
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
getReadline().question(prompt, (answer) => {
|
|
111
|
+
resolve(answer);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function expandHomePath(p: string): string {
|
|
117
|
+
const s = (p || "").trim();
|
|
118
|
+
if (!s) return s;
|
|
119
|
+
if (s === "~") return os.homedir();
|
|
120
|
+
if (s.startsWith("~/") || s.startsWith("~\\")) {
|
|
121
|
+
return path.join(os.homedir(), s.slice(2));
|
|
122
|
+
}
|
|
123
|
+
return s;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function createTtySpinner(
|
|
127
|
+
enabled: boolean,
|
|
128
|
+
initialText: string
|
|
129
|
+
): { update: (text: string) => void; stop: (finalText?: string) => void } {
|
|
130
|
+
if (!enabled) {
|
|
131
|
+
return {
|
|
132
|
+
update: () => {},
|
|
133
|
+
stop: () => {},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const frames = ["|", "/", "-", "\\"];
|
|
138
|
+
const startTs = Date.now();
|
|
139
|
+
let text = initialText;
|
|
140
|
+
let frameIdx = 0;
|
|
141
|
+
let stopped = false;
|
|
142
|
+
|
|
143
|
+
const render = (): void => {
|
|
144
|
+
if (stopped) return;
|
|
145
|
+
const elapsedSec = ((Date.now() - startTs) / 1000).toFixed(1);
|
|
146
|
+
const frame = frames[frameIdx % frames.length] ?? frames[0] ?? "⠿";
|
|
147
|
+
frameIdx += 1;
|
|
148
|
+
process.stdout.write(`\r\x1b[2K${frame} ${text} (${elapsedSec}s)`);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const timer = setInterval(render, 120);
|
|
152
|
+
render(); // immediate feedback
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
update: (t: string) => {
|
|
156
|
+
text = t;
|
|
157
|
+
render();
|
|
158
|
+
},
|
|
159
|
+
stop: (finalText?: string) => {
|
|
160
|
+
if (stopped) return;
|
|
161
|
+
// Set flag first so any queued render() calls exit early.
|
|
162
|
+
// JavaScript is single-threaded, so this is safe: queued callbacks
|
|
163
|
+
// run after stop() returns and will see stopped=true immediately.
|
|
164
|
+
stopped = true;
|
|
165
|
+
clearInterval(timer);
|
|
166
|
+
process.stdout.write("\r\x1b[2K");
|
|
167
|
+
if (finalText && finalText.trim()) {
|
|
168
|
+
process.stdout.write(finalText);
|
|
169
|
+
}
|
|
170
|
+
process.stdout.write("\n");
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ============================================================================
|
|
176
|
+
// Checkup command helpers
|
|
177
|
+
// ============================================================================
|
|
178
|
+
|
|
179
|
+
interface CheckupOptions {
|
|
180
|
+
checkId: string;
|
|
181
|
+
nodeName: string;
|
|
182
|
+
output?: string;
|
|
183
|
+
upload?: boolean;
|
|
184
|
+
project?: string;
|
|
185
|
+
json?: boolean;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
interface UploadConfig {
|
|
189
|
+
apiKey: string;
|
|
190
|
+
apiBaseUrl: string;
|
|
191
|
+
project: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
interface UploadSummary {
|
|
195
|
+
project: string;
|
|
196
|
+
reportId: number;
|
|
197
|
+
uploaded: Array<{ checkId: string; filename: string; chunkId: number }>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Prepare and validate output directory for checkup reports.
|
|
202
|
+
* @returns Output path if valid, null if should exit with error
|
|
203
|
+
*/
|
|
204
|
+
function prepareOutputDirectory(outputOpt: string | undefined): string | null | undefined {
|
|
205
|
+
if (!outputOpt) return undefined;
|
|
206
|
+
|
|
207
|
+
const outputDir = expandHomePath(outputOpt);
|
|
208
|
+
const outputPath = path.isAbsolute(outputDir) ? outputDir : path.resolve(process.cwd(), outputDir);
|
|
209
|
+
|
|
210
|
+
if (!fs.existsSync(outputPath)) {
|
|
211
|
+
try {
|
|
212
|
+
fs.mkdirSync(outputPath, { recursive: true });
|
|
213
|
+
} catch (e) {
|
|
214
|
+
const errAny = e as any;
|
|
215
|
+
const code = typeof errAny?.code === "string" ? errAny.code : "";
|
|
216
|
+
const msg = errAny instanceof Error ? errAny.message : String(errAny);
|
|
217
|
+
if (code === "EACCES" || code === "EPERM" || code === "ENOENT") {
|
|
218
|
+
console.error(`Error: Failed to create output directory: ${outputPath}`);
|
|
219
|
+
console.error(`Reason: ${msg}`);
|
|
220
|
+
console.error("Tip: choose a writable path, e.g. --output ./reports or --output ~/reports");
|
|
221
|
+
return null; // Signal to exit
|
|
222
|
+
}
|
|
223
|
+
throw e;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return outputPath;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Prepare upload configuration for checkup reports.
|
|
231
|
+
* @returns Upload config if valid, null if should exit, undefined if upload not needed
|
|
232
|
+
*/
|
|
233
|
+
function prepareUploadConfig(
|
|
234
|
+
opts: CheckupOptions,
|
|
235
|
+
rootOpts: CliOptions,
|
|
236
|
+
shouldUpload: boolean,
|
|
237
|
+
uploadExplicitlyRequested: boolean
|
|
238
|
+
): { config: UploadConfig; projectWasGenerated: boolean } | null | undefined {
|
|
239
|
+
if (!shouldUpload) return undefined;
|
|
240
|
+
|
|
241
|
+
const { apiKey } = getConfig(rootOpts);
|
|
242
|
+
if (!apiKey) {
|
|
243
|
+
if (uploadExplicitlyRequested) {
|
|
244
|
+
console.error("Error: API key is required for upload");
|
|
245
|
+
console.error("Tip: run 'postgresai auth' or pass --api-key / set PGAI_API_KEY");
|
|
246
|
+
return null; // Signal to exit
|
|
247
|
+
}
|
|
248
|
+
return undefined; // Skip upload silently
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const cfg = config.readConfig();
|
|
252
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
253
|
+
let project = ((opts.project || cfg.defaultProject) || "").trim();
|
|
254
|
+
let projectWasGenerated = false;
|
|
255
|
+
|
|
256
|
+
if (!project) {
|
|
257
|
+
project = `project_${crypto.randomBytes(6).toString("hex")}`;
|
|
258
|
+
projectWasGenerated = true;
|
|
259
|
+
try {
|
|
260
|
+
config.writeConfig({ defaultProject: project });
|
|
261
|
+
} catch (e) {
|
|
262
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
263
|
+
console.error(`Warning: Failed to save generated default project: ${message}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
config: { apiKey, apiBaseUrl, project },
|
|
269
|
+
projectWasGenerated,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Upload checkup reports to PostgresAI API.
|
|
275
|
+
*/
|
|
276
|
+
async function uploadCheckupReports(
|
|
277
|
+
uploadCfg: UploadConfig,
|
|
278
|
+
reports: Record<string, any>,
|
|
279
|
+
spinner: ReturnType<typeof createTtySpinner>,
|
|
280
|
+
logUpload: (msg: string) => void
|
|
281
|
+
): Promise<UploadSummary> {
|
|
282
|
+
spinner.update("Creating remote checkup report");
|
|
283
|
+
const created = await withRetry(
|
|
284
|
+
() => createCheckupReport({
|
|
285
|
+
apiKey: uploadCfg.apiKey,
|
|
286
|
+
apiBaseUrl: uploadCfg.apiBaseUrl,
|
|
287
|
+
project: uploadCfg.project,
|
|
288
|
+
}),
|
|
289
|
+
{ maxAttempts: 3 },
|
|
290
|
+
(attempt, err, delayMs) => {
|
|
291
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
292
|
+
logUpload(`[Retry ${attempt}/3] createCheckupReport failed: ${errMsg}, retrying in ${delayMs}ms...`);
|
|
293
|
+
}
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
const reportId = created.reportId;
|
|
297
|
+
logUpload(`Created remote checkup report: ${reportId}`);
|
|
298
|
+
|
|
299
|
+
const uploaded: Array<{ checkId: string; filename: string; chunkId: number }> = [];
|
|
300
|
+
for (const [checkId, report] of Object.entries(reports)) {
|
|
301
|
+
spinner.update(`Uploading ${checkId}.json`);
|
|
302
|
+
const jsonText = JSON.stringify(report, null, 2);
|
|
303
|
+
const r = await withRetry(
|
|
304
|
+
() => uploadCheckupReportJson({
|
|
305
|
+
apiKey: uploadCfg.apiKey,
|
|
306
|
+
apiBaseUrl: uploadCfg.apiBaseUrl,
|
|
307
|
+
reportId,
|
|
308
|
+
filename: `${checkId}.json`,
|
|
309
|
+
checkId,
|
|
310
|
+
jsonText,
|
|
311
|
+
}),
|
|
312
|
+
{ maxAttempts: 3 },
|
|
313
|
+
(attempt, err, delayMs) => {
|
|
314
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
315
|
+
logUpload(`[Retry ${attempt}/3] Upload ${checkId}.json failed: ${errMsg}, retrying in ${delayMs}ms...`);
|
|
316
|
+
}
|
|
317
|
+
);
|
|
318
|
+
uploaded.push({ checkId, filename: `${checkId}.json`, chunkId: r.reportChunkId });
|
|
319
|
+
}
|
|
320
|
+
logUpload("Upload completed");
|
|
321
|
+
|
|
322
|
+
return { project: uploadCfg.project, reportId, uploaded };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Write checkup reports to files.
|
|
327
|
+
*/
|
|
328
|
+
function writeReportFiles(reports: Record<string, any>, outputPath: string): void {
|
|
329
|
+
for (const [checkId, report] of Object.entries(reports)) {
|
|
330
|
+
const filePath = path.join(outputPath, `${checkId}.json`);
|
|
331
|
+
fs.writeFileSync(filePath, JSON.stringify(report, null, 2), "utf8");
|
|
332
|
+
console.log(`✓ ${checkId}: ${filePath}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Print upload summary to console.
|
|
338
|
+
*/
|
|
339
|
+
function printUploadSummary(
|
|
340
|
+
summary: UploadSummary,
|
|
341
|
+
projectWasGenerated: boolean,
|
|
342
|
+
useStderr: boolean
|
|
343
|
+
): void {
|
|
344
|
+
const out = useStderr ? console.error : console.log;
|
|
345
|
+
out("\nCheckup report uploaded");
|
|
346
|
+
out("======================\n");
|
|
347
|
+
if (projectWasGenerated) {
|
|
348
|
+
out(`Project: ${summary.project} (generated and saved as default)`);
|
|
349
|
+
} else {
|
|
350
|
+
out(`Project: ${summary.project}`);
|
|
351
|
+
}
|
|
352
|
+
out(`Report ID: ${summary.reportId}`);
|
|
353
|
+
out("View in Console: console.postgres.ai → Support → checkup reports");
|
|
354
|
+
out("");
|
|
355
|
+
out("Files:");
|
|
356
|
+
for (const item of summary.uploaded) {
|
|
357
|
+
out(`- ${item.checkId}: ${item.filename}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
19
360
|
|
|
20
|
-
|
|
21
|
-
|
|
361
|
+
// ============================================================================
|
|
362
|
+
// CLI configuration
|
|
363
|
+
// ============================================================================
|
|
22
364
|
|
|
23
365
|
/**
|
|
24
366
|
* CLI configuration options
|
|
@@ -60,6 +402,86 @@ interface PathResolution {
|
|
|
60
402
|
instancesFile: string;
|
|
61
403
|
}
|
|
62
404
|
|
|
405
|
+
function getDefaultMonitoringProjectDir(): string {
|
|
406
|
+
const override = process.env.PGAI_PROJECT_DIR;
|
|
407
|
+
if (override && override.trim()) return override.trim();
|
|
408
|
+
// Keep monitoring project next to user-level config (~/.config/postgresai)
|
|
409
|
+
return path.join(config.getConfigDir(), "monitoring");
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function downloadText(url: string): Promise<string> {
|
|
413
|
+
const controller = new AbortController();
|
|
414
|
+
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
415
|
+
try {
|
|
416
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
417
|
+
if (!response.ok) {
|
|
418
|
+
throw new Error(`HTTP ${response.status} for ${url}`);
|
|
419
|
+
}
|
|
420
|
+
return await response.text();
|
|
421
|
+
} finally {
|
|
422
|
+
clearTimeout(timeout);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
|
|
427
|
+
const projectDir = getDefaultMonitoringProjectDir();
|
|
428
|
+
const composeFile = path.resolve(projectDir, "docker-compose.yml");
|
|
429
|
+
const instancesFile = path.resolve(projectDir, "instances.yml");
|
|
430
|
+
|
|
431
|
+
if (!fs.existsSync(projectDir)) {
|
|
432
|
+
fs.mkdirSync(projectDir, { recursive: true, mode: 0o700 });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (!fs.existsSync(composeFile)) {
|
|
436
|
+
const refs = [
|
|
437
|
+
process.env.PGAI_PROJECT_REF,
|
|
438
|
+
pkg.version,
|
|
439
|
+
`v${pkg.version}`,
|
|
440
|
+
"main",
|
|
441
|
+
].filter((v): v is string => Boolean(v && v.trim()));
|
|
442
|
+
|
|
443
|
+
let lastErr: unknown;
|
|
444
|
+
for (const ref of refs) {
|
|
445
|
+
const url = `https://gitlab.com/postgres-ai/postgres_ai/-/raw/${encodeURIComponent(ref)}/docker-compose.yml`;
|
|
446
|
+
try {
|
|
447
|
+
const text = await downloadText(url);
|
|
448
|
+
fs.writeFileSync(composeFile, text, { encoding: "utf8", mode: 0o600 });
|
|
449
|
+
break;
|
|
450
|
+
} catch (err) {
|
|
451
|
+
lastErr = err;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (!fs.existsSync(composeFile)) {
|
|
456
|
+
const msg = lastErr instanceof Error ? lastErr.message : String(lastErr);
|
|
457
|
+
throw new Error(`Failed to bootstrap docker-compose.yml: ${msg}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Ensure instances.yml exists as a FILE (avoid Docker creating a directory)
|
|
462
|
+
if (!fs.existsSync(instancesFile)) {
|
|
463
|
+
const header =
|
|
464
|
+
"# PostgreSQL instances to monitor\n" +
|
|
465
|
+
"# Add your instances using: pgai mon targets add <connection-string> <name>\n\n";
|
|
466
|
+
fs.writeFileSync(instancesFile, header, { encoding: "utf8", mode: 0o600 });
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Ensure .pgwatch-config exists as a FILE for reporter (may remain empty)
|
|
470
|
+
const pgwatchConfig = path.resolve(projectDir, ".pgwatch-config");
|
|
471
|
+
if (!fs.existsSync(pgwatchConfig)) {
|
|
472
|
+
fs.writeFileSync(pgwatchConfig, "", { encoding: "utf8", mode: 0o600 });
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Ensure .env exists and has PGAI_TAG (compose requires it)
|
|
476
|
+
const envFile = path.resolve(projectDir, ".env");
|
|
477
|
+
if (!fs.existsSync(envFile)) {
|
|
478
|
+
const envText = `PGAI_TAG=${pkg.version}\n# PGAI_REGISTRY=registry.gitlab.com/postgres-ai/postgres_ai\n`;
|
|
479
|
+
fs.writeFileSync(envFile, envText, { encoding: "utf8", mode: 0o600 });
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return { fs, path, projectDir, composeFile, instancesFile };
|
|
483
|
+
}
|
|
484
|
+
|
|
63
485
|
/**
|
|
64
486
|
* Get configuration from various sources
|
|
65
487
|
* @param opts - Command line options
|
|
@@ -118,17 +540,87 @@ program
|
|
|
118
540
|
);
|
|
119
541
|
|
|
120
542
|
program
|
|
121
|
-
.command("
|
|
122
|
-
.description("
|
|
543
|
+
.command("set-default-project <project>")
|
|
544
|
+
.description("store default project for checkup uploads")
|
|
545
|
+
.action(async (project: string) => {
|
|
546
|
+
const value = (project || "").trim();
|
|
547
|
+
if (!value) {
|
|
548
|
+
console.error("Error: project is required");
|
|
549
|
+
process.exitCode = 1;
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
config.writeConfig({ defaultProject: value });
|
|
553
|
+
console.log(`Default project saved: ${value}`);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
program
|
|
557
|
+
.command("prepare-db [conn]")
|
|
558
|
+
.description("prepare database for monitoring: create monitoring user, required view(s), and grant permissions (idempotent)")
|
|
123
559
|
.option("--db-url <url>", "PostgreSQL connection URL (admin) to run the setup against (deprecated; pass it as positional arg)")
|
|
124
560
|
.option("-h, --host <host>", "PostgreSQL host (psql-like)")
|
|
125
561
|
.option("-p, --port <port>", "PostgreSQL port (psql-like)")
|
|
126
562
|
.option("-U, --username <username>", "PostgreSQL user (psql-like)")
|
|
127
563
|
.option("-d, --dbname <dbname>", "PostgreSQL database name (psql-like)")
|
|
128
564
|
.option("--admin-password <password>", "Admin connection password (otherwise uses PGPASSWORD if set)")
|
|
129
|
-
.option("--monitoring-user <name>", "Monitoring role name to create/update",
|
|
565
|
+
.option("--monitoring-user <name>", "Monitoring role name to create/update", DEFAULT_MONITORING_USER)
|
|
130
566
|
.option("--password <password>", "Monitoring role password (overrides PGAI_MON_PASSWORD)")
|
|
131
567
|
.option("--skip-optional-permissions", "Skip optional permissions (RDS/self-managed extras)", false)
|
|
568
|
+
.option("--verify", "Verify that monitoring role/permissions are in place (no changes)", false)
|
|
569
|
+
.option("--reset-password", "Reset monitoring role password only (no other changes)", false)
|
|
570
|
+
.option("--print-sql", "Print SQL plan and exit (no changes applied)", false)
|
|
571
|
+
.option("--print-password", "Print generated monitoring password (DANGEROUS in CI logs)", false)
|
|
572
|
+
.option("--supabase", "Use Supabase Management API instead of direct PostgreSQL connection", false)
|
|
573
|
+
.option("--supabase-access-token <token>", "Supabase Management API access token (or SUPABASE_ACCESS_TOKEN env)")
|
|
574
|
+
.option("--supabase-project-ref <ref>", "Supabase project reference (or SUPABASE_PROJECT_REF env)")
|
|
575
|
+
.option("--json", "Output result as JSON (machine-readable)", false)
|
|
576
|
+
.addHelpText(
|
|
577
|
+
"after",
|
|
578
|
+
[
|
|
579
|
+
"",
|
|
580
|
+
"Examples:",
|
|
581
|
+
" postgresai prepare-db postgresql://admin@host:5432/dbname",
|
|
582
|
+
" postgresai prepare-db \"dbname=dbname host=host user=admin\"",
|
|
583
|
+
" postgresai prepare-db -h host -p 5432 -U admin -d dbname",
|
|
584
|
+
"",
|
|
585
|
+
"Admin password:",
|
|
586
|
+
" --admin-password <password> or PGPASSWORD=... (libpq standard)",
|
|
587
|
+
"",
|
|
588
|
+
"Monitoring password:",
|
|
589
|
+
" --password <password> or PGAI_MON_PASSWORD=... (otherwise auto-generated)",
|
|
590
|
+
" If auto-generated, it is printed only on TTY by default.",
|
|
591
|
+
" To print it in non-interactive mode: --print-password",
|
|
592
|
+
"",
|
|
593
|
+
"SSL connection (sslmode=prefer behavior):",
|
|
594
|
+
" Tries SSL first, falls back to non-SSL if server doesn't support it.",
|
|
595
|
+
" To force SSL: PGSSLMODE=require or ?sslmode=require in URL",
|
|
596
|
+
" To disable SSL: PGSSLMODE=disable or ?sslmode=disable in URL",
|
|
597
|
+
"",
|
|
598
|
+
"Environment variables (libpq standard):",
|
|
599
|
+
" PGHOST, PGPORT, PGUSER, PGDATABASE — connection defaults",
|
|
600
|
+
" PGPASSWORD — admin password",
|
|
601
|
+
" PGSSLMODE — SSL mode (disable, require, verify-full)",
|
|
602
|
+
" PGAI_MON_PASSWORD — monitoring password",
|
|
603
|
+
"",
|
|
604
|
+
"Inspect SQL without applying changes:",
|
|
605
|
+
" postgresai prepare-db <conn> --print-sql",
|
|
606
|
+
"",
|
|
607
|
+
"Verify setup (no changes):",
|
|
608
|
+
" postgresai prepare-db <conn> --verify",
|
|
609
|
+
"",
|
|
610
|
+
"Reset monitoring password only:",
|
|
611
|
+
" postgresai prepare-db <conn> --reset-password --password '...'",
|
|
612
|
+
"",
|
|
613
|
+
"Offline SQL plan (no DB connection):",
|
|
614
|
+
" postgresai prepare-db --print-sql",
|
|
615
|
+
"",
|
|
616
|
+
"Supabase mode (use Management API instead of direct connection):",
|
|
617
|
+
" postgresai prepare-db --supabase --supabase-project-ref <ref>",
|
|
618
|
+
" SUPABASE_ACCESS_TOKEN=... postgresai prepare-db --supabase --supabase-project-ref <ref>",
|
|
619
|
+
"",
|
|
620
|
+
" Generate a token at: https://supabase.com/dashboard/account/tokens",
|
|
621
|
+
" Find your project ref in: https://supabase.com/dashboard/project/<ref>",
|
|
622
|
+
].join("\n")
|
|
623
|
+
)
|
|
132
624
|
.action(async (conn: string | undefined, opts: {
|
|
133
625
|
dbUrl?: string;
|
|
134
626
|
host?: string;
|
|
@@ -139,57 +631,421 @@ program
|
|
|
139
631
|
monitoringUser: string;
|
|
140
632
|
password?: string;
|
|
141
633
|
skipOptionalPermissions?: boolean;
|
|
142
|
-
|
|
634
|
+
verify?: boolean;
|
|
635
|
+
resetPassword?: boolean;
|
|
636
|
+
printSql?: boolean;
|
|
637
|
+
printPassword?: boolean;
|
|
638
|
+
supabase?: boolean;
|
|
639
|
+
supabaseAccessToken?: string;
|
|
640
|
+
supabaseProjectRef?: string;
|
|
641
|
+
json?: boolean;
|
|
642
|
+
}, cmd: Command) => {
|
|
643
|
+
// JSON output helper
|
|
644
|
+
const jsonOutput = opts.json;
|
|
645
|
+
const outputJson = (data: Record<string, unknown>) => {
|
|
646
|
+
console.log(JSON.stringify(data, null, 2));
|
|
647
|
+
};
|
|
648
|
+
const outputError = (error: {
|
|
649
|
+
message: string;
|
|
650
|
+
step?: string;
|
|
651
|
+
code?: string;
|
|
652
|
+
detail?: string;
|
|
653
|
+
hint?: string;
|
|
654
|
+
httpStatus?: number;
|
|
655
|
+
}) => {
|
|
656
|
+
if (jsonOutput) {
|
|
657
|
+
outputJson({
|
|
658
|
+
success: false,
|
|
659
|
+
mode: opts.supabase ? "supabase" : "direct",
|
|
660
|
+
error,
|
|
661
|
+
});
|
|
662
|
+
} else {
|
|
663
|
+
console.error(`Error: prepare-db${opts.supabase ? " (Supabase)" : ""}: ${error.message}`);
|
|
664
|
+
if (error.step) console.error(` Step: ${error.step}`);
|
|
665
|
+
if (error.code) console.error(` Code: ${error.code}`);
|
|
666
|
+
if (error.detail) console.error(` Detail: ${error.detail}`);
|
|
667
|
+
if (error.hint) console.error(` Hint: ${error.hint}`);
|
|
668
|
+
if (error.httpStatus) console.error(` HTTP Status: ${error.httpStatus}`);
|
|
669
|
+
}
|
|
670
|
+
process.exitCode = 1;
|
|
671
|
+
};
|
|
672
|
+
if (opts.verify && opts.resetPassword) {
|
|
673
|
+
outputError({ message: "Provide only one of --verify or --reset-password" });
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
if (opts.verify && opts.printSql) {
|
|
677
|
+
outputError({ message: "--verify cannot be combined with --print-sql" });
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const shouldPrintSql = !!opts.printSql;
|
|
682
|
+
const redactPasswords = (sql: string): string => redactPasswordsInSql(sql);
|
|
683
|
+
|
|
684
|
+
// Offline mode: allow printing SQL without providing/using an admin connection.
|
|
685
|
+
// Useful for audits/reviews; caller can provide -d/PGDATABASE.
|
|
686
|
+
if (!conn && !opts.dbUrl && !opts.host && !opts.port && !opts.username && !opts.adminPassword) {
|
|
687
|
+
if (shouldPrintSql) {
|
|
688
|
+
const database = (opts.dbname ?? process.env.PGDATABASE ?? "postgres").trim();
|
|
689
|
+
const includeOptionalPermissions = !opts.skipOptionalPermissions;
|
|
690
|
+
|
|
691
|
+
// Use explicit password/env if provided; otherwise use a placeholder.
|
|
692
|
+
// Printed SQL always redacts secrets.
|
|
693
|
+
const monPassword =
|
|
694
|
+
(opts.password ?? process.env.PGAI_MON_PASSWORD ?? "<redacted>").toString();
|
|
695
|
+
|
|
696
|
+
const plan = await buildInitPlan({
|
|
697
|
+
database,
|
|
698
|
+
monitoringUser: opts.monitoringUser,
|
|
699
|
+
monitoringPassword: monPassword,
|
|
700
|
+
includeOptionalPermissions,
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
console.log("\n--- SQL plan (offline; not connected) ---");
|
|
704
|
+
console.log(`-- database: ${database}`);
|
|
705
|
+
console.log(`-- monitoring user: ${opts.monitoringUser}`);
|
|
706
|
+
console.log(`-- optional permissions: ${includeOptionalPermissions ? "enabled" : "skipped"}`);
|
|
707
|
+
for (const step of plan.steps) {
|
|
708
|
+
console.log(`\n-- ${step.name}${step.optional ? " (optional)" : ""}`);
|
|
709
|
+
console.log(redactPasswords(step.sql));
|
|
710
|
+
}
|
|
711
|
+
console.log("\n--- end SQL plan ---\n");
|
|
712
|
+
console.log("Note: passwords are redacted in the printed SQL output.");
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Supabase mode: use Supabase Management API instead of direct PG connection
|
|
718
|
+
if (opts.supabase) {
|
|
719
|
+
let supabaseConfig;
|
|
720
|
+
try {
|
|
721
|
+
// Try to extract project ref from connection URL if provided
|
|
722
|
+
let projectRef = opts.supabaseProjectRef;
|
|
723
|
+
if (!projectRef && conn) {
|
|
724
|
+
projectRef = extractProjectRefFromUrl(conn);
|
|
725
|
+
}
|
|
726
|
+
supabaseConfig = resolveSupabaseConfig({
|
|
727
|
+
accessToken: opts.supabaseAccessToken,
|
|
728
|
+
projectRef,
|
|
729
|
+
});
|
|
730
|
+
} catch (e) {
|
|
731
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
732
|
+
outputError({ message: msg });
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const includeOptionalPermissions = !opts.skipOptionalPermissions;
|
|
737
|
+
|
|
738
|
+
if (!jsonOutput) {
|
|
739
|
+
console.log(`Supabase mode: project ref ${supabaseConfig.projectRef}`);
|
|
740
|
+
console.log(`Monitoring user: ${opts.monitoringUser}`);
|
|
741
|
+
console.log(`Optional permissions: ${includeOptionalPermissions ? "enabled" : "skipped"}`);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const supabaseClient = new SupabaseClient(supabaseConfig);
|
|
745
|
+
|
|
746
|
+
try {
|
|
747
|
+
// Get current database name
|
|
748
|
+
const database = await supabaseClient.getCurrentDatabase();
|
|
749
|
+
if (!database) {
|
|
750
|
+
throw new Error("Failed to resolve current database name");
|
|
751
|
+
}
|
|
752
|
+
if (!jsonOutput) {
|
|
753
|
+
console.log(`Database: ${database}`);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (opts.verify) {
|
|
757
|
+
const v = await verifyInitSetupViaSupabase({
|
|
758
|
+
client: supabaseClient,
|
|
759
|
+
database,
|
|
760
|
+
monitoringUser: opts.monitoringUser,
|
|
761
|
+
includeOptionalPermissions,
|
|
762
|
+
});
|
|
763
|
+
if (v.ok) {
|
|
764
|
+
if (jsonOutput) {
|
|
765
|
+
outputJson({
|
|
766
|
+
success: true,
|
|
767
|
+
mode: "supabase",
|
|
768
|
+
action: "verify",
|
|
769
|
+
database,
|
|
770
|
+
monitoringUser: opts.monitoringUser,
|
|
771
|
+
verified: true,
|
|
772
|
+
missingOptional: v.missingOptional,
|
|
773
|
+
});
|
|
774
|
+
} else {
|
|
775
|
+
console.log("✓ prepare-db verify: OK");
|
|
776
|
+
if (v.missingOptional.length > 0) {
|
|
777
|
+
console.log("⚠ Optional items missing:");
|
|
778
|
+
for (const m of v.missingOptional) console.log(`- ${m}`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
if (jsonOutput) {
|
|
784
|
+
outputJson({
|
|
785
|
+
success: false,
|
|
786
|
+
mode: "supabase",
|
|
787
|
+
action: "verify",
|
|
788
|
+
database,
|
|
789
|
+
monitoringUser: opts.monitoringUser,
|
|
790
|
+
verified: false,
|
|
791
|
+
missingRequired: v.missingRequired,
|
|
792
|
+
missingOptional: v.missingOptional,
|
|
793
|
+
});
|
|
794
|
+
} else {
|
|
795
|
+
console.error("✗ prepare-db verify failed: missing required items");
|
|
796
|
+
for (const m of v.missingRequired) console.error(`- ${m}`);
|
|
797
|
+
if (v.missingOptional.length > 0) {
|
|
798
|
+
console.error("Optional items missing:");
|
|
799
|
+
for (const m of v.missingOptional) console.error(`- ${m}`);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
process.exitCode = 1;
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
let monPassword: string;
|
|
807
|
+
let passwordGenerated = false;
|
|
808
|
+
try {
|
|
809
|
+
const resolved = await resolveMonitoringPassword({
|
|
810
|
+
passwordFlag: opts.password,
|
|
811
|
+
passwordEnv: process.env.PGAI_MON_PASSWORD,
|
|
812
|
+
monitoringUser: opts.monitoringUser,
|
|
813
|
+
});
|
|
814
|
+
monPassword = resolved.password;
|
|
815
|
+
passwordGenerated = resolved.generated;
|
|
816
|
+
if (resolved.generated) {
|
|
817
|
+
const canPrint = process.stdout.isTTY || !!opts.printPassword || jsonOutput;
|
|
818
|
+
if (canPrint) {
|
|
819
|
+
if (!jsonOutput) {
|
|
820
|
+
const shellSafe = monPassword.replace(/'/g, "'\\''");
|
|
821
|
+
console.error("");
|
|
822
|
+
console.error(`Generated monitoring password for ${opts.monitoringUser} (copy/paste):`);
|
|
823
|
+
console.error(`PGAI_MON_PASSWORD='${shellSafe}'`);
|
|
824
|
+
console.error("");
|
|
825
|
+
console.log("Store it securely (or rerun with --password / PGAI_MON_PASSWORD to set your own).");
|
|
826
|
+
}
|
|
827
|
+
// For JSON mode, password will be included in the success output below
|
|
828
|
+
} else {
|
|
829
|
+
console.error(
|
|
830
|
+
[
|
|
831
|
+
`✗ Monitoring password was auto-generated for ${opts.monitoringUser} but not printed in non-interactive mode.`,
|
|
832
|
+
"",
|
|
833
|
+
"Provide it explicitly:",
|
|
834
|
+
" --password <password> or PGAI_MON_PASSWORD=...",
|
|
835
|
+
"",
|
|
836
|
+
"Or (NOT recommended) print the generated password:",
|
|
837
|
+
" --print-password",
|
|
838
|
+
].join("\n")
|
|
839
|
+
);
|
|
840
|
+
process.exitCode = 1;
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
} catch (e) {
|
|
845
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
846
|
+
outputError({ message: msg });
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const plan = await buildInitPlan({
|
|
851
|
+
database,
|
|
852
|
+
monitoringUser: opts.monitoringUser,
|
|
853
|
+
monitoringPassword: monPassword,
|
|
854
|
+
includeOptionalPermissions,
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
// For Supabase mode, skip RDS and self-managed steps (they don't apply)
|
|
858
|
+
const supabaseApplicableSteps = plan.steps.filter(
|
|
859
|
+
(s) => s.name !== "03.optional_rds" && s.name !== "04.optional_self_managed"
|
|
860
|
+
);
|
|
861
|
+
|
|
862
|
+
const effectivePlan = opts.resetPassword
|
|
863
|
+
? { ...plan, steps: supabaseApplicableSteps.filter((s) => s.name === "01.role") }
|
|
864
|
+
: { ...plan, steps: supabaseApplicableSteps };
|
|
865
|
+
|
|
866
|
+
if (shouldPrintSql) {
|
|
867
|
+
console.log("\n--- SQL plan ---");
|
|
868
|
+
for (const step of effectivePlan.steps) {
|
|
869
|
+
console.log(`\n-- ${step.name}${step.optional ? " (optional)" : ""}`);
|
|
870
|
+
console.log(redactPasswords(step.sql));
|
|
871
|
+
}
|
|
872
|
+
console.log("\n--- end SQL plan ---\n");
|
|
873
|
+
console.log("Note: passwords are redacted in the printed SQL output.");
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const { applied, skippedOptional } = await applyInitPlanViaSupabase({
|
|
878
|
+
client: supabaseClient,
|
|
879
|
+
plan: effectivePlan,
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
if (jsonOutput) {
|
|
883
|
+
const result: Record<string, unknown> = {
|
|
884
|
+
success: true,
|
|
885
|
+
mode: "supabase",
|
|
886
|
+
action: opts.resetPassword ? "reset-password" : "apply",
|
|
887
|
+
database,
|
|
888
|
+
monitoringUser: opts.monitoringUser,
|
|
889
|
+
applied,
|
|
890
|
+
skippedOptional,
|
|
891
|
+
warnings: skippedOptional.length > 0
|
|
892
|
+
? ["Some optional steps were skipped (not supported or insufficient privileges)"]
|
|
893
|
+
: [],
|
|
894
|
+
};
|
|
895
|
+
if (passwordGenerated) {
|
|
896
|
+
result.generatedPassword = monPassword;
|
|
897
|
+
}
|
|
898
|
+
outputJson(result);
|
|
899
|
+
} else {
|
|
900
|
+
console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
|
|
901
|
+
if (skippedOptional.length > 0) {
|
|
902
|
+
console.log("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
|
|
903
|
+
for (const s of skippedOptional) console.log(`- ${s}`);
|
|
904
|
+
}
|
|
905
|
+
if (process.stdout.isTTY) {
|
|
906
|
+
console.log(`Applied ${applied.length} steps`);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
} catch (error) {
|
|
910
|
+
const errAny = error as PgCompatibleError;
|
|
911
|
+
let message = "";
|
|
912
|
+
if (error instanceof Error && error.message) {
|
|
913
|
+
message = error.message;
|
|
914
|
+
} else if (errAny && typeof errAny === "object" && typeof errAny.message === "string" && errAny.message) {
|
|
915
|
+
message = errAny.message;
|
|
916
|
+
} else {
|
|
917
|
+
message = String(error);
|
|
918
|
+
}
|
|
919
|
+
if (!message || message === "[object Object]") {
|
|
920
|
+
message = "Unknown error";
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Surface step name if this was a plan step failure
|
|
924
|
+
const stepMatch = typeof message === "string" ? message.match(/Failed at step "([^"]+)":/i) : null;
|
|
925
|
+
const failedStep = stepMatch?.[1];
|
|
926
|
+
|
|
927
|
+
// Build error object for JSON output
|
|
928
|
+
const errorObj: {
|
|
929
|
+
message: string;
|
|
930
|
+
step?: string;
|
|
931
|
+
code?: string;
|
|
932
|
+
detail?: string;
|
|
933
|
+
hint?: string;
|
|
934
|
+
httpStatus?: number;
|
|
935
|
+
} = { message };
|
|
936
|
+
|
|
937
|
+
if (failedStep) errorObj.step = failedStep;
|
|
938
|
+
if (errAny && typeof errAny === "object") {
|
|
939
|
+
if (typeof errAny.code === "string" && errAny.code) errorObj.code = errAny.code;
|
|
940
|
+
if (typeof errAny.detail === "string" && errAny.detail) errorObj.detail = errAny.detail;
|
|
941
|
+
if (typeof errAny.hint === "string" && errAny.hint) errorObj.hint = errAny.hint;
|
|
942
|
+
if (typeof errAny.httpStatus === "number") errorObj.httpStatus = errAny.httpStatus;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
if (jsonOutput) {
|
|
946
|
+
outputJson({
|
|
947
|
+
success: false,
|
|
948
|
+
mode: "supabase",
|
|
949
|
+
error: errorObj,
|
|
950
|
+
});
|
|
951
|
+
process.exitCode = 1;
|
|
952
|
+
} else {
|
|
953
|
+
console.error(`Error: prepare-db (Supabase): ${message}`);
|
|
954
|
+
|
|
955
|
+
if (failedStep) {
|
|
956
|
+
console.error(` Step: ${failedStep}`);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Surface PostgreSQL-compatible error details
|
|
960
|
+
if (errAny && typeof errAny === "object") {
|
|
961
|
+
if (typeof errAny.code === "string" && errAny.code) {
|
|
962
|
+
console.error(` Code: ${errAny.code}`);
|
|
963
|
+
}
|
|
964
|
+
if (typeof errAny.detail === "string" && errAny.detail) {
|
|
965
|
+
console.error(` Detail: ${errAny.detail}`);
|
|
966
|
+
}
|
|
967
|
+
if (typeof errAny.hint === "string" && errAny.hint) {
|
|
968
|
+
console.error(` Hint: ${errAny.hint}`);
|
|
969
|
+
}
|
|
970
|
+
if (typeof errAny.httpStatus === "number") {
|
|
971
|
+
console.error(` HTTP Status: ${errAny.httpStatus}`);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// Provide context hints for common errors
|
|
976
|
+
if (errAny && typeof errAny === "object" && typeof errAny.code === "string") {
|
|
977
|
+
if (errAny.code === "42501") {
|
|
978
|
+
if (failedStep === "01.role") {
|
|
979
|
+
console.error(" Context: role creation/update requires CREATEROLE or superuser");
|
|
980
|
+
} else if (failedStep === "02.permissions") {
|
|
981
|
+
console.error(" Context: grants/view/search_path require sufficient GRANT/DDL privileges");
|
|
982
|
+
}
|
|
983
|
+
console.error(" Fix: ensure your Supabase access token has sufficient permissions");
|
|
984
|
+
console.error(" Tip: run with --print-sql to review the exact SQL plan");
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
if (errAny && typeof errAny === "object" && typeof errAny.httpStatus === "number") {
|
|
988
|
+
if (errAny.httpStatus === 401) {
|
|
989
|
+
console.error(" Hint: invalid or expired access token; generate a new one at https://supabase.com/dashboard/account/tokens");
|
|
990
|
+
}
|
|
991
|
+
if (errAny.httpStatus === 403) {
|
|
992
|
+
console.error(" Hint: access denied; check your token permissions and project access");
|
|
993
|
+
}
|
|
994
|
+
if (errAny.httpStatus === 404) {
|
|
995
|
+
console.error(" Hint: project not found; verify the project reference is correct");
|
|
996
|
+
}
|
|
997
|
+
if (errAny.httpStatus === 429) {
|
|
998
|
+
console.error(" Hint: rate limited; wait a moment and try again");
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
process.exitCode = 1;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
143
1007
|
let adminConn;
|
|
144
1008
|
try {
|
|
145
1009
|
adminConn = resolveAdminConnection({
|
|
146
1010
|
conn,
|
|
147
1011
|
dbUrlFlag: opts.dbUrl,
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
1012
|
+
// Allow libpq standard env vars as implicit defaults (common UX).
|
|
1013
|
+
host: opts.host ?? process.env.PGHOST,
|
|
1014
|
+
port: opts.port ?? process.env.PGPORT,
|
|
1015
|
+
username: opts.username ?? process.env.PGUSER,
|
|
1016
|
+
dbname: opts.dbname ?? process.env.PGDATABASE,
|
|
152
1017
|
adminPassword: opts.adminPassword,
|
|
153
1018
|
envPassword: process.env.PGPASSWORD,
|
|
154
1019
|
});
|
|
155
1020
|
} catch (e) {
|
|
156
1021
|
const msg = e instanceof Error ? e.message : String(e);
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
});
|
|
169
|
-
} catch (e) {
|
|
170
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
171
|
-
console.error(`✗ ${msg}`);
|
|
172
|
-
process.exitCode = 1;
|
|
1022
|
+
if (jsonOutput) {
|
|
1023
|
+
outputError({ message: msg });
|
|
1024
|
+
} else {
|
|
1025
|
+
console.error(`Error: prepare-db: ${msg}`);
|
|
1026
|
+
// When connection details are missing, show full init help (options + examples).
|
|
1027
|
+
if (typeof msg === "string" && msg.startsWith("Connection is required.")) {
|
|
1028
|
+
console.error("");
|
|
1029
|
+
cmd.outputHelp({ error: true });
|
|
1030
|
+
}
|
|
1031
|
+
process.exitCode = 1;
|
|
1032
|
+
}
|
|
173
1033
|
return;
|
|
174
1034
|
}
|
|
175
1035
|
|
|
176
1036
|
const includeOptionalPermissions = !opts.skipOptionalPermissions;
|
|
177
1037
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
1038
|
+
if (!jsonOutput) {
|
|
1039
|
+
console.log(`Connecting to: ${adminConn.display}`);
|
|
1040
|
+
console.log(`Monitoring user: ${opts.monitoringUser}`);
|
|
1041
|
+
console.log(`Optional permissions: ${includeOptionalPermissions ? "enabled" : "skipped"}`);
|
|
1042
|
+
}
|
|
181
1043
|
|
|
182
1044
|
// Use native pg client instead of requiring psql to be installed
|
|
183
|
-
|
|
184
|
-
const client = new Client(adminConn.clientConfig);
|
|
185
|
-
|
|
1045
|
+
let client: Client | undefined;
|
|
186
1046
|
try {
|
|
187
|
-
await
|
|
188
|
-
|
|
189
|
-
const roleRes = await client.query("select 1 from pg_catalog.pg_roles where rolname = $1", [
|
|
190
|
-
opts.monitoringUser,
|
|
191
|
-
]);
|
|
192
|
-
const roleExists = roleRes.rowCount > 0;
|
|
1047
|
+
const connResult = await connectWithSslFallback(Client, adminConn);
|
|
1048
|
+
client = connResult.client;
|
|
193
1049
|
|
|
194
1050
|
const dbRes = await client.query("select current_database() as db");
|
|
195
1051
|
const database = dbRes.rows?.[0]?.db;
|
|
@@ -197,40 +1053,379 @@ program
|
|
|
197
1053
|
throw new Error("Failed to resolve current database name");
|
|
198
1054
|
}
|
|
199
1055
|
|
|
1056
|
+
if (opts.verify) {
|
|
1057
|
+
const v = await verifyInitSetup({
|
|
1058
|
+
client,
|
|
1059
|
+
database,
|
|
1060
|
+
monitoringUser: opts.monitoringUser,
|
|
1061
|
+
includeOptionalPermissions,
|
|
1062
|
+
});
|
|
1063
|
+
if (v.ok) {
|
|
1064
|
+
if (jsonOutput) {
|
|
1065
|
+
outputJson({
|
|
1066
|
+
success: true,
|
|
1067
|
+
mode: "direct",
|
|
1068
|
+
action: "verify",
|
|
1069
|
+
database,
|
|
1070
|
+
monitoringUser: opts.monitoringUser,
|
|
1071
|
+
verified: true,
|
|
1072
|
+
missingOptional: v.missingOptional,
|
|
1073
|
+
});
|
|
1074
|
+
} else {
|
|
1075
|
+
console.log("✓ prepare-db verify: OK");
|
|
1076
|
+
if (v.missingOptional.length > 0) {
|
|
1077
|
+
console.log("⚠ Optional items missing:");
|
|
1078
|
+
for (const m of v.missingOptional) console.log(`- ${m}`);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
if (jsonOutput) {
|
|
1084
|
+
outputJson({
|
|
1085
|
+
success: false,
|
|
1086
|
+
mode: "direct",
|
|
1087
|
+
action: "verify",
|
|
1088
|
+
database,
|
|
1089
|
+
monitoringUser: opts.monitoringUser,
|
|
1090
|
+
verified: false,
|
|
1091
|
+
missingRequired: v.missingRequired,
|
|
1092
|
+
missingOptional: v.missingOptional,
|
|
1093
|
+
});
|
|
1094
|
+
} else {
|
|
1095
|
+
console.error("✗ prepare-db verify failed: missing required items");
|
|
1096
|
+
for (const m of v.missingRequired) console.error(`- ${m}`);
|
|
1097
|
+
if (v.missingOptional.length > 0) {
|
|
1098
|
+
console.error("Optional items missing:");
|
|
1099
|
+
for (const m of v.missingOptional) console.error(`- ${m}`);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
process.exitCode = 1;
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
let monPassword: string;
|
|
1107
|
+
let passwordGenerated = false;
|
|
1108
|
+
try {
|
|
1109
|
+
const resolved = await resolveMonitoringPassword({
|
|
1110
|
+
passwordFlag: opts.password,
|
|
1111
|
+
passwordEnv: process.env.PGAI_MON_PASSWORD,
|
|
1112
|
+
monitoringUser: opts.monitoringUser,
|
|
1113
|
+
});
|
|
1114
|
+
monPassword = resolved.password;
|
|
1115
|
+
passwordGenerated = resolved.generated;
|
|
1116
|
+
if (resolved.generated) {
|
|
1117
|
+
const canPrint = process.stdout.isTTY || !!opts.printPassword || jsonOutput;
|
|
1118
|
+
if (canPrint) {
|
|
1119
|
+
if (!jsonOutput) {
|
|
1120
|
+
// Print secrets to stderr to reduce the chance they end up in piped stdout logs.
|
|
1121
|
+
const shellSafe = monPassword.replace(/'/g, "'\\''");
|
|
1122
|
+
console.error("");
|
|
1123
|
+
console.error(`Generated monitoring password for ${opts.monitoringUser} (copy/paste):`);
|
|
1124
|
+
// Quote for shell copy/paste safety.
|
|
1125
|
+
console.error(`PGAI_MON_PASSWORD='${shellSafe}'`);
|
|
1126
|
+
console.error("");
|
|
1127
|
+
console.log("Store it securely (or rerun with --password / PGAI_MON_PASSWORD to set your own).");
|
|
1128
|
+
}
|
|
1129
|
+
// For JSON mode, password will be included in the success output below
|
|
1130
|
+
} else {
|
|
1131
|
+
console.error(
|
|
1132
|
+
[
|
|
1133
|
+
`✗ Monitoring password was auto-generated for ${opts.monitoringUser} but not printed in non-interactive mode.`,
|
|
1134
|
+
"",
|
|
1135
|
+
"Provide it explicitly:",
|
|
1136
|
+
" --password <password> or PGAI_MON_PASSWORD=...",
|
|
1137
|
+
"",
|
|
1138
|
+
"Or (NOT recommended) print the generated password:",
|
|
1139
|
+
" --print-password",
|
|
1140
|
+
].join("\n")
|
|
1141
|
+
);
|
|
1142
|
+
process.exitCode = 1;
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
} catch (e) {
|
|
1147
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1148
|
+
outputError({ message: msg });
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
200
1152
|
const plan = await buildInitPlan({
|
|
201
1153
|
database,
|
|
202
1154
|
monitoringUser: opts.monitoringUser,
|
|
203
1155
|
monitoringPassword: monPassword,
|
|
204
1156
|
includeOptionalPermissions,
|
|
205
|
-
roleExists,
|
|
206
1157
|
});
|
|
207
1158
|
|
|
208
|
-
const
|
|
1159
|
+
const effectivePlan = opts.resetPassword
|
|
1160
|
+
? { ...plan, steps: plan.steps.filter((s) => s.name === "01.role") }
|
|
1161
|
+
: plan;
|
|
209
1162
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
1163
|
+
if (shouldPrintSql) {
|
|
1164
|
+
console.log("\n--- SQL plan ---");
|
|
1165
|
+
for (const step of effectivePlan.steps) {
|
|
1166
|
+
console.log(`\n-- ${step.name}${step.optional ? " (optional)" : ""}`);
|
|
1167
|
+
console.log(redactPasswords(step.sql));
|
|
1168
|
+
}
|
|
1169
|
+
console.log("\n--- end SQL plan ---\n");
|
|
1170
|
+
console.log("Note: passwords are redacted in the printed SQL output.");
|
|
1171
|
+
return;
|
|
214
1172
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
1173
|
+
|
|
1174
|
+
const { applied, skippedOptional } = await applyInitPlan({ client, plan: effectivePlan });
|
|
1175
|
+
|
|
1176
|
+
if (jsonOutput) {
|
|
1177
|
+
const result: Record<string, unknown> = {
|
|
1178
|
+
success: true,
|
|
1179
|
+
mode: "direct",
|
|
1180
|
+
action: opts.resetPassword ? "reset-password" : "apply",
|
|
1181
|
+
database,
|
|
1182
|
+
monitoringUser: opts.monitoringUser,
|
|
1183
|
+
applied,
|
|
1184
|
+
skippedOptional,
|
|
1185
|
+
warnings: skippedOptional.length > 0
|
|
1186
|
+
? ["Some optional steps were skipped (not supported or insufficient privileges)"]
|
|
1187
|
+
: [],
|
|
1188
|
+
};
|
|
1189
|
+
if (passwordGenerated) {
|
|
1190
|
+
result.generatedPassword = monPassword;
|
|
1191
|
+
}
|
|
1192
|
+
outputJson(result);
|
|
1193
|
+
} else {
|
|
1194
|
+
console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
|
|
1195
|
+
if (skippedOptional.length > 0) {
|
|
1196
|
+
console.log("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
|
|
1197
|
+
for (const s of skippedOptional) console.log(`- ${s}`);
|
|
1198
|
+
}
|
|
1199
|
+
// Keep output compact but still useful
|
|
1200
|
+
if (process.stdout.isTTY) {
|
|
1201
|
+
console.log(`Applied ${applied.length} steps`);
|
|
1202
|
+
}
|
|
218
1203
|
}
|
|
219
1204
|
} catch (error) {
|
|
220
1205
|
const errAny = error as any;
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
1206
|
+
let message = "";
|
|
1207
|
+
if (error instanceof Error && error.message) {
|
|
1208
|
+
message = error.message;
|
|
1209
|
+
} else if (errAny && typeof errAny === "object" && typeof errAny.message === "string" && errAny.message) {
|
|
1210
|
+
message = errAny.message;
|
|
1211
|
+
} else {
|
|
1212
|
+
message = String(error);
|
|
1213
|
+
}
|
|
1214
|
+
if (!message || message === "[object Object]") {
|
|
1215
|
+
message = "Unknown error";
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// If this was a plan step failure, surface the step name explicitly to help users diagnose quickly.
|
|
1219
|
+
const stepMatch =
|
|
1220
|
+
typeof message === "string" ? message.match(/Failed at step "([^"]+)":/i) : null;
|
|
1221
|
+
const failedStep = stepMatch?.[1];
|
|
1222
|
+
|
|
1223
|
+
// Build error object for JSON output
|
|
1224
|
+
const errorObj: {
|
|
1225
|
+
message: string;
|
|
1226
|
+
step?: string;
|
|
1227
|
+
code?: string;
|
|
1228
|
+
detail?: string;
|
|
1229
|
+
hint?: string;
|
|
1230
|
+
} = { message };
|
|
1231
|
+
|
|
1232
|
+
if (failedStep) errorObj.step = failedStep;
|
|
1233
|
+
if (errAny && typeof errAny === "object") {
|
|
1234
|
+
if (typeof errAny.code === "string" && errAny.code) errorObj.code = errAny.code;
|
|
1235
|
+
if (typeof errAny.detail === "string" && errAny.detail) errorObj.detail = errAny.detail;
|
|
1236
|
+
if (typeof errAny.hint === "string" && errAny.hint) errorObj.hint = errAny.hint;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
if (jsonOutput) {
|
|
1240
|
+
outputJson({
|
|
1241
|
+
success: false,
|
|
1242
|
+
mode: "direct",
|
|
1243
|
+
error: errorObj,
|
|
1244
|
+
});
|
|
1245
|
+
process.exitCode = 1;
|
|
1246
|
+
} else {
|
|
1247
|
+
console.error(`Error: prepare-db: ${message}`);
|
|
1248
|
+
if (failedStep) {
|
|
1249
|
+
console.error(` Step: ${failedStep}`);
|
|
226
1250
|
}
|
|
1251
|
+
if (errAny && typeof errAny === "object") {
|
|
1252
|
+
if (typeof errAny.code === "string" && errAny.code) {
|
|
1253
|
+
console.error(` Code: ${errAny.code}`);
|
|
1254
|
+
}
|
|
1255
|
+
if (typeof errAny.detail === "string" && errAny.detail) {
|
|
1256
|
+
console.error(` Detail: ${errAny.detail}`);
|
|
1257
|
+
}
|
|
1258
|
+
if (typeof errAny.hint === "string" && errAny.hint) {
|
|
1259
|
+
console.error(` Hint: ${errAny.hint}`);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
if (errAny && typeof errAny === "object" && typeof errAny.code === "string") {
|
|
1263
|
+
if (errAny.code === "42501") {
|
|
1264
|
+
if (failedStep === "01.role") {
|
|
1265
|
+
console.error(" Context: role creation/update requires CREATEROLE or superuser");
|
|
1266
|
+
} else if (failedStep === "02.permissions") {
|
|
1267
|
+
console.error(" Context: grants/view/search_path require sufficient GRANT/DDL privileges");
|
|
1268
|
+
}
|
|
1269
|
+
console.error(" Fix: connect as a superuser (or a role with CREATEROLE and sufficient GRANT privileges)");
|
|
1270
|
+
console.error(" Fix: on managed Postgres, use the provider's admin/master user");
|
|
1271
|
+
console.error(" Tip: run with --print-sql to review the exact SQL plan");
|
|
1272
|
+
}
|
|
1273
|
+
if (errAny.code === "ECONNREFUSED") {
|
|
1274
|
+
console.error(" Hint: check host/port and ensure Postgres is reachable from this machine");
|
|
1275
|
+
}
|
|
1276
|
+
if (errAny.code === "ENOTFOUND") {
|
|
1277
|
+
console.error(" Hint: DNS resolution failed; double-check the host name");
|
|
1278
|
+
}
|
|
1279
|
+
if (errAny.code === "ETIMEDOUT") {
|
|
1280
|
+
console.error(" Hint: connection timed out; check network/firewall rules");
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
process.exitCode = 1;
|
|
1284
|
+
}
|
|
1285
|
+
} finally {
|
|
1286
|
+
if (client) {
|
|
1287
|
+
try {
|
|
1288
|
+
await client.end();
|
|
1289
|
+
} catch {
|
|
1290
|
+
// ignore
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
program
|
|
1297
|
+
.command("checkup [conn]")
|
|
1298
|
+
.description("generate health check reports directly from PostgreSQL (express mode)")
|
|
1299
|
+
.option("--check-id <id>", `specific check to run: ${Object.keys(CHECK_INFO).join(", ")}, or ALL`, "ALL")
|
|
1300
|
+
.option("--node-name <name>", "node name for reports", "node-01")
|
|
1301
|
+
.option("--output <path>", "output directory for JSON files")
|
|
1302
|
+
.option("--[no-]upload", "upload JSON results to PostgresAI (default: enabled; requires API key)", undefined)
|
|
1303
|
+
.option(
|
|
1304
|
+
"--project <project>",
|
|
1305
|
+
"project name or ID for remote upload (used with --upload; defaults to config defaultProject; auto-generated on first run)"
|
|
1306
|
+
)
|
|
1307
|
+
.option("--json", "output JSON to stdout (implies --no-upload)")
|
|
1308
|
+
.addHelpText(
|
|
1309
|
+
"after",
|
|
1310
|
+
[
|
|
1311
|
+
"",
|
|
1312
|
+
"Available checks:",
|
|
1313
|
+
...Object.entries(CHECK_INFO).map(([id, title]) => ` ${id}: ${title}`),
|
|
1314
|
+
"",
|
|
1315
|
+
"Examples:",
|
|
1316
|
+
" postgresai checkup postgresql://user:pass@host:5432/db",
|
|
1317
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --check-id A003",
|
|
1318
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --output ./reports",
|
|
1319
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --project my_project",
|
|
1320
|
+
" postgresai set-default-project my_project",
|
|
1321
|
+
" postgresai checkup postgresql://user:pass@host:5432/db",
|
|
1322
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --no-upload --json",
|
|
1323
|
+
].join("\n")
|
|
1324
|
+
)
|
|
1325
|
+
.action(async (conn: string | undefined, opts: CheckupOptions, cmd: Command) => {
|
|
1326
|
+
if (!conn) {
|
|
1327
|
+
cmd.outputHelp();
|
|
1328
|
+
process.exitCode = 1;
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
const shouldPrintJson = !!opts.json;
|
|
1333
|
+
const uploadExplicitlyRequested = opts.upload === true;
|
|
1334
|
+
const uploadExplicitlyDisabled = opts.upload === false || shouldPrintJson;
|
|
1335
|
+
let shouldUpload = !uploadExplicitlyDisabled;
|
|
1336
|
+
|
|
1337
|
+
// Preflight: validate/create output directory BEFORE connecting / running checks.
|
|
1338
|
+
const outputPath = prepareOutputDirectory(opts.output);
|
|
1339
|
+
if (outputPath === null) {
|
|
1340
|
+
process.exitCode = 1;
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// Preflight: validate upload flags/credentials BEFORE connecting / running checks.
|
|
1345
|
+
const rootOpts = program.opts() as CliOptions;
|
|
1346
|
+
const uploadResult = prepareUploadConfig(opts, rootOpts, shouldUpload, uploadExplicitlyRequested);
|
|
1347
|
+
if (uploadResult === null) {
|
|
1348
|
+
process.exitCode = 1;
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
const uploadCfg = uploadResult?.config;
|
|
1352
|
+
const projectWasGenerated = uploadResult?.projectWasGenerated ?? false;
|
|
1353
|
+
shouldUpload = !!uploadCfg;
|
|
1354
|
+
|
|
1355
|
+
// Connect and run checks
|
|
1356
|
+
const adminConn = resolveAdminConnection({
|
|
1357
|
+
conn,
|
|
1358
|
+
envPassword: process.env.PGPASSWORD,
|
|
1359
|
+
});
|
|
1360
|
+
let client: Client | undefined;
|
|
1361
|
+
const spinnerEnabled = !!process.stdout.isTTY && shouldUpload;
|
|
1362
|
+
const spinner = createTtySpinner(spinnerEnabled, "Connecting to Postgres");
|
|
1363
|
+
|
|
1364
|
+
try {
|
|
1365
|
+
spinner.update("Connecting to Postgres");
|
|
1366
|
+
const connResult = await connectWithSslFallback(Client, adminConn);
|
|
1367
|
+
client = connResult.client as Client;
|
|
1368
|
+
|
|
1369
|
+
// Generate reports
|
|
1370
|
+
let reports: Record<string, any>;
|
|
1371
|
+
if (opts.checkId === "ALL") {
|
|
1372
|
+
reports = await generateAllReports(client, opts.nodeName, (p) => {
|
|
1373
|
+
spinner.update(`Running ${p.checkId}: ${p.checkTitle} (${p.index}/${p.total})`);
|
|
1374
|
+
});
|
|
1375
|
+
} else {
|
|
1376
|
+
const checkId = opts.checkId.toUpperCase();
|
|
1377
|
+
const generator = REPORT_GENERATORS[checkId];
|
|
1378
|
+
if (!generator) {
|
|
1379
|
+
spinner.stop();
|
|
1380
|
+
console.error(`Unknown check ID: ${opts.checkId}`);
|
|
1381
|
+
console.error(`Available: ${Object.keys(CHECK_INFO).join(", ")}, ALL`);
|
|
1382
|
+
process.exitCode = 1;
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
spinner.update(`Running ${checkId}: ${CHECK_INFO[checkId] || checkId}`);
|
|
1386
|
+
reports = { [checkId]: await generator(client, opts.nodeName) };
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// Upload to PostgresAI API (if configured)
|
|
1390
|
+
let uploadSummary: UploadSummary | undefined;
|
|
1391
|
+
if (uploadCfg) {
|
|
1392
|
+
const logUpload = (msg: string): void => {
|
|
1393
|
+
(shouldPrintJson ? console.error : console.log)(msg);
|
|
1394
|
+
};
|
|
1395
|
+
uploadSummary = await uploadCheckupReports(uploadCfg, reports, spinner, logUpload);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
spinner.stop();
|
|
1399
|
+
|
|
1400
|
+
// Write to files (if output path specified)
|
|
1401
|
+
if (outputPath) {
|
|
1402
|
+
writeReportFiles(reports, outputPath);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// Print upload summary
|
|
1406
|
+
if (uploadSummary) {
|
|
1407
|
+
printUploadSummary(uploadSummary, projectWasGenerated, shouldPrintJson);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// Output JSON to stdout
|
|
1411
|
+
if (shouldPrintJson || (!shouldUpload && !opts.output)) {
|
|
1412
|
+
console.log(JSON.stringify(reports, null, 2));
|
|
1413
|
+
}
|
|
1414
|
+
} catch (error) {
|
|
1415
|
+
if (error instanceof RpcError) {
|
|
1416
|
+
for (const line of formatRpcErrorForDisplay(error)) {
|
|
1417
|
+
console.error(line);
|
|
1418
|
+
}
|
|
1419
|
+
} else {
|
|
1420
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1421
|
+
console.error(`Error: ${message}`);
|
|
227
1422
|
}
|
|
228
1423
|
process.exitCode = 1;
|
|
229
1424
|
} finally {
|
|
230
|
-
|
|
1425
|
+
// Always stop spinner to prevent interval leak (idempotent - safe to call multiple times)
|
|
1426
|
+
spinner.stop();
|
|
1427
|
+
if (client) {
|
|
231
1428
|
await client.end();
|
|
232
|
-
} catch {
|
|
233
|
-
// ignore
|
|
234
1429
|
}
|
|
235
1430
|
}
|
|
236
1431
|
});
|
|
@@ -268,6 +1463,14 @@ function resolvePaths(): PathResolution {
|
|
|
268
1463
|
);
|
|
269
1464
|
}
|
|
270
1465
|
|
|
1466
|
+
async function resolveOrInitPaths(): Promise<PathResolution> {
|
|
1467
|
+
try {
|
|
1468
|
+
return resolvePaths();
|
|
1469
|
+
} catch {
|
|
1470
|
+
return ensureDefaultMonitoringProject();
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
271
1474
|
/**
|
|
272
1475
|
* Check if Docker daemon is running
|
|
273
1476
|
*/
|
|
@@ -315,11 +1518,11 @@ function checkRunningContainers(): { running: boolean; containers: string[] } {
|
|
|
315
1518
|
/**
|
|
316
1519
|
* Run docker compose command
|
|
317
1520
|
*/
|
|
318
|
-
async function runCompose(args: string[]): Promise<number> {
|
|
1521
|
+
async function runCompose(args: string[], grafanaPassword?: string): Promise<number> {
|
|
319
1522
|
let composeFile: string;
|
|
320
1523
|
let projectDir: string;
|
|
321
1524
|
try {
|
|
322
|
-
({ composeFile, projectDir } =
|
|
1525
|
+
({ composeFile, projectDir } = await resolveOrInitPaths());
|
|
323
1526
|
} catch (error) {
|
|
324
1527
|
const message = error instanceof Error ? error.message : String(error);
|
|
325
1528
|
console.error(message);
|
|
@@ -341,28 +1544,42 @@ async function runCompose(args: string[]): Promise<number> {
|
|
|
341
1544
|
return 1;
|
|
342
1545
|
}
|
|
343
1546
|
|
|
344
|
-
//
|
|
1547
|
+
// Set Grafana password from parameter or .pgwatch-config
|
|
345
1548
|
const env = { ...process.env };
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
const
|
|
353
|
-
if (
|
|
354
|
-
|
|
1549
|
+
if (grafanaPassword) {
|
|
1550
|
+
env.GF_SECURITY_ADMIN_PASSWORD = grafanaPassword;
|
|
1551
|
+
} else {
|
|
1552
|
+
const cfgPath = path.resolve(projectDir, ".pgwatch-config");
|
|
1553
|
+
if (fs.existsSync(cfgPath)) {
|
|
1554
|
+
try {
|
|
1555
|
+
const stats = fs.statSync(cfgPath);
|
|
1556
|
+
if (!stats.isDirectory()) {
|
|
1557
|
+
const content = fs.readFileSync(cfgPath, "utf8");
|
|
1558
|
+
const match = content.match(/^grafana_password=([^\r\n]+)/m);
|
|
1559
|
+
if (match) {
|
|
1560
|
+
env.GF_SECURITY_ADMIN_PASSWORD = match[1].trim();
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
} catch (err) {
|
|
1564
|
+
// If we can't read the config, log warning and continue without setting the password
|
|
1565
|
+
if (process.env.DEBUG) {
|
|
1566
|
+
console.warn(`Warning: Could not read Grafana password from config: ${err instanceof Error ? err.message : String(err)}`);
|
|
355
1567
|
}
|
|
356
1568
|
}
|
|
357
|
-
} catch (err) {
|
|
358
|
-
// If we can't read the config, continue without setting the password
|
|
359
1569
|
}
|
|
360
1570
|
}
|
|
361
1571
|
|
|
1572
|
+
// On macOS, node-exporter can't mount host root filesystem - skip it
|
|
1573
|
+
const finalArgs = [...args];
|
|
1574
|
+
if (process.platform === "darwin" && args.includes("up")) {
|
|
1575
|
+
finalArgs.push("--scale", "node-exporter=0");
|
|
1576
|
+
}
|
|
1577
|
+
|
|
362
1578
|
return new Promise<number>((resolve) => {
|
|
363
|
-
const child = spawn(cmd[0], [...cmd.slice(1), "-f", composeFile, ...
|
|
1579
|
+
const child = spawn(cmd[0], [...cmd.slice(1), "-f", composeFile, ...finalArgs], {
|
|
364
1580
|
stdio: "inherit",
|
|
365
|
-
env: env
|
|
1581
|
+
env: env,
|
|
1582
|
+
cwd: projectDir
|
|
366
1583
|
});
|
|
367
1584
|
child.on("close", (code) => resolve(code || 0));
|
|
368
1585
|
});
|
|
@@ -376,18 +1593,64 @@ program.command("help", { isDefault: true }).description("show help").action(()
|
|
|
376
1593
|
const mon = program.command("mon").description("monitoring services management");
|
|
377
1594
|
|
|
378
1595
|
mon
|
|
379
|
-
.command("
|
|
380
|
-
.description("
|
|
1596
|
+
.command("local-install")
|
|
1597
|
+
.description("install local monitoring stack (generate config, start services)")
|
|
381
1598
|
.option("--demo", "demo mode with sample database", false)
|
|
382
1599
|
.option("--api-key <key>", "Postgres AI API key for automated report uploads")
|
|
383
1600
|
.option("--db-url <url>", "PostgreSQL connection URL to monitor")
|
|
1601
|
+
.option("--tag <tag>", "Docker image tag to use (e.g., 0.14.0, 0.14.0-dev.33)")
|
|
384
1602
|
.option("-y, --yes", "accept all defaults and skip interactive prompts", false)
|
|
385
|
-
.action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; yes: boolean }) => {
|
|
1603
|
+
.action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; tag?: string; yes: boolean }) => {
|
|
1604
|
+
// Get apiKey from global program options (--api-key is defined globally)
|
|
1605
|
+
// This is needed because Commander.js routes --api-key to the global option, not the subcommand's option
|
|
1606
|
+
const globalOpts = program.opts<CliOptions>();
|
|
1607
|
+
const apiKey = opts.apiKey || globalOpts.apiKey;
|
|
1608
|
+
|
|
386
1609
|
console.log("\n=================================");
|
|
387
|
-
console.log(" PostgresAI
|
|
1610
|
+
console.log(" PostgresAI monitoring local install");
|
|
388
1611
|
console.log("=================================\n");
|
|
389
1612
|
console.log("This will install, configure, and start the monitoring system\n");
|
|
390
1613
|
|
|
1614
|
+
// Ensure we have a project directory with docker-compose.yml even if running from elsewhere
|
|
1615
|
+
const { projectDir } = await resolveOrInitPaths();
|
|
1616
|
+
console.log(`Project directory: ${projectDir}\n`);
|
|
1617
|
+
|
|
1618
|
+
// Update .env with custom tag if provided
|
|
1619
|
+
const envFile = path.resolve(projectDir, ".env");
|
|
1620
|
+
|
|
1621
|
+
// Build .env content, preserving important existing values (registry, password)
|
|
1622
|
+
// Note: PGAI_TAG is intentionally NOT preserved - the CLI version should always match Docker images
|
|
1623
|
+
let existingRegistry: string | null = null;
|
|
1624
|
+
let existingPassword: string | null = null;
|
|
1625
|
+
|
|
1626
|
+
if (fs.existsSync(envFile)) {
|
|
1627
|
+
const existingEnv = fs.readFileSync(envFile, "utf8");
|
|
1628
|
+
// Extract existing values (except tag - always use CLI version)
|
|
1629
|
+
const registryMatch = existingEnv.match(/^PGAI_REGISTRY=(.+)$/m);
|
|
1630
|
+
if (registryMatch) existingRegistry = registryMatch[1].trim();
|
|
1631
|
+
const pwdMatch = existingEnv.match(/^GF_SECURITY_ADMIN_PASSWORD=(.+)$/m);
|
|
1632
|
+
if (pwdMatch) existingPassword = pwdMatch[1].trim();
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// Priority: CLI --tag flag > package version
|
|
1636
|
+
// Note: We intentionally do NOT use process.env.PGAI_TAG here because Bun auto-loads .env files,
|
|
1637
|
+
// which would cause stale .env values to override the CLI version. The CLI version should always
|
|
1638
|
+
// match the Docker images. Users can override with --tag if needed.
|
|
1639
|
+
const imageTag = opts.tag || pkg.version;
|
|
1640
|
+
|
|
1641
|
+
const envLines: string[] = [`PGAI_TAG=${imageTag}`];
|
|
1642
|
+
if (existingRegistry) {
|
|
1643
|
+
envLines.push(`PGAI_REGISTRY=${existingRegistry}`);
|
|
1644
|
+
}
|
|
1645
|
+
if (existingPassword) {
|
|
1646
|
+
envLines.push(`GF_SECURITY_ADMIN_PASSWORD=${existingPassword}`);
|
|
1647
|
+
}
|
|
1648
|
+
fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
|
|
1649
|
+
|
|
1650
|
+
if (opts.tag) {
|
|
1651
|
+
console.log(`Using image tag: ${imageTag}\n`);
|
|
1652
|
+
}
|
|
1653
|
+
|
|
391
1654
|
// Validate conflicting options
|
|
392
1655
|
if (opts.demo && opts.dbUrl) {
|
|
393
1656
|
console.log("⚠ Both --demo and --db-url provided. Demo mode includes its own database.");
|
|
@@ -395,11 +1658,11 @@ mon
|
|
|
395
1658
|
opts.dbUrl = undefined;
|
|
396
1659
|
}
|
|
397
1660
|
|
|
398
|
-
if (opts.demo &&
|
|
1661
|
+
if (opts.demo && apiKey) {
|
|
399
1662
|
console.error("✗ Cannot use --api-key with --demo mode");
|
|
400
1663
|
console.error("✗ Demo mode is for testing only and does not support API key integration");
|
|
401
|
-
console.error("\nUse demo mode without API key: postgres-ai mon
|
|
402
|
-
console.error("Or use production mode with API key: postgres-ai mon
|
|
1664
|
+
console.error("\nUse demo mode without API key: postgres-ai mon local-install --demo");
|
|
1665
|
+
console.error("Or use production mode with API key: postgres-ai mon local-install --api-key=your_key");
|
|
403
1666
|
process.exitCode = 1;
|
|
404
1667
|
return;
|
|
405
1668
|
}
|
|
@@ -417,9 +1680,14 @@ mon
|
|
|
417
1680
|
console.log("Step 1: Postgres AI API Configuration (Optional)");
|
|
418
1681
|
console.log("An API key enables automatic upload of PostgreSQL reports to Postgres AI\n");
|
|
419
1682
|
|
|
420
|
-
if (
|
|
1683
|
+
if (apiKey) {
|
|
421
1684
|
console.log("Using API key provided via --api-key parameter");
|
|
422
|
-
config.writeConfig({ apiKey
|
|
1685
|
+
config.writeConfig({ apiKey });
|
|
1686
|
+
// Keep reporter compatibility (docker-compose mounts .pgwatch-config)
|
|
1687
|
+
fs.writeFileSync(path.resolve(projectDir, ".pgwatch-config"), `api_key=${apiKey}\n`, {
|
|
1688
|
+
encoding: "utf8",
|
|
1689
|
+
mode: 0o600
|
|
1690
|
+
});
|
|
423
1691
|
console.log("✓ API key saved\n");
|
|
424
1692
|
} else if (opts.yes) {
|
|
425
1693
|
// Auto-yes mode without API key - skip API key setup
|
|
@@ -427,43 +1695,36 @@ mon
|
|
|
427
1695
|
console.log("⚠ Reports will be generated locally only");
|
|
428
1696
|
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
429
1697
|
} else {
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
config.writeConfig({ apiKey: trimmedKey });
|
|
449
|
-
console.log("✓ API key saved\n");
|
|
450
|
-
break;
|
|
451
|
-
}
|
|
1698
|
+
const answer = await question("Do you have a Postgres AI API key? (Y/n): ");
|
|
1699
|
+
const proceedWithApiKey = !answer || answer.toLowerCase() === "y";
|
|
1700
|
+
|
|
1701
|
+
if (proceedWithApiKey) {
|
|
1702
|
+
while (true) {
|
|
1703
|
+
const inputApiKey = await question("Enter your Postgres AI API key: ");
|
|
1704
|
+
const trimmedKey = inputApiKey.trim();
|
|
1705
|
+
|
|
1706
|
+
if (trimmedKey) {
|
|
1707
|
+
config.writeConfig({ apiKey: trimmedKey });
|
|
1708
|
+
// Keep reporter compatibility (docker-compose mounts .pgwatch-config)
|
|
1709
|
+
fs.writeFileSync(path.resolve(projectDir, ".pgwatch-config"), `api_key=${trimmedKey}\n`, {
|
|
1710
|
+
encoding: "utf8",
|
|
1711
|
+
mode: 0o600
|
|
1712
|
+
});
|
|
1713
|
+
console.log("✓ API key saved\n");
|
|
1714
|
+
break;
|
|
1715
|
+
}
|
|
452
1716
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
}
|
|
1717
|
+
console.log("⚠ API key cannot be empty");
|
|
1718
|
+
const retry = await question("Try again or skip API key setup, retry? (Y/n): ");
|
|
1719
|
+
if (retry.toLowerCase() === "n") {
|
|
1720
|
+
console.log("⚠ Skipping API key setup - reports will be generated locally only");
|
|
1721
|
+
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
1722
|
+
break;
|
|
460
1723
|
}
|
|
461
|
-
} else {
|
|
462
|
-
console.log("⚠ Skipping API key setup - reports will be generated locally only");
|
|
463
|
-
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
464
1724
|
}
|
|
465
|
-
}
|
|
466
|
-
|
|
1725
|
+
} else {
|
|
1726
|
+
console.log("⚠ Skipping API key setup - reports will be generated locally only");
|
|
1727
|
+
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
467
1728
|
}
|
|
468
1729
|
}
|
|
469
1730
|
} else {
|
|
@@ -476,9 +1737,11 @@ mon
|
|
|
476
1737
|
console.log("Step 2: Add PostgreSQL Instance to Monitor\n");
|
|
477
1738
|
|
|
478
1739
|
// Clear instances.yml in production mode (start fresh)
|
|
479
|
-
const instancesPath =
|
|
1740
|
+
const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
|
|
480
1741
|
const emptyInstancesContent = "# PostgreSQL instances to monitor\n# Add your instances using: postgres-ai mon targets add\n\n";
|
|
481
1742
|
fs.writeFileSync(instancesPath, emptyInstancesContent, "utf8");
|
|
1743
|
+
console.log(`Instances file: ${instancesPath}`);
|
|
1744
|
+
console.log(`Project directory: ${projectDir}\n`);
|
|
482
1745
|
|
|
483
1746
|
if (opts.dbUrl) {
|
|
484
1747
|
console.log("Using database URL provided via --db-url parameter");
|
|
@@ -507,7 +1770,6 @@ mon
|
|
|
507
1770
|
// Test connection
|
|
508
1771
|
console.log("Testing connection to the added instance...");
|
|
509
1772
|
try {
|
|
510
|
-
const { Client } = require("pg");
|
|
511
1773
|
const client = new Client({ connectionString: connStr });
|
|
512
1774
|
await client.connect();
|
|
513
1775
|
const result = await client.query("select version();");
|
|
@@ -524,63 +1786,50 @@ mon
|
|
|
524
1786
|
console.log("⚠ No PostgreSQL instance added");
|
|
525
1787
|
console.log("You can add one later with: postgres-ai mon targets add\n");
|
|
526
1788
|
} else {
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
console.log(" 2. Press Enter to skip for now\n");
|
|
544
|
-
|
|
545
|
-
const connStr = await question("Enter connection string (or press Enter to skip): ");
|
|
546
|
-
|
|
547
|
-
if (connStr.trim()) {
|
|
548
|
-
const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
|
|
549
|
-
if (!m) {
|
|
550
|
-
console.error("✗ Invalid connection string format");
|
|
551
|
-
console.log("⚠ Continuing without adding instance\n");
|
|
552
|
-
} else {
|
|
553
|
-
const host = m[3];
|
|
554
|
-
const db = m[5];
|
|
555
|
-
const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
|
|
556
|
-
|
|
557
|
-
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`;
|
|
558
|
-
fs.appendFileSync(instancesPath, body, "utf8");
|
|
559
|
-
console.log(`✓ Monitoring target '${instanceName}' added\n`);
|
|
560
|
-
|
|
561
|
-
// Test connection
|
|
562
|
-
console.log("Testing connection to the added instance...");
|
|
563
|
-
try {
|
|
564
|
-
const { Client } = require("pg");
|
|
565
|
-
const client = new Client({ connectionString: connStr });
|
|
566
|
-
await client.connect();
|
|
567
|
-
const result = await client.query("select version();");
|
|
568
|
-
console.log("✓ Connection successful");
|
|
569
|
-
console.log(`${result.rows[0].version}\n`);
|
|
570
|
-
await client.end();
|
|
571
|
-
} catch (error) {
|
|
572
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
573
|
-
console.error(`✗ Connection failed: ${message}\n`);
|
|
574
|
-
}
|
|
575
|
-
}
|
|
1789
|
+
console.log("You need to add at least one PostgreSQL instance to monitor");
|
|
1790
|
+
const answer = await question("Do you want to add a PostgreSQL instance now? (Y/n): ");
|
|
1791
|
+
const proceedWithInstance = !answer || answer.toLowerCase() === "y";
|
|
1792
|
+
|
|
1793
|
+
if (proceedWithInstance) {
|
|
1794
|
+
console.log("\nYou can provide either:");
|
|
1795
|
+
console.log(" 1. A full connection string: postgresql://user:pass@host:port/database");
|
|
1796
|
+
console.log(" 2. Press Enter to skip for now\n");
|
|
1797
|
+
|
|
1798
|
+
const connStr = await question("Enter connection string (or press Enter to skip): ");
|
|
1799
|
+
|
|
1800
|
+
if (connStr.trim()) {
|
|
1801
|
+
const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
|
|
1802
|
+
if (!m) {
|
|
1803
|
+
console.error("✗ Invalid connection string format");
|
|
1804
|
+
console.log("⚠ Continuing without adding instance\n");
|
|
576
1805
|
} else {
|
|
577
|
-
|
|
1806
|
+
const host = m[3];
|
|
1807
|
+
const db = m[5];
|
|
1808
|
+
const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
|
|
1809
|
+
|
|
1810
|
+
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`;
|
|
1811
|
+
fs.appendFileSync(instancesPath, body, "utf8");
|
|
1812
|
+
console.log(`✓ Monitoring target '${instanceName}' added\n`);
|
|
1813
|
+
|
|
1814
|
+
// Test connection
|
|
1815
|
+
console.log("Testing connection to the added instance...");
|
|
1816
|
+
try {
|
|
1817
|
+
const client = new Client({ connectionString: connStr });
|
|
1818
|
+
await client.connect();
|
|
1819
|
+
const result = await client.query("select version();");
|
|
1820
|
+
console.log("✓ Connection successful");
|
|
1821
|
+
console.log(`${result.rows[0].version}\n`);
|
|
1822
|
+
await client.end();
|
|
1823
|
+
} catch (error) {
|
|
1824
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1825
|
+
console.error(`✗ Connection failed: ${message}\n`);
|
|
1826
|
+
}
|
|
578
1827
|
}
|
|
579
1828
|
} else {
|
|
580
1829
|
console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
|
|
581
1830
|
}
|
|
582
|
-
}
|
|
583
|
-
|
|
1831
|
+
} else {
|
|
1832
|
+
console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
|
|
584
1833
|
}
|
|
585
1834
|
}
|
|
586
1835
|
} else {
|
|
@@ -598,7 +1847,7 @@ mon
|
|
|
598
1847
|
|
|
599
1848
|
// Step 4: Ensure Grafana password is configured
|
|
600
1849
|
console.log(opts.demo ? "Step 4: Configuring Grafana security..." : "Step 4: Configuring Grafana security...");
|
|
601
|
-
const cfgPath = path.resolve(
|
|
1850
|
+
const cfgPath = path.resolve(projectDir, ".pgwatch-config");
|
|
602
1851
|
let grafanaPassword = "";
|
|
603
1852
|
|
|
604
1853
|
try {
|
|
@@ -639,8 +1888,8 @@ mon
|
|
|
639
1888
|
}
|
|
640
1889
|
|
|
641
1890
|
// Step 5: Start services
|
|
642
|
-
console.log(
|
|
643
|
-
const code2 = await runCompose(["up", "-d", "--force-recreate"]);
|
|
1891
|
+
console.log("Step 5: Starting monitoring services...");
|
|
1892
|
+
const code2 = await runCompose(["up", "-d", "--force-recreate"], grafanaPassword);
|
|
644
1893
|
if (code2 !== 0) {
|
|
645
1894
|
process.exitCode = code2;
|
|
646
1895
|
return;
|
|
@@ -649,7 +1898,7 @@ mon
|
|
|
649
1898
|
|
|
650
1899
|
// Final summary
|
|
651
1900
|
console.log("=================================");
|
|
652
|
-
console.log("
|
|
1901
|
+
console.log(" Local install completed!");
|
|
653
1902
|
console.log("=================================\n");
|
|
654
1903
|
|
|
655
1904
|
console.log("What's running:");
|
|
@@ -698,11 +1947,75 @@ mon
|
|
|
698
1947
|
if (code !== 0) process.exitCode = code;
|
|
699
1948
|
});
|
|
700
1949
|
|
|
1950
|
+
// Known container names for cleanup
|
|
1951
|
+
const MONITORING_CONTAINERS = [
|
|
1952
|
+
"postgres-ai-config-init",
|
|
1953
|
+
"node-exporter",
|
|
1954
|
+
"cadvisor",
|
|
1955
|
+
"grafana-with-datasources",
|
|
1956
|
+
"sink-postgres",
|
|
1957
|
+
"sink-prometheus",
|
|
1958
|
+
"target-db",
|
|
1959
|
+
"pgwatch-postgres",
|
|
1960
|
+
"pgwatch-prometheus",
|
|
1961
|
+
"postgres-exporter-sink",
|
|
1962
|
+
"flask-pgss-api",
|
|
1963
|
+
"sources-generator",
|
|
1964
|
+
"postgres-reports",
|
|
1965
|
+
];
|
|
1966
|
+
|
|
1967
|
+
/**
|
|
1968
|
+
* Network cleanup constants.
|
|
1969
|
+
* Docker Compose creates a default network named "{project}_default".
|
|
1970
|
+
* In CI environments, network cleanup can fail if containers are slow to disconnect.
|
|
1971
|
+
*/
|
|
1972
|
+
const COMPOSE_PROJECT_NAME = "postgres_ai";
|
|
1973
|
+
const DOCKER_NETWORK_NAME = `${COMPOSE_PROJECT_NAME}_default`;
|
|
1974
|
+
/** Delay before retrying network cleanup (allows container network disconnections to complete) */
|
|
1975
|
+
const NETWORK_CLEANUP_DELAY_MS = 2000;
|
|
1976
|
+
|
|
1977
|
+
/** Remove orphaned containers that docker compose down might miss */
|
|
1978
|
+
async function removeOrphanedContainers(): Promise<void> {
|
|
1979
|
+
for (const container of MONITORING_CONTAINERS) {
|
|
1980
|
+
try {
|
|
1981
|
+
await execFilePromise("docker", ["rm", "-f", container]);
|
|
1982
|
+
} catch {
|
|
1983
|
+
// Container doesn't exist, ignore
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
|
|
701
1988
|
mon
|
|
702
1989
|
.command("stop")
|
|
703
1990
|
.description("stop monitoring services")
|
|
704
1991
|
.action(async () => {
|
|
705
|
-
|
|
1992
|
+
// Multi-stage cleanup strategy for reliable shutdown in CI environments:
|
|
1993
|
+
// Stage 1: Standard compose down with orphan removal
|
|
1994
|
+
// Stage 2: Force remove any orphaned containers, then retry compose down
|
|
1995
|
+
// Stage 3: Force remove the Docker network directly
|
|
1996
|
+
// This handles edge cases where containers are slow to disconnect from networks.
|
|
1997
|
+
let code = await runCompose(["down", "--remove-orphans"]);
|
|
1998
|
+
|
|
1999
|
+
// Stage 2: If initial cleanup fails, try removing orphaned containers first
|
|
2000
|
+
if (code !== 0) {
|
|
2001
|
+
await removeOrphanedContainers();
|
|
2002
|
+
// Wait a moment for container network disconnections to complete
|
|
2003
|
+
await new Promise(resolve => setTimeout(resolve, NETWORK_CLEANUP_DELAY_MS));
|
|
2004
|
+
// Retry compose down
|
|
2005
|
+
code = await runCompose(["down", "--remove-orphans"]);
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
// Final cleanup: force remove the network if it still exists
|
|
2009
|
+
if (code !== 0) {
|
|
2010
|
+
try {
|
|
2011
|
+
await execFilePromise("docker", ["network", "rm", DOCKER_NETWORK_NAME]);
|
|
2012
|
+
// Network removal succeeded - cleanup is complete
|
|
2013
|
+
code = 0;
|
|
2014
|
+
} catch {
|
|
2015
|
+
// Network doesn't exist or couldn't be removed, ignore
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
|
|
706
2019
|
if (code !== 0) process.exitCode = code;
|
|
707
2020
|
});
|
|
708
2021
|
|
|
@@ -766,17 +2079,17 @@ mon
|
|
|
766
2079
|
allHealthy = true;
|
|
767
2080
|
for (const service of services) {
|
|
768
2081
|
try {
|
|
769
|
-
const
|
|
770
|
-
const status =
|
|
771
|
-
encoding: 'utf8',
|
|
772
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
773
|
-
}).trim();
|
|
2082
|
+
const result = spawnSync("docker", ["inspect", "-f", "{{.State.Status}}", service.container], { stdio: "pipe" });
|
|
2083
|
+
const status = result.stdout.trim();
|
|
774
2084
|
|
|
775
|
-
if (status === 'running') {
|
|
2085
|
+
if (result.status === 0 && status === 'running') {
|
|
776
2086
|
console.log(`✓ ${service.name}: healthy`);
|
|
777
|
-
} else {
|
|
2087
|
+
} else if (result.status === 0) {
|
|
778
2088
|
console.log(`✗ ${service.name}: unhealthy (status: ${status})`);
|
|
779
2089
|
allHealthy = false;
|
|
2090
|
+
} else {
|
|
2091
|
+
console.log(`✗ ${service.name}: unreachable`);
|
|
2092
|
+
allHealthy = false;
|
|
780
2093
|
}
|
|
781
2094
|
} catch (error) {
|
|
782
2095
|
console.log(`✗ ${service.name}: unreachable`);
|
|
@@ -805,7 +2118,7 @@ mon
|
|
|
805
2118
|
let composeFile: string;
|
|
806
2119
|
let instancesFile: string;
|
|
807
2120
|
try {
|
|
808
|
-
({ projectDir, composeFile, instancesFile } =
|
|
2121
|
+
({ projectDir, composeFile, instancesFile } = await resolveOrInitPaths());
|
|
809
2122
|
} catch (error) {
|
|
810
2123
|
const message = error instanceof Error ? error.message : String(error);
|
|
811
2124
|
console.error(message);
|
|
@@ -880,14 +2193,6 @@ mon
|
|
|
880
2193
|
.command("reset [service]")
|
|
881
2194
|
.description("reset all or specific monitoring service")
|
|
882
2195
|
.action(async (service?: string) => {
|
|
883
|
-
const rl = readline.createInterface({
|
|
884
|
-
input: process.stdin,
|
|
885
|
-
output: process.stdout,
|
|
886
|
-
});
|
|
887
|
-
|
|
888
|
-
const question = (prompt: string): Promise<string> =>
|
|
889
|
-
new Promise((resolve) => rl.question(prompt, resolve));
|
|
890
|
-
|
|
891
2196
|
try {
|
|
892
2197
|
if (service) {
|
|
893
2198
|
// Reset specific service
|
|
@@ -897,7 +2202,6 @@ mon
|
|
|
897
2202
|
const answer = await question("Continue? (y/N): ");
|
|
898
2203
|
if (answer.toLowerCase() !== "y") {
|
|
899
2204
|
console.log("Cancelled");
|
|
900
|
-
rl.close();
|
|
901
2205
|
return;
|
|
902
2206
|
}
|
|
903
2207
|
|
|
@@ -924,7 +2228,6 @@ mon
|
|
|
924
2228
|
const answer = await question("Continue? (y/N): ");
|
|
925
2229
|
if (answer.toLowerCase() !== "y") {
|
|
926
2230
|
console.log("Cancelled");
|
|
927
|
-
rl.close();
|
|
928
2231
|
return;
|
|
929
2232
|
}
|
|
930
2233
|
|
|
@@ -938,10 +2241,7 @@ mon
|
|
|
938
2241
|
process.exitCode = 1;
|
|
939
2242
|
}
|
|
940
2243
|
}
|
|
941
|
-
|
|
942
|
-
rl.close();
|
|
943
2244
|
} catch (error) {
|
|
944
|
-
rl.close();
|
|
945
2245
|
const message = error instanceof Error ? error.message : String(error);
|
|
946
2246
|
console.error(`Reset failed: ${message}`);
|
|
947
2247
|
process.exitCode = 1;
|
|
@@ -949,34 +2249,72 @@ mon
|
|
|
949
2249
|
});
|
|
950
2250
|
mon
|
|
951
2251
|
.command("clean")
|
|
952
|
-
.description("cleanup monitoring services artifacts")
|
|
953
|
-
.
|
|
954
|
-
|
|
2252
|
+
.description("cleanup monitoring services artifacts (stops services and removes volumes)")
|
|
2253
|
+
.option("--keep-volumes", "keep data volumes (only stop and remove containers)")
|
|
2254
|
+
.action(async (options: { keepVolumes?: boolean }) => {
|
|
2255
|
+
console.log("Cleaning up monitoring services...\n");
|
|
955
2256
|
|
|
956
2257
|
try {
|
|
957
|
-
//
|
|
958
|
-
const
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
2258
|
+
// First, use docker-compose down to properly stop and remove containers/volumes
|
|
2259
|
+
const downArgs = options.keepVolumes ? ["down"] : ["down", "-v"];
|
|
2260
|
+
console.log(options.keepVolumes
|
|
2261
|
+
? "Stopping and removing containers (keeping volumes)..."
|
|
2262
|
+
: "Stopping and removing containers and volumes...");
|
|
2263
|
+
|
|
2264
|
+
const downCode = await runCompose(downArgs);
|
|
2265
|
+
if (downCode === 0) {
|
|
2266
|
+
console.log("✓ Monitoring services stopped and removed");
|
|
963
2267
|
} else {
|
|
964
|
-
console.log("
|
|
2268
|
+
console.log("⚠ Could not stop services (may not be running)");
|
|
965
2269
|
}
|
|
966
2270
|
|
|
967
|
-
// Remove
|
|
968
|
-
await
|
|
969
|
-
console.log("✓ Removed
|
|
2271
|
+
// Remove any orphaned containers that docker compose down missed
|
|
2272
|
+
await removeOrphanedContainers();
|
|
2273
|
+
console.log("✓ Removed orphaned containers");
|
|
2274
|
+
|
|
2275
|
+
// Remove orphaned volumes from previous installs with different project names
|
|
2276
|
+
if (!options.keepVolumes) {
|
|
2277
|
+
const volumePatterns = [
|
|
2278
|
+
"monitoring_grafana_data",
|
|
2279
|
+
"monitoring_postgres_ai_configs",
|
|
2280
|
+
"monitoring_sink_postgres_data",
|
|
2281
|
+
"monitoring_target_db_data",
|
|
2282
|
+
"monitoring_victoria_metrics_data",
|
|
2283
|
+
"postgres_ai_configs_grafana_data",
|
|
2284
|
+
"postgres_ai_configs_sink_postgres_data",
|
|
2285
|
+
"postgres_ai_configs_target_db_data",
|
|
2286
|
+
"postgres_ai_configs_victoria_metrics_data",
|
|
2287
|
+
"postgres_ai_configs_postgres_ai_configs",
|
|
2288
|
+
];
|
|
2289
|
+
|
|
2290
|
+
const { stdout: existingVolumes } = await execFilePromise("docker", ["volume", "ls", "-q"]);
|
|
2291
|
+
const volumeList = existingVolumes.trim().split('\n').filter(Boolean);
|
|
2292
|
+
const orphanedVolumes = volumeList.filter(v => volumePatterns.includes(v));
|
|
2293
|
+
|
|
2294
|
+
if (orphanedVolumes.length > 0) {
|
|
2295
|
+
let removedCount = 0;
|
|
2296
|
+
for (const vol of orphanedVolumes) {
|
|
2297
|
+
try {
|
|
2298
|
+
await execFilePromise("docker", ["volume", "rm", vol]);
|
|
2299
|
+
removedCount++;
|
|
2300
|
+
} catch {
|
|
2301
|
+
// Volume might be in use, skip silently
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
if (removedCount > 0) {
|
|
2305
|
+
console.log(`✓ Removed ${removedCount} orphaned volume(s) from previous installs`);
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
970
2309
|
|
|
971
|
-
// Remove
|
|
2310
|
+
// Remove any dangling resources
|
|
972
2311
|
await execFilePromise("docker", ["network", "prune", "-f"]);
|
|
973
2312
|
console.log("✓ Removed unused networks");
|
|
974
2313
|
|
|
975
|
-
// Remove dangling images
|
|
976
2314
|
await execFilePromise("docker", ["image", "prune", "-f"]);
|
|
977
2315
|
console.log("✓ Removed dangling images");
|
|
978
2316
|
|
|
979
|
-
console.log("\
|
|
2317
|
+
console.log("\n✓ Cleanup completed - ready for fresh install");
|
|
980
2318
|
} catch (error) {
|
|
981
2319
|
const message = error instanceof Error ? error.message : String(error);
|
|
982
2320
|
console.error(`Error during cleanup: ${message}`);
|
|
@@ -1005,9 +2343,9 @@ targets
|
|
|
1005
2343
|
.command("list")
|
|
1006
2344
|
.description("list monitoring target databases")
|
|
1007
2345
|
.action(async () => {
|
|
1008
|
-
const instancesPath =
|
|
2346
|
+
const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
|
|
1009
2347
|
if (!fs.existsSync(instancesPath)) {
|
|
1010
|
-
console.error(`instances.yml not found in ${
|
|
2348
|
+
console.error(`instances.yml not found in ${projectDir}`);
|
|
1011
2349
|
process.exitCode = 1;
|
|
1012
2350
|
return;
|
|
1013
2351
|
}
|
|
@@ -1054,7 +2392,7 @@ targets
|
|
|
1054
2392
|
.command("add [connStr] [name]")
|
|
1055
2393
|
.description("add monitoring target database")
|
|
1056
2394
|
.action(async (connStr?: string, name?: string) => {
|
|
1057
|
-
const file =
|
|
2395
|
+
const { instancesFile: file } = await resolveOrInitPaths();
|
|
1058
2396
|
if (!connStr) {
|
|
1059
2397
|
console.error("Connection string required: postgresql://user:pass@host:port/db");
|
|
1060
2398
|
process.exitCode = 1;
|
|
@@ -1104,7 +2442,7 @@ targets
|
|
|
1104
2442
|
.command("remove <name>")
|
|
1105
2443
|
.description("remove monitoring target database")
|
|
1106
2444
|
.action(async (name: string) => {
|
|
1107
|
-
const file =
|
|
2445
|
+
const { instancesFile: file } = await resolveOrInitPaths();
|
|
1108
2446
|
if (!fs.existsSync(file)) {
|
|
1109
2447
|
console.error("instances.yml not found");
|
|
1110
2448
|
process.exitCode = 1;
|
|
@@ -1141,7 +2479,7 @@ targets
|
|
|
1141
2479
|
.command("test <name>")
|
|
1142
2480
|
.description("test monitoring target database connectivity")
|
|
1143
2481
|
.action(async (name: string) => {
|
|
1144
|
-
const instancesPath =
|
|
2482
|
+
const { instancesFile: instancesPath } = await resolveOrInitPaths();
|
|
1145
2483
|
if (!fs.existsSync(instancesPath)) {
|
|
1146
2484
|
console.error("instances.yml not found");
|
|
1147
2485
|
process.exitCode = 1;
|
|
@@ -1175,7 +2513,6 @@ targets
|
|
|
1175
2513
|
console.log(`Testing connection to monitoring target '${name}'...`);
|
|
1176
2514
|
|
|
1177
2515
|
// Use native pg client instead of requiring psql to be installed
|
|
1178
|
-
const { Client } = require('pg');
|
|
1179
2516
|
const client = new Client({ connectionString: instance.conn_str });
|
|
1180
2517
|
|
|
1181
2518
|
try {
|
|
@@ -1194,15 +2531,43 @@ targets
|
|
|
1194
2531
|
});
|
|
1195
2532
|
|
|
1196
2533
|
// Authentication and API key management
|
|
1197
|
-
program
|
|
1198
|
-
|
|
1199
|
-
|
|
2534
|
+
const auth = program.command("auth").description("authentication and API key management");
|
|
2535
|
+
|
|
2536
|
+
auth
|
|
2537
|
+
.command("login", { isDefault: true })
|
|
2538
|
+
.description("authenticate via browser (OAuth) or store API key directly")
|
|
2539
|
+
.option("--set-key <key>", "store API key directly without OAuth flow")
|
|
1200
2540
|
.option("--port <port>", "local callback server port (default: random)", parseInt)
|
|
1201
2541
|
.option("--debug", "enable debug output")
|
|
1202
|
-
.action(async (opts: { port?: number; debug?: boolean }) => {
|
|
1203
|
-
|
|
1204
|
-
|
|
2542
|
+
.action(async (opts: { setKey?: string; port?: number; debug?: boolean }) => {
|
|
2543
|
+
// If --set-key is provided, store it directly without OAuth
|
|
2544
|
+
if (opts.setKey) {
|
|
2545
|
+
const trimmedKey = opts.setKey.trim();
|
|
2546
|
+
if (!trimmedKey) {
|
|
2547
|
+
console.error("Error: API key cannot be empty");
|
|
2548
|
+
process.exitCode = 1;
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
// Read existing config to check for defaultProject before updating
|
|
2553
|
+
const existingConfig = config.readConfig();
|
|
2554
|
+
const existingProject = existingConfig.defaultProject;
|
|
2555
|
+
|
|
2556
|
+
config.writeConfig({ apiKey: trimmedKey });
|
|
2557
|
+
// When API key is set directly, only clear orgId (org selection may differ).
|
|
2558
|
+
// Preserve defaultProject to avoid orphaning historical reports.
|
|
2559
|
+
// If the new key lacks access to the project, upload will fail with a clear error.
|
|
2560
|
+
config.deleteConfigKeys(["orgId"]);
|
|
2561
|
+
|
|
2562
|
+
console.log(`API key saved to ${config.getConfigPath()}`);
|
|
2563
|
+
if (existingProject) {
|
|
2564
|
+
console.log(`Note: Your default project "${existingProject}" has been preserved.`);
|
|
2565
|
+
console.log(` If this key belongs to a different account, use --project to specify a new one.`);
|
|
2566
|
+
}
|
|
2567
|
+
return;
|
|
2568
|
+
}
|
|
1205
2569
|
|
|
2570
|
+
// Otherwise, proceed with OAuth flow
|
|
1206
2571
|
console.log("Starting authentication flow...\n");
|
|
1207
2572
|
|
|
1208
2573
|
// Generate PKCE parameters
|
|
@@ -1223,10 +2588,10 @@ program
|
|
|
1223
2588
|
const requestedPort = opts.port || 0; // 0 = OS assigns available port
|
|
1224
2589
|
const callbackServer = authServer.createCallbackServer(requestedPort, params.state, 120000); // 2 minute timeout
|
|
1225
2590
|
|
|
1226
|
-
// Wait
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
const redirectUri = `http://
|
|
2591
|
+
// Wait for server to start and get the actual port
|
|
2592
|
+
const actualPort = await callbackServer.ready;
|
|
2593
|
+
// Use 127.0.0.1 to match the server bind address (avoids IPv6 issues on some hosts)
|
|
2594
|
+
const redirectUri = `http://127.0.0.1:${actualPort}/callback`;
|
|
1230
2595
|
|
|
1231
2596
|
console.log(`Callback server listening on port ${actualPort}`);
|
|
1232
2597
|
|
|
@@ -1248,173 +2613,180 @@ program
|
|
|
1248
2613
|
console.log(`Debug: Request data: ${initData}`);
|
|
1249
2614
|
}
|
|
1250
2615
|
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
2616
|
+
// Step 2: Initialize OAuth session on backend using fetch
|
|
2617
|
+
let initResponse: Response;
|
|
2618
|
+
try {
|
|
2619
|
+
initResponse = await fetch(initUrl.toString(), {
|
|
1254
2620
|
method: "POST",
|
|
1255
2621
|
headers: {
|
|
1256
2622
|
"Content-Type": "application/json",
|
|
1257
|
-
"Content-Length": Buffer.byteLength(initData),
|
|
1258
2623
|
},
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
if (data.trim().startsWith("<!") || data.trim().startsWith("<html")) {
|
|
1269
|
-
console.error("Error: Received HTML response instead of JSON. This usually means:");
|
|
1270
|
-
console.error(" 1. The API endpoint URL is incorrect");
|
|
1271
|
-
console.error(" 2. The endpoint does not exist (404)");
|
|
1272
|
-
console.error(`\nAPI URL attempted: ${initUrl.toString()}`);
|
|
1273
|
-
console.error("\nPlease verify the --api-base-url parameter.");
|
|
1274
|
-
} else {
|
|
1275
|
-
console.error(data);
|
|
1276
|
-
}
|
|
2624
|
+
body: initData,
|
|
2625
|
+
});
|
|
2626
|
+
} catch (err) {
|
|
2627
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2628
|
+
console.error(`Failed to connect to API: ${message}`);
|
|
2629
|
+
callbackServer.server.stop();
|
|
2630
|
+
process.exit(1);
|
|
2631
|
+
return;
|
|
2632
|
+
}
|
|
1277
2633
|
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
2634
|
+
if (!initResponse.ok) {
|
|
2635
|
+
const data = await initResponse.text();
|
|
2636
|
+
console.error(`Failed to initialize auth session: ${initResponse.status}`);
|
|
2637
|
+
|
|
2638
|
+
// Check if response is HTML (common for 404 pages)
|
|
2639
|
+
if (data.trim().startsWith("<!") || data.trim().startsWith("<html")) {
|
|
2640
|
+
console.error("Error: Received HTML response instead of JSON. This usually means:");
|
|
2641
|
+
console.error(" 1. The API endpoint URL is incorrect");
|
|
2642
|
+
console.error(" 2. The endpoint does not exist (404)");
|
|
2643
|
+
console.error(`\nAPI URL attempted: ${initUrl.toString()}`);
|
|
2644
|
+
console.error("\nPlease verify the --api-base-url parameter.");
|
|
2645
|
+
} else {
|
|
2646
|
+
console.error(data);
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
callbackServer.server.stop();
|
|
2650
|
+
process.exit(1);
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
1281
2653
|
|
|
1282
|
-
|
|
1283
|
-
|
|
2654
|
+
// Step 3: Open browser
|
|
2655
|
+
// Pass api_url so UI calls oauth_approve on the same backend where oauth_init created the session
|
|
2656
|
+
const authUrl = `${uiBaseUrl}/cli/auth?state=${encodeURIComponent(params.state)}&code_challenge=${encodeURIComponent(params.codeChallenge)}&code_challenge_method=S256&redirect_uri=${encodeURIComponent(redirectUri)}&api_url=${encodeURIComponent(apiBaseUrl)}`;
|
|
1284
2657
|
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
2658
|
+
if (opts.debug) {
|
|
2659
|
+
console.log(`Debug: Auth URL: ${authUrl}`);
|
|
2660
|
+
}
|
|
1288
2661
|
|
|
1289
|
-
|
|
1290
|
-
|
|
2662
|
+
console.log(`\nOpening browser for authentication...`);
|
|
2663
|
+
console.log(`If browser does not open automatically, visit:\n${authUrl}\n`);
|
|
1291
2664
|
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
2665
|
+
// Open browser (cross-platform)
|
|
2666
|
+
const openCommand = process.platform === "darwin" ? "open" :
|
|
2667
|
+
process.platform === "win32" ? "start" :
|
|
2668
|
+
"xdg-open";
|
|
2669
|
+
spawn(openCommand, [authUrl], { detached: true, stdio: "ignore" }).unref();
|
|
1297
2670
|
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
2671
|
+
// Step 4: Wait for callback
|
|
2672
|
+
console.log("Waiting for authorization...");
|
|
2673
|
+
console.log("(Press Ctrl+C to cancel)\n");
|
|
1301
2674
|
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
2675
|
+
// Handle Ctrl+C gracefully
|
|
2676
|
+
const cancelHandler = () => {
|
|
2677
|
+
console.log("\n\nAuthentication cancelled by user.");
|
|
2678
|
+
callbackServer.server.stop();
|
|
2679
|
+
process.exit(130); // Standard exit code for SIGINT
|
|
2680
|
+
};
|
|
2681
|
+
process.on("SIGINT", cancelHandler);
|
|
1309
2682
|
|
|
1310
|
-
|
|
1311
|
-
|
|
2683
|
+
try {
|
|
2684
|
+
const { code } = await callbackServer.promise;
|
|
1312
2685
|
|
|
1313
|
-
|
|
1314
|
-
|
|
2686
|
+
// Remove the cancel handler after successful auth
|
|
2687
|
+
process.off("SIGINT", cancelHandler);
|
|
1315
2688
|
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
const exchangeReq = http.request(
|
|
1325
|
-
exchangeUrl,
|
|
1326
|
-
{
|
|
1327
|
-
method: "POST",
|
|
1328
|
-
headers: {
|
|
1329
|
-
"Content-Type": "application/json",
|
|
1330
|
-
"Content-Length": Buffer.byteLength(exchangeData),
|
|
1331
|
-
},
|
|
1332
|
-
},
|
|
1333
|
-
(exchangeRes) => {
|
|
1334
|
-
let exchangeBody = "";
|
|
1335
|
-
exchangeRes.on("data", (chunk) => (exchangeBody += chunk));
|
|
1336
|
-
exchangeRes.on("end", () => {
|
|
1337
|
-
if (exchangeRes.statusCode !== 200) {
|
|
1338
|
-
console.error(`Failed to exchange code for token: ${exchangeRes.statusCode}`);
|
|
1339
|
-
|
|
1340
|
-
// Check if response is HTML (common for 404 pages)
|
|
1341
|
-
if (exchangeBody.trim().startsWith("<!") || exchangeBody.trim().startsWith("<html")) {
|
|
1342
|
-
console.error("Error: Received HTML response instead of JSON. This usually means:");
|
|
1343
|
-
console.error(" 1. The API endpoint URL is incorrect");
|
|
1344
|
-
console.error(" 2. The endpoint does not exist (404)");
|
|
1345
|
-
console.error(`\nAPI URL attempted: ${exchangeUrl.toString()}`);
|
|
1346
|
-
console.error("\nPlease verify the --api-base-url parameter.");
|
|
1347
|
-
} else {
|
|
1348
|
-
console.error(exchangeBody);
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
process.exit(1);
|
|
1352
|
-
return;
|
|
1353
|
-
}
|
|
1354
|
-
|
|
1355
|
-
try {
|
|
1356
|
-
const result = JSON.parse(exchangeBody);
|
|
1357
|
-
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.
|
|
1358
|
-
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.
|
|
1359
|
-
|
|
1360
|
-
// Step 6: Save token to config
|
|
1361
|
-
config.writeConfig({
|
|
1362
|
-
apiKey: apiToken,
|
|
1363
|
-
baseUrl: apiBaseUrl,
|
|
1364
|
-
orgId: orgId,
|
|
1365
|
-
});
|
|
1366
|
-
|
|
1367
|
-
console.log("\nAuthentication successful!");
|
|
1368
|
-
console.log(`API key saved to: ${config.getConfigPath()}`);
|
|
1369
|
-
console.log(`Organization ID: ${orgId}`);
|
|
1370
|
-
console.log(`\nYou can now use the CLI without specifying an API key.`);
|
|
1371
|
-
process.exit(0);
|
|
1372
|
-
} catch (err) {
|
|
1373
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1374
|
-
console.error(`Failed to parse response: ${message}`);
|
|
1375
|
-
process.exit(1);
|
|
1376
|
-
}
|
|
1377
|
-
});
|
|
1378
|
-
}
|
|
1379
|
-
);
|
|
2689
|
+
// Step 5: Exchange code for token using fetch
|
|
2690
|
+
console.log("\nExchanging authorization code for API token...");
|
|
2691
|
+
const exchangeData = JSON.stringify({
|
|
2692
|
+
authorization_code: code,
|
|
2693
|
+
code_verifier: params.codeVerifier,
|
|
2694
|
+
state: params.state,
|
|
2695
|
+
});
|
|
2696
|
+
const exchangeUrl = new URL(`${apiBaseUrl}/rpc/oauth_token_exchange`);
|
|
1380
2697
|
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
2698
|
+
let exchangeResponse: Response;
|
|
2699
|
+
try {
|
|
2700
|
+
exchangeResponse = await fetch(exchangeUrl.toString(), {
|
|
2701
|
+
method: "POST",
|
|
2702
|
+
headers: {
|
|
2703
|
+
"Content-Type": "application/json",
|
|
2704
|
+
},
|
|
2705
|
+
body: exchangeData,
|
|
2706
|
+
});
|
|
2707
|
+
} catch (err) {
|
|
2708
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2709
|
+
console.error(`Exchange request failed: ${message}`);
|
|
2710
|
+
process.exit(1);
|
|
2711
|
+
return;
|
|
2712
|
+
}
|
|
1385
2713
|
|
|
1386
|
-
|
|
1387
|
-
exchangeReq.end();
|
|
2714
|
+
const exchangeBody = await exchangeResponse.text();
|
|
1388
2715
|
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
process.off("SIGINT", cancelHandler);
|
|
2716
|
+
if (!exchangeResponse.ok) {
|
|
2717
|
+
console.error(`Failed to exchange code for token: ${exchangeResponse.status}`);
|
|
1392
2718
|
|
|
1393
|
-
|
|
2719
|
+
// Check if response is HTML (common for 404 pages)
|
|
2720
|
+
if (exchangeBody.trim().startsWith("<!") || exchangeBody.trim().startsWith("<html")) {
|
|
2721
|
+
console.error("Error: Received HTML response instead of JSON. This usually means:");
|
|
2722
|
+
console.error(" 1. The API endpoint URL is incorrect");
|
|
2723
|
+
console.error(" 2. The endpoint does not exist (404)");
|
|
2724
|
+
console.error(`\nAPI URL attempted: ${exchangeUrl.toString()}`);
|
|
2725
|
+
console.error("\nPlease verify the --api-base-url parameter.");
|
|
2726
|
+
} else {
|
|
2727
|
+
console.error(exchangeBody);
|
|
2728
|
+
}
|
|
1394
2729
|
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
console.error(`This usually means you closed the browser window without completing authentication.`);
|
|
1399
|
-
console.error(`Please try again and complete the authentication flow.`);
|
|
1400
|
-
} else {
|
|
1401
|
-
console.error(`\nAuthentication failed: ${message}`);
|
|
1402
|
-
}
|
|
2730
|
+
process.exit(1);
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
1403
2733
|
|
|
1404
|
-
|
|
1405
|
-
|
|
2734
|
+
try {
|
|
2735
|
+
const result = JSON.parse(exchangeBody);
|
|
2736
|
+
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.
|
|
2737
|
+
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.
|
|
2738
|
+
|
|
2739
|
+
// Step 6: Save token to config
|
|
2740
|
+
// Check if org changed to decide whether to preserve defaultProject
|
|
2741
|
+
const existingConfig = config.readConfig();
|
|
2742
|
+
const existingOrgId = existingConfig.orgId;
|
|
2743
|
+
const existingProject = existingConfig.defaultProject;
|
|
2744
|
+
const orgChanged = existingOrgId && existingOrgId !== orgId;
|
|
2745
|
+
|
|
2746
|
+
config.writeConfig({
|
|
2747
|
+
apiKey: apiToken,
|
|
2748
|
+
baseUrl: apiBaseUrl,
|
|
2749
|
+
orgId: orgId,
|
|
1406
2750
|
});
|
|
2751
|
+
|
|
2752
|
+
// Only clear defaultProject if org actually changed
|
|
2753
|
+
if (orgChanged && existingProject) {
|
|
2754
|
+
config.deleteConfigKeys(["defaultProject"]);
|
|
2755
|
+
console.log(`\nNote: Organization changed (${existingOrgId} → ${orgId}).`);
|
|
2756
|
+
console.log(` Default project "${existingProject}" has been cleared.`);
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
console.log("\nAuthentication successful!");
|
|
2760
|
+
console.log(`API key saved to: ${config.getConfigPath()}`);
|
|
2761
|
+
console.log(`Organization ID: ${orgId}`);
|
|
2762
|
+
if (!orgChanged && existingProject) {
|
|
2763
|
+
console.log(`Default project: ${existingProject} (preserved)`);
|
|
2764
|
+
}
|
|
2765
|
+
console.log(`\nYou can now use the CLI without specifying an API key.`);
|
|
2766
|
+
process.exit(0);
|
|
2767
|
+
} catch (err) {
|
|
2768
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2769
|
+
console.error(`Failed to parse response: ${message}`);
|
|
2770
|
+
process.exit(1);
|
|
1407
2771
|
}
|
|
1408
|
-
);
|
|
1409
2772
|
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
2773
|
+
} catch (err) {
|
|
2774
|
+
// Remove the cancel handler in error case too
|
|
2775
|
+
process.off("SIGINT", cancelHandler);
|
|
2776
|
+
|
|
2777
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1415
2778
|
|
|
1416
|
-
|
|
1417
|
-
|
|
2779
|
+
// Provide more helpful error messages
|
|
2780
|
+
if (message.includes("timeout")) {
|
|
2781
|
+
console.error(`\nAuthentication timed out.`);
|
|
2782
|
+
console.error(`This usually means you closed the browser window without completing authentication.`);
|
|
2783
|
+
console.error(`Please try again and complete the authentication flow.`);
|
|
2784
|
+
} else {
|
|
2785
|
+
console.error(`\nAuthentication failed: ${message}`);
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
process.exit(1);
|
|
2789
|
+
}
|
|
1418
2790
|
|
|
1419
2791
|
} catch (err) {
|
|
1420
2792
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1423,15 +2795,7 @@ program
|
|
|
1423
2795
|
}
|
|
1424
2796
|
});
|
|
1425
2797
|
|
|
1426
|
-
|
|
1427
|
-
.command("add-key <apiKey>")
|
|
1428
|
-
.description("store API key")
|
|
1429
|
-
.action(async (apiKey: string) => {
|
|
1430
|
-
config.writeConfig({ apiKey });
|
|
1431
|
-
console.log(`API key saved to ${config.getConfigPath()}`);
|
|
1432
|
-
});
|
|
1433
|
-
|
|
1434
|
-
program
|
|
2798
|
+
auth
|
|
1435
2799
|
.command("show-key")
|
|
1436
2800
|
.description("show API key (masked)")
|
|
1437
2801
|
.action(async () => {
|
|
@@ -1441,7 +2805,6 @@ program
|
|
|
1441
2805
|
console.log(`\nTo authenticate, run: pgai auth`);
|
|
1442
2806
|
return;
|
|
1443
2807
|
}
|
|
1444
|
-
const { maskSecret } = require("../lib/util");
|
|
1445
2808
|
console.log(`Current API key: ${maskSecret(cfg.apiKey)}`);
|
|
1446
2809
|
if (cfg.orgId) {
|
|
1447
2810
|
console.log(`Organization ID: ${cfg.orgId}`);
|
|
@@ -1449,14 +2812,20 @@ program
|
|
|
1449
2812
|
console.log(`Config location: ${config.getConfigPath()}`);
|
|
1450
2813
|
});
|
|
1451
2814
|
|
|
1452
|
-
|
|
2815
|
+
auth
|
|
1453
2816
|
.command("remove-key")
|
|
1454
2817
|
.description("remove API key")
|
|
1455
2818
|
.action(async () => {
|
|
1456
2819
|
// Check both new config and legacy config
|
|
1457
2820
|
const newConfigPath = config.getConfigPath();
|
|
1458
2821
|
const hasNewConfig = fs.existsSync(newConfigPath);
|
|
1459
|
-
|
|
2822
|
+
let legacyPath: string;
|
|
2823
|
+
try {
|
|
2824
|
+
const { projectDir } = await resolveOrInitPaths();
|
|
2825
|
+
legacyPath = path.resolve(projectDir, ".pgwatch-config");
|
|
2826
|
+
} catch {
|
|
2827
|
+
legacyPath = path.resolve(process.cwd(), ".pgwatch-config");
|
|
2828
|
+
}
|
|
1460
2829
|
const hasLegacyConfig = fs.existsSync(legacyPath) && fs.statSync(legacyPath).isFile();
|
|
1461
2830
|
|
|
1462
2831
|
if (!hasNewConfig && !hasLegacyConfig) {
|
|
@@ -1492,7 +2861,8 @@ mon
|
|
|
1492
2861
|
.command("generate-grafana-password")
|
|
1493
2862
|
.description("generate Grafana password for monitoring services")
|
|
1494
2863
|
.action(async () => {
|
|
1495
|
-
const
|
|
2864
|
+
const { projectDir } = await resolveOrInitPaths();
|
|
2865
|
+
const cfgPath = path.resolve(projectDir, ".pgwatch-config");
|
|
1496
2866
|
|
|
1497
2867
|
try {
|
|
1498
2868
|
// Generate secure password using openssl
|
|
@@ -1543,9 +2913,10 @@ mon
|
|
|
1543
2913
|
.command("show-grafana-credentials")
|
|
1544
2914
|
.description("show Grafana credentials for monitoring services")
|
|
1545
2915
|
.action(async () => {
|
|
1546
|
-
const
|
|
2916
|
+
const { projectDir } = await resolveOrInitPaths();
|
|
2917
|
+
const cfgPath = path.resolve(projectDir, ".pgwatch-config");
|
|
1547
2918
|
if (!fs.existsSync(cfgPath)) {
|
|
1548
|
-
console.error("Configuration file not found. Run 'postgres-ai mon
|
|
2919
|
+
console.error("Configuration file not found. Run 'postgres-ai mon local-install' first.");
|
|
1549
2920
|
process.exitCode = 1;
|
|
1550
2921
|
return;
|
|
1551
2922
|
}
|
|
@@ -1671,7 +3042,7 @@ issues
|
|
|
1671
3042
|
});
|
|
1672
3043
|
|
|
1673
3044
|
issues
|
|
1674
|
-
.command("
|
|
3045
|
+
.command("post-comment <issueId> <content>")
|
|
1675
3046
|
.description("post a new comment to an issue")
|
|
1676
3047
|
.option("--parent <uuid>", "parent comment id")
|
|
1677
3048
|
.option("--debug", "enable debug output")
|
|
@@ -1716,6 +3087,194 @@ issues
|
|
|
1716
3087
|
}
|
|
1717
3088
|
});
|
|
1718
3089
|
|
|
3090
|
+
issues
|
|
3091
|
+
.command("create <title>")
|
|
3092
|
+
.description("create a new issue")
|
|
3093
|
+
.option("--org-id <id>", "organization id (defaults to config orgId)", (v) => parseInt(v, 10))
|
|
3094
|
+
.option("--project-id <id>", "project id", (v) => parseInt(v, 10))
|
|
3095
|
+
.option("--description <text>", "issue description (supports \\\\n)")
|
|
3096
|
+
.option(
|
|
3097
|
+
"--label <label>",
|
|
3098
|
+
"issue label (repeatable)",
|
|
3099
|
+
(value: string, previous: string[]) => {
|
|
3100
|
+
previous.push(value);
|
|
3101
|
+
return previous;
|
|
3102
|
+
},
|
|
3103
|
+
[] as string[]
|
|
3104
|
+
)
|
|
3105
|
+
.option("--debug", "enable debug output")
|
|
3106
|
+
.option("--json", "output raw JSON")
|
|
3107
|
+
.action(async (rawTitle: string, opts: { orgId?: number; projectId?: number; description?: string; label?: string[]; debug?: boolean; json?: boolean }) => {
|
|
3108
|
+
try {
|
|
3109
|
+
const rootOpts = program.opts<CliOptions>();
|
|
3110
|
+
const cfg = config.readConfig();
|
|
3111
|
+
const { apiKey } = getConfig(rootOpts);
|
|
3112
|
+
if (!apiKey) {
|
|
3113
|
+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
3114
|
+
process.exitCode = 1;
|
|
3115
|
+
return;
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
const title = interpretEscapes(String(rawTitle || "").trim());
|
|
3119
|
+
if (!title) {
|
|
3120
|
+
console.error("title is required");
|
|
3121
|
+
process.exitCode = 1;
|
|
3122
|
+
return;
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
const orgId = typeof opts.orgId === "number" && !Number.isNaN(opts.orgId) ? opts.orgId : cfg.orgId;
|
|
3126
|
+
if (typeof orgId !== "number") {
|
|
3127
|
+
console.error("org_id is required. Either pass --org-id or run 'pgai auth' to store it in config.");
|
|
3128
|
+
process.exitCode = 1;
|
|
3129
|
+
return;
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
|
|
3133
|
+
const labels = Array.isArray(opts.label) && opts.label.length > 0 ? opts.label.map(String) : undefined;
|
|
3134
|
+
const projectId = typeof opts.projectId === "number" && !Number.isNaN(opts.projectId) ? opts.projectId : undefined;
|
|
3135
|
+
|
|
3136
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
3137
|
+
const result = await createIssue({
|
|
3138
|
+
apiKey,
|
|
3139
|
+
apiBaseUrl,
|
|
3140
|
+
title,
|
|
3141
|
+
orgId,
|
|
3142
|
+
description,
|
|
3143
|
+
projectId,
|
|
3144
|
+
labels,
|
|
3145
|
+
debug: !!opts.debug,
|
|
3146
|
+
});
|
|
3147
|
+
printResult(result, opts.json);
|
|
3148
|
+
} catch (err) {
|
|
3149
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3150
|
+
console.error(message);
|
|
3151
|
+
process.exitCode = 1;
|
|
3152
|
+
}
|
|
3153
|
+
});
|
|
3154
|
+
|
|
3155
|
+
issues
|
|
3156
|
+
.command("update <issueId>")
|
|
3157
|
+
.description("update an existing issue (title/description/status/labels)")
|
|
3158
|
+
.option("--title <text>", "new title (supports \\\\n)")
|
|
3159
|
+
.option("--description <text>", "new description (supports \\\\n)")
|
|
3160
|
+
.option("--status <value>", "status: open|closed|0|1")
|
|
3161
|
+
.option(
|
|
3162
|
+
"--label <label>",
|
|
3163
|
+
"set labels (repeatable). If provided, replaces existing labels.",
|
|
3164
|
+
(value: string, previous: string[]) => {
|
|
3165
|
+
previous.push(value);
|
|
3166
|
+
return previous;
|
|
3167
|
+
},
|
|
3168
|
+
[] as string[]
|
|
3169
|
+
)
|
|
3170
|
+
.option("--clear-labels", "set labels to an empty list")
|
|
3171
|
+
.option("--debug", "enable debug output")
|
|
3172
|
+
.option("--json", "output raw JSON")
|
|
3173
|
+
.action(async (issueId: string, opts: { title?: string; description?: string; status?: string; label?: string[]; clearLabels?: boolean; debug?: boolean; json?: boolean }) => {
|
|
3174
|
+
try {
|
|
3175
|
+
const rootOpts = program.opts<CliOptions>();
|
|
3176
|
+
const cfg = config.readConfig();
|
|
3177
|
+
const { apiKey } = getConfig(rootOpts);
|
|
3178
|
+
if (!apiKey) {
|
|
3179
|
+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
3180
|
+
process.exitCode = 1;
|
|
3181
|
+
return;
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
3185
|
+
|
|
3186
|
+
const title = opts.title !== undefined ? interpretEscapes(String(opts.title)) : undefined;
|
|
3187
|
+
const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
|
|
3188
|
+
|
|
3189
|
+
let status: number | undefined = undefined;
|
|
3190
|
+
if (opts.status !== undefined) {
|
|
3191
|
+
const raw = String(opts.status).trim().toLowerCase();
|
|
3192
|
+
if (raw === "open") status = 0;
|
|
3193
|
+
else if (raw === "closed") status = 1;
|
|
3194
|
+
else {
|
|
3195
|
+
const n = Number(raw);
|
|
3196
|
+
if (!Number.isFinite(n)) {
|
|
3197
|
+
console.error("status must be open|closed|0|1");
|
|
3198
|
+
process.exitCode = 1;
|
|
3199
|
+
return;
|
|
3200
|
+
}
|
|
3201
|
+
status = n;
|
|
3202
|
+
}
|
|
3203
|
+
if (status !== 0 && status !== 1) {
|
|
3204
|
+
console.error("status must be 0 (open) or 1 (closed)");
|
|
3205
|
+
process.exitCode = 1;
|
|
3206
|
+
return;
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
let labels: string[] | undefined = undefined;
|
|
3211
|
+
if (opts.clearLabels) {
|
|
3212
|
+
labels = [];
|
|
3213
|
+
} else if (Array.isArray(opts.label) && opts.label.length > 0) {
|
|
3214
|
+
labels = opts.label.map(String);
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3217
|
+
const result = await updateIssue({
|
|
3218
|
+
apiKey,
|
|
3219
|
+
apiBaseUrl,
|
|
3220
|
+
issueId,
|
|
3221
|
+
title,
|
|
3222
|
+
description,
|
|
3223
|
+
status,
|
|
3224
|
+
labels,
|
|
3225
|
+
debug: !!opts.debug,
|
|
3226
|
+
});
|
|
3227
|
+
printResult(result, opts.json);
|
|
3228
|
+
} catch (err) {
|
|
3229
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3230
|
+
console.error(message);
|
|
3231
|
+
process.exitCode = 1;
|
|
3232
|
+
}
|
|
3233
|
+
});
|
|
3234
|
+
|
|
3235
|
+
issues
|
|
3236
|
+
.command("update-comment <commentId> <content>")
|
|
3237
|
+
.description("update an existing issue comment")
|
|
3238
|
+
.option("--debug", "enable debug output")
|
|
3239
|
+
.option("--json", "output raw JSON")
|
|
3240
|
+
.action(async (commentId: string, content: string, opts: { debug?: boolean; json?: boolean }) => {
|
|
3241
|
+
try {
|
|
3242
|
+
if (opts.debug) {
|
|
3243
|
+
// eslint-disable-next-line no-console
|
|
3244
|
+
console.log(`Debug: Original content: ${JSON.stringify(content)}`);
|
|
3245
|
+
}
|
|
3246
|
+
content = interpretEscapes(content);
|
|
3247
|
+
if (opts.debug) {
|
|
3248
|
+
// eslint-disable-next-line no-console
|
|
3249
|
+
console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
const rootOpts = program.opts<CliOptions>();
|
|
3253
|
+
const cfg = config.readConfig();
|
|
3254
|
+
const { apiKey } = getConfig(rootOpts);
|
|
3255
|
+
if (!apiKey) {
|
|
3256
|
+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
3257
|
+
process.exitCode = 1;
|
|
3258
|
+
return;
|
|
3259
|
+
}
|
|
3260
|
+
|
|
3261
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
3262
|
+
|
|
3263
|
+
const result = await updateIssueComment({
|
|
3264
|
+
apiKey,
|
|
3265
|
+
apiBaseUrl,
|
|
3266
|
+
commentId,
|
|
3267
|
+
content,
|
|
3268
|
+
debug: !!opts.debug,
|
|
3269
|
+
});
|
|
3270
|
+
printResult(result, opts.json);
|
|
3271
|
+
} catch (err) {
|
|
3272
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3273
|
+
console.error(message);
|
|
3274
|
+
process.exitCode = 1;
|
|
3275
|
+
}
|
|
3276
|
+
});
|
|
3277
|
+
|
|
1719
3278
|
// MCP server
|
|
1720
3279
|
const mcp = program.command("mcp").description("MCP server integration");
|
|
1721
3280
|
|
|
@@ -1743,15 +3302,7 @@ mcp
|
|
|
1743
3302
|
console.log(" 4. Codex");
|
|
1744
3303
|
console.log("");
|
|
1745
3304
|
|
|
1746
|
-
const
|
|
1747
|
-
input: process.stdin,
|
|
1748
|
-
output: process.stdout
|
|
1749
|
-
});
|
|
1750
|
-
|
|
1751
|
-
const answer = await new Promise<string>((resolve) => {
|
|
1752
|
-
rl.question("Select your AI coding tool (1-4): ", resolve);
|
|
1753
|
-
});
|
|
1754
|
-
rl.close();
|
|
3305
|
+
const answer = await question("Select your AI coding tool (1-4): ");
|
|
1755
3306
|
|
|
1756
3307
|
const choices: Record<string, string> = {
|
|
1757
3308
|
"1": "cursor",
|
|
@@ -1886,5 +3437,7 @@ mcp
|
|
|
1886
3437
|
}
|
|
1887
3438
|
});
|
|
1888
3439
|
|
|
1889
|
-
program.parseAsync(process.argv)
|
|
3440
|
+
program.parseAsync(process.argv).finally(() => {
|
|
3441
|
+
closeReadline();
|
|
3442
|
+
});
|
|
1890
3443
|
|