postgresai 0.12.0-beta.7 → 0.14.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +154 -53
- package/bin/postgres-ai.ts +1572 -350
- package/bun.lock +258 -0
- package/bunfig.toml +20 -0
- package/dist/bin/postgres-ai.js +28575 -1487
- 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 +1330 -0
- package/lib/config.ts +6 -3
- package/lib/init.ts +760 -0
- 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/util.ts +61 -0
- package/package.json +20 -9
- 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 +891 -0
- package/test/init.integration.test.ts +499 -0
- package/test/init.test.ts +417 -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/test-utils.ts +122 -0
- package/tsconfig.json +12 -20
- package/dist/bin/postgres-ai.d.ts +0 -3
- package/dist/bin/postgres-ai.d.ts.map +0 -1
- package/dist/bin/postgres-ai.js.map +0 -1
- package/dist/lib/auth-server.d.ts +0 -31
- package/dist/lib/auth-server.d.ts.map +0 -1
- package/dist/lib/auth-server.js +0 -263
- package/dist/lib/auth-server.js.map +0 -1
- package/dist/lib/config.d.ts +0 -45
- package/dist/lib/config.d.ts.map +0 -1
- package/dist/lib/config.js +0 -181
- package/dist/lib/config.js.map +0 -1
- package/dist/lib/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 -45
package/bin/postgres-ai.ts
CHANGED
|
@@ -1,23 +1,365 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
import { Command } from "commander";
|
|
4
|
-
import
|
|
4
|
+
import pkg from "../package.json";
|
|
5
5
|
import * as config from "../lib/config";
|
|
6
6
|
import * as yaml from "js-yaml";
|
|
7
7
|
import * as fs from "fs";
|
|
8
8
|
import * as path from "path";
|
|
9
9
|
import * as os from "os";
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
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";
|
|
15
|
+
import { applyInitPlan, buildInitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, verifyInitSetup } from "../lib/init";
|
|
16
|
+
import * as pkce from "../lib/pkce";
|
|
17
|
+
import * as authServer from "../lib/auth-server";
|
|
18
|
+
import { maskSecret } from "../lib/util";
|
|
19
|
+
import { createInterface } from "readline";
|
|
20
|
+
import * as childProcess from "child_process";
|
|
21
|
+
import { REPORT_GENERATORS, CHECK_INFO, generateAllReports } from "../lib/checkup";
|
|
22
|
+
import { createCheckupReport, uploadCheckupReportJson, RpcError, formatRpcErrorForDisplay, withRetry } from "../lib/checkup-api";
|
|
23
|
+
|
|
24
|
+
// Singleton readline interface for stdin prompts
|
|
25
|
+
let rl: ReturnType<typeof createInterface> | null = null;
|
|
26
|
+
function getReadline() {
|
|
27
|
+
if (!rl) {
|
|
28
|
+
rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
29
|
+
}
|
|
30
|
+
return rl;
|
|
31
|
+
}
|
|
32
|
+
function closeReadline() {
|
|
33
|
+
if (rl) {
|
|
34
|
+
rl.close();
|
|
35
|
+
rl = null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Helper functions for spawning processes - use Node.js child_process for compatibility
|
|
40
|
+
async function execPromise(command: string): Promise<{ stdout: string; stderr: string }> {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
childProcess.exec(command, (error, stdout, stderr) => {
|
|
43
|
+
if (error) {
|
|
44
|
+
const err = error as Error & { code: number };
|
|
45
|
+
err.code = typeof error.code === "number" ? error.code : 1;
|
|
46
|
+
reject(err);
|
|
47
|
+
} else {
|
|
48
|
+
resolve({ stdout, stderr });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function execFilePromise(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
childProcess.execFile(file, args, (error, stdout, stderr) => {
|
|
57
|
+
if (error) {
|
|
58
|
+
const err = error as Error & { code: number };
|
|
59
|
+
err.code = typeof error.code === "number" ? error.code : 1;
|
|
60
|
+
reject(err);
|
|
61
|
+
} else {
|
|
62
|
+
resolve({ stdout, stderr });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
18
67
|
|
|
19
|
-
|
|
20
|
-
const
|
|
68
|
+
function spawnSync(cmd: string, args: string[], options?: { stdio?: "pipe" | "ignore" | "inherit"; encoding?: string; env?: Record<string, string | undefined>; cwd?: string }): { status: number | null; stdout: string; stderr: string } {
|
|
69
|
+
const result = childProcess.spawnSync(cmd, args, {
|
|
70
|
+
stdio: options?.stdio === "inherit" ? "inherit" : "pipe",
|
|
71
|
+
env: options?.env as NodeJS.ProcessEnv,
|
|
72
|
+
cwd: options?.cwd,
|
|
73
|
+
encoding: "utf8",
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
status: result.status,
|
|
77
|
+
stdout: typeof result.stdout === "string" ? result.stdout : "",
|
|
78
|
+
stderr: typeof result.stderr === "string" ? result.stderr : "",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function spawn(cmd: string, args: string[], options?: { stdio?: "pipe" | "ignore" | "inherit"; env?: Record<string, string | undefined>; cwd?: string; detached?: boolean }): { on: (event: string, cb: (code: number | null, signal?: string) => void) => void; unref: () => void; pid?: number } {
|
|
83
|
+
const proc = childProcess.spawn(cmd, args, {
|
|
84
|
+
stdio: options?.stdio ?? "pipe",
|
|
85
|
+
env: options?.env as NodeJS.ProcessEnv,
|
|
86
|
+
cwd: options?.cwd,
|
|
87
|
+
detached: options?.detached,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
on(event: string, cb: (code: number | null, signal?: string) => void) {
|
|
92
|
+
if (event === "close" || event === "exit") {
|
|
93
|
+
proc.on(event, (code, signal) => cb(code, signal ?? undefined));
|
|
94
|
+
} else if (event === "error") {
|
|
95
|
+
proc.on("error", (err) => cb(null, String(err)));
|
|
96
|
+
}
|
|
97
|
+
return this;
|
|
98
|
+
},
|
|
99
|
+
unref() {
|
|
100
|
+
proc.unref();
|
|
101
|
+
},
|
|
102
|
+
pid: proc.pid,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Simple readline-like interface for prompts using Bun
|
|
107
|
+
async function question(prompt: string): Promise<string> {
|
|
108
|
+
return new Promise((resolve) => {
|
|
109
|
+
getReadline().question(prompt, (answer) => {
|
|
110
|
+
resolve(answer);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function expandHomePath(p: string): string {
|
|
116
|
+
const s = (p || "").trim();
|
|
117
|
+
if (!s) return s;
|
|
118
|
+
if (s === "~") return os.homedir();
|
|
119
|
+
if (s.startsWith("~/") || s.startsWith("~\\")) {
|
|
120
|
+
return path.join(os.homedir(), s.slice(2));
|
|
121
|
+
}
|
|
122
|
+
return s;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function createTtySpinner(
|
|
126
|
+
enabled: boolean,
|
|
127
|
+
initialText: string
|
|
128
|
+
): { update: (text: string) => void; stop: (finalText?: string) => void } {
|
|
129
|
+
if (!enabled) {
|
|
130
|
+
return {
|
|
131
|
+
update: () => {},
|
|
132
|
+
stop: () => {},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const frames = ["|", "/", "-", "\\"];
|
|
137
|
+
const startTs = Date.now();
|
|
138
|
+
let text = initialText;
|
|
139
|
+
let frameIdx = 0;
|
|
140
|
+
let stopped = false;
|
|
141
|
+
|
|
142
|
+
const render = (): void => {
|
|
143
|
+
if (stopped) return;
|
|
144
|
+
const elapsedSec = ((Date.now() - startTs) / 1000).toFixed(1);
|
|
145
|
+
const frame = frames[frameIdx % frames.length] ?? frames[0] ?? "⠿";
|
|
146
|
+
frameIdx += 1;
|
|
147
|
+
process.stdout.write(`\r\x1b[2K${frame} ${text} (${elapsedSec}s)`);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const timer = setInterval(render, 120);
|
|
151
|
+
render(); // immediate feedback
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
update: (t: string) => {
|
|
155
|
+
text = t;
|
|
156
|
+
render();
|
|
157
|
+
},
|
|
158
|
+
stop: (finalText?: string) => {
|
|
159
|
+
if (stopped) return;
|
|
160
|
+
// Set flag first so any queued render() calls exit early.
|
|
161
|
+
// JavaScript is single-threaded, so this is safe: queued callbacks
|
|
162
|
+
// run after stop() returns and will see stopped=true immediately.
|
|
163
|
+
stopped = true;
|
|
164
|
+
clearInterval(timer);
|
|
165
|
+
process.stdout.write("\r\x1b[2K");
|
|
166
|
+
if (finalText && finalText.trim()) {
|
|
167
|
+
process.stdout.write(finalText);
|
|
168
|
+
}
|
|
169
|
+
process.stdout.write("\n");
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ============================================================================
|
|
175
|
+
// Checkup command helpers
|
|
176
|
+
// ============================================================================
|
|
177
|
+
|
|
178
|
+
interface CheckupOptions {
|
|
179
|
+
checkId: string;
|
|
180
|
+
nodeName: string;
|
|
181
|
+
output?: string;
|
|
182
|
+
upload?: boolean;
|
|
183
|
+
project?: string;
|
|
184
|
+
json?: boolean;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface UploadConfig {
|
|
188
|
+
apiKey: string;
|
|
189
|
+
apiBaseUrl: string;
|
|
190
|
+
project: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
interface UploadSummary {
|
|
194
|
+
project: string;
|
|
195
|
+
reportId: number;
|
|
196
|
+
uploaded: Array<{ checkId: string; filename: string; chunkId: number }>;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Prepare and validate output directory for checkup reports.
|
|
201
|
+
* @returns Output path if valid, null if should exit with error
|
|
202
|
+
*/
|
|
203
|
+
function prepareOutputDirectory(outputOpt: string | undefined): string | null | undefined {
|
|
204
|
+
if (!outputOpt) return undefined;
|
|
205
|
+
|
|
206
|
+
const outputDir = expandHomePath(outputOpt);
|
|
207
|
+
const outputPath = path.isAbsolute(outputDir) ? outputDir : path.resolve(process.cwd(), outputDir);
|
|
208
|
+
|
|
209
|
+
if (!fs.existsSync(outputPath)) {
|
|
210
|
+
try {
|
|
211
|
+
fs.mkdirSync(outputPath, { recursive: true });
|
|
212
|
+
} catch (e) {
|
|
213
|
+
const errAny = e as any;
|
|
214
|
+
const code = typeof errAny?.code === "string" ? errAny.code : "";
|
|
215
|
+
const msg = errAny instanceof Error ? errAny.message : String(errAny);
|
|
216
|
+
if (code === "EACCES" || code === "EPERM" || code === "ENOENT") {
|
|
217
|
+
console.error(`Error: Failed to create output directory: ${outputPath}`);
|
|
218
|
+
console.error(`Reason: ${msg}`);
|
|
219
|
+
console.error("Tip: choose a writable path, e.g. --output ./reports or --output ~/reports");
|
|
220
|
+
return null; // Signal to exit
|
|
221
|
+
}
|
|
222
|
+
throw e;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return outputPath;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Prepare upload configuration for checkup reports.
|
|
230
|
+
* @returns Upload config if valid, null if should exit, undefined if upload not needed
|
|
231
|
+
*/
|
|
232
|
+
function prepareUploadConfig(
|
|
233
|
+
opts: CheckupOptions,
|
|
234
|
+
rootOpts: CliOptions,
|
|
235
|
+
shouldUpload: boolean,
|
|
236
|
+
uploadExplicitlyRequested: boolean
|
|
237
|
+
): { config: UploadConfig; projectWasGenerated: boolean } | null | undefined {
|
|
238
|
+
if (!shouldUpload) return undefined;
|
|
239
|
+
|
|
240
|
+
const { apiKey } = getConfig(rootOpts);
|
|
241
|
+
if (!apiKey) {
|
|
242
|
+
if (uploadExplicitlyRequested) {
|
|
243
|
+
console.error("Error: API key is required for upload");
|
|
244
|
+
console.error("Tip: run 'postgresai auth' or pass --api-key / set PGAI_API_KEY");
|
|
245
|
+
return null; // Signal to exit
|
|
246
|
+
}
|
|
247
|
+
return undefined; // Skip upload silently
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const cfg = config.readConfig();
|
|
251
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
252
|
+
let project = ((opts.project || cfg.defaultProject) || "").trim();
|
|
253
|
+
let projectWasGenerated = false;
|
|
254
|
+
|
|
255
|
+
if (!project) {
|
|
256
|
+
project = `project_${crypto.randomBytes(6).toString("hex")}`;
|
|
257
|
+
projectWasGenerated = true;
|
|
258
|
+
try {
|
|
259
|
+
config.writeConfig({ defaultProject: project });
|
|
260
|
+
} catch (e) {
|
|
261
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
262
|
+
console.error(`Warning: Failed to save generated default project: ${message}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
config: { apiKey, apiBaseUrl, project },
|
|
268
|
+
projectWasGenerated,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Upload checkup reports to PostgresAI API.
|
|
274
|
+
*/
|
|
275
|
+
async function uploadCheckupReports(
|
|
276
|
+
uploadCfg: UploadConfig,
|
|
277
|
+
reports: Record<string, any>,
|
|
278
|
+
spinner: ReturnType<typeof createTtySpinner>,
|
|
279
|
+
logUpload: (msg: string) => void
|
|
280
|
+
): Promise<UploadSummary> {
|
|
281
|
+
spinner.update("Creating remote checkup report");
|
|
282
|
+
const created = await withRetry(
|
|
283
|
+
() => createCheckupReport({
|
|
284
|
+
apiKey: uploadCfg.apiKey,
|
|
285
|
+
apiBaseUrl: uploadCfg.apiBaseUrl,
|
|
286
|
+
project: uploadCfg.project,
|
|
287
|
+
}),
|
|
288
|
+
{ maxAttempts: 3 },
|
|
289
|
+
(attempt, err, delayMs) => {
|
|
290
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
291
|
+
logUpload(`[Retry ${attempt}/3] createCheckupReport failed: ${errMsg}, retrying in ${delayMs}ms...`);
|
|
292
|
+
}
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const reportId = created.reportId;
|
|
296
|
+
logUpload(`Created remote checkup report: ${reportId}`);
|
|
297
|
+
|
|
298
|
+
const uploaded: Array<{ checkId: string; filename: string; chunkId: number }> = [];
|
|
299
|
+
for (const [checkId, report] of Object.entries(reports)) {
|
|
300
|
+
spinner.update(`Uploading ${checkId}.json`);
|
|
301
|
+
const jsonText = JSON.stringify(report, null, 2);
|
|
302
|
+
const r = await withRetry(
|
|
303
|
+
() => uploadCheckupReportJson({
|
|
304
|
+
apiKey: uploadCfg.apiKey,
|
|
305
|
+
apiBaseUrl: uploadCfg.apiBaseUrl,
|
|
306
|
+
reportId,
|
|
307
|
+
filename: `${checkId}.json`,
|
|
308
|
+
checkId,
|
|
309
|
+
jsonText,
|
|
310
|
+
}),
|
|
311
|
+
{ maxAttempts: 3 },
|
|
312
|
+
(attempt, err, delayMs) => {
|
|
313
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
314
|
+
logUpload(`[Retry ${attempt}/3] Upload ${checkId}.json failed: ${errMsg}, retrying in ${delayMs}ms...`);
|
|
315
|
+
}
|
|
316
|
+
);
|
|
317
|
+
uploaded.push({ checkId, filename: `${checkId}.json`, chunkId: r.reportChunkId });
|
|
318
|
+
}
|
|
319
|
+
logUpload("Upload completed");
|
|
320
|
+
|
|
321
|
+
return { project: uploadCfg.project, reportId, uploaded };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Write checkup reports to files.
|
|
326
|
+
*/
|
|
327
|
+
function writeReportFiles(reports: Record<string, any>, outputPath: string): void {
|
|
328
|
+
for (const [checkId, report] of Object.entries(reports)) {
|
|
329
|
+
const filePath = path.join(outputPath, `${checkId}.json`);
|
|
330
|
+
fs.writeFileSync(filePath, JSON.stringify(report, null, 2), "utf8");
|
|
331
|
+
console.log(`✓ ${checkId}: ${filePath}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Print upload summary to console.
|
|
337
|
+
*/
|
|
338
|
+
function printUploadSummary(
|
|
339
|
+
summary: UploadSummary,
|
|
340
|
+
projectWasGenerated: boolean,
|
|
341
|
+
useStderr: boolean
|
|
342
|
+
): void {
|
|
343
|
+
const out = useStderr ? console.error : console.log;
|
|
344
|
+
out("\nCheckup report uploaded");
|
|
345
|
+
out("======================\n");
|
|
346
|
+
if (projectWasGenerated) {
|
|
347
|
+
out(`Project: ${summary.project} (generated and saved as default)`);
|
|
348
|
+
} else {
|
|
349
|
+
out(`Project: ${summary.project}`);
|
|
350
|
+
}
|
|
351
|
+
out(`Report ID: ${summary.reportId}`);
|
|
352
|
+
out("View in Console: console.postgres.ai → Support → checkup reports");
|
|
353
|
+
out("");
|
|
354
|
+
out("Files:");
|
|
355
|
+
for (const item of summary.uploaded) {
|
|
356
|
+
out(`- ${item.checkId}: ${item.filename}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ============================================================================
|
|
361
|
+
// CLI configuration
|
|
362
|
+
// ============================================================================
|
|
21
363
|
|
|
22
364
|
/**
|
|
23
365
|
* CLI configuration options
|
|
@@ -59,6 +401,86 @@ interface PathResolution {
|
|
|
59
401
|
instancesFile: string;
|
|
60
402
|
}
|
|
61
403
|
|
|
404
|
+
function getDefaultMonitoringProjectDir(): string {
|
|
405
|
+
const override = process.env.PGAI_PROJECT_DIR;
|
|
406
|
+
if (override && override.trim()) return override.trim();
|
|
407
|
+
// Keep monitoring project next to user-level config (~/.config/postgresai)
|
|
408
|
+
return path.join(config.getConfigDir(), "monitoring");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function downloadText(url: string): Promise<string> {
|
|
412
|
+
const controller = new AbortController();
|
|
413
|
+
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
414
|
+
try {
|
|
415
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
416
|
+
if (!response.ok) {
|
|
417
|
+
throw new Error(`HTTP ${response.status} for ${url}`);
|
|
418
|
+
}
|
|
419
|
+
return await response.text();
|
|
420
|
+
} finally {
|
|
421
|
+
clearTimeout(timeout);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
|
|
426
|
+
const projectDir = getDefaultMonitoringProjectDir();
|
|
427
|
+
const composeFile = path.resolve(projectDir, "docker-compose.yml");
|
|
428
|
+
const instancesFile = path.resolve(projectDir, "instances.yml");
|
|
429
|
+
|
|
430
|
+
if (!fs.existsSync(projectDir)) {
|
|
431
|
+
fs.mkdirSync(projectDir, { recursive: true, mode: 0o700 });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (!fs.existsSync(composeFile)) {
|
|
435
|
+
const refs = [
|
|
436
|
+
process.env.PGAI_PROJECT_REF,
|
|
437
|
+
pkg.version,
|
|
438
|
+
`v${pkg.version}`,
|
|
439
|
+
"main",
|
|
440
|
+
].filter((v): v is string => Boolean(v && v.trim()));
|
|
441
|
+
|
|
442
|
+
let lastErr: unknown;
|
|
443
|
+
for (const ref of refs) {
|
|
444
|
+
const url = `https://gitlab.com/postgres-ai/postgres_ai/-/raw/${encodeURIComponent(ref)}/docker-compose.yml`;
|
|
445
|
+
try {
|
|
446
|
+
const text = await downloadText(url);
|
|
447
|
+
fs.writeFileSync(composeFile, text, { encoding: "utf8", mode: 0o600 });
|
|
448
|
+
break;
|
|
449
|
+
} catch (err) {
|
|
450
|
+
lastErr = err;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (!fs.existsSync(composeFile)) {
|
|
455
|
+
const msg = lastErr instanceof Error ? lastErr.message : String(lastErr);
|
|
456
|
+
throw new Error(`Failed to bootstrap docker-compose.yml: ${msg}`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Ensure instances.yml exists as a FILE (avoid Docker creating a directory)
|
|
461
|
+
if (!fs.existsSync(instancesFile)) {
|
|
462
|
+
const header =
|
|
463
|
+
"# PostgreSQL instances to monitor\n" +
|
|
464
|
+
"# Add your instances using: pgai mon targets add <connection-string> <name>\n\n";
|
|
465
|
+
fs.writeFileSync(instancesFile, header, { encoding: "utf8", mode: 0o600 });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Ensure .pgwatch-config exists as a FILE for reporter (may remain empty)
|
|
469
|
+
const pgwatchConfig = path.resolve(projectDir, ".pgwatch-config");
|
|
470
|
+
if (!fs.existsSync(pgwatchConfig)) {
|
|
471
|
+
fs.writeFileSync(pgwatchConfig, "", { encoding: "utf8", mode: 0o600 });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Ensure .env exists and has PGAI_TAG (compose requires it)
|
|
475
|
+
const envFile = path.resolve(projectDir, ".env");
|
|
476
|
+
if (!fs.existsSync(envFile)) {
|
|
477
|
+
const envText = `PGAI_TAG=${pkg.version}\n# PGAI_REGISTRY=registry.gitlab.com/postgres-ai/postgres_ai\n`;
|
|
478
|
+
fs.writeFileSync(envFile, envText, { encoding: "utf8", mode: 0o600 });
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return { fs, path, projectDir, composeFile, instancesFile };
|
|
482
|
+
}
|
|
483
|
+
|
|
62
484
|
/**
|
|
63
485
|
* Get configuration from various sources
|
|
64
486
|
* @param opts - Command line options
|
|
@@ -116,6 +538,484 @@ program
|
|
|
116
538
|
"UI base URL for browser routes (overrides PGAI_UI_BASE_URL)"
|
|
117
539
|
);
|
|
118
540
|
|
|
541
|
+
program
|
|
542
|
+
.command("set-default-project <project>")
|
|
543
|
+
.description("store default project for checkup uploads")
|
|
544
|
+
.action(async (project: string) => {
|
|
545
|
+
const value = (project || "").trim();
|
|
546
|
+
if (!value) {
|
|
547
|
+
console.error("Error: project is required");
|
|
548
|
+
process.exitCode = 1;
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
config.writeConfig({ defaultProject: value });
|
|
552
|
+
console.log(`Default project saved: ${value}`);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
program
|
|
556
|
+
.command("prepare-db [conn]")
|
|
557
|
+
.description("prepare database for monitoring: create monitoring user, required view(s), and grant permissions (idempotent)")
|
|
558
|
+
.option("--db-url <url>", "PostgreSQL connection URL (admin) to run the setup against (deprecated; pass it as positional arg)")
|
|
559
|
+
.option("-h, --host <host>", "PostgreSQL host (psql-like)")
|
|
560
|
+
.option("-p, --port <port>", "PostgreSQL port (psql-like)")
|
|
561
|
+
.option("-U, --username <username>", "PostgreSQL user (psql-like)")
|
|
562
|
+
.option("-d, --dbname <dbname>", "PostgreSQL database name (psql-like)")
|
|
563
|
+
.option("--admin-password <password>", "Admin connection password (otherwise uses PGPASSWORD if set)")
|
|
564
|
+
.option("--monitoring-user <name>", "Monitoring role name to create/update", DEFAULT_MONITORING_USER)
|
|
565
|
+
.option("--password <password>", "Monitoring role password (overrides PGAI_MON_PASSWORD)")
|
|
566
|
+
.option("--skip-optional-permissions", "Skip optional permissions (RDS/self-managed extras)", false)
|
|
567
|
+
.option("--verify", "Verify that monitoring role/permissions are in place (no changes)", false)
|
|
568
|
+
.option("--reset-password", "Reset monitoring role password only (no other changes)", false)
|
|
569
|
+
.option("--print-sql", "Print SQL plan and exit (no changes applied)", false)
|
|
570
|
+
.option("--print-password", "Print generated monitoring password (DANGEROUS in CI logs)", false)
|
|
571
|
+
.addHelpText(
|
|
572
|
+
"after",
|
|
573
|
+
[
|
|
574
|
+
"",
|
|
575
|
+
"Examples:",
|
|
576
|
+
" postgresai prepare-db postgresql://admin@host:5432/dbname",
|
|
577
|
+
" postgresai prepare-db \"dbname=dbname host=host user=admin\"",
|
|
578
|
+
" postgresai prepare-db -h host -p 5432 -U admin -d dbname",
|
|
579
|
+
"",
|
|
580
|
+
"Admin password:",
|
|
581
|
+
" --admin-password <password> or PGPASSWORD=... (libpq standard)",
|
|
582
|
+
"",
|
|
583
|
+
"Monitoring password:",
|
|
584
|
+
" --password <password> or PGAI_MON_PASSWORD=... (otherwise auto-generated)",
|
|
585
|
+
" If auto-generated, it is printed only on TTY by default.",
|
|
586
|
+
" To print it in non-interactive mode: --print-password",
|
|
587
|
+
"",
|
|
588
|
+
"SSL connection (sslmode=prefer behavior):",
|
|
589
|
+
" Tries SSL first, falls back to non-SSL if server doesn't support it.",
|
|
590
|
+
" To force SSL: PGSSLMODE=require or ?sslmode=require in URL",
|
|
591
|
+
" To disable SSL: PGSSLMODE=disable or ?sslmode=disable in URL",
|
|
592
|
+
"",
|
|
593
|
+
"Environment variables (libpq standard):",
|
|
594
|
+
" PGHOST, PGPORT, PGUSER, PGDATABASE — connection defaults",
|
|
595
|
+
" PGPASSWORD — admin password",
|
|
596
|
+
" PGSSLMODE — SSL mode (disable, require, verify-full)",
|
|
597
|
+
" PGAI_MON_PASSWORD — monitoring password",
|
|
598
|
+
"",
|
|
599
|
+
"Inspect SQL without applying changes:",
|
|
600
|
+
" postgresai prepare-db <conn> --print-sql",
|
|
601
|
+
"",
|
|
602
|
+
"Verify setup (no changes):",
|
|
603
|
+
" postgresai prepare-db <conn> --verify",
|
|
604
|
+
"",
|
|
605
|
+
"Reset monitoring password only:",
|
|
606
|
+
" postgresai prepare-db <conn> --reset-password --password '...'",
|
|
607
|
+
"",
|
|
608
|
+
"Offline SQL plan (no DB connection):",
|
|
609
|
+
" postgresai prepare-db --print-sql",
|
|
610
|
+
].join("\n")
|
|
611
|
+
)
|
|
612
|
+
.action(async (conn: string | undefined, opts: {
|
|
613
|
+
dbUrl?: string;
|
|
614
|
+
host?: string;
|
|
615
|
+
port?: string;
|
|
616
|
+
username?: string;
|
|
617
|
+
dbname?: string;
|
|
618
|
+
adminPassword?: string;
|
|
619
|
+
monitoringUser: string;
|
|
620
|
+
password?: string;
|
|
621
|
+
skipOptionalPermissions?: boolean;
|
|
622
|
+
verify?: boolean;
|
|
623
|
+
resetPassword?: boolean;
|
|
624
|
+
printSql?: boolean;
|
|
625
|
+
printPassword?: boolean;
|
|
626
|
+
}, cmd: Command) => {
|
|
627
|
+
if (opts.verify && opts.resetPassword) {
|
|
628
|
+
console.error("✗ Provide only one of --verify or --reset-password");
|
|
629
|
+
process.exitCode = 1;
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (opts.verify && opts.printSql) {
|
|
633
|
+
console.error("✗ --verify cannot be combined with --print-sql");
|
|
634
|
+
process.exitCode = 1;
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const shouldPrintSql = !!opts.printSql;
|
|
639
|
+
const redactPasswords = (sql: string): string => redactPasswordsInSql(sql);
|
|
640
|
+
|
|
641
|
+
// Offline mode: allow printing SQL without providing/using an admin connection.
|
|
642
|
+
// Useful for audits/reviews; caller can provide -d/PGDATABASE.
|
|
643
|
+
if (!conn && !opts.dbUrl && !opts.host && !opts.port && !opts.username && !opts.adminPassword) {
|
|
644
|
+
if (shouldPrintSql) {
|
|
645
|
+
const database = (opts.dbname ?? process.env.PGDATABASE ?? "postgres").trim();
|
|
646
|
+
const includeOptionalPermissions = !opts.skipOptionalPermissions;
|
|
647
|
+
|
|
648
|
+
// Use explicit password/env if provided; otherwise use a placeholder.
|
|
649
|
+
// Printed SQL always redacts secrets.
|
|
650
|
+
const monPassword =
|
|
651
|
+
(opts.password ?? process.env.PGAI_MON_PASSWORD ?? "<redacted>").toString();
|
|
652
|
+
|
|
653
|
+
const plan = await buildInitPlan({
|
|
654
|
+
database,
|
|
655
|
+
monitoringUser: opts.monitoringUser,
|
|
656
|
+
monitoringPassword: monPassword,
|
|
657
|
+
includeOptionalPermissions,
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
console.log("\n--- SQL plan (offline; not connected) ---");
|
|
661
|
+
console.log(`-- database: ${database}`);
|
|
662
|
+
console.log(`-- monitoring user: ${opts.monitoringUser}`);
|
|
663
|
+
console.log(`-- optional permissions: ${includeOptionalPermissions ? "enabled" : "skipped"}`);
|
|
664
|
+
for (const step of plan.steps) {
|
|
665
|
+
console.log(`\n-- ${step.name}${step.optional ? " (optional)" : ""}`);
|
|
666
|
+
console.log(redactPasswords(step.sql));
|
|
667
|
+
}
|
|
668
|
+
console.log("\n--- end SQL plan ---\n");
|
|
669
|
+
console.log("Note: passwords are redacted in the printed SQL output.");
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
let adminConn;
|
|
675
|
+
try {
|
|
676
|
+
adminConn = resolveAdminConnection({
|
|
677
|
+
conn,
|
|
678
|
+
dbUrlFlag: opts.dbUrl,
|
|
679
|
+
// Allow libpq standard env vars as implicit defaults (common UX).
|
|
680
|
+
host: opts.host ?? process.env.PGHOST,
|
|
681
|
+
port: opts.port ?? process.env.PGPORT,
|
|
682
|
+
username: opts.username ?? process.env.PGUSER,
|
|
683
|
+
dbname: opts.dbname ?? process.env.PGDATABASE,
|
|
684
|
+
adminPassword: opts.adminPassword,
|
|
685
|
+
envPassword: process.env.PGPASSWORD,
|
|
686
|
+
});
|
|
687
|
+
} catch (e) {
|
|
688
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
689
|
+
console.error(`Error: prepare-db: ${msg}`);
|
|
690
|
+
// When connection details are missing, show full init help (options + examples).
|
|
691
|
+
if (typeof msg === "string" && msg.startsWith("Connection is required.")) {
|
|
692
|
+
console.error("");
|
|
693
|
+
cmd.outputHelp({ error: true });
|
|
694
|
+
}
|
|
695
|
+
process.exitCode = 1;
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const includeOptionalPermissions = !opts.skipOptionalPermissions;
|
|
700
|
+
|
|
701
|
+
console.log(`Connecting to: ${adminConn.display}`);
|
|
702
|
+
console.log(`Monitoring user: ${opts.monitoringUser}`);
|
|
703
|
+
console.log(`Optional permissions: ${includeOptionalPermissions ? "enabled" : "skipped"}`);
|
|
704
|
+
|
|
705
|
+
// Use native pg client instead of requiring psql to be installed
|
|
706
|
+
let client: Client | undefined;
|
|
707
|
+
try {
|
|
708
|
+
const connResult = await connectWithSslFallback(Client, adminConn);
|
|
709
|
+
client = connResult.client;
|
|
710
|
+
|
|
711
|
+
const dbRes = await client.query("select current_database() as db");
|
|
712
|
+
const database = dbRes.rows?.[0]?.db;
|
|
713
|
+
if (typeof database !== "string" || !database) {
|
|
714
|
+
throw new Error("Failed to resolve current database name");
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (opts.verify) {
|
|
718
|
+
const v = await verifyInitSetup({
|
|
719
|
+
client,
|
|
720
|
+
database,
|
|
721
|
+
monitoringUser: opts.monitoringUser,
|
|
722
|
+
includeOptionalPermissions,
|
|
723
|
+
});
|
|
724
|
+
if (v.ok) {
|
|
725
|
+
console.log("✓ prepare-db verify: OK");
|
|
726
|
+
if (v.missingOptional.length > 0) {
|
|
727
|
+
console.log("⚠ Optional items missing:");
|
|
728
|
+
for (const m of v.missingOptional) console.log(`- ${m}`);
|
|
729
|
+
}
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
console.error("✗ prepare-db verify failed: missing required items");
|
|
733
|
+
for (const m of v.missingRequired) console.error(`- ${m}`);
|
|
734
|
+
if (v.missingOptional.length > 0) {
|
|
735
|
+
console.error("Optional items missing:");
|
|
736
|
+
for (const m of v.missingOptional) console.error(`- ${m}`);
|
|
737
|
+
}
|
|
738
|
+
process.exitCode = 1;
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
let monPassword: string;
|
|
743
|
+
try {
|
|
744
|
+
const resolved = await resolveMonitoringPassword({
|
|
745
|
+
passwordFlag: opts.password,
|
|
746
|
+
passwordEnv: process.env.PGAI_MON_PASSWORD,
|
|
747
|
+
monitoringUser: opts.monitoringUser,
|
|
748
|
+
});
|
|
749
|
+
monPassword = resolved.password;
|
|
750
|
+
if (resolved.generated) {
|
|
751
|
+
const canPrint = process.stdout.isTTY || !!opts.printPassword;
|
|
752
|
+
if (canPrint) {
|
|
753
|
+
// Print secrets to stderr to reduce the chance they end up in piped stdout logs.
|
|
754
|
+
const shellSafe = monPassword.replace(/'/g, "'\\''");
|
|
755
|
+
console.error("");
|
|
756
|
+
console.error(`Generated monitoring password for ${opts.monitoringUser} (copy/paste):`);
|
|
757
|
+
// Quote for shell copy/paste safety.
|
|
758
|
+
console.error(`PGAI_MON_PASSWORD='${shellSafe}'`);
|
|
759
|
+
console.error("");
|
|
760
|
+
console.log("Store it securely (or rerun with --password / PGAI_MON_PASSWORD to set your own).");
|
|
761
|
+
} else {
|
|
762
|
+
console.error(
|
|
763
|
+
[
|
|
764
|
+
`✗ Monitoring password was auto-generated for ${opts.monitoringUser} but not printed in non-interactive mode.`,
|
|
765
|
+
"",
|
|
766
|
+
"Provide it explicitly:",
|
|
767
|
+
" --password <password> or PGAI_MON_PASSWORD=...",
|
|
768
|
+
"",
|
|
769
|
+
"Or (NOT recommended) print the generated password:",
|
|
770
|
+
" --print-password",
|
|
771
|
+
].join("\n")
|
|
772
|
+
);
|
|
773
|
+
process.exitCode = 1;
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
} catch (e) {
|
|
778
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
779
|
+
console.error(`✗ ${msg}`);
|
|
780
|
+
process.exitCode = 1;
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const plan = await buildInitPlan({
|
|
785
|
+
database,
|
|
786
|
+
monitoringUser: opts.monitoringUser,
|
|
787
|
+
monitoringPassword: monPassword,
|
|
788
|
+
includeOptionalPermissions,
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
const effectivePlan = opts.resetPassword
|
|
792
|
+
? { ...plan, steps: plan.steps.filter((s) => s.name === "01.role") }
|
|
793
|
+
: plan;
|
|
794
|
+
|
|
795
|
+
if (shouldPrintSql) {
|
|
796
|
+
console.log("\n--- SQL plan ---");
|
|
797
|
+
for (const step of effectivePlan.steps) {
|
|
798
|
+
console.log(`\n-- ${step.name}${step.optional ? " (optional)" : ""}`);
|
|
799
|
+
console.log(redactPasswords(step.sql));
|
|
800
|
+
}
|
|
801
|
+
console.log("\n--- end SQL plan ---\n");
|
|
802
|
+
console.log("Note: passwords are redacted in the printed SQL output.");
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const { applied, skippedOptional } = await applyInitPlan({ client, plan: effectivePlan });
|
|
807
|
+
|
|
808
|
+
console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
|
|
809
|
+
if (skippedOptional.length > 0) {
|
|
810
|
+
console.log("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
|
|
811
|
+
for (const s of skippedOptional) console.log(`- ${s}`);
|
|
812
|
+
}
|
|
813
|
+
// Keep output compact but still useful
|
|
814
|
+
if (process.stdout.isTTY) {
|
|
815
|
+
console.log(`Applied ${applied.length} steps`);
|
|
816
|
+
}
|
|
817
|
+
} catch (error) {
|
|
818
|
+
const errAny = error as any;
|
|
819
|
+
let message = "";
|
|
820
|
+
if (error instanceof Error && error.message) {
|
|
821
|
+
message = error.message;
|
|
822
|
+
} else if (errAny && typeof errAny === "object" && typeof errAny.message === "string" && errAny.message) {
|
|
823
|
+
message = errAny.message;
|
|
824
|
+
} else {
|
|
825
|
+
message = String(error);
|
|
826
|
+
}
|
|
827
|
+
if (!message || message === "[object Object]") {
|
|
828
|
+
message = "Unknown error";
|
|
829
|
+
}
|
|
830
|
+
console.error(`Error: prepare-db: ${message}`);
|
|
831
|
+
// If this was a plan step failure, surface the step name explicitly to help users diagnose quickly.
|
|
832
|
+
const stepMatch =
|
|
833
|
+
typeof message === "string" ? message.match(/Failed at step "([^"]+)":/i) : null;
|
|
834
|
+
const failedStep = stepMatch?.[1];
|
|
835
|
+
if (failedStep) {
|
|
836
|
+
console.error(` Step: ${failedStep}`);
|
|
837
|
+
}
|
|
838
|
+
if (errAny && typeof errAny === "object") {
|
|
839
|
+
if (typeof errAny.code === "string" && errAny.code) {
|
|
840
|
+
console.error(` Code: ${errAny.code}`);
|
|
841
|
+
}
|
|
842
|
+
if (typeof errAny.detail === "string" && errAny.detail) {
|
|
843
|
+
console.error(` Detail: ${errAny.detail}`);
|
|
844
|
+
}
|
|
845
|
+
if (typeof errAny.hint === "string" && errAny.hint) {
|
|
846
|
+
console.error(` Hint: ${errAny.hint}`);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (errAny && typeof errAny === "object" && typeof errAny.code === "string") {
|
|
850
|
+
if (errAny.code === "42501") {
|
|
851
|
+
if (failedStep === "01.role") {
|
|
852
|
+
console.error(" Context: role creation/update requires CREATEROLE or superuser");
|
|
853
|
+
} else if (failedStep === "02.permissions") {
|
|
854
|
+
console.error(" Context: grants/view/search_path require sufficient GRANT/DDL privileges");
|
|
855
|
+
}
|
|
856
|
+
console.error(" Fix: connect as a superuser (or a role with CREATEROLE and sufficient GRANT privileges)");
|
|
857
|
+
console.error(" Fix: on managed Postgres, use the provider's admin/master user");
|
|
858
|
+
console.error(" Tip: run with --print-sql to review the exact SQL plan");
|
|
859
|
+
}
|
|
860
|
+
if (errAny.code === "ECONNREFUSED") {
|
|
861
|
+
console.error(" Hint: check host/port and ensure Postgres is reachable from this machine");
|
|
862
|
+
}
|
|
863
|
+
if (errAny.code === "ENOTFOUND") {
|
|
864
|
+
console.error(" Hint: DNS resolution failed; double-check the host name");
|
|
865
|
+
}
|
|
866
|
+
if (errAny.code === "ETIMEDOUT") {
|
|
867
|
+
console.error(" Hint: connection timed out; check network/firewall rules");
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
process.exitCode = 1;
|
|
871
|
+
} finally {
|
|
872
|
+
if (client) {
|
|
873
|
+
try {
|
|
874
|
+
await client.end();
|
|
875
|
+
} catch {
|
|
876
|
+
// ignore
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
program
|
|
883
|
+
.command("checkup [conn]")
|
|
884
|
+
.description("generate health check reports directly from PostgreSQL (express mode)")
|
|
885
|
+
.option("--check-id <id>", `specific check to run: ${Object.keys(CHECK_INFO).join(", ")}, or ALL`, "ALL")
|
|
886
|
+
.option("--node-name <name>", "node name for reports", "node-01")
|
|
887
|
+
.option("--output <path>", "output directory for JSON files")
|
|
888
|
+
.option("--[no-]upload", "upload JSON results to PostgresAI (default: enabled; requires API key)", undefined)
|
|
889
|
+
.option(
|
|
890
|
+
"--project <project>",
|
|
891
|
+
"project name or ID for remote upload (used with --upload; defaults to config defaultProject; auto-generated on first run)"
|
|
892
|
+
)
|
|
893
|
+
.option("--json", "output JSON to stdout (implies --no-upload)")
|
|
894
|
+
.addHelpText(
|
|
895
|
+
"after",
|
|
896
|
+
[
|
|
897
|
+
"",
|
|
898
|
+
"Available checks:",
|
|
899
|
+
...Object.entries(CHECK_INFO).map(([id, title]) => ` ${id}: ${title}`),
|
|
900
|
+
"",
|
|
901
|
+
"Examples:",
|
|
902
|
+
" postgresai checkup postgresql://user:pass@host:5432/db",
|
|
903
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --check-id A003",
|
|
904
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --output ./reports",
|
|
905
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --project my_project",
|
|
906
|
+
" postgresai set-default-project my_project",
|
|
907
|
+
" postgresai checkup postgresql://user:pass@host:5432/db",
|
|
908
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --no-upload --json",
|
|
909
|
+
].join("\n")
|
|
910
|
+
)
|
|
911
|
+
.action(async (conn: string | undefined, opts: CheckupOptions, cmd: Command) => {
|
|
912
|
+
if (!conn) {
|
|
913
|
+
cmd.outputHelp();
|
|
914
|
+
process.exitCode = 1;
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const shouldPrintJson = !!opts.json;
|
|
919
|
+
const uploadExplicitlyRequested = opts.upload === true;
|
|
920
|
+
const uploadExplicitlyDisabled = opts.upload === false || shouldPrintJson;
|
|
921
|
+
let shouldUpload = !uploadExplicitlyDisabled;
|
|
922
|
+
|
|
923
|
+
// Preflight: validate/create output directory BEFORE connecting / running checks.
|
|
924
|
+
const outputPath = prepareOutputDirectory(opts.output);
|
|
925
|
+
if (outputPath === null) {
|
|
926
|
+
process.exitCode = 1;
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Preflight: validate upload flags/credentials BEFORE connecting / running checks.
|
|
931
|
+
const rootOpts = program.opts() as CliOptions;
|
|
932
|
+
const uploadResult = prepareUploadConfig(opts, rootOpts, shouldUpload, uploadExplicitlyRequested);
|
|
933
|
+
if (uploadResult === null) {
|
|
934
|
+
process.exitCode = 1;
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
const uploadCfg = uploadResult?.config;
|
|
938
|
+
const projectWasGenerated = uploadResult?.projectWasGenerated ?? false;
|
|
939
|
+
shouldUpload = !!uploadCfg;
|
|
940
|
+
|
|
941
|
+
// Connect and run checks
|
|
942
|
+
const adminConn = resolveAdminConnection({
|
|
943
|
+
conn,
|
|
944
|
+
envPassword: process.env.PGPASSWORD,
|
|
945
|
+
});
|
|
946
|
+
let client: Client | undefined;
|
|
947
|
+
const spinnerEnabled = !!process.stdout.isTTY && shouldUpload;
|
|
948
|
+
const spinner = createTtySpinner(spinnerEnabled, "Connecting to Postgres");
|
|
949
|
+
|
|
950
|
+
try {
|
|
951
|
+
spinner.update("Connecting to Postgres");
|
|
952
|
+
const connResult = await connectWithSslFallback(Client, adminConn);
|
|
953
|
+
client = connResult.client as Client;
|
|
954
|
+
|
|
955
|
+
// Generate reports
|
|
956
|
+
let reports: Record<string, any>;
|
|
957
|
+
if (opts.checkId === "ALL") {
|
|
958
|
+
reports = await generateAllReports(client, opts.nodeName, (p) => {
|
|
959
|
+
spinner.update(`Running ${p.checkId}: ${p.checkTitle} (${p.index}/${p.total})`);
|
|
960
|
+
});
|
|
961
|
+
} else {
|
|
962
|
+
const checkId = opts.checkId.toUpperCase();
|
|
963
|
+
const generator = REPORT_GENERATORS[checkId];
|
|
964
|
+
if (!generator) {
|
|
965
|
+
spinner.stop();
|
|
966
|
+
console.error(`Unknown check ID: ${opts.checkId}`);
|
|
967
|
+
console.error(`Available: ${Object.keys(CHECK_INFO).join(", ")}, ALL`);
|
|
968
|
+
process.exitCode = 1;
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
spinner.update(`Running ${checkId}: ${CHECK_INFO[checkId] || checkId}`);
|
|
972
|
+
reports = { [checkId]: await generator(client, opts.nodeName) };
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// Upload to PostgresAI API (if configured)
|
|
976
|
+
let uploadSummary: UploadSummary | undefined;
|
|
977
|
+
if (uploadCfg) {
|
|
978
|
+
const logUpload = (msg: string): void => {
|
|
979
|
+
(shouldPrintJson ? console.error : console.log)(msg);
|
|
980
|
+
};
|
|
981
|
+
uploadSummary = await uploadCheckupReports(uploadCfg, reports, spinner, logUpload);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
spinner.stop();
|
|
985
|
+
|
|
986
|
+
// Write to files (if output path specified)
|
|
987
|
+
if (outputPath) {
|
|
988
|
+
writeReportFiles(reports, outputPath);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Print upload summary
|
|
992
|
+
if (uploadSummary) {
|
|
993
|
+
printUploadSummary(uploadSummary, projectWasGenerated, shouldPrintJson);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Output JSON to stdout
|
|
997
|
+
if (shouldPrintJson || (!shouldUpload && !opts.output)) {
|
|
998
|
+
console.log(JSON.stringify(reports, null, 2));
|
|
999
|
+
}
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
if (error instanceof RpcError) {
|
|
1002
|
+
for (const line of formatRpcErrorForDisplay(error)) {
|
|
1003
|
+
console.error(line);
|
|
1004
|
+
}
|
|
1005
|
+
} else {
|
|
1006
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1007
|
+
console.error(`Error: ${message}`);
|
|
1008
|
+
}
|
|
1009
|
+
process.exitCode = 1;
|
|
1010
|
+
} finally {
|
|
1011
|
+
// Always stop spinner to prevent interval leak (idempotent - safe to call multiple times)
|
|
1012
|
+
spinner.stop();
|
|
1013
|
+
if (client) {
|
|
1014
|
+
await client.end();
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
});
|
|
1018
|
+
|
|
119
1019
|
/**
|
|
120
1020
|
* Stub function for not implemented commands
|
|
121
1021
|
*/
|
|
@@ -149,6 +1049,14 @@ function resolvePaths(): PathResolution {
|
|
|
149
1049
|
);
|
|
150
1050
|
}
|
|
151
1051
|
|
|
1052
|
+
async function resolveOrInitPaths(): Promise<PathResolution> {
|
|
1053
|
+
try {
|
|
1054
|
+
return resolvePaths();
|
|
1055
|
+
} catch {
|
|
1056
|
+
return ensureDefaultMonitoringProject();
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
152
1060
|
/**
|
|
153
1061
|
* Check if Docker daemon is running
|
|
154
1062
|
*/
|
|
@@ -196,11 +1104,11 @@ function checkRunningContainers(): { running: boolean; containers: string[] } {
|
|
|
196
1104
|
/**
|
|
197
1105
|
* Run docker compose command
|
|
198
1106
|
*/
|
|
199
|
-
async function runCompose(args: string[]): Promise<number> {
|
|
1107
|
+
async function runCompose(args: string[], grafanaPassword?: string): Promise<number> {
|
|
200
1108
|
let composeFile: string;
|
|
201
1109
|
let projectDir: string;
|
|
202
1110
|
try {
|
|
203
|
-
({ composeFile, projectDir } =
|
|
1111
|
+
({ composeFile, projectDir } = await resolveOrInitPaths());
|
|
204
1112
|
} catch (error) {
|
|
205
1113
|
const message = error instanceof Error ? error.message : String(error);
|
|
206
1114
|
console.error(message);
|
|
@@ -222,28 +1130,42 @@ async function runCompose(args: string[]): Promise<number> {
|
|
|
222
1130
|
return 1;
|
|
223
1131
|
}
|
|
224
1132
|
|
|
225
|
-
//
|
|
1133
|
+
// Set Grafana password from parameter or .pgwatch-config
|
|
226
1134
|
const env = { ...process.env };
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
if (
|
|
235
|
-
|
|
1135
|
+
if (grafanaPassword) {
|
|
1136
|
+
env.GF_SECURITY_ADMIN_PASSWORD = grafanaPassword;
|
|
1137
|
+
} else {
|
|
1138
|
+
const cfgPath = path.resolve(projectDir, ".pgwatch-config");
|
|
1139
|
+
if (fs.existsSync(cfgPath)) {
|
|
1140
|
+
try {
|
|
1141
|
+
const stats = fs.statSync(cfgPath);
|
|
1142
|
+
if (!stats.isDirectory()) {
|
|
1143
|
+
const content = fs.readFileSync(cfgPath, "utf8");
|
|
1144
|
+
const match = content.match(/^grafana_password=([^\r\n]+)/m);
|
|
1145
|
+
if (match) {
|
|
1146
|
+
env.GF_SECURITY_ADMIN_PASSWORD = match[1].trim();
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
} catch (err) {
|
|
1150
|
+
// If we can't read the config, log warning and continue without setting the password
|
|
1151
|
+
if (process.env.DEBUG) {
|
|
1152
|
+
console.warn(`Warning: Could not read Grafana password from config: ${err instanceof Error ? err.message : String(err)}`);
|
|
236
1153
|
}
|
|
237
1154
|
}
|
|
238
|
-
} catch (err) {
|
|
239
|
-
// If we can't read the config, continue without setting the password
|
|
240
1155
|
}
|
|
241
1156
|
}
|
|
242
1157
|
|
|
1158
|
+
// On macOS, node-exporter can't mount host root filesystem - skip it
|
|
1159
|
+
const finalArgs = [...args];
|
|
1160
|
+
if (process.platform === "darwin" && args.includes("up")) {
|
|
1161
|
+
finalArgs.push("--scale", "node-exporter=0");
|
|
1162
|
+
}
|
|
1163
|
+
|
|
243
1164
|
return new Promise<number>((resolve) => {
|
|
244
|
-
const child = spawn(cmd[0], [...cmd.slice(1), "-f", composeFile, ...
|
|
1165
|
+
const child = spawn(cmd[0], [...cmd.slice(1), "-f", composeFile, ...finalArgs], {
|
|
245
1166
|
stdio: "inherit",
|
|
246
|
-
env: env
|
|
1167
|
+
env: env,
|
|
1168
|
+
cwd: projectDir
|
|
247
1169
|
});
|
|
248
1170
|
child.on("close", (code) => resolve(code || 0));
|
|
249
1171
|
});
|
|
@@ -257,18 +1179,64 @@ program.command("help", { isDefault: true }).description("show help").action(()
|
|
|
257
1179
|
const mon = program.command("mon").description("monitoring services management");
|
|
258
1180
|
|
|
259
1181
|
mon
|
|
260
|
-
.command("
|
|
261
|
-
.description("
|
|
1182
|
+
.command("local-install")
|
|
1183
|
+
.description("install local monitoring stack (generate config, start services)")
|
|
262
1184
|
.option("--demo", "demo mode with sample database", false)
|
|
263
1185
|
.option("--api-key <key>", "Postgres AI API key for automated report uploads")
|
|
264
1186
|
.option("--db-url <url>", "PostgreSQL connection URL to monitor")
|
|
1187
|
+
.option("--tag <tag>", "Docker image tag to use (e.g., 0.14.0, 0.14.0-dev.33)")
|
|
265
1188
|
.option("-y, --yes", "accept all defaults and skip interactive prompts", false)
|
|
266
|
-
.action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; yes: boolean }) => {
|
|
1189
|
+
.action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; tag?: string; yes: boolean }) => {
|
|
1190
|
+
// Get apiKey from global program options (--api-key is defined globally)
|
|
1191
|
+
// This is needed because Commander.js routes --api-key to the global option, not the subcommand's option
|
|
1192
|
+
const globalOpts = program.opts<CliOptions>();
|
|
1193
|
+
const apiKey = opts.apiKey || globalOpts.apiKey;
|
|
1194
|
+
|
|
267
1195
|
console.log("\n=================================");
|
|
268
|
-
console.log(" PostgresAI
|
|
1196
|
+
console.log(" PostgresAI monitoring local install");
|
|
269
1197
|
console.log("=================================\n");
|
|
270
1198
|
console.log("This will install, configure, and start the monitoring system\n");
|
|
271
1199
|
|
|
1200
|
+
// Ensure we have a project directory with docker-compose.yml even if running from elsewhere
|
|
1201
|
+
const { projectDir } = await resolveOrInitPaths();
|
|
1202
|
+
console.log(`Project directory: ${projectDir}\n`);
|
|
1203
|
+
|
|
1204
|
+
// Update .env with custom tag if provided
|
|
1205
|
+
const envFile = path.resolve(projectDir, ".env");
|
|
1206
|
+
|
|
1207
|
+
// Build .env content, preserving important existing values
|
|
1208
|
+
// Read existing .env first to preserve CI/custom settings
|
|
1209
|
+
let existingTag: string | null = null;
|
|
1210
|
+
let existingRegistry: string | null = null;
|
|
1211
|
+
let existingPassword: string | null = null;
|
|
1212
|
+
|
|
1213
|
+
if (fs.existsSync(envFile)) {
|
|
1214
|
+
const existingEnv = fs.readFileSync(envFile, "utf8");
|
|
1215
|
+
// Extract existing values
|
|
1216
|
+
const tagMatch = existingEnv.match(/^PGAI_TAG=(.+)$/m);
|
|
1217
|
+
if (tagMatch) existingTag = tagMatch[1].trim();
|
|
1218
|
+
const registryMatch = existingEnv.match(/^PGAI_REGISTRY=(.+)$/m);
|
|
1219
|
+
if (registryMatch) existingRegistry = registryMatch[1].trim();
|
|
1220
|
+
const pwdMatch = existingEnv.match(/^GF_SECURITY_ADMIN_PASSWORD=(.+)$/m);
|
|
1221
|
+
if (pwdMatch) existingPassword = pwdMatch[1].trim();
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// Priority: CLI --tag flag > PGAI_TAG env var > existing .env > package version
|
|
1225
|
+
const imageTag = opts.tag || process.env.PGAI_TAG || existingTag || pkg.version;
|
|
1226
|
+
|
|
1227
|
+
const envLines: string[] = [`PGAI_TAG=${imageTag}`];
|
|
1228
|
+
if (existingRegistry) {
|
|
1229
|
+
envLines.push(`PGAI_REGISTRY=${existingRegistry}`);
|
|
1230
|
+
}
|
|
1231
|
+
if (existingPassword) {
|
|
1232
|
+
envLines.push(`GF_SECURITY_ADMIN_PASSWORD=${existingPassword}`);
|
|
1233
|
+
}
|
|
1234
|
+
fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
|
|
1235
|
+
|
|
1236
|
+
if (opts.tag) {
|
|
1237
|
+
console.log(`Using image tag: ${imageTag}\n`);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
272
1240
|
// Validate conflicting options
|
|
273
1241
|
if (opts.demo && opts.dbUrl) {
|
|
274
1242
|
console.log("⚠ Both --demo and --db-url provided. Demo mode includes its own database.");
|
|
@@ -276,11 +1244,11 @@ mon
|
|
|
276
1244
|
opts.dbUrl = undefined;
|
|
277
1245
|
}
|
|
278
1246
|
|
|
279
|
-
if (opts.demo &&
|
|
1247
|
+
if (opts.demo && apiKey) {
|
|
280
1248
|
console.error("✗ Cannot use --api-key with --demo mode");
|
|
281
1249
|
console.error("✗ Demo mode is for testing only and does not support API key integration");
|
|
282
|
-
console.error("\nUse demo mode without API key: postgres-ai mon
|
|
283
|
-
console.error("Or use production mode with API key: postgres-ai mon
|
|
1250
|
+
console.error("\nUse demo mode without API key: postgres-ai mon local-install --demo");
|
|
1251
|
+
console.error("Or use production mode with API key: postgres-ai mon local-install --api-key=your_key");
|
|
284
1252
|
process.exitCode = 1;
|
|
285
1253
|
return;
|
|
286
1254
|
}
|
|
@@ -298,9 +1266,14 @@ mon
|
|
|
298
1266
|
console.log("Step 1: Postgres AI API Configuration (Optional)");
|
|
299
1267
|
console.log("An API key enables automatic upload of PostgreSQL reports to Postgres AI\n");
|
|
300
1268
|
|
|
301
|
-
if (
|
|
1269
|
+
if (apiKey) {
|
|
302
1270
|
console.log("Using API key provided via --api-key parameter");
|
|
303
|
-
config.writeConfig({ apiKey
|
|
1271
|
+
config.writeConfig({ apiKey });
|
|
1272
|
+
// Keep reporter compatibility (docker-compose mounts .pgwatch-config)
|
|
1273
|
+
fs.writeFileSync(path.resolve(projectDir, ".pgwatch-config"), `api_key=${apiKey}\n`, {
|
|
1274
|
+
encoding: "utf8",
|
|
1275
|
+
mode: 0o600
|
|
1276
|
+
});
|
|
304
1277
|
console.log("✓ API key saved\n");
|
|
305
1278
|
} else if (opts.yes) {
|
|
306
1279
|
// Auto-yes mode without API key - skip API key setup
|
|
@@ -308,43 +1281,36 @@ mon
|
|
|
308
1281
|
console.log("⚠ Reports will be generated locally only");
|
|
309
1282
|
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
310
1283
|
} else {
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
config.writeConfig({ apiKey: trimmedKey });
|
|
330
|
-
console.log("✓ API key saved\n");
|
|
331
|
-
break;
|
|
332
|
-
}
|
|
1284
|
+
const answer = await question("Do you have a Postgres AI API key? (Y/n): ");
|
|
1285
|
+
const proceedWithApiKey = !answer || answer.toLowerCase() === "y";
|
|
1286
|
+
|
|
1287
|
+
if (proceedWithApiKey) {
|
|
1288
|
+
while (true) {
|
|
1289
|
+
const inputApiKey = await question("Enter your Postgres AI API key: ");
|
|
1290
|
+
const trimmedKey = inputApiKey.trim();
|
|
1291
|
+
|
|
1292
|
+
if (trimmedKey) {
|
|
1293
|
+
config.writeConfig({ apiKey: trimmedKey });
|
|
1294
|
+
// Keep reporter compatibility (docker-compose mounts .pgwatch-config)
|
|
1295
|
+
fs.writeFileSync(path.resolve(projectDir, ".pgwatch-config"), `api_key=${trimmedKey}\n`, {
|
|
1296
|
+
encoding: "utf8",
|
|
1297
|
+
mode: 0o600
|
|
1298
|
+
});
|
|
1299
|
+
console.log("✓ API key saved\n");
|
|
1300
|
+
break;
|
|
1301
|
+
}
|
|
333
1302
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
}
|
|
1303
|
+
console.log("⚠ API key cannot be empty");
|
|
1304
|
+
const retry = await question("Try again or skip API key setup, retry? (Y/n): ");
|
|
1305
|
+
if (retry.toLowerCase() === "n") {
|
|
1306
|
+
console.log("⚠ Skipping API key setup - reports will be generated locally only");
|
|
1307
|
+
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
1308
|
+
break;
|
|
341
1309
|
}
|
|
342
|
-
} else {
|
|
343
|
-
console.log("⚠ Skipping API key setup - reports will be generated locally only");
|
|
344
|
-
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
345
1310
|
}
|
|
346
|
-
}
|
|
347
|
-
|
|
1311
|
+
} else {
|
|
1312
|
+
console.log("⚠ Skipping API key setup - reports will be generated locally only");
|
|
1313
|
+
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
348
1314
|
}
|
|
349
1315
|
}
|
|
350
1316
|
} else {
|
|
@@ -357,9 +1323,11 @@ mon
|
|
|
357
1323
|
console.log("Step 2: Add PostgreSQL Instance to Monitor\n");
|
|
358
1324
|
|
|
359
1325
|
// Clear instances.yml in production mode (start fresh)
|
|
360
|
-
const instancesPath =
|
|
1326
|
+
const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
|
|
361
1327
|
const emptyInstancesContent = "# PostgreSQL instances to monitor\n# Add your instances using: postgres-ai mon targets add\n\n";
|
|
362
1328
|
fs.writeFileSync(instancesPath, emptyInstancesContent, "utf8");
|
|
1329
|
+
console.log(`Instances file: ${instancesPath}`);
|
|
1330
|
+
console.log(`Project directory: ${projectDir}\n`);
|
|
363
1331
|
|
|
364
1332
|
if (opts.dbUrl) {
|
|
365
1333
|
console.log("Using database URL provided via --db-url parameter");
|
|
@@ -388,7 +1356,6 @@ mon
|
|
|
388
1356
|
// Test connection
|
|
389
1357
|
console.log("Testing connection to the added instance...");
|
|
390
1358
|
try {
|
|
391
|
-
const { Client } = require("pg");
|
|
392
1359
|
const client = new Client({ connectionString: connStr });
|
|
393
1360
|
await client.connect();
|
|
394
1361
|
const result = await client.query("select version();");
|
|
@@ -405,63 +1372,50 @@ mon
|
|
|
405
1372
|
console.log("⚠ No PostgreSQL instance added");
|
|
406
1373
|
console.log("You can add one later with: postgres-ai mon targets add\n");
|
|
407
1374
|
} else {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
console.log(" 2. Press Enter to skip for now\n");
|
|
425
|
-
|
|
426
|
-
const connStr = await question("Enter connection string (or press Enter to skip): ");
|
|
427
|
-
|
|
428
|
-
if (connStr.trim()) {
|
|
429
|
-
const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
|
|
430
|
-
if (!m) {
|
|
431
|
-
console.error("✗ Invalid connection string format");
|
|
432
|
-
console.log("⚠ Continuing without adding instance\n");
|
|
433
|
-
} else {
|
|
434
|
-
const host = m[3];
|
|
435
|
-
const db = m[5];
|
|
436
|
-
const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
|
|
437
|
-
|
|
438
|
-
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`;
|
|
439
|
-
fs.appendFileSync(instancesPath, body, "utf8");
|
|
440
|
-
console.log(`✓ Monitoring target '${instanceName}' added\n`);
|
|
441
|
-
|
|
442
|
-
// Test connection
|
|
443
|
-
console.log("Testing connection to the added instance...");
|
|
444
|
-
try {
|
|
445
|
-
const { Client } = require("pg");
|
|
446
|
-
const client = new Client({ connectionString: connStr });
|
|
447
|
-
await client.connect();
|
|
448
|
-
const result = await client.query("select version();");
|
|
449
|
-
console.log("✓ Connection successful");
|
|
450
|
-
console.log(`${result.rows[0].version}\n`);
|
|
451
|
-
await client.end();
|
|
452
|
-
} catch (error) {
|
|
453
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
454
|
-
console.error(`✗ Connection failed: ${message}\n`);
|
|
455
|
-
}
|
|
456
|
-
}
|
|
1375
|
+
console.log("You need to add at least one PostgreSQL instance to monitor");
|
|
1376
|
+
const answer = await question("Do you want to add a PostgreSQL instance now? (Y/n): ");
|
|
1377
|
+
const proceedWithInstance = !answer || answer.toLowerCase() === "y";
|
|
1378
|
+
|
|
1379
|
+
if (proceedWithInstance) {
|
|
1380
|
+
console.log("\nYou can provide either:");
|
|
1381
|
+
console.log(" 1. A full connection string: postgresql://user:pass@host:port/database");
|
|
1382
|
+
console.log(" 2. Press Enter to skip for now\n");
|
|
1383
|
+
|
|
1384
|
+
const connStr = await question("Enter connection string (or press Enter to skip): ");
|
|
1385
|
+
|
|
1386
|
+
if (connStr.trim()) {
|
|
1387
|
+
const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
|
|
1388
|
+
if (!m) {
|
|
1389
|
+
console.error("✗ Invalid connection string format");
|
|
1390
|
+
console.log("⚠ Continuing without adding instance\n");
|
|
457
1391
|
} else {
|
|
458
|
-
|
|
1392
|
+
const host = m[3];
|
|
1393
|
+
const db = m[5];
|
|
1394
|
+
const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
|
|
1395
|
+
|
|
1396
|
+
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`;
|
|
1397
|
+
fs.appendFileSync(instancesPath, body, "utf8");
|
|
1398
|
+
console.log(`✓ Monitoring target '${instanceName}' added\n`);
|
|
1399
|
+
|
|
1400
|
+
// Test connection
|
|
1401
|
+
console.log("Testing connection to the added instance...");
|
|
1402
|
+
try {
|
|
1403
|
+
const client = new Client({ connectionString: connStr });
|
|
1404
|
+
await client.connect();
|
|
1405
|
+
const result = await client.query("select version();");
|
|
1406
|
+
console.log("✓ Connection successful");
|
|
1407
|
+
console.log(`${result.rows[0].version}\n`);
|
|
1408
|
+
await client.end();
|
|
1409
|
+
} catch (error) {
|
|
1410
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1411
|
+
console.error(`✗ Connection failed: ${message}\n`);
|
|
1412
|
+
}
|
|
459
1413
|
}
|
|
460
1414
|
} else {
|
|
461
1415
|
console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
|
|
462
1416
|
}
|
|
463
|
-
}
|
|
464
|
-
|
|
1417
|
+
} else {
|
|
1418
|
+
console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
|
|
465
1419
|
}
|
|
466
1420
|
}
|
|
467
1421
|
} else {
|
|
@@ -479,7 +1433,7 @@ mon
|
|
|
479
1433
|
|
|
480
1434
|
// Step 4: Ensure Grafana password is configured
|
|
481
1435
|
console.log(opts.demo ? "Step 4: Configuring Grafana security..." : "Step 4: Configuring Grafana security...");
|
|
482
|
-
const cfgPath = path.resolve(
|
|
1436
|
+
const cfgPath = path.resolve(projectDir, ".pgwatch-config");
|
|
483
1437
|
let grafanaPassword = "";
|
|
484
1438
|
|
|
485
1439
|
try {
|
|
@@ -520,8 +1474,8 @@ mon
|
|
|
520
1474
|
}
|
|
521
1475
|
|
|
522
1476
|
// Step 5: Start services
|
|
523
|
-
console.log(
|
|
524
|
-
const code2 = await runCompose(["up", "-d", "--force-recreate"]);
|
|
1477
|
+
console.log("Step 5: Starting monitoring services...");
|
|
1478
|
+
const code2 = await runCompose(["up", "-d", "--force-recreate"], grafanaPassword);
|
|
525
1479
|
if (code2 !== 0) {
|
|
526
1480
|
process.exitCode = code2;
|
|
527
1481
|
return;
|
|
@@ -530,7 +1484,7 @@ mon
|
|
|
530
1484
|
|
|
531
1485
|
// Final summary
|
|
532
1486
|
console.log("=================================");
|
|
533
|
-
console.log("
|
|
1487
|
+
console.log(" Local install completed!");
|
|
534
1488
|
console.log("=================================\n");
|
|
535
1489
|
|
|
536
1490
|
console.log("What's running:");
|
|
@@ -579,6 +1533,34 @@ mon
|
|
|
579
1533
|
if (code !== 0) process.exitCode = code;
|
|
580
1534
|
});
|
|
581
1535
|
|
|
1536
|
+
// Known container names for cleanup
|
|
1537
|
+
const MONITORING_CONTAINERS = [
|
|
1538
|
+
"postgres-ai-config-init",
|
|
1539
|
+
"node-exporter",
|
|
1540
|
+
"cadvisor",
|
|
1541
|
+
"grafana-with-datasources",
|
|
1542
|
+
"sink-postgres",
|
|
1543
|
+
"sink-prometheus",
|
|
1544
|
+
"target-db",
|
|
1545
|
+
"pgwatch-postgres",
|
|
1546
|
+
"pgwatch-prometheus",
|
|
1547
|
+
"postgres-exporter-sink",
|
|
1548
|
+
"flask-pgss-api",
|
|
1549
|
+
"sources-generator",
|
|
1550
|
+
"postgres-reports",
|
|
1551
|
+
];
|
|
1552
|
+
|
|
1553
|
+
/** Remove orphaned containers that docker compose down might miss */
|
|
1554
|
+
async function removeOrphanedContainers(): Promise<void> {
|
|
1555
|
+
for (const container of MONITORING_CONTAINERS) {
|
|
1556
|
+
try {
|
|
1557
|
+
await execFilePromise("docker", ["rm", "-f", container]);
|
|
1558
|
+
} catch {
|
|
1559
|
+
// Container doesn't exist, ignore
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
|
|
582
1564
|
mon
|
|
583
1565
|
.command("stop")
|
|
584
1566
|
.description("stop monitoring services")
|
|
@@ -647,17 +1629,17 @@ mon
|
|
|
647
1629
|
allHealthy = true;
|
|
648
1630
|
for (const service of services) {
|
|
649
1631
|
try {
|
|
650
|
-
const
|
|
651
|
-
const status =
|
|
652
|
-
encoding: 'utf8',
|
|
653
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
654
|
-
}).trim();
|
|
1632
|
+
const result = spawnSync("docker", ["inspect", "-f", "{{.State.Status}}", service.container], { stdio: "pipe" });
|
|
1633
|
+
const status = result.stdout.trim();
|
|
655
1634
|
|
|
656
|
-
if (status === 'running') {
|
|
1635
|
+
if (result.status === 0 && status === 'running') {
|
|
657
1636
|
console.log(`✓ ${service.name}: healthy`);
|
|
658
|
-
} else {
|
|
1637
|
+
} else if (result.status === 0) {
|
|
659
1638
|
console.log(`✗ ${service.name}: unhealthy (status: ${status})`);
|
|
660
1639
|
allHealthy = false;
|
|
1640
|
+
} else {
|
|
1641
|
+
console.log(`✗ ${service.name}: unreachable`);
|
|
1642
|
+
allHealthy = false;
|
|
661
1643
|
}
|
|
662
1644
|
} catch (error) {
|
|
663
1645
|
console.log(`✗ ${service.name}: unreachable`);
|
|
@@ -686,7 +1668,7 @@ mon
|
|
|
686
1668
|
let composeFile: string;
|
|
687
1669
|
let instancesFile: string;
|
|
688
1670
|
try {
|
|
689
|
-
({ projectDir, composeFile, instancesFile } =
|
|
1671
|
+
({ projectDir, composeFile, instancesFile } = await resolveOrInitPaths());
|
|
690
1672
|
} catch (error) {
|
|
691
1673
|
const message = error instanceof Error ? error.message : String(error);
|
|
692
1674
|
console.error(message);
|
|
@@ -761,14 +1743,6 @@ mon
|
|
|
761
1743
|
.command("reset [service]")
|
|
762
1744
|
.description("reset all or specific monitoring service")
|
|
763
1745
|
.action(async (service?: string) => {
|
|
764
|
-
const rl = readline.createInterface({
|
|
765
|
-
input: process.stdin,
|
|
766
|
-
output: process.stdout,
|
|
767
|
-
});
|
|
768
|
-
|
|
769
|
-
const question = (prompt: string): Promise<string> =>
|
|
770
|
-
new Promise((resolve) => rl.question(prompt, resolve));
|
|
771
|
-
|
|
772
1746
|
try {
|
|
773
1747
|
if (service) {
|
|
774
1748
|
// Reset specific service
|
|
@@ -778,7 +1752,6 @@ mon
|
|
|
778
1752
|
const answer = await question("Continue? (y/N): ");
|
|
779
1753
|
if (answer.toLowerCase() !== "y") {
|
|
780
1754
|
console.log("Cancelled");
|
|
781
|
-
rl.close();
|
|
782
1755
|
return;
|
|
783
1756
|
}
|
|
784
1757
|
|
|
@@ -805,7 +1778,6 @@ mon
|
|
|
805
1778
|
const answer = await question("Continue? (y/N): ");
|
|
806
1779
|
if (answer.toLowerCase() !== "y") {
|
|
807
1780
|
console.log("Cancelled");
|
|
808
|
-
rl.close();
|
|
809
1781
|
return;
|
|
810
1782
|
}
|
|
811
1783
|
|
|
@@ -819,10 +1791,7 @@ mon
|
|
|
819
1791
|
process.exitCode = 1;
|
|
820
1792
|
}
|
|
821
1793
|
}
|
|
822
|
-
|
|
823
|
-
rl.close();
|
|
824
1794
|
} catch (error) {
|
|
825
|
-
rl.close();
|
|
826
1795
|
const message = error instanceof Error ? error.message : String(error);
|
|
827
1796
|
console.error(`Reset failed: ${message}`);
|
|
828
1797
|
process.exitCode = 1;
|
|
@@ -830,34 +1799,72 @@ mon
|
|
|
830
1799
|
});
|
|
831
1800
|
mon
|
|
832
1801
|
.command("clean")
|
|
833
|
-
.description("cleanup monitoring services artifacts")
|
|
834
|
-
.
|
|
835
|
-
|
|
1802
|
+
.description("cleanup monitoring services artifacts (stops services and removes volumes)")
|
|
1803
|
+
.option("--keep-volumes", "keep data volumes (only stop and remove containers)")
|
|
1804
|
+
.action(async (options: { keepVolumes?: boolean }) => {
|
|
1805
|
+
console.log("Cleaning up monitoring services...\n");
|
|
836
1806
|
|
|
837
1807
|
try {
|
|
838
|
-
//
|
|
839
|
-
const
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
1808
|
+
// First, use docker-compose down to properly stop and remove containers/volumes
|
|
1809
|
+
const downArgs = options.keepVolumes ? ["down"] : ["down", "-v"];
|
|
1810
|
+
console.log(options.keepVolumes
|
|
1811
|
+
? "Stopping and removing containers (keeping volumes)..."
|
|
1812
|
+
: "Stopping and removing containers and volumes...");
|
|
1813
|
+
|
|
1814
|
+
const downCode = await runCompose(downArgs);
|
|
1815
|
+
if (downCode === 0) {
|
|
1816
|
+
console.log("✓ Monitoring services stopped and removed");
|
|
844
1817
|
} else {
|
|
845
|
-
console.log("
|
|
1818
|
+
console.log("⚠ Could not stop services (may not be running)");
|
|
846
1819
|
}
|
|
847
1820
|
|
|
848
|
-
// Remove
|
|
849
|
-
await
|
|
850
|
-
console.log("✓ Removed
|
|
1821
|
+
// Remove any orphaned containers that docker compose down missed
|
|
1822
|
+
await removeOrphanedContainers();
|
|
1823
|
+
console.log("✓ Removed orphaned containers");
|
|
1824
|
+
|
|
1825
|
+
// Remove orphaned volumes from previous installs with different project names
|
|
1826
|
+
if (!options.keepVolumes) {
|
|
1827
|
+
const volumePatterns = [
|
|
1828
|
+
"monitoring_grafana_data",
|
|
1829
|
+
"monitoring_postgres_ai_configs",
|
|
1830
|
+
"monitoring_sink_postgres_data",
|
|
1831
|
+
"monitoring_target_db_data",
|
|
1832
|
+
"monitoring_victoria_metrics_data",
|
|
1833
|
+
"postgres_ai_configs_grafana_data",
|
|
1834
|
+
"postgres_ai_configs_sink_postgres_data",
|
|
1835
|
+
"postgres_ai_configs_target_db_data",
|
|
1836
|
+
"postgres_ai_configs_victoria_metrics_data",
|
|
1837
|
+
"postgres_ai_configs_postgres_ai_configs",
|
|
1838
|
+
];
|
|
1839
|
+
|
|
1840
|
+
const { stdout: existingVolumes } = await execFilePromise("docker", ["volume", "ls", "-q"]);
|
|
1841
|
+
const volumeList = existingVolumes.trim().split('\n').filter(Boolean);
|
|
1842
|
+
const orphanedVolumes = volumeList.filter(v => volumePatterns.includes(v));
|
|
1843
|
+
|
|
1844
|
+
if (orphanedVolumes.length > 0) {
|
|
1845
|
+
let removedCount = 0;
|
|
1846
|
+
for (const vol of orphanedVolumes) {
|
|
1847
|
+
try {
|
|
1848
|
+
await execFilePromise("docker", ["volume", "rm", vol]);
|
|
1849
|
+
removedCount++;
|
|
1850
|
+
} catch {
|
|
1851
|
+
// Volume might be in use, skip silently
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
if (removedCount > 0) {
|
|
1855
|
+
console.log(`✓ Removed ${removedCount} orphaned volume(s) from previous installs`);
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
851
1859
|
|
|
852
|
-
// Remove
|
|
1860
|
+
// Remove any dangling resources
|
|
853
1861
|
await execFilePromise("docker", ["network", "prune", "-f"]);
|
|
854
1862
|
console.log("✓ Removed unused networks");
|
|
855
1863
|
|
|
856
|
-
// Remove dangling images
|
|
857
1864
|
await execFilePromise("docker", ["image", "prune", "-f"]);
|
|
858
1865
|
console.log("✓ Removed dangling images");
|
|
859
1866
|
|
|
860
|
-
console.log("\
|
|
1867
|
+
console.log("\n✓ Cleanup completed - ready for fresh install");
|
|
861
1868
|
} catch (error) {
|
|
862
1869
|
const message = error instanceof Error ? error.message : String(error);
|
|
863
1870
|
console.error(`Error during cleanup: ${message}`);
|
|
@@ -886,9 +1893,9 @@ targets
|
|
|
886
1893
|
.command("list")
|
|
887
1894
|
.description("list monitoring target databases")
|
|
888
1895
|
.action(async () => {
|
|
889
|
-
const instancesPath =
|
|
1896
|
+
const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
|
|
890
1897
|
if (!fs.existsSync(instancesPath)) {
|
|
891
|
-
console.error(`instances.yml not found in ${
|
|
1898
|
+
console.error(`instances.yml not found in ${projectDir}`);
|
|
892
1899
|
process.exitCode = 1;
|
|
893
1900
|
return;
|
|
894
1901
|
}
|
|
@@ -935,7 +1942,7 @@ targets
|
|
|
935
1942
|
.command("add [connStr] [name]")
|
|
936
1943
|
.description("add monitoring target database")
|
|
937
1944
|
.action(async (connStr?: string, name?: string) => {
|
|
938
|
-
const file =
|
|
1945
|
+
const { instancesFile: file } = await resolveOrInitPaths();
|
|
939
1946
|
if (!connStr) {
|
|
940
1947
|
console.error("Connection string required: postgresql://user:pass@host:port/db");
|
|
941
1948
|
process.exitCode = 1;
|
|
@@ -985,7 +1992,7 @@ targets
|
|
|
985
1992
|
.command("remove <name>")
|
|
986
1993
|
.description("remove monitoring target database")
|
|
987
1994
|
.action(async (name: string) => {
|
|
988
|
-
const file =
|
|
1995
|
+
const { instancesFile: file } = await resolveOrInitPaths();
|
|
989
1996
|
if (!fs.existsSync(file)) {
|
|
990
1997
|
console.error("instances.yml not found");
|
|
991
1998
|
process.exitCode = 1;
|
|
@@ -1022,7 +2029,7 @@ targets
|
|
|
1022
2029
|
.command("test <name>")
|
|
1023
2030
|
.description("test monitoring target database connectivity")
|
|
1024
2031
|
.action(async (name: string) => {
|
|
1025
|
-
const instancesPath =
|
|
2032
|
+
const { instancesFile: instancesPath } = await resolveOrInitPaths();
|
|
1026
2033
|
if (!fs.existsSync(instancesPath)) {
|
|
1027
2034
|
console.error("instances.yml not found");
|
|
1028
2035
|
process.exitCode = 1;
|
|
@@ -1056,7 +2063,6 @@ targets
|
|
|
1056
2063
|
console.log(`Testing connection to monitoring target '${name}'...`);
|
|
1057
2064
|
|
|
1058
2065
|
// Use native pg client instead of requiring psql to be installed
|
|
1059
|
-
const { Client } = require('pg');
|
|
1060
2066
|
const client = new Client({ connectionString: instance.conn_str });
|
|
1061
2067
|
|
|
1062
2068
|
try {
|
|
@@ -1075,15 +2081,43 @@ targets
|
|
|
1075
2081
|
});
|
|
1076
2082
|
|
|
1077
2083
|
// Authentication and API key management
|
|
1078
|
-
program
|
|
1079
|
-
|
|
1080
|
-
|
|
2084
|
+
const auth = program.command("auth").description("authentication and API key management");
|
|
2085
|
+
|
|
2086
|
+
auth
|
|
2087
|
+
.command("login", { isDefault: true })
|
|
2088
|
+
.description("authenticate via browser (OAuth) or store API key directly")
|
|
2089
|
+
.option("--set-key <key>", "store API key directly without OAuth flow")
|
|
1081
2090
|
.option("--port <port>", "local callback server port (default: random)", parseInt)
|
|
1082
2091
|
.option("--debug", "enable debug output")
|
|
1083
|
-
.action(async (opts: { port?: number; debug?: boolean }) => {
|
|
1084
|
-
|
|
1085
|
-
|
|
2092
|
+
.action(async (opts: { setKey?: string; port?: number; debug?: boolean }) => {
|
|
2093
|
+
// If --set-key is provided, store it directly without OAuth
|
|
2094
|
+
if (opts.setKey) {
|
|
2095
|
+
const trimmedKey = opts.setKey.trim();
|
|
2096
|
+
if (!trimmedKey) {
|
|
2097
|
+
console.error("Error: API key cannot be empty");
|
|
2098
|
+
process.exitCode = 1;
|
|
2099
|
+
return;
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
// Read existing config to check for defaultProject before updating
|
|
2103
|
+
const existingConfig = config.readConfig();
|
|
2104
|
+
const existingProject = existingConfig.defaultProject;
|
|
2105
|
+
|
|
2106
|
+
config.writeConfig({ apiKey: trimmedKey });
|
|
2107
|
+
// When API key is set directly, only clear orgId (org selection may differ).
|
|
2108
|
+
// Preserve defaultProject to avoid orphaning historical reports.
|
|
2109
|
+
// If the new key lacks access to the project, upload will fail with a clear error.
|
|
2110
|
+
config.deleteConfigKeys(["orgId"]);
|
|
2111
|
+
|
|
2112
|
+
console.log(`API key saved to ${config.getConfigPath()}`);
|
|
2113
|
+
if (existingProject) {
|
|
2114
|
+
console.log(`Note: Your default project "${existingProject}" has been preserved.`);
|
|
2115
|
+
console.log(` If this key belongs to a different account, use --project to specify a new one.`);
|
|
2116
|
+
}
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
1086
2119
|
|
|
2120
|
+
// Otherwise, proceed with OAuth flow
|
|
1087
2121
|
console.log("Starting authentication flow...\n");
|
|
1088
2122
|
|
|
1089
2123
|
// Generate PKCE parameters
|
|
@@ -1104,10 +2138,10 @@ program
|
|
|
1104
2138
|
const requestedPort = opts.port || 0; // 0 = OS assigns available port
|
|
1105
2139
|
const callbackServer = authServer.createCallbackServer(requestedPort, params.state, 120000); // 2 minute timeout
|
|
1106
2140
|
|
|
1107
|
-
// Wait
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
const redirectUri = `http://
|
|
2141
|
+
// Wait for server to start and get the actual port
|
|
2142
|
+
const actualPort = await callbackServer.ready;
|
|
2143
|
+
// Use 127.0.0.1 to match the server bind address (avoids IPv6 issues on some hosts)
|
|
2144
|
+
const redirectUri = `http://127.0.0.1:${actualPort}/callback`;
|
|
1111
2145
|
|
|
1112
2146
|
console.log(`Callback server listening on port ${actualPort}`);
|
|
1113
2147
|
|
|
@@ -1129,173 +2163,180 @@ program
|
|
|
1129
2163
|
console.log(`Debug: Request data: ${initData}`);
|
|
1130
2164
|
}
|
|
1131
2165
|
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
2166
|
+
// Step 2: Initialize OAuth session on backend using fetch
|
|
2167
|
+
let initResponse: Response;
|
|
2168
|
+
try {
|
|
2169
|
+
initResponse = await fetch(initUrl.toString(), {
|
|
1135
2170
|
method: "POST",
|
|
1136
2171
|
headers: {
|
|
1137
2172
|
"Content-Type": "application/json",
|
|
1138
|
-
"Content-Length": Buffer.byteLength(initData),
|
|
1139
2173
|
},
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
if (data.trim().startsWith("<!") || data.trim().startsWith("<html")) {
|
|
1150
|
-
console.error("Error: Received HTML response instead of JSON. This usually means:");
|
|
1151
|
-
console.error(" 1. The API endpoint URL is incorrect");
|
|
1152
|
-
console.error(" 2. The endpoint does not exist (404)");
|
|
1153
|
-
console.error(`\nAPI URL attempted: ${initUrl.toString()}`);
|
|
1154
|
-
console.error("\nPlease verify the --api-base-url parameter.");
|
|
1155
|
-
} else {
|
|
1156
|
-
console.error(data);
|
|
1157
|
-
}
|
|
2174
|
+
body: initData,
|
|
2175
|
+
});
|
|
2176
|
+
} catch (err) {
|
|
2177
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2178
|
+
console.error(`Failed to connect to API: ${message}`);
|
|
2179
|
+
callbackServer.server.stop();
|
|
2180
|
+
process.exit(1);
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
1158
2183
|
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
2184
|
+
if (!initResponse.ok) {
|
|
2185
|
+
const data = await initResponse.text();
|
|
2186
|
+
console.error(`Failed to initialize auth session: ${initResponse.status}`);
|
|
2187
|
+
|
|
2188
|
+
// Check if response is HTML (common for 404 pages)
|
|
2189
|
+
if (data.trim().startsWith("<!") || data.trim().startsWith("<html")) {
|
|
2190
|
+
console.error("Error: Received HTML response instead of JSON. This usually means:");
|
|
2191
|
+
console.error(" 1. The API endpoint URL is incorrect");
|
|
2192
|
+
console.error(" 2. The endpoint does not exist (404)");
|
|
2193
|
+
console.error(`\nAPI URL attempted: ${initUrl.toString()}`);
|
|
2194
|
+
console.error("\nPlease verify the --api-base-url parameter.");
|
|
2195
|
+
} else {
|
|
2196
|
+
console.error(data);
|
|
2197
|
+
}
|
|
1162
2198
|
|
|
1163
|
-
|
|
1164
|
-
|
|
2199
|
+
callbackServer.server.stop();
|
|
2200
|
+
process.exit(1);
|
|
2201
|
+
return;
|
|
2202
|
+
}
|
|
1165
2203
|
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
2204
|
+
// Step 3: Open browser
|
|
2205
|
+
// Pass api_url so UI calls oauth_approve on the same backend where oauth_init created the session
|
|
2206
|
+
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)}`;
|
|
1169
2207
|
|
|
1170
|
-
|
|
1171
|
-
|
|
2208
|
+
if (opts.debug) {
|
|
2209
|
+
console.log(`Debug: Auth URL: ${authUrl}`);
|
|
2210
|
+
}
|
|
1172
2211
|
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
process.platform === "win32" ? "start" :
|
|
1176
|
-
"xdg-open";
|
|
1177
|
-
spawn(openCommand, [authUrl], { detached: true, stdio: "ignore" }).unref();
|
|
2212
|
+
console.log(`\nOpening browser for authentication...`);
|
|
2213
|
+
console.log(`If browser does not open automatically, visit:\n${authUrl}\n`);
|
|
1178
2214
|
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
2215
|
+
// Open browser (cross-platform)
|
|
2216
|
+
const openCommand = process.platform === "darwin" ? "open" :
|
|
2217
|
+
process.platform === "win32" ? "start" :
|
|
2218
|
+
"xdg-open";
|
|
2219
|
+
spawn(openCommand, [authUrl], { detached: true, stdio: "ignore" }).unref();
|
|
1182
2220
|
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
callbackServer.server.close();
|
|
1187
|
-
process.exit(130); // Standard exit code for SIGINT
|
|
1188
|
-
};
|
|
1189
|
-
process.on("SIGINT", cancelHandler);
|
|
2221
|
+
// Step 4: Wait for callback
|
|
2222
|
+
console.log("Waiting for authorization...");
|
|
2223
|
+
console.log("(Press Ctrl+C to cancel)\n");
|
|
1190
2224
|
|
|
1191
|
-
|
|
1192
|
-
|
|
2225
|
+
// Handle Ctrl+C gracefully
|
|
2226
|
+
const cancelHandler = () => {
|
|
2227
|
+
console.log("\n\nAuthentication cancelled by user.");
|
|
2228
|
+
callbackServer.server.stop();
|
|
2229
|
+
process.exit(130); // Standard exit code for SIGINT
|
|
2230
|
+
};
|
|
2231
|
+
process.on("SIGINT", cancelHandler);
|
|
1193
2232
|
|
|
1194
|
-
|
|
1195
|
-
|
|
2233
|
+
try {
|
|
2234
|
+
const { code } = await callbackServer.promise;
|
|
1196
2235
|
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
method: "POST",
|
|
1209
|
-
headers: {
|
|
1210
|
-
"Content-Type": "application/json",
|
|
1211
|
-
"Content-Length": Buffer.byteLength(exchangeData),
|
|
1212
|
-
},
|
|
1213
|
-
},
|
|
1214
|
-
(exchangeRes) => {
|
|
1215
|
-
let exchangeBody = "";
|
|
1216
|
-
exchangeRes.on("data", (chunk) => (exchangeBody += chunk));
|
|
1217
|
-
exchangeRes.on("end", () => {
|
|
1218
|
-
if (exchangeRes.statusCode !== 200) {
|
|
1219
|
-
console.error(`Failed to exchange code for token: ${exchangeRes.statusCode}`);
|
|
1220
|
-
|
|
1221
|
-
// Check if response is HTML (common for 404 pages)
|
|
1222
|
-
if (exchangeBody.trim().startsWith("<!") || exchangeBody.trim().startsWith("<html")) {
|
|
1223
|
-
console.error("Error: Received HTML response instead of JSON. This usually means:");
|
|
1224
|
-
console.error(" 1. The API endpoint URL is incorrect");
|
|
1225
|
-
console.error(" 2. The endpoint does not exist (404)");
|
|
1226
|
-
console.error(`\nAPI URL attempted: ${exchangeUrl.toString()}`);
|
|
1227
|
-
console.error("\nPlease verify the --api-base-url parameter.");
|
|
1228
|
-
} else {
|
|
1229
|
-
console.error(exchangeBody);
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
process.exit(1);
|
|
1233
|
-
return;
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
try {
|
|
1237
|
-
const result = JSON.parse(exchangeBody);
|
|
1238
|
-
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.
|
|
1239
|
-
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.
|
|
1240
|
-
|
|
1241
|
-
// Step 6: Save token to config
|
|
1242
|
-
config.writeConfig({
|
|
1243
|
-
apiKey: apiToken,
|
|
1244
|
-
baseUrl: apiBaseUrl,
|
|
1245
|
-
orgId: orgId,
|
|
1246
|
-
});
|
|
1247
|
-
|
|
1248
|
-
console.log("\nAuthentication successful!");
|
|
1249
|
-
console.log(`API key saved to: ${config.getConfigPath()}`);
|
|
1250
|
-
console.log(`Organization ID: ${orgId}`);
|
|
1251
|
-
console.log(`\nYou can now use the CLI without specifying an API key.`);
|
|
1252
|
-
process.exit(0);
|
|
1253
|
-
} catch (err) {
|
|
1254
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1255
|
-
console.error(`Failed to parse response: ${message}`);
|
|
1256
|
-
process.exit(1);
|
|
1257
|
-
}
|
|
1258
|
-
});
|
|
1259
|
-
}
|
|
1260
|
-
);
|
|
1261
|
-
|
|
1262
|
-
exchangeReq.on("error", (err: Error) => {
|
|
1263
|
-
console.error(`Exchange request failed: ${err.message}`);
|
|
1264
|
-
process.exit(1);
|
|
1265
|
-
});
|
|
2236
|
+
// Remove the cancel handler after successful auth
|
|
2237
|
+
process.off("SIGINT", cancelHandler);
|
|
2238
|
+
|
|
2239
|
+
// Step 5: Exchange code for token using fetch
|
|
2240
|
+
console.log("\nExchanging authorization code for API token...");
|
|
2241
|
+
const exchangeData = JSON.stringify({
|
|
2242
|
+
authorization_code: code,
|
|
2243
|
+
code_verifier: params.codeVerifier,
|
|
2244
|
+
state: params.state,
|
|
2245
|
+
});
|
|
2246
|
+
const exchangeUrl = new URL(`${apiBaseUrl}/rpc/oauth_token_exchange`);
|
|
1266
2247
|
|
|
1267
|
-
|
|
1268
|
-
|
|
2248
|
+
let exchangeResponse: Response;
|
|
2249
|
+
try {
|
|
2250
|
+
exchangeResponse = await fetch(exchangeUrl.toString(), {
|
|
2251
|
+
method: "POST",
|
|
2252
|
+
headers: {
|
|
2253
|
+
"Content-Type": "application/json",
|
|
2254
|
+
},
|
|
2255
|
+
body: exchangeData,
|
|
2256
|
+
});
|
|
2257
|
+
} catch (err) {
|
|
2258
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2259
|
+
console.error(`Exchange request failed: ${message}`);
|
|
2260
|
+
process.exit(1);
|
|
2261
|
+
return;
|
|
2262
|
+
}
|
|
1269
2263
|
|
|
1270
|
-
|
|
1271
|
-
// Remove the cancel handler in error case too
|
|
1272
|
-
process.off("SIGINT", cancelHandler);
|
|
2264
|
+
const exchangeBody = await exchangeResponse.text();
|
|
1273
2265
|
|
|
1274
|
-
|
|
2266
|
+
if (!exchangeResponse.ok) {
|
|
2267
|
+
console.error(`Failed to exchange code for token: ${exchangeResponse.status}`);
|
|
1275
2268
|
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
2269
|
+
// Check if response is HTML (common for 404 pages)
|
|
2270
|
+
if (exchangeBody.trim().startsWith("<!") || exchangeBody.trim().startsWith("<html")) {
|
|
2271
|
+
console.error("Error: Received HTML response instead of JSON. This usually means:");
|
|
2272
|
+
console.error(" 1. The API endpoint URL is incorrect");
|
|
2273
|
+
console.error(" 2. The endpoint does not exist (404)");
|
|
2274
|
+
console.error(`\nAPI URL attempted: ${exchangeUrl.toString()}`);
|
|
2275
|
+
console.error("\nPlease verify the --api-base-url parameter.");
|
|
2276
|
+
} else {
|
|
2277
|
+
console.error(exchangeBody);
|
|
2278
|
+
}
|
|
1284
2279
|
|
|
1285
|
-
|
|
1286
|
-
|
|
2280
|
+
process.exit(1);
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
try {
|
|
2285
|
+
const result = JSON.parse(exchangeBody);
|
|
2286
|
+
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.
|
|
2287
|
+
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.
|
|
2288
|
+
|
|
2289
|
+
// Step 6: Save token to config
|
|
2290
|
+
// Check if org changed to decide whether to preserve defaultProject
|
|
2291
|
+
const existingConfig = config.readConfig();
|
|
2292
|
+
const existingOrgId = existingConfig.orgId;
|
|
2293
|
+
const existingProject = existingConfig.defaultProject;
|
|
2294
|
+
const orgChanged = existingOrgId && existingOrgId !== orgId;
|
|
2295
|
+
|
|
2296
|
+
config.writeConfig({
|
|
2297
|
+
apiKey: apiToken,
|
|
2298
|
+
baseUrl: apiBaseUrl,
|
|
2299
|
+
orgId: orgId,
|
|
1287
2300
|
});
|
|
2301
|
+
|
|
2302
|
+
// Only clear defaultProject if org actually changed
|
|
2303
|
+
if (orgChanged && existingProject) {
|
|
2304
|
+
config.deleteConfigKeys(["defaultProject"]);
|
|
2305
|
+
console.log(`\nNote: Organization changed (${existingOrgId} → ${orgId}).`);
|
|
2306
|
+
console.log(` Default project "${existingProject}" has been cleared.`);
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
console.log("\nAuthentication successful!");
|
|
2310
|
+
console.log(`API key saved to: ${config.getConfigPath()}`);
|
|
2311
|
+
console.log(`Organization ID: ${orgId}`);
|
|
2312
|
+
if (!orgChanged && existingProject) {
|
|
2313
|
+
console.log(`Default project: ${existingProject} (preserved)`);
|
|
2314
|
+
}
|
|
2315
|
+
console.log(`\nYou can now use the CLI without specifying an API key.`);
|
|
2316
|
+
process.exit(0);
|
|
2317
|
+
} catch (err) {
|
|
2318
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2319
|
+
console.error(`Failed to parse response: ${message}`);
|
|
2320
|
+
process.exit(1);
|
|
1288
2321
|
}
|
|
1289
|
-
);
|
|
1290
2322
|
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
process.exit(1);
|
|
1295
|
-
});
|
|
2323
|
+
} catch (err) {
|
|
2324
|
+
// Remove the cancel handler in error case too
|
|
2325
|
+
process.off("SIGINT", cancelHandler);
|
|
1296
2326
|
|
|
1297
|
-
|
|
1298
|
-
|
|
2327
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2328
|
+
|
|
2329
|
+
// Provide more helpful error messages
|
|
2330
|
+
if (message.includes("timeout")) {
|
|
2331
|
+
console.error(`\nAuthentication timed out.`);
|
|
2332
|
+
console.error(`This usually means you closed the browser window without completing authentication.`);
|
|
2333
|
+
console.error(`Please try again and complete the authentication flow.`);
|
|
2334
|
+
} else {
|
|
2335
|
+
console.error(`\nAuthentication failed: ${message}`);
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
process.exit(1);
|
|
2339
|
+
}
|
|
1299
2340
|
|
|
1300
2341
|
} catch (err) {
|
|
1301
2342
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1304,15 +2345,7 @@ program
|
|
|
1304
2345
|
}
|
|
1305
2346
|
});
|
|
1306
2347
|
|
|
1307
|
-
|
|
1308
|
-
.command("add-key <apiKey>")
|
|
1309
|
-
.description("store API key")
|
|
1310
|
-
.action(async (apiKey: string) => {
|
|
1311
|
-
config.writeConfig({ apiKey });
|
|
1312
|
-
console.log(`API key saved to ${config.getConfigPath()}`);
|
|
1313
|
-
});
|
|
1314
|
-
|
|
1315
|
-
program
|
|
2348
|
+
auth
|
|
1316
2349
|
.command("show-key")
|
|
1317
2350
|
.description("show API key (masked)")
|
|
1318
2351
|
.action(async () => {
|
|
@@ -1322,7 +2355,6 @@ program
|
|
|
1322
2355
|
console.log(`\nTo authenticate, run: pgai auth`);
|
|
1323
2356
|
return;
|
|
1324
2357
|
}
|
|
1325
|
-
const { maskSecret } = require("../lib/util");
|
|
1326
2358
|
console.log(`Current API key: ${maskSecret(cfg.apiKey)}`);
|
|
1327
2359
|
if (cfg.orgId) {
|
|
1328
2360
|
console.log(`Organization ID: ${cfg.orgId}`);
|
|
@@ -1330,14 +2362,20 @@ program
|
|
|
1330
2362
|
console.log(`Config location: ${config.getConfigPath()}`);
|
|
1331
2363
|
});
|
|
1332
2364
|
|
|
1333
|
-
|
|
2365
|
+
auth
|
|
1334
2366
|
.command("remove-key")
|
|
1335
2367
|
.description("remove API key")
|
|
1336
2368
|
.action(async () => {
|
|
1337
2369
|
// Check both new config and legacy config
|
|
1338
2370
|
const newConfigPath = config.getConfigPath();
|
|
1339
2371
|
const hasNewConfig = fs.existsSync(newConfigPath);
|
|
1340
|
-
|
|
2372
|
+
let legacyPath: string;
|
|
2373
|
+
try {
|
|
2374
|
+
const { projectDir } = await resolveOrInitPaths();
|
|
2375
|
+
legacyPath = path.resolve(projectDir, ".pgwatch-config");
|
|
2376
|
+
} catch {
|
|
2377
|
+
legacyPath = path.resolve(process.cwd(), ".pgwatch-config");
|
|
2378
|
+
}
|
|
1341
2379
|
const hasLegacyConfig = fs.existsSync(legacyPath) && fs.statSync(legacyPath).isFile();
|
|
1342
2380
|
|
|
1343
2381
|
if (!hasNewConfig && !hasLegacyConfig) {
|
|
@@ -1373,7 +2411,8 @@ mon
|
|
|
1373
2411
|
.command("generate-grafana-password")
|
|
1374
2412
|
.description("generate Grafana password for monitoring services")
|
|
1375
2413
|
.action(async () => {
|
|
1376
|
-
const
|
|
2414
|
+
const { projectDir } = await resolveOrInitPaths();
|
|
2415
|
+
const cfgPath = path.resolve(projectDir, ".pgwatch-config");
|
|
1377
2416
|
|
|
1378
2417
|
try {
|
|
1379
2418
|
// Generate secure password using openssl
|
|
@@ -1424,9 +2463,10 @@ mon
|
|
|
1424
2463
|
.command("show-grafana-credentials")
|
|
1425
2464
|
.description("show Grafana credentials for monitoring services")
|
|
1426
2465
|
.action(async () => {
|
|
1427
|
-
const
|
|
2466
|
+
const { projectDir } = await resolveOrInitPaths();
|
|
2467
|
+
const cfgPath = path.resolve(projectDir, ".pgwatch-config");
|
|
1428
2468
|
if (!fs.existsSync(cfgPath)) {
|
|
1429
|
-
console.error("Configuration file not found. Run 'postgres-ai mon
|
|
2469
|
+
console.error("Configuration file not found. Run 'postgres-ai mon local-install' first.");
|
|
1430
2470
|
process.exitCode = 1;
|
|
1431
2471
|
return;
|
|
1432
2472
|
}
|
|
@@ -1552,7 +2592,7 @@ issues
|
|
|
1552
2592
|
});
|
|
1553
2593
|
|
|
1554
2594
|
issues
|
|
1555
|
-
.command("
|
|
2595
|
+
.command("post-comment <issueId> <content>")
|
|
1556
2596
|
.description("post a new comment to an issue")
|
|
1557
2597
|
.option("--parent <uuid>", "parent comment id")
|
|
1558
2598
|
.option("--debug", "enable debug output")
|
|
@@ -1597,6 +2637,194 @@ issues
|
|
|
1597
2637
|
}
|
|
1598
2638
|
});
|
|
1599
2639
|
|
|
2640
|
+
issues
|
|
2641
|
+
.command("create <title>")
|
|
2642
|
+
.description("create a new issue")
|
|
2643
|
+
.option("--org-id <id>", "organization id (defaults to config orgId)", (v) => parseInt(v, 10))
|
|
2644
|
+
.option("--project-id <id>", "project id", (v) => parseInt(v, 10))
|
|
2645
|
+
.option("--description <text>", "issue description (supports \\\\n)")
|
|
2646
|
+
.option(
|
|
2647
|
+
"--label <label>",
|
|
2648
|
+
"issue label (repeatable)",
|
|
2649
|
+
(value: string, previous: string[]) => {
|
|
2650
|
+
previous.push(value);
|
|
2651
|
+
return previous;
|
|
2652
|
+
},
|
|
2653
|
+
[] as string[]
|
|
2654
|
+
)
|
|
2655
|
+
.option("--debug", "enable debug output")
|
|
2656
|
+
.option("--json", "output raw JSON")
|
|
2657
|
+
.action(async (rawTitle: string, opts: { orgId?: number; projectId?: number; description?: string; label?: string[]; debug?: boolean; json?: boolean }) => {
|
|
2658
|
+
try {
|
|
2659
|
+
const rootOpts = program.opts<CliOptions>();
|
|
2660
|
+
const cfg = config.readConfig();
|
|
2661
|
+
const { apiKey } = getConfig(rootOpts);
|
|
2662
|
+
if (!apiKey) {
|
|
2663
|
+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
2664
|
+
process.exitCode = 1;
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
const title = interpretEscapes(String(rawTitle || "").trim());
|
|
2669
|
+
if (!title) {
|
|
2670
|
+
console.error("title is required");
|
|
2671
|
+
process.exitCode = 1;
|
|
2672
|
+
return;
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
const orgId = typeof opts.orgId === "number" && !Number.isNaN(opts.orgId) ? opts.orgId : cfg.orgId;
|
|
2676
|
+
if (typeof orgId !== "number") {
|
|
2677
|
+
console.error("org_id is required. Either pass --org-id or run 'pgai auth' to store it in config.");
|
|
2678
|
+
process.exitCode = 1;
|
|
2679
|
+
return;
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
|
|
2683
|
+
const labels = Array.isArray(opts.label) && opts.label.length > 0 ? opts.label.map(String) : undefined;
|
|
2684
|
+
const projectId = typeof opts.projectId === "number" && !Number.isNaN(opts.projectId) ? opts.projectId : undefined;
|
|
2685
|
+
|
|
2686
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
2687
|
+
const result = await createIssue({
|
|
2688
|
+
apiKey,
|
|
2689
|
+
apiBaseUrl,
|
|
2690
|
+
title,
|
|
2691
|
+
orgId,
|
|
2692
|
+
description,
|
|
2693
|
+
projectId,
|
|
2694
|
+
labels,
|
|
2695
|
+
debug: !!opts.debug,
|
|
2696
|
+
});
|
|
2697
|
+
printResult(result, opts.json);
|
|
2698
|
+
} catch (err) {
|
|
2699
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2700
|
+
console.error(message);
|
|
2701
|
+
process.exitCode = 1;
|
|
2702
|
+
}
|
|
2703
|
+
});
|
|
2704
|
+
|
|
2705
|
+
issues
|
|
2706
|
+
.command("update <issueId>")
|
|
2707
|
+
.description("update an existing issue (title/description/status/labels)")
|
|
2708
|
+
.option("--title <text>", "new title (supports \\\\n)")
|
|
2709
|
+
.option("--description <text>", "new description (supports \\\\n)")
|
|
2710
|
+
.option("--status <value>", "status: open|closed|0|1")
|
|
2711
|
+
.option(
|
|
2712
|
+
"--label <label>",
|
|
2713
|
+
"set labels (repeatable). If provided, replaces existing labels.",
|
|
2714
|
+
(value: string, previous: string[]) => {
|
|
2715
|
+
previous.push(value);
|
|
2716
|
+
return previous;
|
|
2717
|
+
},
|
|
2718
|
+
[] as string[]
|
|
2719
|
+
)
|
|
2720
|
+
.option("--clear-labels", "set labels to an empty list")
|
|
2721
|
+
.option("--debug", "enable debug output")
|
|
2722
|
+
.option("--json", "output raw JSON")
|
|
2723
|
+
.action(async (issueId: string, opts: { title?: string; description?: string; status?: string; label?: string[]; clearLabels?: boolean; debug?: boolean; json?: boolean }) => {
|
|
2724
|
+
try {
|
|
2725
|
+
const rootOpts = program.opts<CliOptions>();
|
|
2726
|
+
const cfg = config.readConfig();
|
|
2727
|
+
const { apiKey } = getConfig(rootOpts);
|
|
2728
|
+
if (!apiKey) {
|
|
2729
|
+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
2730
|
+
process.exitCode = 1;
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2734
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
2735
|
+
|
|
2736
|
+
const title = opts.title !== undefined ? interpretEscapes(String(opts.title)) : undefined;
|
|
2737
|
+
const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
|
|
2738
|
+
|
|
2739
|
+
let status: number | undefined = undefined;
|
|
2740
|
+
if (opts.status !== undefined) {
|
|
2741
|
+
const raw = String(opts.status).trim().toLowerCase();
|
|
2742
|
+
if (raw === "open") status = 0;
|
|
2743
|
+
else if (raw === "closed") status = 1;
|
|
2744
|
+
else {
|
|
2745
|
+
const n = Number(raw);
|
|
2746
|
+
if (!Number.isFinite(n)) {
|
|
2747
|
+
console.error("status must be open|closed|0|1");
|
|
2748
|
+
process.exitCode = 1;
|
|
2749
|
+
return;
|
|
2750
|
+
}
|
|
2751
|
+
status = n;
|
|
2752
|
+
}
|
|
2753
|
+
if (status !== 0 && status !== 1) {
|
|
2754
|
+
console.error("status must be 0 (open) or 1 (closed)");
|
|
2755
|
+
process.exitCode = 1;
|
|
2756
|
+
return;
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
let labels: string[] | undefined = undefined;
|
|
2761
|
+
if (opts.clearLabels) {
|
|
2762
|
+
labels = [];
|
|
2763
|
+
} else if (Array.isArray(opts.label) && opts.label.length > 0) {
|
|
2764
|
+
labels = opts.label.map(String);
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
const result = await updateIssue({
|
|
2768
|
+
apiKey,
|
|
2769
|
+
apiBaseUrl,
|
|
2770
|
+
issueId,
|
|
2771
|
+
title,
|
|
2772
|
+
description,
|
|
2773
|
+
status,
|
|
2774
|
+
labels,
|
|
2775
|
+
debug: !!opts.debug,
|
|
2776
|
+
});
|
|
2777
|
+
printResult(result, opts.json);
|
|
2778
|
+
} catch (err) {
|
|
2779
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2780
|
+
console.error(message);
|
|
2781
|
+
process.exitCode = 1;
|
|
2782
|
+
}
|
|
2783
|
+
});
|
|
2784
|
+
|
|
2785
|
+
issues
|
|
2786
|
+
.command("update-comment <commentId> <content>")
|
|
2787
|
+
.description("update an existing issue comment")
|
|
2788
|
+
.option("--debug", "enable debug output")
|
|
2789
|
+
.option("--json", "output raw JSON")
|
|
2790
|
+
.action(async (commentId: string, content: string, opts: { debug?: boolean; json?: boolean }) => {
|
|
2791
|
+
try {
|
|
2792
|
+
if (opts.debug) {
|
|
2793
|
+
// eslint-disable-next-line no-console
|
|
2794
|
+
console.log(`Debug: Original content: ${JSON.stringify(content)}`);
|
|
2795
|
+
}
|
|
2796
|
+
content = interpretEscapes(content);
|
|
2797
|
+
if (opts.debug) {
|
|
2798
|
+
// eslint-disable-next-line no-console
|
|
2799
|
+
console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
const rootOpts = program.opts<CliOptions>();
|
|
2803
|
+
const cfg = config.readConfig();
|
|
2804
|
+
const { apiKey } = getConfig(rootOpts);
|
|
2805
|
+
if (!apiKey) {
|
|
2806
|
+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
2807
|
+
process.exitCode = 1;
|
|
2808
|
+
return;
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
2812
|
+
|
|
2813
|
+
const result = await updateIssueComment({
|
|
2814
|
+
apiKey,
|
|
2815
|
+
apiBaseUrl,
|
|
2816
|
+
commentId,
|
|
2817
|
+
content,
|
|
2818
|
+
debug: !!opts.debug,
|
|
2819
|
+
});
|
|
2820
|
+
printResult(result, opts.json);
|
|
2821
|
+
} catch (err) {
|
|
2822
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2823
|
+
console.error(message);
|
|
2824
|
+
process.exitCode = 1;
|
|
2825
|
+
}
|
|
2826
|
+
});
|
|
2827
|
+
|
|
1600
2828
|
// MCP server
|
|
1601
2829
|
const mcp = program.command("mcp").description("MCP server integration");
|
|
1602
2830
|
|
|
@@ -1624,15 +2852,7 @@ mcp
|
|
|
1624
2852
|
console.log(" 4. Codex");
|
|
1625
2853
|
console.log("");
|
|
1626
2854
|
|
|
1627
|
-
const
|
|
1628
|
-
input: process.stdin,
|
|
1629
|
-
output: process.stdout
|
|
1630
|
-
});
|
|
1631
|
-
|
|
1632
|
-
const answer = await new Promise<string>((resolve) => {
|
|
1633
|
-
rl.question("Select your AI coding tool (1-4): ", resolve);
|
|
1634
|
-
});
|
|
1635
|
-
rl.close();
|
|
2855
|
+
const answer = await question("Select your AI coding tool (1-4): ");
|
|
1636
2856
|
|
|
1637
2857
|
const choices: Record<string, string> = {
|
|
1638
2858
|
"1": "cursor",
|
|
@@ -1767,5 +2987,7 @@ mcp
|
|
|
1767
2987
|
}
|
|
1768
2988
|
});
|
|
1769
2989
|
|
|
1770
|
-
program.parseAsync(process.argv)
|
|
2990
|
+
program.parseAsync(process.argv).finally(() => {
|
|
2991
|
+
closeReadline();
|
|
2992
|
+
});
|
|
1771
2993
|
|