postgresai 0.14.0-beta.1 → 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 +104 -61
- package/bin/postgres-ai.ts +1304 -417
- package/bun.lock +258 -0
- package/bunfig.toml +20 -0
- package/dist/bin/postgres-ai.js +28559 -1778
- 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 +415 -220
- 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 -10
- package/packages/postgres-ai/README.md +26 -0
- package/packages/postgres-ai/bin/postgres-ai.js +27 -0
- package/packages/postgres-ai/package.json +27 -0
- package/scripts/embed-metrics.ts +154 -0
- package/sql/01.role.sql +8 -7
- package/sql/02.permissions.sql +9 -5
- 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/init.d.ts +0 -75
- package/dist/lib/init.d.ts.map +0 -1
- package/dist/lib/init.js +0 -483
- package/dist/lib/init.js.map +0 -1
- package/dist/lib/issues.d.ts +0 -75
- package/dist/lib/issues.d.ts.map +0 -1
- package/dist/lib/issues.js +0 -336
- package/dist/lib/issues.js.map +0 -1
- package/dist/lib/mcp-server.d.ts +0 -9
- package/dist/lib/mcp-server.d.ts.map +0 -1
- package/dist/lib/mcp-server.js +0 -168
- package/dist/lib/mcp-server.js.map +0 -1
- package/dist/lib/pkce.d.ts +0 -32
- package/dist/lib/pkce.d.ts.map +0 -1
- package/dist/lib/pkce.js +0 -101
- package/dist/lib/pkce.js.map +0 -1
- package/dist/lib/util.d.ts +0 -27
- package/dist/lib/util.d.ts.map +0 -1
- package/dist/lib/util.js +0 -46
- package/dist/lib/util.js.map +0 -1
- package/dist/package.json +0 -46
- package/test/init.integration.test.cjs +0 -368
- package/test/init.test.cjs +0 -154
package/bin/postgres-ai.ts
CHANGED
|
@@ -1,25 +1,365 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
import { Command } from "commander";
|
|
4
|
-
import
|
|
4
|
+
import pkg from "../package.json";
|
|
5
5
|
import * as config from "../lib/config";
|
|
6
6
|
import * as yaml from "js-yaml";
|
|
7
7
|
import * as fs from "fs";
|
|
8
8
|
import * as path from "path";
|
|
9
9
|
import * as os from "os";
|
|
10
|
-
import
|
|
11
|
-
import { promisify } from "util";
|
|
12
|
-
import * as readline from "readline";
|
|
13
|
-
import * as http from "https";
|
|
14
|
-
import { URL } from "url";
|
|
10
|
+
import * as crypto from "node:crypto";
|
|
15
11
|
import { Client } from "pg";
|
|
16
12
|
import { startMcpServer } from "../lib/mcp-server";
|
|
17
|
-
import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue } from "../lib/issues";
|
|
13
|
+
import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment } from "../lib/issues";
|
|
18
14
|
import { resolveBaseUrls } from "../lib/util";
|
|
19
|
-
import { applyInitPlan, buildInitPlan, resolveAdminConnection, resolveMonitoringPassword, verifyInitSetup } from "../lib/init";
|
|
15
|
+
import { applyInitPlan, buildInitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, verifyInitSetup } from "../lib/init";
|
|
16
|
+
import * as pkce from "../lib/pkce";
|
|
17
|
+
import * as authServer from "../lib/auth-server";
|
|
18
|
+
import { maskSecret } from "../lib/util";
|
|
19
|
+
import { createInterface } from "readline";
|
|
20
|
+
import * as childProcess from "child_process";
|
|
21
|
+
import { REPORT_GENERATORS, CHECK_INFO, generateAllReports } from "../lib/checkup";
|
|
22
|
+
import { createCheckupReport, uploadCheckupReportJson, RpcError, formatRpcErrorForDisplay, withRetry } from "../lib/checkup-api";
|
|
23
|
+
|
|
24
|
+
// Singleton readline interface for stdin prompts
|
|
25
|
+
let rl: ReturnType<typeof createInterface> | null = null;
|
|
26
|
+
function getReadline() {
|
|
27
|
+
if (!rl) {
|
|
28
|
+
rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
29
|
+
}
|
|
30
|
+
return rl;
|
|
31
|
+
}
|
|
32
|
+
function closeReadline() {
|
|
33
|
+
if (rl) {
|
|
34
|
+
rl.close();
|
|
35
|
+
rl = null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Helper functions for spawning processes - use Node.js child_process for compatibility
|
|
40
|
+
async function execPromise(command: string): Promise<{ stdout: string; stderr: string }> {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
childProcess.exec(command, (error, stdout, stderr) => {
|
|
43
|
+
if (error) {
|
|
44
|
+
const err = error as Error & { code: number };
|
|
45
|
+
err.code = 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
|
+
}
|
|
67
|
+
|
|
68
|
+
function spawnSync(cmd: string, args: string[], options?: { stdio?: "pipe" | "ignore" | "inherit"; encoding?: string; env?: Record<string, string | undefined>; cwd?: string }): { status: number | null; stdout: string; stderr: string } {
|
|
69
|
+
const result = childProcess.spawnSync(cmd, args, {
|
|
70
|
+
stdio: options?.stdio === "inherit" ? "inherit" : "pipe",
|
|
71
|
+
env: options?.env as NodeJS.ProcessEnv,
|
|
72
|
+
cwd: options?.cwd,
|
|
73
|
+
encoding: "utf8",
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
status: result.status,
|
|
77
|
+
stdout: typeof result.stdout === "string" ? result.stdout : "",
|
|
78
|
+
stderr: typeof result.stderr === "string" ? result.stderr : "",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function spawn(cmd: string, args: string[], options?: { stdio?: "pipe" | "ignore" | "inherit"; env?: Record<string, string | undefined>; cwd?: string; detached?: boolean }): { on: (event: string, cb: (code: number | null, signal?: string) => void) => void; unref: () => void; pid?: number } {
|
|
83
|
+
const proc = childProcess.spawn(cmd, args, {
|
|
84
|
+
stdio: options?.stdio ?? "pipe",
|
|
85
|
+
env: options?.env as NodeJS.ProcessEnv,
|
|
86
|
+
cwd: options?.cwd,
|
|
87
|
+
detached: options?.detached,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
on(event: string, cb: (code: number | null, signal?: string) => void) {
|
|
92
|
+
if (event === "close" || event === "exit") {
|
|
93
|
+
proc.on(event, (code, signal) => cb(code, signal ?? undefined));
|
|
94
|
+
} else if (event === "error") {
|
|
95
|
+
proc.on("error", (err) => cb(null, String(err)));
|
|
96
|
+
}
|
|
97
|
+
return this;
|
|
98
|
+
},
|
|
99
|
+
unref() {
|
|
100
|
+
proc.unref();
|
|
101
|
+
},
|
|
102
|
+
pid: proc.pid,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Simple readline-like interface for prompts using Bun
|
|
107
|
+
async function question(prompt: string): Promise<string> {
|
|
108
|
+
return new Promise((resolve) => {
|
|
109
|
+
getReadline().question(prompt, (answer) => {
|
|
110
|
+
resolve(answer);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
20
114
|
|
|
21
|
-
|
|
22
|
-
const
|
|
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
|
+
// ============================================================================
|
|
23
363
|
|
|
24
364
|
/**
|
|
25
365
|
* CLI configuration options
|
|
@@ -61,6 +401,86 @@ interface PathResolution {
|
|
|
61
401
|
instancesFile: string;
|
|
62
402
|
}
|
|
63
403
|
|
|
404
|
+
function getDefaultMonitoringProjectDir(): string {
|
|
405
|
+
const override = process.env.PGAI_PROJECT_DIR;
|
|
406
|
+
if (override && override.trim()) return override.trim();
|
|
407
|
+
// Keep monitoring project next to user-level config (~/.config/postgresai)
|
|
408
|
+
return path.join(config.getConfigDir(), "monitoring");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function downloadText(url: string): Promise<string> {
|
|
412
|
+
const controller = new AbortController();
|
|
413
|
+
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
414
|
+
try {
|
|
415
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
416
|
+
if (!response.ok) {
|
|
417
|
+
throw new Error(`HTTP ${response.status} for ${url}`);
|
|
418
|
+
}
|
|
419
|
+
return await response.text();
|
|
420
|
+
} finally {
|
|
421
|
+
clearTimeout(timeout);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
|
|
426
|
+
const projectDir = getDefaultMonitoringProjectDir();
|
|
427
|
+
const composeFile = path.resolve(projectDir, "docker-compose.yml");
|
|
428
|
+
const instancesFile = path.resolve(projectDir, "instances.yml");
|
|
429
|
+
|
|
430
|
+
if (!fs.existsSync(projectDir)) {
|
|
431
|
+
fs.mkdirSync(projectDir, { recursive: true, mode: 0o700 });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (!fs.existsSync(composeFile)) {
|
|
435
|
+
const refs = [
|
|
436
|
+
process.env.PGAI_PROJECT_REF,
|
|
437
|
+
pkg.version,
|
|
438
|
+
`v${pkg.version}`,
|
|
439
|
+
"main",
|
|
440
|
+
].filter((v): v is string => Boolean(v && v.trim()));
|
|
441
|
+
|
|
442
|
+
let lastErr: unknown;
|
|
443
|
+
for (const ref of refs) {
|
|
444
|
+
const url = `https://gitlab.com/postgres-ai/postgres_ai/-/raw/${encodeURIComponent(ref)}/docker-compose.yml`;
|
|
445
|
+
try {
|
|
446
|
+
const text = await downloadText(url);
|
|
447
|
+
fs.writeFileSync(composeFile, text, { encoding: "utf8", mode: 0o600 });
|
|
448
|
+
break;
|
|
449
|
+
} catch (err) {
|
|
450
|
+
lastErr = err;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (!fs.existsSync(composeFile)) {
|
|
455
|
+
const msg = lastErr instanceof Error ? lastErr.message : String(lastErr);
|
|
456
|
+
throw new Error(`Failed to bootstrap docker-compose.yml: ${msg}`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Ensure instances.yml exists as a FILE (avoid Docker creating a directory)
|
|
461
|
+
if (!fs.existsSync(instancesFile)) {
|
|
462
|
+
const header =
|
|
463
|
+
"# PostgreSQL instances to monitor\n" +
|
|
464
|
+
"# Add your instances using: pgai mon targets add <connection-string> <name>\n\n";
|
|
465
|
+
fs.writeFileSync(instancesFile, header, { encoding: "utf8", mode: 0o600 });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Ensure .pgwatch-config exists as a FILE for reporter (may remain empty)
|
|
469
|
+
const pgwatchConfig = path.resolve(projectDir, ".pgwatch-config");
|
|
470
|
+
if (!fs.existsSync(pgwatchConfig)) {
|
|
471
|
+
fs.writeFileSync(pgwatchConfig, "", { encoding: "utf8", mode: 0o600 });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Ensure .env exists and has PGAI_TAG (compose requires it)
|
|
475
|
+
const envFile = path.resolve(projectDir, ".env");
|
|
476
|
+
if (!fs.existsSync(envFile)) {
|
|
477
|
+
const envText = `PGAI_TAG=${pkg.version}\n# PGAI_REGISTRY=registry.gitlab.com/postgres-ai/postgres_ai\n`;
|
|
478
|
+
fs.writeFileSync(envFile, envText, { encoding: "utf8", mode: 0o600 });
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return { fs, path, projectDir, composeFile, instancesFile };
|
|
482
|
+
}
|
|
483
|
+
|
|
64
484
|
/**
|
|
65
485
|
* Get configuration from various sources
|
|
66
486
|
* @param opts - Command line options
|
|
@@ -119,30 +539,43 @@ program
|
|
|
119
539
|
);
|
|
120
540
|
|
|
121
541
|
program
|
|
122
|
-
.command("
|
|
123
|
-
.description("
|
|
542
|
+
.command("set-default-project <project>")
|
|
543
|
+
.description("store default project for checkup uploads")
|
|
544
|
+
.action(async (project: string) => {
|
|
545
|
+
const value = (project || "").trim();
|
|
546
|
+
if (!value) {
|
|
547
|
+
console.error("Error: project is required");
|
|
548
|
+
process.exitCode = 1;
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
config.writeConfig({ defaultProject: value });
|
|
552
|
+
console.log(`Default project saved: ${value}`);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
program
|
|
556
|
+
.command("prepare-db [conn]")
|
|
557
|
+
.description("prepare database for monitoring: create monitoring user, required view(s), and grant permissions (idempotent)")
|
|
124
558
|
.option("--db-url <url>", "PostgreSQL connection URL (admin) to run the setup against (deprecated; pass it as positional arg)")
|
|
125
559
|
.option("-h, --host <host>", "PostgreSQL host (psql-like)")
|
|
126
560
|
.option("-p, --port <port>", "PostgreSQL port (psql-like)")
|
|
127
561
|
.option("-U, --username <username>", "PostgreSQL user (psql-like)")
|
|
128
562
|
.option("-d, --dbname <dbname>", "PostgreSQL database name (psql-like)")
|
|
129
563
|
.option("--admin-password <password>", "Admin connection password (otherwise uses PGPASSWORD if set)")
|
|
130
|
-
.option("--monitoring-user <name>", "Monitoring role name to create/update",
|
|
564
|
+
.option("--monitoring-user <name>", "Monitoring role name to create/update", DEFAULT_MONITORING_USER)
|
|
131
565
|
.option("--password <password>", "Monitoring role password (overrides PGAI_MON_PASSWORD)")
|
|
132
566
|
.option("--skip-optional-permissions", "Skip optional permissions (RDS/self-managed extras)", false)
|
|
133
567
|
.option("--verify", "Verify that monitoring role/permissions are in place (no changes)", false)
|
|
134
568
|
.option("--reset-password", "Reset monitoring role password only (no other changes)", false)
|
|
135
569
|
.option("--print-sql", "Print SQL plan and exit (no changes applied)", false)
|
|
136
|
-
.option("--show-secrets", "When printing SQL, do not redact secrets (DANGEROUS)", false)
|
|
137
570
|
.option("--print-password", "Print generated monitoring password (DANGEROUS in CI logs)", false)
|
|
138
571
|
.addHelpText(
|
|
139
572
|
"after",
|
|
140
573
|
[
|
|
141
574
|
"",
|
|
142
575
|
"Examples:",
|
|
143
|
-
" postgresai
|
|
144
|
-
" postgresai
|
|
145
|
-
" postgresai
|
|
576
|
+
" postgresai prepare-db postgresql://admin@host:5432/dbname",
|
|
577
|
+
" postgresai prepare-db \"dbname=dbname host=host user=admin\"",
|
|
578
|
+
" postgresai prepare-db -h host -p 5432 -U admin -d dbname",
|
|
146
579
|
"",
|
|
147
580
|
"Admin password:",
|
|
148
581
|
" --admin-password <password> or PGPASSWORD=... (libpq standard)",
|
|
@@ -152,17 +585,28 @@ program
|
|
|
152
585
|
" If auto-generated, it is printed only on TTY by default.",
|
|
153
586
|
" To print it in non-interactive mode: --print-password",
|
|
154
587
|
"",
|
|
588
|
+
"SSL connection (sslmode=prefer behavior):",
|
|
589
|
+
" Tries SSL first, falls back to non-SSL if server doesn't support it.",
|
|
590
|
+
" To force SSL: PGSSLMODE=require or ?sslmode=require in URL",
|
|
591
|
+
" To disable SSL: PGSSLMODE=disable or ?sslmode=disable in URL",
|
|
592
|
+
"",
|
|
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
|
+
"",
|
|
155
599
|
"Inspect SQL without applying changes:",
|
|
156
|
-
" postgresai
|
|
600
|
+
" postgresai prepare-db <conn> --print-sql",
|
|
157
601
|
"",
|
|
158
602
|
"Verify setup (no changes):",
|
|
159
|
-
" postgresai
|
|
603
|
+
" postgresai prepare-db <conn> --verify",
|
|
160
604
|
"",
|
|
161
605
|
"Reset monitoring password only:",
|
|
162
|
-
" postgresai
|
|
606
|
+
" postgresai prepare-db <conn> --reset-password --password '...'",
|
|
163
607
|
"",
|
|
164
608
|
"Offline SQL plan (no DB connection):",
|
|
165
|
-
" postgresai
|
|
609
|
+
" postgresai prepare-db --print-sql",
|
|
166
610
|
].join("\n")
|
|
167
611
|
)
|
|
168
612
|
.action(async (conn: string | undefined, opts: {
|
|
@@ -178,7 +622,6 @@ program
|
|
|
178
622
|
verify?: boolean;
|
|
179
623
|
resetPassword?: boolean;
|
|
180
624
|
printSql?: boolean;
|
|
181
|
-
showSecrets?: boolean;
|
|
182
625
|
printPassword?: boolean;
|
|
183
626
|
}, cmd: Command) => {
|
|
184
627
|
if (opts.verify && opts.resetPassword) {
|
|
@@ -193,30 +636,25 @@ program
|
|
|
193
636
|
}
|
|
194
637
|
|
|
195
638
|
const shouldPrintSql = !!opts.printSql;
|
|
196
|
-
const
|
|
197
|
-
const redactPasswords = (sql: string): string => {
|
|
198
|
-
if (!shouldRedactSecrets) return sql;
|
|
199
|
-
// Replace PASSWORD '<literal>' (handles doubled quotes inside).
|
|
200
|
-
return sql.replace(/password\s+'(?:''|[^'])*'/gi, "password '<redacted>'");
|
|
201
|
-
};
|
|
639
|
+
const redactPasswords = (sql: string): string => redactPasswordsInSql(sql);
|
|
202
640
|
|
|
203
641
|
// Offline mode: allow printing SQL without providing/using an admin connection.
|
|
204
|
-
// Useful for audits/reviews; caller can provide -d/PGDATABASE
|
|
642
|
+
// Useful for audits/reviews; caller can provide -d/PGDATABASE.
|
|
205
643
|
if (!conn && !opts.dbUrl && !opts.host && !opts.port && !opts.username && !opts.adminPassword) {
|
|
206
644
|
if (shouldPrintSql) {
|
|
207
645
|
const database = (opts.dbname ?? process.env.PGDATABASE ?? "postgres").trim();
|
|
208
646
|
const includeOptionalPermissions = !opts.skipOptionalPermissions;
|
|
209
647
|
|
|
210
|
-
// Use explicit password/env if provided; otherwise use a placeholder
|
|
648
|
+
// Use explicit password/env if provided; otherwise use a placeholder.
|
|
649
|
+
// Printed SQL always redacts secrets.
|
|
211
650
|
const monPassword =
|
|
212
|
-
(opts.password ?? process.env.PGAI_MON_PASSWORD ?? "
|
|
651
|
+
(opts.password ?? process.env.PGAI_MON_PASSWORD ?? "<redacted>").toString();
|
|
213
652
|
|
|
214
653
|
const plan = await buildInitPlan({
|
|
215
654
|
database,
|
|
216
655
|
monitoringUser: opts.monitoringUser,
|
|
217
656
|
monitoringPassword: monPassword,
|
|
218
657
|
includeOptionalPermissions,
|
|
219
|
-
roleExists: undefined,
|
|
220
658
|
});
|
|
221
659
|
|
|
222
660
|
console.log("\n--- SQL plan (offline; not connected) ---");
|
|
@@ -228,9 +666,7 @@ program
|
|
|
228
666
|
console.log(redactPasswords(step.sql));
|
|
229
667
|
}
|
|
230
668
|
console.log("\n--- end SQL plan ---\n");
|
|
231
|
-
|
|
232
|
-
console.log("Note: passwords are redacted in the printed SQL (use --show-secrets to print them).");
|
|
233
|
-
}
|
|
669
|
+
console.log("Note: passwords are redacted in the printed SQL output.");
|
|
234
670
|
return;
|
|
235
671
|
}
|
|
236
672
|
}
|
|
@@ -250,7 +686,7 @@ program
|
|
|
250
686
|
});
|
|
251
687
|
} catch (e) {
|
|
252
688
|
const msg = e instanceof Error ? e.message : String(e);
|
|
253
|
-
console.error(
|
|
689
|
+
console.error(`Error: prepare-db: ${msg}`);
|
|
254
690
|
// When connection details are missing, show full init help (options + examples).
|
|
255
691
|
if (typeof msg === "string" && msg.startsWith("Connection is required.")) {
|
|
256
692
|
console.error("");
|
|
@@ -267,15 +703,10 @@ program
|
|
|
267
703
|
console.log(`Optional permissions: ${includeOptionalPermissions ? "enabled" : "skipped"}`);
|
|
268
704
|
|
|
269
705
|
// Use native pg client instead of requiring psql to be installed
|
|
270
|
-
|
|
271
|
-
|
|
706
|
+
let client: Client | undefined;
|
|
272
707
|
try {
|
|
273
|
-
await
|
|
274
|
-
|
|
275
|
-
const roleRes = await client.query("select 1 from pg_catalog.pg_roles where rolname = $1", [
|
|
276
|
-
opts.monitoringUser,
|
|
277
|
-
]);
|
|
278
|
-
const roleExists = (roleRes.rowCount ?? 0) > 0;
|
|
708
|
+
const connResult = await connectWithSslFallback(Client, adminConn);
|
|
709
|
+
client = connResult.client;
|
|
279
710
|
|
|
280
711
|
const dbRes = await client.query("select current_database() as db");
|
|
281
712
|
const database = dbRes.rows?.[0]?.db;
|
|
@@ -291,14 +722,14 @@ program
|
|
|
291
722
|
includeOptionalPermissions,
|
|
292
723
|
});
|
|
293
724
|
if (v.ok) {
|
|
294
|
-
console.log("✓
|
|
725
|
+
console.log("✓ prepare-db verify: OK");
|
|
295
726
|
if (v.missingOptional.length > 0) {
|
|
296
727
|
console.log("⚠ Optional items missing:");
|
|
297
728
|
for (const m of v.missingOptional) console.log(`- ${m}`);
|
|
298
729
|
}
|
|
299
730
|
return;
|
|
300
731
|
}
|
|
301
|
-
console.error("✗
|
|
732
|
+
console.error("✗ prepare-db verify failed: missing required items");
|
|
302
733
|
for (const m of v.missingRequired) console.error(`- ${m}`);
|
|
303
734
|
if (v.missingOptional.length > 0) {
|
|
304
735
|
console.error("Optional items missing:");
|
|
@@ -319,10 +750,13 @@ program
|
|
|
319
750
|
if (resolved.generated) {
|
|
320
751
|
const canPrint = process.stdout.isTTY || !!opts.printPassword;
|
|
321
752
|
if (canPrint) {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
console.
|
|
325
|
-
console.
|
|
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("");
|
|
326
760
|
console.log("Store it securely (or rerun with --password / PGAI_MON_PASSWORD to set your own).");
|
|
327
761
|
} else {
|
|
328
762
|
console.error(
|
|
@@ -352,7 +786,6 @@ program
|
|
|
352
786
|
monitoringUser: opts.monitoringUser,
|
|
353
787
|
monitoringPassword: monPassword,
|
|
354
788
|
includeOptionalPermissions,
|
|
355
|
-
roleExists,
|
|
356
789
|
});
|
|
357
790
|
|
|
358
791
|
const effectivePlan = opts.resetPassword
|
|
@@ -366,15 +799,13 @@ program
|
|
|
366
799
|
console.log(redactPasswords(step.sql));
|
|
367
800
|
}
|
|
368
801
|
console.log("\n--- end SQL plan ---\n");
|
|
369
|
-
|
|
370
|
-
console.log("Note: passwords are redacted in the printed SQL (use --show-secrets to print them).");
|
|
371
|
-
}
|
|
802
|
+
console.log("Note: passwords are redacted in the printed SQL output.");
|
|
372
803
|
return;
|
|
373
804
|
}
|
|
374
805
|
|
|
375
806
|
const { applied, skippedOptional } = await applyInitPlan({ client, plan: effectivePlan });
|
|
376
807
|
|
|
377
|
-
console.log(opts.resetPassword ? "✓
|
|
808
|
+
console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
|
|
378
809
|
if (skippedOptional.length > 0) {
|
|
379
810
|
console.log("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
|
|
380
811
|
for (const s of skippedOptional) console.log(`- ${s}`);
|
|
@@ -396,57 +827,191 @@ program
|
|
|
396
827
|
if (!message || message === "[object Object]") {
|
|
397
828
|
message = "Unknown error";
|
|
398
829
|
}
|
|
399
|
-
console.error(
|
|
830
|
+
console.error(`Error: prepare-db: ${message}`);
|
|
400
831
|
// If this was a plan step failure, surface the step name explicitly to help users diagnose quickly.
|
|
401
832
|
const stepMatch =
|
|
402
833
|
typeof message === "string" ? message.match(/Failed at step "([^"]+)":/i) : null;
|
|
403
834
|
const failedStep = stepMatch?.[1];
|
|
404
835
|
if (failedStep) {
|
|
405
|
-
console.error(`Step: ${failedStep}`);
|
|
836
|
+
console.error(` Step: ${failedStep}`);
|
|
406
837
|
}
|
|
407
838
|
if (errAny && typeof errAny === "object") {
|
|
408
839
|
if (typeof errAny.code === "string" && errAny.code) {
|
|
409
|
-
console.error(`
|
|
840
|
+
console.error(` Code: ${errAny.code}`);
|
|
410
841
|
}
|
|
411
842
|
if (typeof errAny.detail === "string" && errAny.detail) {
|
|
412
|
-
console.error(`Detail: ${errAny.detail}`);
|
|
843
|
+
console.error(` Detail: ${errAny.detail}`);
|
|
413
844
|
}
|
|
414
845
|
if (typeof errAny.hint === "string" && errAny.hint) {
|
|
415
|
-
console.error(`Hint: ${errAny.hint}`);
|
|
846
|
+
console.error(` Hint: ${errAny.hint}`);
|
|
416
847
|
}
|
|
417
848
|
}
|
|
418
849
|
if (errAny && typeof errAny === "object" && typeof errAny.code === "string") {
|
|
419
850
|
if (errAny.code === "42501") {
|
|
420
|
-
console.error("");
|
|
421
|
-
console.error("Permission error: your admin connection is not allowed to complete the setup.");
|
|
422
851
|
if (failedStep === "01.role") {
|
|
423
|
-
console.error("
|
|
852
|
+
console.error(" Context: role creation/update requires CREATEROLE or superuser");
|
|
424
853
|
} else if (failedStep === "02.permissions") {
|
|
425
|
-
console.error("
|
|
854
|
+
console.error(" Context: grants/view/search_path require sufficient GRANT/DDL privileges");
|
|
426
855
|
}
|
|
427
|
-
console.error("
|
|
428
|
-
console.error("
|
|
429
|
-
console.error("
|
|
430
|
-
console.error("Tip: run with --print-sql to review the exact SQL plan.");
|
|
431
|
-
console.error("");
|
|
432
|
-
console.error("Hint: connect as a superuser (or a role with CREATEROLE and sufficient GRANT privileges).");
|
|
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");
|
|
433
859
|
}
|
|
434
860
|
if (errAny.code === "ECONNREFUSED") {
|
|
435
|
-
console.error("Hint: check host/port and ensure Postgres is reachable from this machine
|
|
861
|
+
console.error(" Hint: check host/port and ensure Postgres is reachable from this machine");
|
|
436
862
|
}
|
|
437
863
|
if (errAny.code === "ENOTFOUND") {
|
|
438
|
-
console.error("Hint: DNS resolution failed; double-check the host name
|
|
864
|
+
console.error(" Hint: DNS resolution failed; double-check the host name");
|
|
439
865
|
}
|
|
440
866
|
if (errAny.code === "ETIMEDOUT") {
|
|
441
|
-
console.error("Hint: connection timed out; check network/firewall rules
|
|
867
|
+
console.error(" Hint: connection timed out; check network/firewall rules");
|
|
442
868
|
}
|
|
443
869
|
}
|
|
444
870
|
process.exitCode = 1;
|
|
445
871
|
} finally {
|
|
446
|
-
|
|
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) {
|
|
447
1014
|
await client.end();
|
|
448
|
-
} catch {
|
|
449
|
-
// ignore
|
|
450
1015
|
}
|
|
451
1016
|
}
|
|
452
1017
|
});
|
|
@@ -484,6 +1049,14 @@ function resolvePaths(): PathResolution {
|
|
|
484
1049
|
);
|
|
485
1050
|
}
|
|
486
1051
|
|
|
1052
|
+
async function resolveOrInitPaths(): Promise<PathResolution> {
|
|
1053
|
+
try {
|
|
1054
|
+
return resolvePaths();
|
|
1055
|
+
} catch {
|
|
1056
|
+
return ensureDefaultMonitoringProject();
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
487
1060
|
/**
|
|
488
1061
|
* Check if Docker daemon is running
|
|
489
1062
|
*/
|
|
@@ -531,11 +1104,11 @@ function checkRunningContainers(): { running: boolean; containers: string[] } {
|
|
|
531
1104
|
/**
|
|
532
1105
|
* Run docker compose command
|
|
533
1106
|
*/
|
|
534
|
-
async function runCompose(args: string[]): Promise<number> {
|
|
1107
|
+
async function runCompose(args: string[], grafanaPassword?: string): Promise<number> {
|
|
535
1108
|
let composeFile: string;
|
|
536
1109
|
let projectDir: string;
|
|
537
1110
|
try {
|
|
538
|
-
({ composeFile, projectDir } =
|
|
1111
|
+
({ composeFile, projectDir } = await resolveOrInitPaths());
|
|
539
1112
|
} catch (error) {
|
|
540
1113
|
const message = error instanceof Error ? error.message : String(error);
|
|
541
1114
|
console.error(message);
|
|
@@ -557,28 +1130,42 @@ async function runCompose(args: string[]): Promise<number> {
|
|
|
557
1130
|
return 1;
|
|
558
1131
|
}
|
|
559
1132
|
|
|
560
|
-
//
|
|
1133
|
+
// Set Grafana password from parameter or .pgwatch-config
|
|
561
1134
|
const env = { ...process.env };
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
const
|
|
569
|
-
if (
|
|
570
|
-
|
|
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)}`);
|
|
571
1153
|
}
|
|
572
1154
|
}
|
|
573
|
-
} catch (err) {
|
|
574
|
-
// If we can't read the config, continue without setting the password
|
|
575
1155
|
}
|
|
576
1156
|
}
|
|
577
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
|
+
|
|
578
1164
|
return new Promise<number>((resolve) => {
|
|
579
|
-
const child = spawn(cmd[0], [...cmd.slice(1), "-f", composeFile, ...
|
|
1165
|
+
const child = spawn(cmd[0], [...cmd.slice(1), "-f", composeFile, ...finalArgs], {
|
|
580
1166
|
stdio: "inherit",
|
|
581
|
-
env: env
|
|
1167
|
+
env: env,
|
|
1168
|
+
cwd: projectDir
|
|
582
1169
|
});
|
|
583
1170
|
child.on("close", (code) => resolve(code || 0));
|
|
584
1171
|
});
|
|
@@ -592,18 +1179,64 @@ program.command("help", { isDefault: true }).description("show help").action(()
|
|
|
592
1179
|
const mon = program.command("mon").description("monitoring services management");
|
|
593
1180
|
|
|
594
1181
|
mon
|
|
595
|
-
.command("
|
|
596
|
-
.description("
|
|
1182
|
+
.command("local-install")
|
|
1183
|
+
.description("install local monitoring stack (generate config, start services)")
|
|
597
1184
|
.option("--demo", "demo mode with sample database", false)
|
|
598
1185
|
.option("--api-key <key>", "Postgres AI API key for automated report uploads")
|
|
599
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)")
|
|
600
1188
|
.option("-y, --yes", "accept all defaults and skip interactive prompts", false)
|
|
601
|
-
.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
|
+
|
|
602
1195
|
console.log("\n=================================");
|
|
603
|
-
console.log(" PostgresAI
|
|
1196
|
+
console.log(" PostgresAI monitoring local install");
|
|
604
1197
|
console.log("=================================\n");
|
|
605
1198
|
console.log("This will install, configure, and start the monitoring system\n");
|
|
606
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
|
+
|
|
607
1240
|
// Validate conflicting options
|
|
608
1241
|
if (opts.demo && opts.dbUrl) {
|
|
609
1242
|
console.log("⚠ Both --demo and --db-url provided. Demo mode includes its own database.");
|
|
@@ -611,11 +1244,11 @@ mon
|
|
|
611
1244
|
opts.dbUrl = undefined;
|
|
612
1245
|
}
|
|
613
1246
|
|
|
614
|
-
if (opts.demo &&
|
|
1247
|
+
if (opts.demo && apiKey) {
|
|
615
1248
|
console.error("✗ Cannot use --api-key with --demo mode");
|
|
616
1249
|
console.error("✗ Demo mode is for testing only and does not support API key integration");
|
|
617
|
-
console.error("\nUse demo mode without API key: postgres-ai mon
|
|
618
|
-
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");
|
|
619
1252
|
process.exitCode = 1;
|
|
620
1253
|
return;
|
|
621
1254
|
}
|
|
@@ -633,9 +1266,14 @@ mon
|
|
|
633
1266
|
console.log("Step 1: Postgres AI API Configuration (Optional)");
|
|
634
1267
|
console.log("An API key enables automatic upload of PostgreSQL reports to Postgres AI\n");
|
|
635
1268
|
|
|
636
|
-
if (
|
|
1269
|
+
if (apiKey) {
|
|
637
1270
|
console.log("Using API key provided via --api-key parameter");
|
|
638
|
-
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
|
+
});
|
|
639
1277
|
console.log("✓ API key saved\n");
|
|
640
1278
|
} else if (opts.yes) {
|
|
641
1279
|
// Auto-yes mode without API key - skip API key setup
|
|
@@ -643,43 +1281,36 @@ mon
|
|
|
643
1281
|
console.log("⚠ Reports will be generated locally only");
|
|
644
1282
|
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
645
1283
|
} else {
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
config.writeConfig({ apiKey: trimmedKey });
|
|
665
|
-
console.log("✓ API key saved\n");
|
|
666
|
-
break;
|
|
667
|
-
}
|
|
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
|
+
}
|
|
668
1302
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
}
|
|
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;
|
|
676
1309
|
}
|
|
677
|
-
} else {
|
|
678
|
-
console.log("⚠ Skipping API key setup - reports will be generated locally only");
|
|
679
|
-
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
680
1310
|
}
|
|
681
|
-
}
|
|
682
|
-
|
|
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");
|
|
683
1314
|
}
|
|
684
1315
|
}
|
|
685
1316
|
} else {
|
|
@@ -692,9 +1323,11 @@ mon
|
|
|
692
1323
|
console.log("Step 2: Add PostgreSQL Instance to Monitor\n");
|
|
693
1324
|
|
|
694
1325
|
// Clear instances.yml in production mode (start fresh)
|
|
695
|
-
const instancesPath =
|
|
1326
|
+
const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
|
|
696
1327
|
const emptyInstancesContent = "# PostgreSQL instances to monitor\n# Add your instances using: postgres-ai mon targets add\n\n";
|
|
697
1328
|
fs.writeFileSync(instancesPath, emptyInstancesContent, "utf8");
|
|
1329
|
+
console.log(`Instances file: ${instancesPath}`);
|
|
1330
|
+
console.log(`Project directory: ${projectDir}\n`);
|
|
698
1331
|
|
|
699
1332
|
if (opts.dbUrl) {
|
|
700
1333
|
console.log("Using database URL provided via --db-url parameter");
|
|
@@ -723,7 +1356,6 @@ mon
|
|
|
723
1356
|
// Test connection
|
|
724
1357
|
console.log("Testing connection to the added instance...");
|
|
725
1358
|
try {
|
|
726
|
-
const { Client } = require("pg");
|
|
727
1359
|
const client = new Client({ connectionString: connStr });
|
|
728
1360
|
await client.connect();
|
|
729
1361
|
const result = await client.query("select version();");
|
|
@@ -740,63 +1372,50 @@ mon
|
|
|
740
1372
|
console.log("⚠ No PostgreSQL instance added");
|
|
741
1373
|
console.log("You can add one later with: postgres-ai mon targets add\n");
|
|
742
1374
|
} else {
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
console.log(" 2. Press Enter to skip for now\n");
|
|
760
|
-
|
|
761
|
-
const connStr = await question("Enter connection string (or press Enter to skip): ");
|
|
762
|
-
|
|
763
|
-
if (connStr.trim()) {
|
|
764
|
-
const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
|
|
765
|
-
if (!m) {
|
|
766
|
-
console.error("✗ Invalid connection string format");
|
|
767
|
-
console.log("⚠ Continuing without adding instance\n");
|
|
768
|
-
} else {
|
|
769
|
-
const host = m[3];
|
|
770
|
-
const db = m[5];
|
|
771
|
-
const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
|
|
772
|
-
|
|
773
|
-
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`;
|
|
774
|
-
fs.appendFileSync(instancesPath, body, "utf8");
|
|
775
|
-
console.log(`✓ Monitoring target '${instanceName}' added\n`);
|
|
776
|
-
|
|
777
|
-
// Test connection
|
|
778
|
-
console.log("Testing connection to the added instance...");
|
|
779
|
-
try {
|
|
780
|
-
const { Client } = require("pg");
|
|
781
|
-
const client = new Client({ connectionString: connStr });
|
|
782
|
-
await client.connect();
|
|
783
|
-
const result = await client.query("select version();");
|
|
784
|
-
console.log("✓ Connection successful");
|
|
785
|
-
console.log(`${result.rows[0].version}\n`);
|
|
786
|
-
await client.end();
|
|
787
|
-
} catch (error) {
|
|
788
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
789
|
-
console.error(`✗ Connection failed: ${message}\n`);
|
|
790
|
-
}
|
|
791
|
-
}
|
|
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");
|
|
792
1391
|
} else {
|
|
793
|
-
|
|
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
|
+
}
|
|
794
1413
|
}
|
|
795
1414
|
} else {
|
|
796
1415
|
console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
|
|
797
1416
|
}
|
|
798
|
-
}
|
|
799
|
-
|
|
1417
|
+
} else {
|
|
1418
|
+
console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
|
|
800
1419
|
}
|
|
801
1420
|
}
|
|
802
1421
|
} else {
|
|
@@ -814,7 +1433,7 @@ mon
|
|
|
814
1433
|
|
|
815
1434
|
// Step 4: Ensure Grafana password is configured
|
|
816
1435
|
console.log(opts.demo ? "Step 4: Configuring Grafana security..." : "Step 4: Configuring Grafana security...");
|
|
817
|
-
const cfgPath = path.resolve(
|
|
1436
|
+
const cfgPath = path.resolve(projectDir, ".pgwatch-config");
|
|
818
1437
|
let grafanaPassword = "";
|
|
819
1438
|
|
|
820
1439
|
try {
|
|
@@ -855,8 +1474,8 @@ mon
|
|
|
855
1474
|
}
|
|
856
1475
|
|
|
857
1476
|
// Step 5: Start services
|
|
858
|
-
console.log(
|
|
859
|
-
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);
|
|
860
1479
|
if (code2 !== 0) {
|
|
861
1480
|
process.exitCode = code2;
|
|
862
1481
|
return;
|
|
@@ -865,7 +1484,7 @@ mon
|
|
|
865
1484
|
|
|
866
1485
|
// Final summary
|
|
867
1486
|
console.log("=================================");
|
|
868
|
-
console.log("
|
|
1487
|
+
console.log(" Local install completed!");
|
|
869
1488
|
console.log("=================================\n");
|
|
870
1489
|
|
|
871
1490
|
console.log("What's running:");
|
|
@@ -914,6 +1533,34 @@ mon
|
|
|
914
1533
|
if (code !== 0) process.exitCode = code;
|
|
915
1534
|
});
|
|
916
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
|
+
|
|
917
1564
|
mon
|
|
918
1565
|
.command("stop")
|
|
919
1566
|
.description("stop monitoring services")
|
|
@@ -982,17 +1629,17 @@ mon
|
|
|
982
1629
|
allHealthy = true;
|
|
983
1630
|
for (const service of services) {
|
|
984
1631
|
try {
|
|
985
|
-
const
|
|
986
|
-
const status =
|
|
987
|
-
encoding: 'utf8',
|
|
988
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
989
|
-
}).trim();
|
|
1632
|
+
const result = spawnSync("docker", ["inspect", "-f", "{{.State.Status}}", service.container], { stdio: "pipe" });
|
|
1633
|
+
const status = result.stdout.trim();
|
|
990
1634
|
|
|
991
|
-
if (status === 'running') {
|
|
1635
|
+
if (result.status === 0 && status === 'running') {
|
|
992
1636
|
console.log(`✓ ${service.name}: healthy`);
|
|
993
|
-
} else {
|
|
1637
|
+
} else if (result.status === 0) {
|
|
994
1638
|
console.log(`✗ ${service.name}: unhealthy (status: ${status})`);
|
|
995
1639
|
allHealthy = false;
|
|
1640
|
+
} else {
|
|
1641
|
+
console.log(`✗ ${service.name}: unreachable`);
|
|
1642
|
+
allHealthy = false;
|
|
996
1643
|
}
|
|
997
1644
|
} catch (error) {
|
|
998
1645
|
console.log(`✗ ${service.name}: unreachable`);
|
|
@@ -1021,7 +1668,7 @@ mon
|
|
|
1021
1668
|
let composeFile: string;
|
|
1022
1669
|
let instancesFile: string;
|
|
1023
1670
|
try {
|
|
1024
|
-
({ projectDir, composeFile, instancesFile } =
|
|
1671
|
+
({ projectDir, composeFile, instancesFile } = await resolveOrInitPaths());
|
|
1025
1672
|
} catch (error) {
|
|
1026
1673
|
const message = error instanceof Error ? error.message : String(error);
|
|
1027
1674
|
console.error(message);
|
|
@@ -1096,14 +1743,6 @@ mon
|
|
|
1096
1743
|
.command("reset [service]")
|
|
1097
1744
|
.description("reset all or specific monitoring service")
|
|
1098
1745
|
.action(async (service?: string) => {
|
|
1099
|
-
const rl = readline.createInterface({
|
|
1100
|
-
input: process.stdin,
|
|
1101
|
-
output: process.stdout,
|
|
1102
|
-
});
|
|
1103
|
-
|
|
1104
|
-
const question = (prompt: string): Promise<string> =>
|
|
1105
|
-
new Promise((resolve) => rl.question(prompt, resolve));
|
|
1106
|
-
|
|
1107
1746
|
try {
|
|
1108
1747
|
if (service) {
|
|
1109
1748
|
// Reset specific service
|
|
@@ -1113,7 +1752,6 @@ mon
|
|
|
1113
1752
|
const answer = await question("Continue? (y/N): ");
|
|
1114
1753
|
if (answer.toLowerCase() !== "y") {
|
|
1115
1754
|
console.log("Cancelled");
|
|
1116
|
-
rl.close();
|
|
1117
1755
|
return;
|
|
1118
1756
|
}
|
|
1119
1757
|
|
|
@@ -1140,7 +1778,6 @@ mon
|
|
|
1140
1778
|
const answer = await question("Continue? (y/N): ");
|
|
1141
1779
|
if (answer.toLowerCase() !== "y") {
|
|
1142
1780
|
console.log("Cancelled");
|
|
1143
|
-
rl.close();
|
|
1144
1781
|
return;
|
|
1145
1782
|
}
|
|
1146
1783
|
|
|
@@ -1154,10 +1791,7 @@ mon
|
|
|
1154
1791
|
process.exitCode = 1;
|
|
1155
1792
|
}
|
|
1156
1793
|
}
|
|
1157
|
-
|
|
1158
|
-
rl.close();
|
|
1159
1794
|
} catch (error) {
|
|
1160
|
-
rl.close();
|
|
1161
1795
|
const message = error instanceof Error ? error.message : String(error);
|
|
1162
1796
|
console.error(`Reset failed: ${message}`);
|
|
1163
1797
|
process.exitCode = 1;
|
|
@@ -1165,34 +1799,72 @@ mon
|
|
|
1165
1799
|
});
|
|
1166
1800
|
mon
|
|
1167
1801
|
.command("clean")
|
|
1168
|
-
.description("cleanup monitoring services artifacts")
|
|
1169
|
-
.
|
|
1170
|
-
|
|
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");
|
|
1171
1806
|
|
|
1172
1807
|
try {
|
|
1173
|
-
//
|
|
1174
|
-
const
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
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");
|
|
1179
1817
|
} else {
|
|
1180
|
-
console.log("
|
|
1818
|
+
console.log("⚠ Could not stop services (may not be running)");
|
|
1181
1819
|
}
|
|
1182
1820
|
|
|
1183
|
-
// Remove
|
|
1184
|
-
await
|
|
1185
|
-
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
|
+
}
|
|
1186
1859
|
|
|
1187
|
-
// Remove
|
|
1860
|
+
// Remove any dangling resources
|
|
1188
1861
|
await execFilePromise("docker", ["network", "prune", "-f"]);
|
|
1189
1862
|
console.log("✓ Removed unused networks");
|
|
1190
1863
|
|
|
1191
|
-
// Remove dangling images
|
|
1192
1864
|
await execFilePromise("docker", ["image", "prune", "-f"]);
|
|
1193
1865
|
console.log("✓ Removed dangling images");
|
|
1194
1866
|
|
|
1195
|
-
console.log("\
|
|
1867
|
+
console.log("\n✓ Cleanup completed - ready for fresh install");
|
|
1196
1868
|
} catch (error) {
|
|
1197
1869
|
const message = error instanceof Error ? error.message : String(error);
|
|
1198
1870
|
console.error(`Error during cleanup: ${message}`);
|
|
@@ -1221,9 +1893,9 @@ targets
|
|
|
1221
1893
|
.command("list")
|
|
1222
1894
|
.description("list monitoring target databases")
|
|
1223
1895
|
.action(async () => {
|
|
1224
|
-
const instancesPath =
|
|
1896
|
+
const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
|
|
1225
1897
|
if (!fs.existsSync(instancesPath)) {
|
|
1226
|
-
console.error(`instances.yml not found in ${
|
|
1898
|
+
console.error(`instances.yml not found in ${projectDir}`);
|
|
1227
1899
|
process.exitCode = 1;
|
|
1228
1900
|
return;
|
|
1229
1901
|
}
|
|
@@ -1270,7 +1942,7 @@ targets
|
|
|
1270
1942
|
.command("add [connStr] [name]")
|
|
1271
1943
|
.description("add monitoring target database")
|
|
1272
1944
|
.action(async (connStr?: string, name?: string) => {
|
|
1273
|
-
const file =
|
|
1945
|
+
const { instancesFile: file } = await resolveOrInitPaths();
|
|
1274
1946
|
if (!connStr) {
|
|
1275
1947
|
console.error("Connection string required: postgresql://user:pass@host:port/db");
|
|
1276
1948
|
process.exitCode = 1;
|
|
@@ -1320,7 +1992,7 @@ targets
|
|
|
1320
1992
|
.command("remove <name>")
|
|
1321
1993
|
.description("remove monitoring target database")
|
|
1322
1994
|
.action(async (name: string) => {
|
|
1323
|
-
const file =
|
|
1995
|
+
const { instancesFile: file } = await resolveOrInitPaths();
|
|
1324
1996
|
if (!fs.existsSync(file)) {
|
|
1325
1997
|
console.error("instances.yml not found");
|
|
1326
1998
|
process.exitCode = 1;
|
|
@@ -1357,7 +2029,7 @@ targets
|
|
|
1357
2029
|
.command("test <name>")
|
|
1358
2030
|
.description("test monitoring target database connectivity")
|
|
1359
2031
|
.action(async (name: string) => {
|
|
1360
|
-
const instancesPath =
|
|
2032
|
+
const { instancesFile: instancesPath } = await resolveOrInitPaths();
|
|
1361
2033
|
if (!fs.existsSync(instancesPath)) {
|
|
1362
2034
|
console.error("instances.yml not found");
|
|
1363
2035
|
process.exitCode = 1;
|
|
@@ -1391,7 +2063,6 @@ targets
|
|
|
1391
2063
|
console.log(`Testing connection to monitoring target '${name}'...`);
|
|
1392
2064
|
|
|
1393
2065
|
// Use native pg client instead of requiring psql to be installed
|
|
1394
|
-
const { Client } = require('pg');
|
|
1395
2066
|
const client = new Client({ connectionString: instance.conn_str });
|
|
1396
2067
|
|
|
1397
2068
|
try {
|
|
@@ -1410,15 +2081,43 @@ targets
|
|
|
1410
2081
|
});
|
|
1411
2082
|
|
|
1412
2083
|
// Authentication and API key management
|
|
1413
|
-
program
|
|
1414
|
-
|
|
1415
|
-
|
|
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")
|
|
1416
2090
|
.option("--port <port>", "local callback server port (default: random)", parseInt)
|
|
1417
2091
|
.option("--debug", "enable debug output")
|
|
1418
|
-
.action(async (opts: { port?: number; debug?: boolean }) => {
|
|
1419
|
-
|
|
1420
|
-
|
|
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
|
+
}
|
|
1421
2119
|
|
|
2120
|
+
// Otherwise, proceed with OAuth flow
|
|
1422
2121
|
console.log("Starting authentication flow...\n");
|
|
1423
2122
|
|
|
1424
2123
|
// Generate PKCE parameters
|
|
@@ -1439,10 +2138,10 @@ program
|
|
|
1439
2138
|
const requestedPort = opts.port || 0; // 0 = OS assigns available port
|
|
1440
2139
|
const callbackServer = authServer.createCallbackServer(requestedPort, params.state, 120000); // 2 minute timeout
|
|
1441
2140
|
|
|
1442
|
-
// Wait
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
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`;
|
|
1446
2145
|
|
|
1447
2146
|
console.log(`Callback server listening on port ${actualPort}`);
|
|
1448
2147
|
|
|
@@ -1464,173 +2163,180 @@ program
|
|
|
1464
2163
|
console.log(`Debug: Request data: ${initData}`);
|
|
1465
2164
|
}
|
|
1466
2165
|
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
2166
|
+
// Step 2: Initialize OAuth session on backend using fetch
|
|
2167
|
+
let initResponse: Response;
|
|
2168
|
+
try {
|
|
2169
|
+
initResponse = await fetch(initUrl.toString(), {
|
|
1470
2170
|
method: "POST",
|
|
1471
2171
|
headers: {
|
|
1472
2172
|
"Content-Type": "application/json",
|
|
1473
|
-
"Content-Length": Buffer.byteLength(initData),
|
|
1474
2173
|
},
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
if (data.trim().startsWith("<!") || data.trim().startsWith("<html")) {
|
|
1485
|
-
console.error("Error: Received HTML response instead of JSON. This usually means:");
|
|
1486
|
-
console.error(" 1. The API endpoint URL is incorrect");
|
|
1487
|
-
console.error(" 2. The endpoint does not exist (404)");
|
|
1488
|
-
console.error(`\nAPI URL attempted: ${initUrl.toString()}`);
|
|
1489
|
-
console.error("\nPlease verify the --api-base-url parameter.");
|
|
1490
|
-
} else {
|
|
1491
|
-
console.error(data);
|
|
1492
|
-
}
|
|
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
|
+
}
|
|
1493
2183
|
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
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
|
+
}
|
|
1497
2198
|
|
|
1498
|
-
|
|
1499
|
-
|
|
2199
|
+
callbackServer.server.stop();
|
|
2200
|
+
process.exit(1);
|
|
2201
|
+
return;
|
|
2202
|
+
}
|
|
1500
2203
|
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
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)}`;
|
|
1504
2207
|
|
|
1505
|
-
|
|
1506
|
-
|
|
2208
|
+
if (opts.debug) {
|
|
2209
|
+
console.log(`Debug: Auth URL: ${authUrl}`);
|
|
2210
|
+
}
|
|
1507
2211
|
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
process.platform === "win32" ? "start" :
|
|
1511
|
-
"xdg-open";
|
|
1512
|
-
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`);
|
|
1513
2214
|
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
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();
|
|
1517
2220
|
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
callbackServer.server.close();
|
|
1522
|
-
process.exit(130); // Standard exit code for SIGINT
|
|
1523
|
-
};
|
|
1524
|
-
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");
|
|
1525
2224
|
|
|
1526
|
-
|
|
1527
|
-
|
|
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);
|
|
1528
2232
|
|
|
1529
|
-
|
|
1530
|
-
|
|
2233
|
+
try {
|
|
2234
|
+
const { code } = await callbackServer.promise;
|
|
1531
2235
|
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
console.error(" 1. The API endpoint URL is incorrect");
|
|
1560
|
-
console.error(" 2. The endpoint does not exist (404)");
|
|
1561
|
-
console.error(`\nAPI URL attempted: ${exchangeUrl.toString()}`);
|
|
1562
|
-
console.error("\nPlease verify the --api-base-url parameter.");
|
|
1563
|
-
} else {
|
|
1564
|
-
console.error(exchangeBody);
|
|
1565
|
-
}
|
|
1566
|
-
|
|
1567
|
-
process.exit(1);
|
|
1568
|
-
return;
|
|
1569
|
-
}
|
|
1570
|
-
|
|
1571
|
-
try {
|
|
1572
|
-
const result = JSON.parse(exchangeBody);
|
|
1573
|
-
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.
|
|
1574
|
-
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.
|
|
1575
|
-
|
|
1576
|
-
// Step 6: Save token to config
|
|
1577
|
-
config.writeConfig({
|
|
1578
|
-
apiKey: apiToken,
|
|
1579
|
-
baseUrl: apiBaseUrl,
|
|
1580
|
-
orgId: orgId,
|
|
1581
|
-
});
|
|
1582
|
-
|
|
1583
|
-
console.log("\nAuthentication successful!");
|
|
1584
|
-
console.log(`API key saved to: ${config.getConfigPath()}`);
|
|
1585
|
-
console.log(`Organization ID: ${orgId}`);
|
|
1586
|
-
console.log(`\nYou can now use the CLI without specifying an API key.`);
|
|
1587
|
-
process.exit(0);
|
|
1588
|
-
} catch (err) {
|
|
1589
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1590
|
-
console.error(`Failed to parse response: ${message}`);
|
|
1591
|
-
process.exit(1);
|
|
1592
|
-
}
|
|
1593
|
-
});
|
|
1594
|
-
}
|
|
1595
|
-
);
|
|
1596
|
-
|
|
1597
|
-
exchangeReq.on("error", (err: Error) => {
|
|
1598
|
-
console.error(`Exchange request failed: ${err.message}`);
|
|
1599
|
-
process.exit(1);
|
|
1600
|
-
});
|
|
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`);
|
|
2247
|
+
|
|
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
|
+
}
|
|
1601
2263
|
|
|
1602
|
-
|
|
1603
|
-
exchangeReq.end();
|
|
2264
|
+
const exchangeBody = await exchangeResponse.text();
|
|
1604
2265
|
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
process.off("SIGINT", cancelHandler);
|
|
2266
|
+
if (!exchangeResponse.ok) {
|
|
2267
|
+
console.error(`Failed to exchange code for token: ${exchangeResponse.status}`);
|
|
1608
2268
|
|
|
1609
|
-
|
|
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
|
+
}
|
|
1610
2279
|
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
console.error(`This usually means you closed the browser window without completing authentication.`);
|
|
1615
|
-
console.error(`Please try again and complete the authentication flow.`);
|
|
1616
|
-
} else {
|
|
1617
|
-
console.error(`\nAuthentication failed: ${message}`);
|
|
1618
|
-
}
|
|
2280
|
+
process.exit(1);
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
1619
2283
|
|
|
1620
|
-
|
|
1621
|
-
|
|
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,
|
|
1622
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);
|
|
1623
2321
|
}
|
|
1624
|
-
);
|
|
1625
2322
|
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
2323
|
+
} catch (err) {
|
|
2324
|
+
// Remove the cancel handler in error case too
|
|
2325
|
+
process.off("SIGINT", cancelHandler);
|
|
2326
|
+
|
|
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
|
+
}
|
|
1631
2337
|
|
|
1632
|
-
|
|
1633
|
-
|
|
2338
|
+
process.exit(1);
|
|
2339
|
+
}
|
|
1634
2340
|
|
|
1635
2341
|
} catch (err) {
|
|
1636
2342
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1639,15 +2345,7 @@ program
|
|
|
1639
2345
|
}
|
|
1640
2346
|
});
|
|
1641
2347
|
|
|
1642
|
-
|
|
1643
|
-
.command("add-key <apiKey>")
|
|
1644
|
-
.description("store API key")
|
|
1645
|
-
.action(async (apiKey: string) => {
|
|
1646
|
-
config.writeConfig({ apiKey });
|
|
1647
|
-
console.log(`API key saved to ${config.getConfigPath()}`);
|
|
1648
|
-
});
|
|
1649
|
-
|
|
1650
|
-
program
|
|
2348
|
+
auth
|
|
1651
2349
|
.command("show-key")
|
|
1652
2350
|
.description("show API key (masked)")
|
|
1653
2351
|
.action(async () => {
|
|
@@ -1657,7 +2355,6 @@ program
|
|
|
1657
2355
|
console.log(`\nTo authenticate, run: pgai auth`);
|
|
1658
2356
|
return;
|
|
1659
2357
|
}
|
|
1660
|
-
const { maskSecret } = require("../lib/util");
|
|
1661
2358
|
console.log(`Current API key: ${maskSecret(cfg.apiKey)}`);
|
|
1662
2359
|
if (cfg.orgId) {
|
|
1663
2360
|
console.log(`Organization ID: ${cfg.orgId}`);
|
|
@@ -1665,14 +2362,20 @@ program
|
|
|
1665
2362
|
console.log(`Config location: ${config.getConfigPath()}`);
|
|
1666
2363
|
});
|
|
1667
2364
|
|
|
1668
|
-
|
|
2365
|
+
auth
|
|
1669
2366
|
.command("remove-key")
|
|
1670
2367
|
.description("remove API key")
|
|
1671
2368
|
.action(async () => {
|
|
1672
2369
|
// Check both new config and legacy config
|
|
1673
2370
|
const newConfigPath = config.getConfigPath();
|
|
1674
2371
|
const hasNewConfig = fs.existsSync(newConfigPath);
|
|
1675
|
-
|
|
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
|
+
}
|
|
1676
2379
|
const hasLegacyConfig = fs.existsSync(legacyPath) && fs.statSync(legacyPath).isFile();
|
|
1677
2380
|
|
|
1678
2381
|
if (!hasNewConfig && !hasLegacyConfig) {
|
|
@@ -1708,7 +2411,8 @@ mon
|
|
|
1708
2411
|
.command("generate-grafana-password")
|
|
1709
2412
|
.description("generate Grafana password for monitoring services")
|
|
1710
2413
|
.action(async () => {
|
|
1711
|
-
const
|
|
2414
|
+
const { projectDir } = await resolveOrInitPaths();
|
|
2415
|
+
const cfgPath = path.resolve(projectDir, ".pgwatch-config");
|
|
1712
2416
|
|
|
1713
2417
|
try {
|
|
1714
2418
|
// Generate secure password using openssl
|
|
@@ -1759,9 +2463,10 @@ mon
|
|
|
1759
2463
|
.command("show-grafana-credentials")
|
|
1760
2464
|
.description("show Grafana credentials for monitoring services")
|
|
1761
2465
|
.action(async () => {
|
|
1762
|
-
const
|
|
2466
|
+
const { projectDir } = await resolveOrInitPaths();
|
|
2467
|
+
const cfgPath = path.resolve(projectDir, ".pgwatch-config");
|
|
1763
2468
|
if (!fs.existsSync(cfgPath)) {
|
|
1764
|
-
console.error("Configuration file not found. Run 'postgres-ai mon
|
|
2469
|
+
console.error("Configuration file not found. Run 'postgres-ai mon local-install' first.");
|
|
1765
2470
|
process.exitCode = 1;
|
|
1766
2471
|
return;
|
|
1767
2472
|
}
|
|
@@ -1887,7 +2592,7 @@ issues
|
|
|
1887
2592
|
});
|
|
1888
2593
|
|
|
1889
2594
|
issues
|
|
1890
|
-
.command("
|
|
2595
|
+
.command("post-comment <issueId> <content>")
|
|
1891
2596
|
.description("post a new comment to an issue")
|
|
1892
2597
|
.option("--parent <uuid>", "parent comment id")
|
|
1893
2598
|
.option("--debug", "enable debug output")
|
|
@@ -1932,6 +2637,194 @@ issues
|
|
|
1932
2637
|
}
|
|
1933
2638
|
});
|
|
1934
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
|
+
|
|
1935
2828
|
// MCP server
|
|
1936
2829
|
const mcp = program.command("mcp").description("MCP server integration");
|
|
1937
2830
|
|
|
@@ -1959,15 +2852,7 @@ mcp
|
|
|
1959
2852
|
console.log(" 4. Codex");
|
|
1960
2853
|
console.log("");
|
|
1961
2854
|
|
|
1962
|
-
const
|
|
1963
|
-
input: process.stdin,
|
|
1964
|
-
output: process.stdout
|
|
1965
|
-
});
|
|
1966
|
-
|
|
1967
|
-
const answer = await new Promise<string>((resolve) => {
|
|
1968
|
-
rl.question("Select your AI coding tool (1-4): ", resolve);
|
|
1969
|
-
});
|
|
1970
|
-
rl.close();
|
|
2855
|
+
const answer = await question("Select your AI coding tool (1-4): ");
|
|
1971
2856
|
|
|
1972
2857
|
const choices: Record<string, string> = {
|
|
1973
2858
|
"1": "cursor",
|
|
@@ -2102,5 +2987,7 @@ mcp
|
|
|
2102
2987
|
}
|
|
2103
2988
|
});
|
|
2104
2989
|
|
|
2105
|
-
program.parseAsync(process.argv)
|
|
2990
|
+
program.parseAsync(process.argv).finally(() => {
|
|
2991
|
+
closeReadline();
|
|
2992
|
+
});
|
|
2106
2993
|
|