postgresai 0.14.0-dev.8 → 0.14.0-dev.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -61
- package/bin/postgres-ai.ts +2596 -428
- package/bun.lock +258 -0
- package/bunfig.toml +20 -0
- package/dist/bin/postgres-ai.js +31277 -1575
- package/dist/sql/01.role.sql +16 -0
- package/dist/sql/02.extensions.sql +8 -0
- package/dist/sql/03.permissions.sql +38 -0
- package/dist/sql/04.optional_rds.sql +6 -0
- package/dist/sql/05.optional_self_managed.sql +8 -0
- package/dist/sql/06.helpers.sql +439 -0
- package/dist/sql/sql/01.role.sql +16 -0
- package/dist/sql/sql/02.extensions.sql +8 -0
- package/dist/sql/sql/03.permissions.sql +38 -0
- package/dist/sql/sql/04.optional_rds.sql +6 -0
- package/dist/sql/sql/05.optional_self_managed.sql +8 -0
- package/dist/sql/sql/06.helpers.sql +439 -0
- package/dist/sql/sql/uninit/01.helpers.sql +5 -0
- package/dist/sql/sql/uninit/02.permissions.sql +30 -0
- package/dist/sql/sql/uninit/03.role.sql +27 -0
- package/dist/sql/uninit/01.helpers.sql +5 -0
- package/dist/sql/uninit/02.permissions.sql +30 -0
- package/dist/sql/uninit/03.role.sql +27 -0
- package/lib/auth-server.ts +124 -106
- package/lib/checkup-api.ts +386 -0
- package/lib/checkup-dictionary.ts +113 -0
- package/lib/checkup.ts +1512 -0
- package/lib/config.ts +6 -3
- package/lib/init.ts +655 -189
- package/lib/issues.ts +848 -193
- package/lib/mcp-server.ts +391 -91
- package/lib/metrics-loader.ts +127 -0
- package/lib/supabase.ts +824 -0
- package/lib/util.ts +61 -0
- package/package.json +22 -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-checkup-dictionary.ts +106 -0
- package/scripts/embed-metrics.ts +154 -0
- package/sql/01.role.sql +16 -0
- package/sql/02.extensions.sql +8 -0
- package/sql/03.permissions.sql +38 -0
- package/sql/04.optional_rds.sql +6 -0
- package/sql/05.optional_self_managed.sql +8 -0
- package/sql/06.helpers.sql +439 -0
- package/sql/uninit/01.helpers.sql +5 -0
- package/sql/uninit/02.permissions.sql +30 -0
- package/sql/uninit/03.role.sql +27 -0
- package/test/auth.test.ts +258 -0
- package/test/checkup.integration.test.ts +321 -0
- package/test/checkup.test.ts +1116 -0
- package/test/config-consistency.test.ts +36 -0
- package/test/init.integration.test.ts +508 -0
- package/test/init.test.ts +916 -0
- package/test/issues.cli.test.ts +538 -0
- package/test/issues.test.ts +456 -0
- package/test/mcp-server.test.ts +1527 -0
- package/test/schema-validation.test.ts +81 -0
- package/test/supabase.test.ts +568 -0
- package/test/test-utils.ts +128 -0
- package/tsconfig.json +12 -20
- package/dist/bin/postgres-ai.d.ts +0 -3
- package/dist/bin/postgres-ai.d.ts.map +0 -1
- package/dist/bin/postgres-ai.js.map +0 -1
- package/dist/lib/auth-server.d.ts +0 -31
- package/dist/lib/auth-server.d.ts.map +0 -1
- package/dist/lib/auth-server.js +0 -263
- package/dist/lib/auth-server.js.map +0 -1
- package/dist/lib/config.d.ts +0 -45
- package/dist/lib/config.d.ts.map +0 -1
- package/dist/lib/config.js +0 -181
- package/dist/lib/config.js.map +0 -1
- package/dist/lib/init.d.ts +0 -64
- package/dist/lib/init.d.ts.map +0 -1
- package/dist/lib/init.js +0 -399
- package/dist/lib/init.js.map +0 -1
- package/dist/lib/issues.d.ts +0 -75
- package/dist/lib/issues.d.ts.map +0 -1
- package/dist/lib/issues.js +0 -336
- package/dist/lib/issues.js.map +0 -1
- package/dist/lib/mcp-server.d.ts +0 -9
- package/dist/lib/mcp-server.d.ts.map +0 -1
- package/dist/lib/mcp-server.js +0 -168
- package/dist/lib/mcp-server.js.map +0 -1
- package/dist/lib/pkce.d.ts +0 -32
- package/dist/lib/pkce.d.ts.map +0 -1
- package/dist/lib/pkce.js +0 -101
- package/dist/lib/pkce.js.map +0 -1
- package/dist/lib/util.d.ts +0 -27
- package/dist/lib/util.d.ts.map +0 -1
- package/dist/lib/util.js +0 -46
- package/dist/lib/util.js.map +0 -1
- package/dist/package.json +0 -46
- package/test/init.integration.test.cjs +0 -269
- package/test/init.test.cjs +0 -76
package/bin/postgres-ai.ts
CHANGED
|
@@ -1,24 +1,367 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
import { Command } from "commander";
|
|
4
|
-
import
|
|
4
|
+
import pkg from "../package.json";
|
|
5
5
|
import * as config from "../lib/config";
|
|
6
6
|
import * as yaml from "js-yaml";
|
|
7
7
|
import * as fs from "fs";
|
|
8
8
|
import * as path from "path";
|
|
9
9
|
import * as os from "os";
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
12
|
-
import * as readline from "readline";
|
|
13
|
-
import * as http from "https";
|
|
14
|
-
import { URL } from "url";
|
|
10
|
+
import * as crypto from "node:crypto";
|
|
11
|
+
import { Client } from "pg";
|
|
15
12
|
import { startMcpServer } from "../lib/mcp-server";
|
|
16
|
-
import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue } from "../lib/issues";
|
|
13
|
+
import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment, fetchActionItem, fetchActionItems, createActionItem, updateActionItem, type ConfigChange } from "../lib/issues";
|
|
17
14
|
import { resolveBaseUrls } from "../lib/util";
|
|
18
|
-
import { applyInitPlan, buildInitPlan, resolveAdminConnection, resolveMonitoringPassword } from "../lib/init";
|
|
15
|
+
import { applyInitPlan, applyUninitPlan, buildInitPlan, buildUninitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, KNOWN_PROVIDERS, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, validateProvider, verifyInitSetup } from "../lib/init";
|
|
16
|
+
import { SupabaseClient, resolveSupabaseConfig, extractProjectRefFromUrl, applyInitPlanViaSupabase, verifyInitSetupViaSupabase, fetchPoolerDatabaseUrl, type PgCompatibleError } from "../lib/supabase";
|
|
17
|
+
import * as pkce from "../lib/pkce";
|
|
18
|
+
import * as authServer from "../lib/auth-server";
|
|
19
|
+
import { maskSecret } from "../lib/util";
|
|
20
|
+
import { createInterface } from "readline";
|
|
21
|
+
import * as childProcess from "child_process";
|
|
22
|
+
import { REPORT_GENERATORS, CHECK_INFO, generateAllReports } from "../lib/checkup";
|
|
23
|
+
import { getCheckupEntry } from "../lib/checkup-dictionary";
|
|
24
|
+
import { createCheckupReport, uploadCheckupReportJson, RpcError, formatRpcErrorForDisplay, withRetry } from "../lib/checkup-api";
|
|
25
|
+
|
|
26
|
+
// Singleton readline interface for stdin prompts
|
|
27
|
+
let rl: ReturnType<typeof createInterface> | null = null;
|
|
28
|
+
function getReadline() {
|
|
29
|
+
if (!rl) {
|
|
30
|
+
rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
31
|
+
}
|
|
32
|
+
return rl;
|
|
33
|
+
}
|
|
34
|
+
function closeReadline() {
|
|
35
|
+
if (rl) {
|
|
36
|
+
rl.close();
|
|
37
|
+
rl = null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Helper functions for spawning processes - use Node.js child_process for compatibility
|
|
42
|
+
async function execPromise(command: string): Promise<{ stdout: string; stderr: string }> {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
childProcess.exec(command, (error, stdout, stderr) => {
|
|
45
|
+
if (error) {
|
|
46
|
+
const err = error as Error & { code: number };
|
|
47
|
+
err.code = typeof error.code === "number" ? error.code : 1;
|
|
48
|
+
reject(err);
|
|
49
|
+
} else {
|
|
50
|
+
resolve({ stdout, stderr });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function execFilePromise(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
childProcess.execFile(file, args, (error, stdout, stderr) => {
|
|
59
|
+
if (error) {
|
|
60
|
+
const err = error as Error & { code: number };
|
|
61
|
+
err.code = typeof error.code === "number" ? error.code : 1;
|
|
62
|
+
reject(err);
|
|
63
|
+
} else {
|
|
64
|
+
resolve({ stdout, stderr });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
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 } {
|
|
71
|
+
const result = childProcess.spawnSync(cmd, args, {
|
|
72
|
+
stdio: options?.stdio === "inherit" ? "inherit" : "pipe",
|
|
73
|
+
env: options?.env as NodeJS.ProcessEnv,
|
|
74
|
+
cwd: options?.cwd,
|
|
75
|
+
encoding: "utf8",
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
status: result.status,
|
|
79
|
+
stdout: typeof result.stdout === "string" ? result.stdout : "",
|
|
80
|
+
stderr: typeof result.stderr === "string" ? result.stderr : "",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
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 } {
|
|
85
|
+
const proc = childProcess.spawn(cmd, args, {
|
|
86
|
+
stdio: options?.stdio ?? "pipe",
|
|
87
|
+
env: options?.env as NodeJS.ProcessEnv,
|
|
88
|
+
cwd: options?.cwd,
|
|
89
|
+
detached: options?.detached,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
on(event: string, cb: (code: number | null, signal?: string) => void) {
|
|
94
|
+
if (event === "close" || event === "exit") {
|
|
95
|
+
proc.on(event, (code, signal) => cb(code, signal ?? undefined));
|
|
96
|
+
} else if (event === "error") {
|
|
97
|
+
proc.on("error", (err) => cb(null, String(err)));
|
|
98
|
+
}
|
|
99
|
+
return this;
|
|
100
|
+
},
|
|
101
|
+
unref() {
|
|
102
|
+
proc.unref();
|
|
103
|
+
},
|
|
104
|
+
pid: proc.pid,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Simple readline-like interface for prompts using Bun
|
|
109
|
+
async function question(prompt: string): Promise<string> {
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
getReadline().question(prompt, (answer) => {
|
|
112
|
+
resolve(answer);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function expandHomePath(p: string): string {
|
|
118
|
+
const s = (p || "").trim();
|
|
119
|
+
if (!s) return s;
|
|
120
|
+
if (s === "~") return os.homedir();
|
|
121
|
+
if (s.startsWith("~/") || s.startsWith("~\\")) {
|
|
122
|
+
return path.join(os.homedir(), s.slice(2));
|
|
123
|
+
}
|
|
124
|
+
return s;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function createTtySpinner(
|
|
128
|
+
enabled: boolean,
|
|
129
|
+
initialText: string
|
|
130
|
+
): { update: (text: string) => void; stop: (finalText?: string) => void } {
|
|
131
|
+
if (!enabled) {
|
|
132
|
+
return {
|
|
133
|
+
update: () => {},
|
|
134
|
+
stop: () => {},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const frames = ["|", "/", "-", "\\"];
|
|
139
|
+
const startTs = Date.now();
|
|
140
|
+
let text = initialText;
|
|
141
|
+
let frameIdx = 0;
|
|
142
|
+
let stopped = false;
|
|
143
|
+
|
|
144
|
+
const render = (): void => {
|
|
145
|
+
if (stopped) return;
|
|
146
|
+
const elapsedSec = ((Date.now() - startTs) / 1000).toFixed(1);
|
|
147
|
+
const frame = frames[frameIdx % frames.length] ?? frames[0] ?? "⠿";
|
|
148
|
+
frameIdx += 1;
|
|
149
|
+
process.stdout.write(`\r\x1b[2K${frame} ${text} (${elapsedSec}s)`);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const timer = setInterval(render, 120);
|
|
153
|
+
render(); // immediate feedback
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
update: (t: string) => {
|
|
157
|
+
text = t;
|
|
158
|
+
render();
|
|
159
|
+
},
|
|
160
|
+
stop: (finalText?: string) => {
|
|
161
|
+
if (stopped) return;
|
|
162
|
+
// Set flag first so any queued render() calls exit early.
|
|
163
|
+
// JavaScript is single-threaded, so this is safe: queued callbacks
|
|
164
|
+
// run after stop() returns and will see stopped=true immediately.
|
|
165
|
+
stopped = true;
|
|
166
|
+
clearInterval(timer);
|
|
167
|
+
process.stdout.write("\r\x1b[2K");
|
|
168
|
+
if (finalText && finalText.trim()) {
|
|
169
|
+
process.stdout.write(finalText);
|
|
170
|
+
}
|
|
171
|
+
process.stdout.write("\n");
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ============================================================================
|
|
177
|
+
// Checkup command helpers
|
|
178
|
+
// ============================================================================
|
|
179
|
+
|
|
180
|
+
interface CheckupOptions {
|
|
181
|
+
checkId: string;
|
|
182
|
+
nodeName: string;
|
|
183
|
+
output?: string;
|
|
184
|
+
upload?: boolean;
|
|
185
|
+
project?: string;
|
|
186
|
+
json?: boolean;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
interface UploadConfig {
|
|
190
|
+
apiKey: string;
|
|
191
|
+
apiBaseUrl: string;
|
|
192
|
+
project: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
interface UploadSummary {
|
|
196
|
+
project: string;
|
|
197
|
+
reportId: number;
|
|
198
|
+
uploaded: Array<{ checkId: string; filename: string; chunkId: number }>;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Prepare and validate output directory for checkup reports.
|
|
203
|
+
* @returns Output path if valid, null if should exit with error
|
|
204
|
+
*/
|
|
205
|
+
function prepareOutputDirectory(outputOpt: string | undefined): string | null | undefined {
|
|
206
|
+
if (!outputOpt) return undefined;
|
|
207
|
+
|
|
208
|
+
const outputDir = expandHomePath(outputOpt);
|
|
209
|
+
const outputPath = path.isAbsolute(outputDir) ? outputDir : path.resolve(process.cwd(), outputDir);
|
|
210
|
+
|
|
211
|
+
if (!fs.existsSync(outputPath)) {
|
|
212
|
+
try {
|
|
213
|
+
fs.mkdirSync(outputPath, { recursive: true });
|
|
214
|
+
} catch (e) {
|
|
215
|
+
const errAny = e as any;
|
|
216
|
+
const code = typeof errAny?.code === "string" ? errAny.code : "";
|
|
217
|
+
const msg = errAny instanceof Error ? errAny.message : String(errAny);
|
|
218
|
+
if (code === "EACCES" || code === "EPERM" || code === "ENOENT") {
|
|
219
|
+
console.error(`Error: Failed to create output directory: ${outputPath}`);
|
|
220
|
+
console.error(`Reason: ${msg}`);
|
|
221
|
+
console.error("Tip: choose a writable path, e.g. --output ./reports or --output ~/reports");
|
|
222
|
+
return null; // Signal to exit
|
|
223
|
+
}
|
|
224
|
+
throw e;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return outputPath;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Prepare upload configuration for checkup reports.
|
|
232
|
+
* @returns Upload config if valid, null if should exit, undefined if upload not needed
|
|
233
|
+
*/
|
|
234
|
+
function prepareUploadConfig(
|
|
235
|
+
opts: CheckupOptions,
|
|
236
|
+
rootOpts: CliOptions,
|
|
237
|
+
shouldUpload: boolean,
|
|
238
|
+
uploadExplicitlyRequested: boolean
|
|
239
|
+
): { config: UploadConfig; projectWasGenerated: boolean } | null | undefined {
|
|
240
|
+
if (!shouldUpload) return undefined;
|
|
241
|
+
|
|
242
|
+
const { apiKey } = getConfig(rootOpts);
|
|
243
|
+
if (!apiKey) {
|
|
244
|
+
if (uploadExplicitlyRequested) {
|
|
245
|
+
console.error("Error: API key is required for upload");
|
|
246
|
+
console.error("Tip: run 'postgresai auth' or pass --api-key / set PGAI_API_KEY");
|
|
247
|
+
return null; // Signal to exit
|
|
248
|
+
}
|
|
249
|
+
return undefined; // Skip upload silently
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const cfg = config.readConfig();
|
|
253
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
254
|
+
let project = ((opts.project || cfg.defaultProject) || "").trim();
|
|
255
|
+
let projectWasGenerated = false;
|
|
256
|
+
|
|
257
|
+
if (!project) {
|
|
258
|
+
project = `project_${crypto.randomBytes(6).toString("hex")}`;
|
|
259
|
+
projectWasGenerated = true;
|
|
260
|
+
try {
|
|
261
|
+
config.writeConfig({ defaultProject: project });
|
|
262
|
+
} catch (e) {
|
|
263
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
264
|
+
console.error(`Warning: Failed to save generated default project: ${message}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
config: { apiKey, apiBaseUrl, project },
|
|
270
|
+
projectWasGenerated,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Upload checkup reports to PostgresAI API.
|
|
276
|
+
*/
|
|
277
|
+
async function uploadCheckupReports(
|
|
278
|
+
uploadCfg: UploadConfig,
|
|
279
|
+
reports: Record<string, any>,
|
|
280
|
+
spinner: ReturnType<typeof createTtySpinner>,
|
|
281
|
+
logUpload: (msg: string) => void
|
|
282
|
+
): Promise<UploadSummary> {
|
|
283
|
+
spinner.update("Creating remote checkup report");
|
|
284
|
+
const created = await withRetry(
|
|
285
|
+
() => createCheckupReport({
|
|
286
|
+
apiKey: uploadCfg.apiKey,
|
|
287
|
+
apiBaseUrl: uploadCfg.apiBaseUrl,
|
|
288
|
+
project: uploadCfg.project,
|
|
289
|
+
}),
|
|
290
|
+
{ maxAttempts: 3 },
|
|
291
|
+
(attempt, err, delayMs) => {
|
|
292
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
293
|
+
logUpload(`[Retry ${attempt}/3] createCheckupReport failed: ${errMsg}, retrying in ${delayMs}ms...`);
|
|
294
|
+
}
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const reportId = created.reportId;
|
|
298
|
+
logUpload(`Created remote checkup report: ${reportId}`);
|
|
299
|
+
|
|
300
|
+
const uploaded: Array<{ checkId: string; filename: string; chunkId: number }> = [];
|
|
301
|
+
for (const [checkId, report] of Object.entries(reports)) {
|
|
302
|
+
spinner.update(`Uploading ${checkId}.json`);
|
|
303
|
+
const jsonText = JSON.stringify(report, null, 2);
|
|
304
|
+
const r = await withRetry(
|
|
305
|
+
() => uploadCheckupReportJson({
|
|
306
|
+
apiKey: uploadCfg.apiKey,
|
|
307
|
+
apiBaseUrl: uploadCfg.apiBaseUrl,
|
|
308
|
+
reportId,
|
|
309
|
+
filename: `${checkId}.json`,
|
|
310
|
+
checkId,
|
|
311
|
+
jsonText,
|
|
312
|
+
}),
|
|
313
|
+
{ maxAttempts: 3 },
|
|
314
|
+
(attempt, err, delayMs) => {
|
|
315
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
316
|
+
logUpload(`[Retry ${attempt}/3] Upload ${checkId}.json failed: ${errMsg}, retrying in ${delayMs}ms...`);
|
|
317
|
+
}
|
|
318
|
+
);
|
|
319
|
+
uploaded.push({ checkId, filename: `${checkId}.json`, chunkId: r.reportChunkId });
|
|
320
|
+
}
|
|
321
|
+
logUpload("Upload completed");
|
|
322
|
+
|
|
323
|
+
return { project: uploadCfg.project, reportId, uploaded };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Write checkup reports to files.
|
|
328
|
+
*/
|
|
329
|
+
function writeReportFiles(reports: Record<string, any>, outputPath: string): void {
|
|
330
|
+
for (const [checkId, report] of Object.entries(reports)) {
|
|
331
|
+
const filePath = path.join(outputPath, `${checkId}.json`);
|
|
332
|
+
fs.writeFileSync(filePath, JSON.stringify(report, null, 2), "utf8");
|
|
333
|
+
console.log(`✓ ${checkId}: ${filePath}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
19
336
|
|
|
20
|
-
|
|
21
|
-
|
|
337
|
+
/**
|
|
338
|
+
* Print upload summary to console.
|
|
339
|
+
*/
|
|
340
|
+
function printUploadSummary(
|
|
341
|
+
summary: UploadSummary,
|
|
342
|
+
projectWasGenerated: boolean,
|
|
343
|
+
useStderr: boolean
|
|
344
|
+
): void {
|
|
345
|
+
const out = useStderr ? console.error : console.log;
|
|
346
|
+
out("\nCheckup report uploaded");
|
|
347
|
+
out("======================\n");
|
|
348
|
+
if (projectWasGenerated) {
|
|
349
|
+
out(`Project: ${summary.project} (generated and saved as default)`);
|
|
350
|
+
} else {
|
|
351
|
+
out(`Project: ${summary.project}`);
|
|
352
|
+
}
|
|
353
|
+
out(`Report ID: ${summary.reportId}`);
|
|
354
|
+
out("View in Console: console.postgres.ai → Support → checkup reports");
|
|
355
|
+
out("");
|
|
356
|
+
out("Files:");
|
|
357
|
+
for (const item of summary.uploaded) {
|
|
358
|
+
out(`- ${item.checkId}: ${item.filename}`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ============================================================================
|
|
363
|
+
// CLI configuration
|
|
364
|
+
// ============================================================================
|
|
22
365
|
|
|
23
366
|
/**
|
|
24
367
|
* CLI configuration options
|
|
@@ -60,6 +403,86 @@ interface PathResolution {
|
|
|
60
403
|
instancesFile: string;
|
|
61
404
|
}
|
|
62
405
|
|
|
406
|
+
function getDefaultMonitoringProjectDir(): string {
|
|
407
|
+
const override = process.env.PGAI_PROJECT_DIR;
|
|
408
|
+
if (override && override.trim()) return override.trim();
|
|
409
|
+
// Keep monitoring project next to user-level config (~/.config/postgresai)
|
|
410
|
+
return path.join(config.getConfigDir(), "monitoring");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function downloadText(url: string): Promise<string> {
|
|
414
|
+
const controller = new AbortController();
|
|
415
|
+
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
416
|
+
try {
|
|
417
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
418
|
+
if (!response.ok) {
|
|
419
|
+
throw new Error(`HTTP ${response.status} for ${url}`);
|
|
420
|
+
}
|
|
421
|
+
return await response.text();
|
|
422
|
+
} finally {
|
|
423
|
+
clearTimeout(timeout);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
|
|
428
|
+
const projectDir = getDefaultMonitoringProjectDir();
|
|
429
|
+
const composeFile = path.resolve(projectDir, "docker-compose.yml");
|
|
430
|
+
const instancesFile = path.resolve(projectDir, "instances.yml");
|
|
431
|
+
|
|
432
|
+
if (!fs.existsSync(projectDir)) {
|
|
433
|
+
fs.mkdirSync(projectDir, { recursive: true, mode: 0o700 });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (!fs.existsSync(composeFile)) {
|
|
437
|
+
const refs = [
|
|
438
|
+
process.env.PGAI_PROJECT_REF,
|
|
439
|
+
pkg.version,
|
|
440
|
+
`v${pkg.version}`,
|
|
441
|
+
"main",
|
|
442
|
+
].filter((v): v is string => Boolean(v && v.trim()));
|
|
443
|
+
|
|
444
|
+
let lastErr: unknown;
|
|
445
|
+
for (const ref of refs) {
|
|
446
|
+
const url = `https://gitlab.com/postgres-ai/postgres_ai/-/raw/${encodeURIComponent(ref)}/docker-compose.yml`;
|
|
447
|
+
try {
|
|
448
|
+
const text = await downloadText(url);
|
|
449
|
+
fs.writeFileSync(composeFile, text, { encoding: "utf8", mode: 0o600 });
|
|
450
|
+
break;
|
|
451
|
+
} catch (err) {
|
|
452
|
+
lastErr = err;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (!fs.existsSync(composeFile)) {
|
|
457
|
+
const msg = lastErr instanceof Error ? lastErr.message : String(lastErr);
|
|
458
|
+
throw new Error(`Failed to bootstrap docker-compose.yml: ${msg}`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Ensure instances.yml exists as a FILE (avoid Docker creating a directory)
|
|
463
|
+
if (!fs.existsSync(instancesFile)) {
|
|
464
|
+
const header =
|
|
465
|
+
"# PostgreSQL instances to monitor\n" +
|
|
466
|
+
"# Add your instances using: pgai mon targets add <connection-string> <name>\n\n";
|
|
467
|
+
fs.writeFileSync(instancesFile, header, { encoding: "utf8", mode: 0o600 });
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Ensure .pgwatch-config exists as a FILE for reporter (may remain empty)
|
|
471
|
+
const pgwatchConfig = path.resolve(projectDir, ".pgwatch-config");
|
|
472
|
+
if (!fs.existsSync(pgwatchConfig)) {
|
|
473
|
+
fs.writeFileSync(pgwatchConfig, "", { encoding: "utf8", mode: 0o600 });
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Ensure .env exists and has PGAI_TAG (compose requires it)
|
|
477
|
+
const envFile = path.resolve(projectDir, ".env");
|
|
478
|
+
if (!fs.existsSync(envFile)) {
|
|
479
|
+
const envText = `PGAI_TAG=${pkg.version}\n# PGAI_REGISTRY=registry.gitlab.com/postgres-ai/postgres_ai\n`;
|
|
480
|
+
fs.writeFileSync(envFile, envText, { encoding: "utf8", mode: 0o600 });
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return { fs, path, projectDir, composeFile, instancesFile };
|
|
484
|
+
}
|
|
485
|
+
|
|
63
486
|
/**
|
|
64
487
|
* Get configuration from various sources
|
|
65
488
|
* @param opts - Command line options
|
|
@@ -118,17 +541,92 @@ program
|
|
|
118
541
|
);
|
|
119
542
|
|
|
120
543
|
program
|
|
121
|
-
.command("
|
|
122
|
-
.description("
|
|
544
|
+
.command("set-default-project <project>")
|
|
545
|
+
.description("store default project for checkup uploads")
|
|
546
|
+
.action(async (project: string) => {
|
|
547
|
+
const value = (project || "").trim();
|
|
548
|
+
if (!value) {
|
|
549
|
+
console.error("Error: project is required");
|
|
550
|
+
process.exitCode = 1;
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
config.writeConfig({ defaultProject: value });
|
|
554
|
+
console.log(`Default project saved: ${value}`);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
program
|
|
558
|
+
.command("prepare-db [conn]")
|
|
559
|
+
.description("prepare database for monitoring: create monitoring user, required view(s), and grant permissions (idempotent)")
|
|
123
560
|
.option("--db-url <url>", "PostgreSQL connection URL (admin) to run the setup against (deprecated; pass it as positional arg)")
|
|
124
561
|
.option("-h, --host <host>", "PostgreSQL host (psql-like)")
|
|
125
562
|
.option("-p, --port <port>", "PostgreSQL port (psql-like)")
|
|
126
563
|
.option("-U, --username <username>", "PostgreSQL user (psql-like)")
|
|
127
564
|
.option("-d, --dbname <dbname>", "PostgreSQL database name (psql-like)")
|
|
128
565
|
.option("--admin-password <password>", "Admin connection password (otherwise uses PGPASSWORD if set)")
|
|
129
|
-
.option("--monitoring-user <name>", "Monitoring role name to create/update",
|
|
566
|
+
.option("--monitoring-user <name>", "Monitoring role name to create/update", DEFAULT_MONITORING_USER)
|
|
130
567
|
.option("--password <password>", "Monitoring role password (overrides PGAI_MON_PASSWORD)")
|
|
131
568
|
.option("--skip-optional-permissions", "Skip optional permissions (RDS/self-managed extras)", false)
|
|
569
|
+
.option("--provider <provider>", "Database provider (e.g., supabase). Affects which steps are executed.")
|
|
570
|
+
.option("--verify", "Verify that monitoring role/permissions are in place (no changes)", false)
|
|
571
|
+
.option("--reset-password", "Reset monitoring role password only (no other changes)", false)
|
|
572
|
+
.option("--print-sql", "Print SQL plan and exit (no changes applied)", false)
|
|
573
|
+
.option("--print-password", "Print generated monitoring password (DANGEROUS in CI logs)", false)
|
|
574
|
+
.option("--supabase", "Use Supabase Management API instead of direct PostgreSQL connection", false)
|
|
575
|
+
.option("--supabase-access-token <token>", "Supabase Management API access token (or SUPABASE_ACCESS_TOKEN env)")
|
|
576
|
+
.option("--supabase-project-ref <ref>", "Supabase project reference (or SUPABASE_PROJECT_REF env)")
|
|
577
|
+
.option("--json", "Output result as JSON (machine-readable)", false)
|
|
578
|
+
.addHelpText(
|
|
579
|
+
"after",
|
|
580
|
+
[
|
|
581
|
+
"",
|
|
582
|
+
"Examples:",
|
|
583
|
+
" postgresai prepare-db postgresql://admin@host:5432/dbname",
|
|
584
|
+
" postgresai prepare-db \"dbname=dbname host=host user=admin\"",
|
|
585
|
+
" postgresai prepare-db -h host -p 5432 -U admin -d dbname",
|
|
586
|
+
"",
|
|
587
|
+
"Admin password:",
|
|
588
|
+
" --admin-password <password> or PGPASSWORD=... (libpq standard)",
|
|
589
|
+
"",
|
|
590
|
+
"Monitoring password:",
|
|
591
|
+
" --password <password> or PGAI_MON_PASSWORD=... (otherwise auto-generated)",
|
|
592
|
+
" If auto-generated, it is printed only on TTY by default.",
|
|
593
|
+
" To print it in non-interactive mode: --print-password",
|
|
594
|
+
"",
|
|
595
|
+
"SSL connection (sslmode=prefer behavior):",
|
|
596
|
+
" Tries SSL first, falls back to non-SSL if server doesn't support it.",
|
|
597
|
+
" To force SSL: PGSSLMODE=require or ?sslmode=require in URL",
|
|
598
|
+
" To disable SSL: PGSSLMODE=disable or ?sslmode=disable in URL",
|
|
599
|
+
"",
|
|
600
|
+
"Environment variables (libpq standard):",
|
|
601
|
+
" PGHOST, PGPORT, PGUSER, PGDATABASE — connection defaults",
|
|
602
|
+
" PGPASSWORD — admin password",
|
|
603
|
+
" PGSSLMODE — SSL mode (disable, require, verify-full)",
|
|
604
|
+
" PGAI_MON_PASSWORD — monitoring password",
|
|
605
|
+
"",
|
|
606
|
+
"Inspect SQL without applying changes:",
|
|
607
|
+
" postgresai prepare-db <conn> --print-sql",
|
|
608
|
+
"",
|
|
609
|
+
"Verify setup (no changes):",
|
|
610
|
+
" postgresai prepare-db <conn> --verify",
|
|
611
|
+
"",
|
|
612
|
+
"Reset monitoring password only:",
|
|
613
|
+
" postgresai prepare-db <conn> --reset-password --password '...'",
|
|
614
|
+
"",
|
|
615
|
+
"Offline SQL plan (no DB connection):",
|
|
616
|
+
" postgresai prepare-db --print-sql",
|
|
617
|
+
"",
|
|
618
|
+
"Supabase mode (use Management API instead of direct connection):",
|
|
619
|
+
" postgresai prepare-db --supabase --supabase-project-ref <ref>",
|
|
620
|
+
" SUPABASE_ACCESS_TOKEN=... postgresai prepare-db --supabase --supabase-project-ref <ref>",
|
|
621
|
+
"",
|
|
622
|
+
" Generate a token at: https://supabase.com/dashboard/account/tokens",
|
|
623
|
+
" Find your project ref in: https://supabase.com/dashboard/project/<ref>",
|
|
624
|
+
"",
|
|
625
|
+
"Provider-specific behavior (for direct connections):",
|
|
626
|
+
" --provider supabase Skip role creation (create user in Supabase dashboard)",
|
|
627
|
+
" Skip ALTER USER (restricted by Supabase)",
|
|
628
|
+
].join("\n")
|
|
629
|
+
)
|
|
132
630
|
.action(async (conn: string | undefined, opts: {
|
|
133
631
|
dbUrl?: string;
|
|
134
632
|
host?: string;
|
|
@@ -139,43 +637,883 @@ program
|
|
|
139
637
|
monitoringUser: string;
|
|
140
638
|
password?: string;
|
|
141
639
|
skipOptionalPermissions?: boolean;
|
|
142
|
-
|
|
640
|
+
provider?: string;
|
|
641
|
+
verify?: boolean;
|
|
642
|
+
resetPassword?: boolean;
|
|
643
|
+
printSql?: boolean;
|
|
644
|
+
printPassword?: boolean;
|
|
645
|
+
supabase?: boolean;
|
|
646
|
+
supabaseAccessToken?: string;
|
|
647
|
+
supabaseProjectRef?: string;
|
|
648
|
+
json?: boolean;
|
|
649
|
+
}, cmd: Command) => {
|
|
650
|
+
// JSON output helper
|
|
651
|
+
const jsonOutput = opts.json;
|
|
652
|
+
const outputJson = (data: Record<string, unknown>) => {
|
|
653
|
+
console.log(JSON.stringify(data, null, 2));
|
|
654
|
+
};
|
|
655
|
+
const outputError = (error: {
|
|
656
|
+
message: string;
|
|
657
|
+
step?: string;
|
|
658
|
+
code?: string;
|
|
659
|
+
detail?: string;
|
|
660
|
+
hint?: string;
|
|
661
|
+
httpStatus?: number;
|
|
662
|
+
}) => {
|
|
663
|
+
if (jsonOutput) {
|
|
664
|
+
outputJson({
|
|
665
|
+
success: false,
|
|
666
|
+
mode: opts.supabase ? "supabase" : "direct",
|
|
667
|
+
error,
|
|
668
|
+
});
|
|
669
|
+
} else {
|
|
670
|
+
console.error(`Error: prepare-db${opts.supabase ? " (Supabase)" : ""}: ${error.message}`);
|
|
671
|
+
if (error.step) console.error(` Step: ${error.step}`);
|
|
672
|
+
if (error.code) console.error(` Code: ${error.code}`);
|
|
673
|
+
if (error.detail) console.error(` Detail: ${error.detail}`);
|
|
674
|
+
if (error.hint) console.error(` Hint: ${error.hint}`);
|
|
675
|
+
if (error.httpStatus) console.error(` HTTP Status: ${error.httpStatus}`);
|
|
676
|
+
}
|
|
677
|
+
process.exitCode = 1;
|
|
678
|
+
};
|
|
679
|
+
if (opts.verify && opts.resetPassword) {
|
|
680
|
+
outputError({ message: "Provide only one of --verify or --reset-password" });
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (opts.verify && opts.printSql) {
|
|
684
|
+
outputError({ message: "--verify cannot be combined with --print-sql" });
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const shouldPrintSql = !!opts.printSql;
|
|
689
|
+
const redactPasswords = (sql: string): string => redactPasswordsInSql(sql);
|
|
690
|
+
|
|
691
|
+
// Validate provider and warn if unknown
|
|
692
|
+
const providerWarning = validateProvider(opts.provider);
|
|
693
|
+
if (providerWarning) {
|
|
694
|
+
console.warn(`⚠ ${providerWarning}`);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Offline mode: allow printing SQL without providing/using an admin connection.
|
|
698
|
+
// Useful for audits/reviews; caller can provide -d/PGDATABASE.
|
|
699
|
+
if (!conn && !opts.dbUrl && !opts.host && !opts.port && !opts.username && !opts.adminPassword) {
|
|
700
|
+
if (shouldPrintSql) {
|
|
701
|
+
const database = (opts.dbname ?? process.env.PGDATABASE ?? "postgres").trim();
|
|
702
|
+
const includeOptionalPermissions = !opts.skipOptionalPermissions;
|
|
703
|
+
|
|
704
|
+
// Use explicit password/env if provided; otherwise use a placeholder.
|
|
705
|
+
// Printed SQL always redacts secrets.
|
|
706
|
+
const monPassword =
|
|
707
|
+
(opts.password ?? process.env.PGAI_MON_PASSWORD ?? "<redacted>").toString();
|
|
708
|
+
|
|
709
|
+
const plan = await buildInitPlan({
|
|
710
|
+
database,
|
|
711
|
+
monitoringUser: opts.monitoringUser,
|
|
712
|
+
monitoringPassword: monPassword,
|
|
713
|
+
includeOptionalPermissions,
|
|
714
|
+
provider: opts.provider,
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
console.log("\n--- SQL plan (offline; not connected) ---");
|
|
718
|
+
console.log(`-- database: ${database}`);
|
|
719
|
+
console.log(`-- monitoring user: ${opts.monitoringUser}`);
|
|
720
|
+
console.log(`-- provider: ${opts.provider ?? "self-managed"}`);
|
|
721
|
+
console.log(`-- optional permissions: ${includeOptionalPermissions ? "enabled" : "skipped"}`);
|
|
722
|
+
for (const step of plan.steps) {
|
|
723
|
+
console.log(`\n-- ${step.name}${step.optional ? " (optional)" : ""}`);
|
|
724
|
+
console.log(redactPasswords(step.sql));
|
|
725
|
+
}
|
|
726
|
+
console.log("\n--- end SQL plan ---\n");
|
|
727
|
+
console.log("Note: passwords are redacted in the printed SQL output.");
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Supabase mode: use Supabase Management API instead of direct PG connection
|
|
733
|
+
if (opts.supabase) {
|
|
734
|
+
let supabaseConfig;
|
|
735
|
+
try {
|
|
736
|
+
// Try to extract project ref from connection URL if provided
|
|
737
|
+
let projectRef = opts.supabaseProjectRef;
|
|
738
|
+
if (!projectRef && conn) {
|
|
739
|
+
projectRef = extractProjectRefFromUrl(conn);
|
|
740
|
+
}
|
|
741
|
+
supabaseConfig = resolveSupabaseConfig({
|
|
742
|
+
accessToken: opts.supabaseAccessToken,
|
|
743
|
+
projectRef,
|
|
744
|
+
});
|
|
745
|
+
} catch (e) {
|
|
746
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
747
|
+
outputError({ message: msg });
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const includeOptionalPermissions = !opts.skipOptionalPermissions;
|
|
752
|
+
|
|
753
|
+
if (!jsonOutput) {
|
|
754
|
+
console.log(`Supabase mode: project ref ${supabaseConfig.projectRef}`);
|
|
755
|
+
console.log(`Monitoring user: ${opts.monitoringUser}`);
|
|
756
|
+
console.log(`Optional permissions: ${includeOptionalPermissions ? "enabled" : "skipped"}`);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const supabaseClient = new SupabaseClient(supabaseConfig);
|
|
760
|
+
|
|
761
|
+
// Fetch database URL for JSON output (non-blocking, best-effort)
|
|
762
|
+
let databaseUrl: string | null = null;
|
|
763
|
+
if (jsonOutput) {
|
|
764
|
+
databaseUrl = await fetchPoolerDatabaseUrl(supabaseConfig, opts.monitoringUser);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
try {
|
|
768
|
+
// Get current database name
|
|
769
|
+
const database = await supabaseClient.getCurrentDatabase();
|
|
770
|
+
if (!database) {
|
|
771
|
+
throw new Error("Failed to resolve current database name");
|
|
772
|
+
}
|
|
773
|
+
if (!jsonOutput) {
|
|
774
|
+
console.log(`Database: ${database}`);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (opts.verify) {
|
|
778
|
+
const v = await verifyInitSetupViaSupabase({
|
|
779
|
+
client: supabaseClient,
|
|
780
|
+
database,
|
|
781
|
+
monitoringUser: opts.monitoringUser,
|
|
782
|
+
includeOptionalPermissions,
|
|
783
|
+
});
|
|
784
|
+
if (v.ok) {
|
|
785
|
+
if (jsonOutput) {
|
|
786
|
+
const result: Record<string, unknown> = {
|
|
787
|
+
success: true,
|
|
788
|
+
mode: "supabase",
|
|
789
|
+
action: "verify",
|
|
790
|
+
database,
|
|
791
|
+
monitoringUser: opts.monitoringUser,
|
|
792
|
+
verified: true,
|
|
793
|
+
missingOptional: v.missingOptional,
|
|
794
|
+
};
|
|
795
|
+
if (databaseUrl) {
|
|
796
|
+
result.databaseUrl = databaseUrl;
|
|
797
|
+
}
|
|
798
|
+
outputJson(result);
|
|
799
|
+
} else {
|
|
800
|
+
console.log("✓ prepare-db verify: OK");
|
|
801
|
+
if (v.missingOptional.length > 0) {
|
|
802
|
+
console.log("⚠ Optional items missing:");
|
|
803
|
+
for (const m of v.missingOptional) console.log(`- ${m}`);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
if (jsonOutput) {
|
|
809
|
+
const result: Record<string, unknown> = {
|
|
810
|
+
success: false,
|
|
811
|
+
mode: "supabase",
|
|
812
|
+
action: "verify",
|
|
813
|
+
database,
|
|
814
|
+
monitoringUser: opts.monitoringUser,
|
|
815
|
+
verified: false,
|
|
816
|
+
missingRequired: v.missingRequired,
|
|
817
|
+
missingOptional: v.missingOptional,
|
|
818
|
+
};
|
|
819
|
+
if (databaseUrl) {
|
|
820
|
+
result.databaseUrl = databaseUrl;
|
|
821
|
+
}
|
|
822
|
+
outputJson(result);
|
|
823
|
+
} else {
|
|
824
|
+
console.error("✗ prepare-db verify failed: missing required items");
|
|
825
|
+
for (const m of v.missingRequired) console.error(`- ${m}`);
|
|
826
|
+
if (v.missingOptional.length > 0) {
|
|
827
|
+
console.error("Optional items missing:");
|
|
828
|
+
for (const m of v.missingOptional) console.error(`- ${m}`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
process.exitCode = 1;
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
let monPassword: string;
|
|
836
|
+
let passwordGenerated = false;
|
|
837
|
+
try {
|
|
838
|
+
const resolved = await resolveMonitoringPassword({
|
|
839
|
+
passwordFlag: opts.password,
|
|
840
|
+
passwordEnv: process.env.PGAI_MON_PASSWORD,
|
|
841
|
+
monitoringUser: opts.monitoringUser,
|
|
842
|
+
});
|
|
843
|
+
monPassword = resolved.password;
|
|
844
|
+
passwordGenerated = resolved.generated;
|
|
845
|
+
if (resolved.generated) {
|
|
846
|
+
const canPrint = process.stdout.isTTY || !!opts.printPassword || jsonOutput;
|
|
847
|
+
if (canPrint) {
|
|
848
|
+
if (!jsonOutput) {
|
|
849
|
+
const shellSafe = monPassword.replace(/'/g, "'\\''");
|
|
850
|
+
console.error("");
|
|
851
|
+
console.error(`Generated monitoring password for ${opts.monitoringUser} (copy/paste):`);
|
|
852
|
+
console.error(`PGAI_MON_PASSWORD='${shellSafe}'`);
|
|
853
|
+
console.error("");
|
|
854
|
+
console.log("Store it securely (or rerun with --password / PGAI_MON_PASSWORD to set your own).");
|
|
855
|
+
}
|
|
856
|
+
// For JSON mode, password will be included in the success output below
|
|
857
|
+
} else {
|
|
858
|
+
console.error(
|
|
859
|
+
[
|
|
860
|
+
`✗ Monitoring password was auto-generated for ${opts.monitoringUser} but not printed in non-interactive mode.`,
|
|
861
|
+
"",
|
|
862
|
+
"Provide it explicitly:",
|
|
863
|
+
" --password <password> or PGAI_MON_PASSWORD=...",
|
|
864
|
+
"",
|
|
865
|
+
"Or (NOT recommended) print the generated password:",
|
|
866
|
+
" --print-password",
|
|
867
|
+
].join("\n")
|
|
868
|
+
);
|
|
869
|
+
process.exitCode = 1;
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
} catch (e) {
|
|
874
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
875
|
+
outputError({ message: msg });
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const plan = await buildInitPlan({
|
|
880
|
+
database,
|
|
881
|
+
monitoringUser: opts.monitoringUser,
|
|
882
|
+
monitoringPassword: monPassword,
|
|
883
|
+
includeOptionalPermissions,
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
// For Supabase mode, skip RDS and self-managed steps (they don't apply)
|
|
887
|
+
const supabaseApplicableSteps = plan.steps.filter(
|
|
888
|
+
(s) => s.name !== "03.optional_rds" && s.name !== "04.optional_self_managed"
|
|
889
|
+
);
|
|
890
|
+
|
|
891
|
+
const effectivePlan = opts.resetPassword
|
|
892
|
+
? { ...plan, steps: supabaseApplicableSteps.filter((s) => s.name === "01.role") }
|
|
893
|
+
: { ...plan, steps: supabaseApplicableSteps };
|
|
894
|
+
|
|
895
|
+
if (shouldPrintSql) {
|
|
896
|
+
console.log("\n--- SQL plan ---");
|
|
897
|
+
for (const step of effectivePlan.steps) {
|
|
898
|
+
console.log(`\n-- ${step.name}${step.optional ? " (optional)" : ""}`);
|
|
899
|
+
console.log(redactPasswords(step.sql));
|
|
900
|
+
}
|
|
901
|
+
console.log("\n--- end SQL plan ---\n");
|
|
902
|
+
console.log("Note: passwords are redacted in the printed SQL output.");
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const { applied, skippedOptional } = await applyInitPlanViaSupabase({
|
|
907
|
+
client: supabaseClient,
|
|
908
|
+
plan: effectivePlan,
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
if (jsonOutput) {
|
|
912
|
+
const result: Record<string, unknown> = {
|
|
913
|
+
success: true,
|
|
914
|
+
mode: "supabase",
|
|
915
|
+
action: opts.resetPassword ? "reset-password" : "apply",
|
|
916
|
+
database,
|
|
917
|
+
monitoringUser: opts.monitoringUser,
|
|
918
|
+
applied,
|
|
919
|
+
skippedOptional,
|
|
920
|
+
warnings: skippedOptional.length > 0
|
|
921
|
+
? ["Some optional steps were skipped (not supported or insufficient privileges)"]
|
|
922
|
+
: [],
|
|
923
|
+
};
|
|
924
|
+
if (passwordGenerated) {
|
|
925
|
+
result.generatedPassword = monPassword;
|
|
926
|
+
}
|
|
927
|
+
if (databaseUrl) {
|
|
928
|
+
result.databaseUrl = databaseUrl;
|
|
929
|
+
}
|
|
930
|
+
outputJson(result);
|
|
931
|
+
} else {
|
|
932
|
+
console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
|
|
933
|
+
if (skippedOptional.length > 0) {
|
|
934
|
+
console.log("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
|
|
935
|
+
for (const s of skippedOptional) console.log(`- ${s}`);
|
|
936
|
+
}
|
|
937
|
+
if (process.stdout.isTTY) {
|
|
938
|
+
console.log(`Applied ${applied.length} steps`);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
} catch (error) {
|
|
942
|
+
const errAny = error as PgCompatibleError;
|
|
943
|
+
let message = "";
|
|
944
|
+
if (error instanceof Error && error.message) {
|
|
945
|
+
message = error.message;
|
|
946
|
+
} else if (errAny && typeof errAny === "object" && typeof errAny.message === "string" && errAny.message) {
|
|
947
|
+
message = errAny.message;
|
|
948
|
+
} else {
|
|
949
|
+
message = String(error);
|
|
950
|
+
}
|
|
951
|
+
if (!message || message === "[object Object]") {
|
|
952
|
+
message = "Unknown error";
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Surface step name if this was a plan step failure
|
|
956
|
+
const stepMatch = typeof message === "string" ? message.match(/Failed at step "([^"]+)":/i) : null;
|
|
957
|
+
const failedStep = stepMatch?.[1];
|
|
958
|
+
|
|
959
|
+
// Build error object for JSON output
|
|
960
|
+
const errorObj: {
|
|
961
|
+
message: string;
|
|
962
|
+
step?: string;
|
|
963
|
+
code?: string;
|
|
964
|
+
detail?: string;
|
|
965
|
+
hint?: string;
|
|
966
|
+
httpStatus?: number;
|
|
967
|
+
} = { message };
|
|
968
|
+
|
|
969
|
+
if (failedStep) errorObj.step = failedStep;
|
|
970
|
+
if (errAny && typeof errAny === "object") {
|
|
971
|
+
if (typeof errAny.code === "string" && errAny.code) errorObj.code = errAny.code;
|
|
972
|
+
if (typeof errAny.detail === "string" && errAny.detail) errorObj.detail = errAny.detail;
|
|
973
|
+
if (typeof errAny.hint === "string" && errAny.hint) errorObj.hint = errAny.hint;
|
|
974
|
+
if (typeof errAny.httpStatus === "number") errorObj.httpStatus = errAny.httpStatus;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
if (jsonOutput) {
|
|
978
|
+
outputJson({
|
|
979
|
+
success: false,
|
|
980
|
+
mode: "supabase",
|
|
981
|
+
error: errorObj,
|
|
982
|
+
});
|
|
983
|
+
process.exitCode = 1;
|
|
984
|
+
} else {
|
|
985
|
+
console.error(`Error: prepare-db (Supabase): ${message}`);
|
|
986
|
+
|
|
987
|
+
if (failedStep) {
|
|
988
|
+
console.error(` Step: ${failedStep}`);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Surface PostgreSQL-compatible error details
|
|
992
|
+
if (errAny && typeof errAny === "object") {
|
|
993
|
+
if (typeof errAny.code === "string" && errAny.code) {
|
|
994
|
+
console.error(` Code: ${errAny.code}`);
|
|
995
|
+
}
|
|
996
|
+
if (typeof errAny.detail === "string" && errAny.detail) {
|
|
997
|
+
console.error(` Detail: ${errAny.detail}`);
|
|
998
|
+
}
|
|
999
|
+
if (typeof errAny.hint === "string" && errAny.hint) {
|
|
1000
|
+
console.error(` Hint: ${errAny.hint}`);
|
|
1001
|
+
}
|
|
1002
|
+
if (typeof errAny.httpStatus === "number") {
|
|
1003
|
+
console.error(` HTTP Status: ${errAny.httpStatus}`);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Provide context hints for common errors
|
|
1008
|
+
if (errAny && typeof errAny === "object" && typeof errAny.code === "string") {
|
|
1009
|
+
if (errAny.code === "42501") {
|
|
1010
|
+
if (failedStep === "01.role") {
|
|
1011
|
+
console.error(" Context: role creation/update requires CREATEROLE or superuser");
|
|
1012
|
+
} else if (failedStep === "03.permissions") {
|
|
1013
|
+
console.error(" Context: grants/view/search_path require sufficient GRANT/DDL privileges");
|
|
1014
|
+
}
|
|
1015
|
+
console.error(" Fix: ensure your Supabase access token has sufficient permissions");
|
|
1016
|
+
console.error(" Tip: run with --print-sql to review the exact SQL plan");
|
|
1017
|
+
}
|
|
1018
|
+
// Schema already exists (42P06) or other duplicate object errors
|
|
1019
|
+
if (errAny.code === "42P06" || (message.includes("already exists") && failedStep === "03.permissions")) {
|
|
1020
|
+
console.error(" Hint: postgres_ai schema or objects already exist from a previous setup.");
|
|
1021
|
+
console.error(" Fix: run 'postgresai unprepare-db <connection>' first to clean up, then retry prepare-db.");
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
if (errAny && typeof errAny === "object" && typeof errAny.httpStatus === "number") {
|
|
1025
|
+
if (errAny.httpStatus === 401) {
|
|
1026
|
+
console.error(" Hint: invalid or expired access token; generate a new one at https://supabase.com/dashboard/account/tokens");
|
|
1027
|
+
}
|
|
1028
|
+
if (errAny.httpStatus === 403) {
|
|
1029
|
+
console.error(" Hint: access denied; check your token permissions and project access");
|
|
1030
|
+
}
|
|
1031
|
+
if (errAny.httpStatus === 404) {
|
|
1032
|
+
console.error(" Hint: project not found; verify the project reference is correct");
|
|
1033
|
+
}
|
|
1034
|
+
if (errAny.httpStatus === 429) {
|
|
1035
|
+
console.error(" Hint: rate limited; wait a moment and try again");
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
process.exitCode = 1;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
let adminConn;
|
|
1045
|
+
try {
|
|
1046
|
+
adminConn = resolveAdminConnection({
|
|
1047
|
+
conn,
|
|
1048
|
+
dbUrlFlag: opts.dbUrl,
|
|
1049
|
+
// Allow libpq standard env vars as implicit defaults (common UX).
|
|
1050
|
+
host: opts.host ?? process.env.PGHOST,
|
|
1051
|
+
port: opts.port ?? process.env.PGPORT,
|
|
1052
|
+
username: opts.username ?? process.env.PGUSER,
|
|
1053
|
+
dbname: opts.dbname ?? process.env.PGDATABASE,
|
|
1054
|
+
adminPassword: opts.adminPassword,
|
|
1055
|
+
envPassword: process.env.PGPASSWORD,
|
|
1056
|
+
});
|
|
1057
|
+
} catch (e) {
|
|
1058
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1059
|
+
if (jsonOutput) {
|
|
1060
|
+
outputError({ message: msg });
|
|
1061
|
+
} else {
|
|
1062
|
+
console.error(`Error: prepare-db: ${msg}`);
|
|
1063
|
+
// When connection details are missing, show full init help (options + examples).
|
|
1064
|
+
if (typeof msg === "string" && msg.startsWith("Connection is required.")) {
|
|
1065
|
+
console.error("");
|
|
1066
|
+
cmd.outputHelp({ error: true });
|
|
1067
|
+
}
|
|
1068
|
+
process.exitCode = 1;
|
|
1069
|
+
}
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const includeOptionalPermissions = !opts.skipOptionalPermissions;
|
|
1074
|
+
|
|
1075
|
+
if (!jsonOutput) {
|
|
1076
|
+
console.log(`Connecting to: ${adminConn.display}`);
|
|
1077
|
+
console.log(`Monitoring user: ${opts.monitoringUser}`);
|
|
1078
|
+
console.log(`Optional permissions: ${includeOptionalPermissions ? "enabled" : "skipped"}`);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// Use native pg client instead of requiring psql to be installed
|
|
1082
|
+
let client: Client | undefined;
|
|
1083
|
+
try {
|
|
1084
|
+
const connResult = await connectWithSslFallback(Client, adminConn);
|
|
1085
|
+
client = connResult.client;
|
|
1086
|
+
|
|
1087
|
+
const dbRes = await client.query("select current_database() as db");
|
|
1088
|
+
const database = dbRes.rows?.[0]?.db;
|
|
1089
|
+
if (typeof database !== "string" || !database) {
|
|
1090
|
+
throw new Error("Failed to resolve current database name");
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
if (opts.verify) {
|
|
1094
|
+
const v = await verifyInitSetup({
|
|
1095
|
+
client,
|
|
1096
|
+
database,
|
|
1097
|
+
monitoringUser: opts.monitoringUser,
|
|
1098
|
+
includeOptionalPermissions,
|
|
1099
|
+
provider: opts.provider,
|
|
1100
|
+
});
|
|
1101
|
+
if (v.ok) {
|
|
1102
|
+
if (jsonOutput) {
|
|
1103
|
+
outputJson({
|
|
1104
|
+
success: true,
|
|
1105
|
+
mode: "direct",
|
|
1106
|
+
action: "verify",
|
|
1107
|
+
database,
|
|
1108
|
+
monitoringUser: opts.monitoringUser,
|
|
1109
|
+
provider: opts.provider,
|
|
1110
|
+
verified: true,
|
|
1111
|
+
missingOptional: v.missingOptional,
|
|
1112
|
+
});
|
|
1113
|
+
} else {
|
|
1114
|
+
console.log(`✓ prepare-db verify: OK${opts.provider ? ` (provider: ${opts.provider})` : ""}`);
|
|
1115
|
+
if (v.missingOptional.length > 0) {
|
|
1116
|
+
console.log("⚠ Optional items missing:");
|
|
1117
|
+
for (const m of v.missingOptional) console.log(`- ${m}`);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
if (jsonOutput) {
|
|
1123
|
+
outputJson({
|
|
1124
|
+
success: false,
|
|
1125
|
+
mode: "direct",
|
|
1126
|
+
action: "verify",
|
|
1127
|
+
database,
|
|
1128
|
+
monitoringUser: opts.monitoringUser,
|
|
1129
|
+
verified: false,
|
|
1130
|
+
missingRequired: v.missingRequired,
|
|
1131
|
+
missingOptional: v.missingOptional,
|
|
1132
|
+
});
|
|
1133
|
+
} else {
|
|
1134
|
+
console.error("✗ prepare-db verify failed: missing required items");
|
|
1135
|
+
for (const m of v.missingRequired) console.error(`- ${m}`);
|
|
1136
|
+
if (v.missingOptional.length > 0) {
|
|
1137
|
+
console.error("Optional items missing:");
|
|
1138
|
+
for (const m of v.missingOptional) console.error(`- ${m}`);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
process.exitCode = 1;
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
let monPassword: string;
|
|
1146
|
+
let passwordGenerated = false;
|
|
1147
|
+
try {
|
|
1148
|
+
const resolved = await resolveMonitoringPassword({
|
|
1149
|
+
passwordFlag: opts.password,
|
|
1150
|
+
passwordEnv: process.env.PGAI_MON_PASSWORD,
|
|
1151
|
+
monitoringUser: opts.monitoringUser,
|
|
1152
|
+
});
|
|
1153
|
+
monPassword = resolved.password;
|
|
1154
|
+
passwordGenerated = resolved.generated;
|
|
1155
|
+
if (resolved.generated) {
|
|
1156
|
+
const canPrint = process.stdout.isTTY || !!opts.printPassword || jsonOutput;
|
|
1157
|
+
if (canPrint) {
|
|
1158
|
+
if (!jsonOutput) {
|
|
1159
|
+
// Print secrets to stderr to reduce the chance they end up in piped stdout logs.
|
|
1160
|
+
const shellSafe = monPassword.replace(/'/g, "'\\''");
|
|
1161
|
+
console.error("");
|
|
1162
|
+
console.error(`Generated monitoring password for ${opts.monitoringUser} (copy/paste):`);
|
|
1163
|
+
// Quote for shell copy/paste safety.
|
|
1164
|
+
console.error(`PGAI_MON_PASSWORD='${shellSafe}'`);
|
|
1165
|
+
console.error("");
|
|
1166
|
+
console.log("Store it securely (or rerun with --password / PGAI_MON_PASSWORD to set your own).");
|
|
1167
|
+
}
|
|
1168
|
+
// For JSON mode, password will be included in the success output below
|
|
1169
|
+
} else {
|
|
1170
|
+
console.error(
|
|
1171
|
+
[
|
|
1172
|
+
`✗ Monitoring password was auto-generated for ${opts.monitoringUser} but not printed in non-interactive mode.`,
|
|
1173
|
+
"",
|
|
1174
|
+
"Provide it explicitly:",
|
|
1175
|
+
" --password <password> or PGAI_MON_PASSWORD=...",
|
|
1176
|
+
"",
|
|
1177
|
+
"Or (NOT recommended) print the generated password:",
|
|
1178
|
+
" --print-password",
|
|
1179
|
+
].join("\n")
|
|
1180
|
+
);
|
|
1181
|
+
process.exitCode = 1;
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
} catch (e) {
|
|
1186
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1187
|
+
outputError({ message: msg });
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const plan = await buildInitPlan({
|
|
1192
|
+
database,
|
|
1193
|
+
monitoringUser: opts.monitoringUser,
|
|
1194
|
+
monitoringPassword: monPassword,
|
|
1195
|
+
includeOptionalPermissions,
|
|
1196
|
+
provider: opts.provider,
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
// For reset-password, we only want the role step. But if provider skips role creation,
|
|
1200
|
+
// reset-password doesn't make sense - warn the user.
|
|
1201
|
+
const effectivePlan = opts.resetPassword
|
|
1202
|
+
? { ...plan, steps: plan.steps.filter((s) => s.name === "01.role") }
|
|
1203
|
+
: plan;
|
|
1204
|
+
|
|
1205
|
+
if (opts.resetPassword && effectivePlan.steps.length === 0) {
|
|
1206
|
+
console.error(`✗ --reset-password not supported for provider "${opts.provider}" (role creation is skipped)`);
|
|
1207
|
+
process.exitCode = 1;
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
if (shouldPrintSql) {
|
|
1212
|
+
console.log("\n--- SQL plan ---");
|
|
1213
|
+
for (const step of effectivePlan.steps) {
|
|
1214
|
+
console.log(`\n-- ${step.name}${step.optional ? " (optional)" : ""}`);
|
|
1215
|
+
console.log(redactPasswords(step.sql));
|
|
1216
|
+
}
|
|
1217
|
+
console.log("\n--- end SQL plan ---\n");
|
|
1218
|
+
console.log("Note: passwords are redacted in the printed SQL output.");
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const { applied, skippedOptional } = await applyInitPlan({ client, plan: effectivePlan });
|
|
1223
|
+
|
|
1224
|
+
if (jsonOutput) {
|
|
1225
|
+
const result: Record<string, unknown> = {
|
|
1226
|
+
success: true,
|
|
1227
|
+
mode: "direct",
|
|
1228
|
+
action: opts.resetPassword ? "reset-password" : "apply",
|
|
1229
|
+
database,
|
|
1230
|
+
monitoringUser: opts.monitoringUser,
|
|
1231
|
+
applied,
|
|
1232
|
+
skippedOptional,
|
|
1233
|
+
warnings: skippedOptional.length > 0
|
|
1234
|
+
? ["Some optional steps were skipped (not supported or insufficient privileges)"]
|
|
1235
|
+
: [],
|
|
1236
|
+
};
|
|
1237
|
+
if (passwordGenerated) {
|
|
1238
|
+
result.generatedPassword = monPassword;
|
|
1239
|
+
}
|
|
1240
|
+
outputJson(result);
|
|
1241
|
+
} else {
|
|
1242
|
+
console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
|
|
1243
|
+
if (skippedOptional.length > 0) {
|
|
1244
|
+
console.log("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
|
|
1245
|
+
for (const s of skippedOptional) console.log(`- ${s}`);
|
|
1246
|
+
}
|
|
1247
|
+
// Keep output compact but still useful
|
|
1248
|
+
if (process.stdout.isTTY) {
|
|
1249
|
+
console.log(`Applied ${applied.length} steps`);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
} catch (error) {
|
|
1253
|
+
const errAny = error as any;
|
|
1254
|
+
let message = "";
|
|
1255
|
+
if (error instanceof Error && error.message) {
|
|
1256
|
+
message = error.message;
|
|
1257
|
+
} else if (errAny && typeof errAny === "object" && typeof errAny.message === "string" && errAny.message) {
|
|
1258
|
+
message = errAny.message;
|
|
1259
|
+
} else {
|
|
1260
|
+
message = String(error);
|
|
1261
|
+
}
|
|
1262
|
+
if (!message || message === "[object Object]") {
|
|
1263
|
+
message = "Unknown error";
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// If this was a plan step failure, surface the step name explicitly to help users diagnose quickly.
|
|
1267
|
+
const stepMatch =
|
|
1268
|
+
typeof message === "string" ? message.match(/Failed at step "([^"]+)":/i) : null;
|
|
1269
|
+
const failedStep = stepMatch?.[1];
|
|
1270
|
+
|
|
1271
|
+
// Build error object for JSON output
|
|
1272
|
+
const errorObj: {
|
|
1273
|
+
message: string;
|
|
1274
|
+
step?: string;
|
|
1275
|
+
code?: string;
|
|
1276
|
+
detail?: string;
|
|
1277
|
+
hint?: string;
|
|
1278
|
+
} = { message };
|
|
1279
|
+
|
|
1280
|
+
if (failedStep) errorObj.step = failedStep;
|
|
1281
|
+
if (errAny && typeof errAny === "object") {
|
|
1282
|
+
if (typeof errAny.code === "string" && errAny.code) errorObj.code = errAny.code;
|
|
1283
|
+
if (typeof errAny.detail === "string" && errAny.detail) errorObj.detail = errAny.detail;
|
|
1284
|
+
if (typeof errAny.hint === "string" && errAny.hint) errorObj.hint = errAny.hint;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
if (jsonOutput) {
|
|
1288
|
+
outputJson({
|
|
1289
|
+
success: false,
|
|
1290
|
+
mode: "direct",
|
|
1291
|
+
error: errorObj,
|
|
1292
|
+
});
|
|
1293
|
+
process.exitCode = 1;
|
|
1294
|
+
} else {
|
|
1295
|
+
console.error(`Error: prepare-db: ${message}`);
|
|
1296
|
+
if (failedStep) {
|
|
1297
|
+
console.error(` Step: ${failedStep}`);
|
|
1298
|
+
}
|
|
1299
|
+
if (errAny && typeof errAny === "object") {
|
|
1300
|
+
if (typeof errAny.code === "string" && errAny.code) {
|
|
1301
|
+
console.error(` Code: ${errAny.code}`);
|
|
1302
|
+
}
|
|
1303
|
+
if (typeof errAny.detail === "string" && errAny.detail) {
|
|
1304
|
+
console.error(` Detail: ${errAny.detail}`);
|
|
1305
|
+
}
|
|
1306
|
+
if (typeof errAny.hint === "string" && errAny.hint) {
|
|
1307
|
+
console.error(` Hint: ${errAny.hint}`);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
if (errAny && typeof errAny === "object" && typeof errAny.code === "string") {
|
|
1311
|
+
if (errAny.code === "42501") {
|
|
1312
|
+
if (failedStep === "01.role") {
|
|
1313
|
+
console.error(" Context: role creation/update requires CREATEROLE or superuser");
|
|
1314
|
+
} else if (failedStep === "03.permissions") {
|
|
1315
|
+
console.error(" Context: grants/view/search_path require sufficient GRANT/DDL privileges");
|
|
1316
|
+
}
|
|
1317
|
+
console.error(" Fix: connect as a superuser (or a role with CREATEROLE and sufficient GRANT privileges)");
|
|
1318
|
+
console.error(" Fix: on managed Postgres, use the provider's admin/master user");
|
|
1319
|
+
console.error(" Tip: run with --print-sql to review the exact SQL plan");
|
|
1320
|
+
}
|
|
1321
|
+
// Schema already exists (42P06) or other duplicate object errors
|
|
1322
|
+
if (errAny.code === "42P06" || (message.includes("already exists") && failedStep === "03.permissions")) {
|
|
1323
|
+
console.error(" Hint: postgres_ai schema or objects already exist from a previous setup.");
|
|
1324
|
+
console.error(" Fix: run 'postgresai unprepare-db <connection>' first to clean up, then retry prepare-db.");
|
|
1325
|
+
}
|
|
1326
|
+
if (errAny.code === "ECONNREFUSED") {
|
|
1327
|
+
console.error(" Hint: check host/port and ensure Postgres is reachable from this machine");
|
|
1328
|
+
}
|
|
1329
|
+
if (errAny.code === "ENOTFOUND") {
|
|
1330
|
+
console.error(" Hint: DNS resolution failed; double-check the host name");
|
|
1331
|
+
}
|
|
1332
|
+
if (errAny.code === "ETIMEDOUT") {
|
|
1333
|
+
console.error(" Hint: connection timed out; check network/firewall rules");
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
process.exitCode = 1;
|
|
1337
|
+
}
|
|
1338
|
+
} finally {
|
|
1339
|
+
if (client) {
|
|
1340
|
+
try {
|
|
1341
|
+
await client.end();
|
|
1342
|
+
} catch {
|
|
1343
|
+
// ignore
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
program
|
|
1350
|
+
.command("unprepare-db [conn]")
|
|
1351
|
+
.description("remove monitoring setup: drop monitoring user, views, schema, and revoke permissions")
|
|
1352
|
+
.option("--db-url <url>", "PostgreSQL connection URL (admin) (deprecated; pass it as positional arg)")
|
|
1353
|
+
.option("-h, --host <host>", "PostgreSQL host (psql-like)")
|
|
1354
|
+
.option("-p, --port <port>", "PostgreSQL port (psql-like)")
|
|
1355
|
+
.option("-U, --username <username>", "PostgreSQL user (psql-like)")
|
|
1356
|
+
.option("-d, --dbname <dbname>", "PostgreSQL database name (psql-like)")
|
|
1357
|
+
.option("--admin-password <password>", "Admin connection password (otherwise uses PGPASSWORD if set)")
|
|
1358
|
+
.option("--monitoring-user <name>", "Monitoring role name to remove", DEFAULT_MONITORING_USER)
|
|
1359
|
+
.option("--keep-role", "Keep the monitoring role (only revoke permissions and drop objects)", false)
|
|
1360
|
+
.option("--provider <provider>", "Database provider (e.g., supabase). Affects which steps are executed.")
|
|
1361
|
+
.option("--print-sql", "Print SQL plan and exit (no changes applied)", false)
|
|
1362
|
+
.option("--force", "Skip confirmation prompt", false)
|
|
1363
|
+
.option("--json", "Output result as JSON (machine-readable)", false)
|
|
1364
|
+
.addHelpText(
|
|
1365
|
+
"after",
|
|
1366
|
+
[
|
|
1367
|
+
"",
|
|
1368
|
+
"Examples:",
|
|
1369
|
+
" postgresai unprepare-db postgresql://admin@host:5432/dbname",
|
|
1370
|
+
" postgresai unprepare-db \"dbname=dbname host=host user=admin\"",
|
|
1371
|
+
" postgresai unprepare-db -h host -p 5432 -U admin -d dbname",
|
|
1372
|
+
"",
|
|
1373
|
+
"Admin password:",
|
|
1374
|
+
" --admin-password <password> or PGPASSWORD=... (libpq standard)",
|
|
1375
|
+
"",
|
|
1376
|
+
"Keep role but remove objects/permissions:",
|
|
1377
|
+
" postgresai unprepare-db <conn> --keep-role",
|
|
1378
|
+
"",
|
|
1379
|
+
"Inspect SQL without applying changes:",
|
|
1380
|
+
" postgresai unprepare-db <conn> --print-sql",
|
|
1381
|
+
"",
|
|
1382
|
+
"Offline SQL plan (no DB connection):",
|
|
1383
|
+
" postgresai unprepare-db --print-sql",
|
|
1384
|
+
"",
|
|
1385
|
+
"Skip confirmation prompt:",
|
|
1386
|
+
" postgresai unprepare-db <conn> --force",
|
|
1387
|
+
].join("\n")
|
|
1388
|
+
)
|
|
1389
|
+
.action(async (conn: string | undefined, opts: {
|
|
1390
|
+
dbUrl?: string;
|
|
1391
|
+
host?: string;
|
|
1392
|
+
port?: string;
|
|
1393
|
+
username?: string;
|
|
1394
|
+
dbname?: string;
|
|
1395
|
+
adminPassword?: string;
|
|
1396
|
+
monitoringUser: string;
|
|
1397
|
+
keepRole?: boolean;
|
|
1398
|
+
provider?: string;
|
|
1399
|
+
printSql?: boolean;
|
|
1400
|
+
force?: boolean;
|
|
1401
|
+
json?: boolean;
|
|
1402
|
+
}, cmd: Command) => {
|
|
1403
|
+
// JSON output helper
|
|
1404
|
+
const jsonOutput = opts.json;
|
|
1405
|
+
const outputJson = (data: Record<string, unknown>) => {
|
|
1406
|
+
console.log(JSON.stringify(data, null, 2));
|
|
1407
|
+
};
|
|
1408
|
+
const outputError = (error: {
|
|
1409
|
+
message: string;
|
|
1410
|
+
step?: string;
|
|
1411
|
+
code?: string;
|
|
1412
|
+
detail?: string;
|
|
1413
|
+
hint?: string;
|
|
1414
|
+
}) => {
|
|
1415
|
+
if (jsonOutput) {
|
|
1416
|
+
outputJson({
|
|
1417
|
+
success: false,
|
|
1418
|
+
error,
|
|
1419
|
+
});
|
|
1420
|
+
} else {
|
|
1421
|
+
console.error(`Error: unprepare-db: ${error.message}`);
|
|
1422
|
+
if (error.step) console.error(` Step: ${error.step}`);
|
|
1423
|
+
if (error.code) console.error(` Code: ${error.code}`);
|
|
1424
|
+
if (error.detail) console.error(` Detail: ${error.detail}`);
|
|
1425
|
+
if (error.hint) console.error(` Hint: ${error.hint}`);
|
|
1426
|
+
}
|
|
1427
|
+
process.exitCode = 1;
|
|
1428
|
+
};
|
|
1429
|
+
|
|
1430
|
+
const shouldPrintSql = !!opts.printSql;
|
|
1431
|
+
const dropRole = !opts.keepRole;
|
|
1432
|
+
|
|
1433
|
+
// Validate provider and warn if unknown
|
|
1434
|
+
const providerWarning = validateProvider(opts.provider);
|
|
1435
|
+
if (providerWarning) {
|
|
1436
|
+
console.warn(`⚠ ${providerWarning}`);
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// Offline mode: allow printing SQL without providing/using an admin connection.
|
|
1440
|
+
if (!conn && !opts.dbUrl && !opts.host && !opts.port && !opts.username && !opts.adminPassword) {
|
|
1441
|
+
if (shouldPrintSql) {
|
|
1442
|
+
const database = (opts.dbname ?? process.env.PGDATABASE ?? "postgres").trim();
|
|
1443
|
+
|
|
1444
|
+
const plan = await buildUninitPlan({
|
|
1445
|
+
database,
|
|
1446
|
+
monitoringUser: opts.monitoringUser,
|
|
1447
|
+
dropRole,
|
|
1448
|
+
provider: opts.provider,
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
console.log("\n--- SQL plan (offline; not connected) ---");
|
|
1452
|
+
console.log(`-- database: ${database}`);
|
|
1453
|
+
console.log(`-- monitoring user: ${opts.monitoringUser}`);
|
|
1454
|
+
console.log(`-- provider: ${opts.provider ?? "self-managed"}`);
|
|
1455
|
+
console.log(`-- drop role: ${dropRole}`);
|
|
1456
|
+
for (const step of plan.steps) {
|
|
1457
|
+
console.log(`\n-- ${step.name}`);
|
|
1458
|
+
console.log(step.sql);
|
|
1459
|
+
}
|
|
1460
|
+
console.log("\n--- end SQL plan ---\n");
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
143
1465
|
let adminConn;
|
|
144
1466
|
try {
|
|
145
1467
|
adminConn = resolveAdminConnection({
|
|
146
1468
|
conn,
|
|
147
1469
|
dbUrlFlag: opts.dbUrl,
|
|
148
|
-
host: opts.host,
|
|
149
|
-
port: opts.port,
|
|
150
|
-
username: opts.username,
|
|
151
|
-
dbname: opts.dbname,
|
|
1470
|
+
host: opts.host ?? process.env.PGHOST,
|
|
1471
|
+
port: opts.port ?? process.env.PGPORT,
|
|
1472
|
+
username: opts.username ?? process.env.PGUSER,
|
|
1473
|
+
dbname: opts.dbname ?? process.env.PGDATABASE,
|
|
152
1474
|
adminPassword: opts.adminPassword,
|
|
153
1475
|
envPassword: process.env.PGPASSWORD,
|
|
154
1476
|
});
|
|
155
1477
|
} catch (e) {
|
|
156
1478
|
const msg = e instanceof Error ? e.message : String(e);
|
|
157
|
-
|
|
158
|
-
|
|
1479
|
+
if (jsonOutput) {
|
|
1480
|
+
outputError({ message: msg });
|
|
1481
|
+
} else {
|
|
1482
|
+
console.error(`Error: unprepare-db: ${msg}`);
|
|
1483
|
+
if (typeof msg === "string" && msg.startsWith("Connection is required.")) {
|
|
1484
|
+
console.error("");
|
|
1485
|
+
cmd.outputHelp({ error: true });
|
|
1486
|
+
}
|
|
1487
|
+
process.exitCode = 1;
|
|
1488
|
+
}
|
|
159
1489
|
return;
|
|
160
1490
|
}
|
|
161
1491
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
1492
|
+
if (!jsonOutput) {
|
|
1493
|
+
console.log(`Connecting to: ${adminConn.display}`);
|
|
1494
|
+
console.log(`Monitoring user: ${opts.monitoringUser}`);
|
|
1495
|
+
console.log(`Drop role: ${dropRole}`);
|
|
1496
|
+
}
|
|
167
1497
|
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
1498
|
+
// Confirmation prompt (unless --force or --json)
|
|
1499
|
+
if (!opts.force && !jsonOutput && !shouldPrintSql) {
|
|
1500
|
+
const answer = await new Promise<string>((resolve) => {
|
|
1501
|
+
const readline = getReadline();
|
|
1502
|
+
readline.question(
|
|
1503
|
+
`This will remove the monitoring setup for user "${opts.monitoringUser}"${dropRole ? " and drop the role" : ""}. Continue? [y/N] `,
|
|
1504
|
+
(ans) => resolve(ans.trim().toLowerCase())
|
|
1505
|
+
);
|
|
1506
|
+
});
|
|
1507
|
+
if (answer !== "y" && answer !== "yes") {
|
|
1508
|
+
console.log("Aborted.");
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
171
1512
|
|
|
1513
|
+
let client: Client | undefined;
|
|
172
1514
|
try {
|
|
173
|
-
await
|
|
174
|
-
|
|
175
|
-
const roleRes = await client.query("select 1 from pg_catalog.pg_roles where rolname = $1", [
|
|
176
|
-
opts.monitoringUser,
|
|
177
|
-
]);
|
|
178
|
-
const roleExists = roleRes.rowCount > 0;
|
|
1515
|
+
const connResult = await connectWithSslFallback(Client, adminConn);
|
|
1516
|
+
client = connResult.client;
|
|
179
1517
|
|
|
180
1518
|
const dbRes = await client.query("select current_database() as db");
|
|
181
1519
|
const database = dbRes.rows?.[0]?.db;
|
|
@@ -183,59 +1521,265 @@ program
|
|
|
183
1521
|
throw new Error("Failed to resolve current database name");
|
|
184
1522
|
}
|
|
185
1523
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
1524
|
+
const plan = await buildUninitPlan({
|
|
1525
|
+
database,
|
|
1526
|
+
monitoringUser: opts.monitoringUser,
|
|
1527
|
+
dropRole,
|
|
1528
|
+
provider: opts.provider,
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
if (shouldPrintSql) {
|
|
1532
|
+
console.log("\n--- SQL plan ---");
|
|
1533
|
+
for (const step of plan.steps) {
|
|
1534
|
+
console.log(`\n-- ${step.name}`);
|
|
1535
|
+
console.log(step.sql);
|
|
1536
|
+
}
|
|
1537
|
+
console.log("\n--- end SQL plan ---\n");
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
const { applied, errors } = await applyUninitPlan({ client, plan });
|
|
1542
|
+
|
|
1543
|
+
if (jsonOutput) {
|
|
1544
|
+
outputJson({
|
|
1545
|
+
success: errors.length === 0,
|
|
1546
|
+
action: "unprepare",
|
|
1547
|
+
database,
|
|
191
1548
|
monitoringUser: opts.monitoringUser,
|
|
1549
|
+
dropRole,
|
|
1550
|
+
applied,
|
|
1551
|
+
errors,
|
|
192
1552
|
});
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
console.log(`Generated password for monitoring user ${opts.monitoringUser}: ${monPassword}`);
|
|
196
|
-
console.log("Store it securely (or rerun with --password / PGAI_MON_PASSWORD to set your own).");
|
|
1553
|
+
if (errors.length > 0) {
|
|
1554
|
+
process.exitCode = 1;
|
|
197
1555
|
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
|
|
1556
|
+
} else {
|
|
1557
|
+
if (errors.length === 0) {
|
|
1558
|
+
console.log("✓ unprepare-db completed");
|
|
1559
|
+
console.log(`Applied ${applied.length} steps`);
|
|
1560
|
+
} else {
|
|
1561
|
+
console.log("⚠ unprepare-db completed with errors");
|
|
1562
|
+
console.log(`Applied ${applied.length} steps`);
|
|
1563
|
+
console.log("Errors:");
|
|
1564
|
+
for (const err of errors) {
|
|
1565
|
+
console.log(` - ${err}`);
|
|
1566
|
+
}
|
|
1567
|
+
process.exitCode = 1;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
} catch (error) {
|
|
1571
|
+
const errAny = error as any;
|
|
1572
|
+
let message = "";
|
|
1573
|
+
if (error instanceof Error && error.message) {
|
|
1574
|
+
message = error.message;
|
|
1575
|
+
} else if (errAny && typeof errAny === "object" && typeof errAny.message === "string" && errAny.message) {
|
|
1576
|
+
message = errAny.message;
|
|
1577
|
+
} else {
|
|
1578
|
+
message = String(error);
|
|
1579
|
+
}
|
|
1580
|
+
if (!message || message === "[object Object]") {
|
|
1581
|
+
message = "Unknown error";
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
const errorObj: {
|
|
1585
|
+
message: string;
|
|
1586
|
+
code?: string;
|
|
1587
|
+
detail?: string;
|
|
1588
|
+
hint?: string;
|
|
1589
|
+
} = { message };
|
|
1590
|
+
|
|
1591
|
+
if (errAny && typeof errAny === "object") {
|
|
1592
|
+
if (typeof errAny.code === "string" && errAny.code) errorObj.code = errAny.code;
|
|
1593
|
+
if (typeof errAny.detail === "string" && errAny.detail) errorObj.detail = errAny.detail;
|
|
1594
|
+
if (typeof errAny.hint === "string" && errAny.hint) errorObj.hint = errAny.hint;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
if (jsonOutput) {
|
|
1598
|
+
outputJson({
|
|
1599
|
+
success: false,
|
|
1600
|
+
error: errorObj,
|
|
1601
|
+
});
|
|
201
1602
|
process.exitCode = 1;
|
|
202
|
-
|
|
1603
|
+
} else {
|
|
1604
|
+
console.error(`Error: unprepare-db: ${message}`);
|
|
1605
|
+
if (errAny && typeof errAny === "object") {
|
|
1606
|
+
if (typeof errAny.code === "string" && errAny.code) {
|
|
1607
|
+
console.error(` Code: ${errAny.code}`);
|
|
1608
|
+
}
|
|
1609
|
+
if (typeof errAny.detail === "string" && errAny.detail) {
|
|
1610
|
+
console.error(` Detail: ${errAny.detail}`);
|
|
1611
|
+
}
|
|
1612
|
+
if (typeof errAny.hint === "string" && errAny.hint) {
|
|
1613
|
+
console.error(` Hint: ${errAny.hint}`);
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
if (errAny && typeof errAny === "object" && typeof errAny.code === "string") {
|
|
1617
|
+
if (errAny.code === "42501") {
|
|
1618
|
+
console.error(" Context: dropping roles/objects requires sufficient privileges");
|
|
1619
|
+
console.error(" Fix: connect as a superuser (or a role with appropriate DROP privileges)");
|
|
1620
|
+
}
|
|
1621
|
+
if (errAny.code === "ECONNREFUSED") {
|
|
1622
|
+
console.error(" Hint: check host/port and ensure Postgres is reachable from this machine");
|
|
1623
|
+
}
|
|
1624
|
+
if (errAny.code === "ENOTFOUND") {
|
|
1625
|
+
console.error(" Hint: DNS resolution failed; double-check the host name");
|
|
1626
|
+
}
|
|
1627
|
+
if (errAny.code === "ETIMEDOUT") {
|
|
1628
|
+
console.error(" Hint: connection timed out; check network/firewall rules");
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
process.exitCode = 1;
|
|
1632
|
+
}
|
|
1633
|
+
} finally {
|
|
1634
|
+
if (client) {
|
|
1635
|
+
try {
|
|
1636
|
+
await client.end();
|
|
1637
|
+
} catch {
|
|
1638
|
+
// ignore
|
|
1639
|
+
}
|
|
203
1640
|
}
|
|
1641
|
+
closeReadline();
|
|
1642
|
+
}
|
|
1643
|
+
});
|
|
204
1644
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
1645
|
+
program
|
|
1646
|
+
.command("checkup [conn]")
|
|
1647
|
+
.description("generate health check reports directly from PostgreSQL (express mode)")
|
|
1648
|
+
.option("--check-id <id>", `specific check to run (see list below), or ALL`, "ALL")
|
|
1649
|
+
.option("--node-name <name>", "node name for reports", "node-01")
|
|
1650
|
+
.option("--output <path>", "output directory for JSON files")
|
|
1651
|
+
.option("--[no-]upload", "upload JSON results to PostgresAI (default: enabled; requires API key)", undefined)
|
|
1652
|
+
.option(
|
|
1653
|
+
"--project <project>",
|
|
1654
|
+
"project name or ID for remote upload (used with --upload; defaults to config defaultProject; auto-generated on first run)"
|
|
1655
|
+
)
|
|
1656
|
+
.option("--json", "output JSON to stdout (implies --no-upload)")
|
|
1657
|
+
.addHelpText(
|
|
1658
|
+
"after",
|
|
1659
|
+
[
|
|
1660
|
+
"",
|
|
1661
|
+
"Available checks:",
|
|
1662
|
+
...Object.entries(CHECK_INFO).map(([id, title]) => ` ${id}: ${title}`),
|
|
1663
|
+
"",
|
|
1664
|
+
"Examples:",
|
|
1665
|
+
" postgresai checkup postgresql://user:pass@host:5432/db",
|
|
1666
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --check-id D001",
|
|
1667
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --output ./reports",
|
|
1668
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --project my_project",
|
|
1669
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --no-upload --json",
|
|
1670
|
+
].join("\n")
|
|
1671
|
+
)
|
|
1672
|
+
.action(async (conn: string | undefined, opts: CheckupOptions, cmd: Command) => {
|
|
1673
|
+
if (!conn) {
|
|
1674
|
+
cmd.outputHelp();
|
|
1675
|
+
process.exitCode = 1;
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
212
1678
|
|
|
213
|
-
|
|
1679
|
+
const shouldPrintJson = !!opts.json;
|
|
1680
|
+
const uploadExplicitlyRequested = opts.upload === true;
|
|
1681
|
+
const uploadExplicitlyDisabled = opts.upload === false || shouldPrintJson;
|
|
1682
|
+
let shouldUpload = !uploadExplicitlyDisabled;
|
|
214
1683
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
1684
|
+
// Preflight: validate/create output directory BEFORE connecting / running checks.
|
|
1685
|
+
const outputPath = prepareOutputDirectory(opts.output);
|
|
1686
|
+
if (outputPath === null) {
|
|
1687
|
+
process.exitCode = 1;
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// Preflight: validate upload flags/credentials BEFORE connecting / running checks.
|
|
1692
|
+
const rootOpts = program.opts() as CliOptions;
|
|
1693
|
+
const uploadResult = prepareUploadConfig(opts, rootOpts, shouldUpload, uploadExplicitlyRequested);
|
|
1694
|
+
if (uploadResult === null) {
|
|
1695
|
+
process.exitCode = 1;
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
const uploadCfg = uploadResult?.config;
|
|
1699
|
+
const projectWasGenerated = uploadResult?.projectWasGenerated ?? false;
|
|
1700
|
+
shouldUpload = !!uploadCfg;
|
|
1701
|
+
|
|
1702
|
+
// Connect and run checks
|
|
1703
|
+
const adminConn = resolveAdminConnection({
|
|
1704
|
+
conn,
|
|
1705
|
+
envPassword: process.env.PGPASSWORD,
|
|
1706
|
+
});
|
|
1707
|
+
let client: Client | undefined;
|
|
1708
|
+
const spinnerEnabled = !!process.stdout.isTTY && shouldUpload;
|
|
1709
|
+
const spinner = createTtySpinner(spinnerEnabled, "Connecting to Postgres");
|
|
1710
|
+
|
|
1711
|
+
try {
|
|
1712
|
+
spinner.update("Connecting to Postgres");
|
|
1713
|
+
const connResult = await connectWithSslFallback(Client, adminConn);
|
|
1714
|
+
client = connResult.client as Client;
|
|
1715
|
+
|
|
1716
|
+
// Generate reports
|
|
1717
|
+
let reports: Record<string, any>;
|
|
1718
|
+
if (opts.checkId === "ALL") {
|
|
1719
|
+
reports = await generateAllReports(client, opts.nodeName, (p) => {
|
|
1720
|
+
spinner.update(`Running ${p.checkId}: ${p.checkTitle} (${p.index}/${p.total})`);
|
|
1721
|
+
});
|
|
1722
|
+
} else {
|
|
1723
|
+
const checkId = opts.checkId.toUpperCase();
|
|
1724
|
+
const generator = REPORT_GENERATORS[checkId];
|
|
1725
|
+
if (!generator) {
|
|
1726
|
+
spinner.stop();
|
|
1727
|
+
// Check if it's a valid check ID from the dictionary (just not implemented in express mode)
|
|
1728
|
+
const dictEntry = getCheckupEntry(checkId);
|
|
1729
|
+
if (dictEntry) {
|
|
1730
|
+
console.error(`Check ${checkId} (${dictEntry.title}) is not yet available in express mode.`);
|
|
1731
|
+
console.error(`Express-mode checks: ${Object.keys(CHECK_INFO).join(", ")}`);
|
|
1732
|
+
} else {
|
|
1733
|
+
console.error(`Unknown check ID: ${opts.checkId}`);
|
|
1734
|
+
console.error(`See 'postgresai checkup --help' for available checks.`);
|
|
1735
|
+
}
|
|
1736
|
+
process.exitCode = 1;
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
spinner.update(`Running ${checkId}: ${CHECK_INFO[checkId] || checkId}`);
|
|
1740
|
+
reports = { [checkId]: await generator(client, opts.nodeName) };
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// Upload to PostgresAI API (if configured)
|
|
1744
|
+
let uploadSummary: UploadSummary | undefined;
|
|
1745
|
+
if (uploadCfg) {
|
|
1746
|
+
const logUpload = (msg: string): void => {
|
|
1747
|
+
(shouldPrintJson ? console.error : console.log)(msg);
|
|
1748
|
+
};
|
|
1749
|
+
uploadSummary = await uploadCheckupReports(uploadCfg, reports, spinner, logUpload);
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
spinner.stop();
|
|
1753
|
+
|
|
1754
|
+
// Write to files (if output path specified)
|
|
1755
|
+
if (outputPath) {
|
|
1756
|
+
writeReportFiles(reports, outputPath);
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// Print upload summary
|
|
1760
|
+
if (uploadSummary) {
|
|
1761
|
+
printUploadSummary(uploadSummary, projectWasGenerated, shouldPrintJson);
|
|
219
1762
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
1763
|
+
|
|
1764
|
+
// Output JSON to stdout
|
|
1765
|
+
if (shouldPrintJson || (!shouldUpload && !opts.output)) {
|
|
1766
|
+
console.log(JSON.stringify(reports, null, 2));
|
|
223
1767
|
}
|
|
224
1768
|
} catch (error) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (errAny && typeof errAny === "object" && typeof errAny.code === "string") {
|
|
229
|
-
if (errAny.code === "42501") {
|
|
230
|
-
console.error("Hint: connect as a superuser (or a role with CREATEROLE and sufficient GRANT privileges).");
|
|
1769
|
+
if (error instanceof RpcError) {
|
|
1770
|
+
for (const line of formatRpcErrorForDisplay(error)) {
|
|
1771
|
+
console.error(line);
|
|
231
1772
|
}
|
|
1773
|
+
} else {
|
|
1774
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1775
|
+
console.error(`Error: ${message}`);
|
|
232
1776
|
}
|
|
233
1777
|
process.exitCode = 1;
|
|
234
1778
|
} finally {
|
|
235
|
-
|
|
1779
|
+
// Always stop spinner to prevent interval leak (idempotent - safe to call multiple times)
|
|
1780
|
+
spinner.stop();
|
|
1781
|
+
if (client) {
|
|
236
1782
|
await client.end();
|
|
237
|
-
} catch {
|
|
238
|
-
// ignore
|
|
239
1783
|
}
|
|
240
1784
|
}
|
|
241
1785
|
});
|
|
@@ -273,12 +1817,20 @@ function resolvePaths(): PathResolution {
|
|
|
273
1817
|
);
|
|
274
1818
|
}
|
|
275
1819
|
|
|
1820
|
+
async function resolveOrInitPaths(): Promise<PathResolution> {
|
|
1821
|
+
try {
|
|
1822
|
+
return resolvePaths();
|
|
1823
|
+
} catch {
|
|
1824
|
+
return ensureDefaultMonitoringProject();
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
|
|
276
1828
|
/**
|
|
277
1829
|
* Check if Docker daemon is running
|
|
278
1830
|
*/
|
|
279
1831
|
function isDockerRunning(): boolean {
|
|
280
1832
|
try {
|
|
281
|
-
const result = spawnSync("docker", ["info"], { stdio: "pipe" });
|
|
1833
|
+
const result = spawnSync("docker", ["info"], { stdio: "pipe", timeout: 5000 });
|
|
282
1834
|
return result.status === 0;
|
|
283
1835
|
} catch {
|
|
284
1836
|
return false;
|
|
@@ -290,7 +1842,7 @@ function isDockerRunning(): boolean {
|
|
|
290
1842
|
*/
|
|
291
1843
|
function getComposeCmd(): string[] | null {
|
|
292
1844
|
const tryCmd = (cmd: string, args: string[]): boolean =>
|
|
293
|
-
spawnSync(cmd, args, { stdio: "ignore" }).status === 0;
|
|
1845
|
+
spawnSync(cmd, args, { stdio: "ignore", timeout: 5000 }).status === 0;
|
|
294
1846
|
if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
|
|
295
1847
|
if (tryCmd("docker", ["compose", "version"])) return ["docker", "compose"];
|
|
296
1848
|
return null;
|
|
@@ -304,7 +1856,7 @@ function checkRunningContainers(): { running: boolean; containers: string[] } {
|
|
|
304
1856
|
const result = spawnSync(
|
|
305
1857
|
"docker",
|
|
306
1858
|
["ps", "--filter", "name=grafana-with-datasources", "--filter", "name=pgwatch", "--format", "{{.Names}}"],
|
|
307
|
-
{ stdio: "pipe", encoding: "utf8" }
|
|
1859
|
+
{ stdio: "pipe", encoding: "utf8", timeout: 5000 }
|
|
308
1860
|
);
|
|
309
1861
|
|
|
310
1862
|
if (result.status === 0 && result.stdout) {
|
|
@@ -320,11 +1872,11 @@ function checkRunningContainers(): { running: boolean; containers: string[] } {
|
|
|
320
1872
|
/**
|
|
321
1873
|
* Run docker compose command
|
|
322
1874
|
*/
|
|
323
|
-
async function runCompose(args: string[]): Promise<number> {
|
|
1875
|
+
async function runCompose(args: string[], grafanaPassword?: string): Promise<number> {
|
|
324
1876
|
let composeFile: string;
|
|
325
1877
|
let projectDir: string;
|
|
326
1878
|
try {
|
|
327
|
-
({ composeFile, projectDir } =
|
|
1879
|
+
({ composeFile, projectDir } = await resolveOrInitPaths());
|
|
328
1880
|
} catch (error) {
|
|
329
1881
|
const message = error instanceof Error ? error.message : String(error);
|
|
330
1882
|
console.error(message);
|
|
@@ -346,28 +1898,42 @@ async function runCompose(args: string[]): Promise<number> {
|
|
|
346
1898
|
return 1;
|
|
347
1899
|
}
|
|
348
1900
|
|
|
349
|
-
//
|
|
1901
|
+
// Set Grafana password from parameter or .pgwatch-config
|
|
350
1902
|
const env = { ...process.env };
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const
|
|
358
|
-
if (
|
|
359
|
-
|
|
1903
|
+
if (grafanaPassword) {
|
|
1904
|
+
env.GF_SECURITY_ADMIN_PASSWORD = grafanaPassword;
|
|
1905
|
+
} else {
|
|
1906
|
+
const cfgPath = path.resolve(projectDir, ".pgwatch-config");
|
|
1907
|
+
if (fs.existsSync(cfgPath)) {
|
|
1908
|
+
try {
|
|
1909
|
+
const stats = fs.statSync(cfgPath);
|
|
1910
|
+
if (!stats.isDirectory()) {
|
|
1911
|
+
const content = fs.readFileSync(cfgPath, "utf8");
|
|
1912
|
+
const match = content.match(/^grafana_password=([^\r\n]+)/m);
|
|
1913
|
+
if (match) {
|
|
1914
|
+
env.GF_SECURITY_ADMIN_PASSWORD = match[1].trim();
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
} catch (err) {
|
|
1918
|
+
// If we can't read the config, log warning and continue without setting the password
|
|
1919
|
+
if (process.env.DEBUG) {
|
|
1920
|
+
console.warn(`Warning: Could not read Grafana password from config: ${err instanceof Error ? err.message : String(err)}`);
|
|
360
1921
|
}
|
|
361
1922
|
}
|
|
362
|
-
} catch (err) {
|
|
363
|
-
// If we can't read the config, continue without setting the password
|
|
364
1923
|
}
|
|
365
1924
|
}
|
|
366
1925
|
|
|
1926
|
+
// On macOS, node-exporter can't mount host root filesystem - skip it
|
|
1927
|
+
const finalArgs = [...args];
|
|
1928
|
+
if (process.platform === "darwin" && args.includes("up")) {
|
|
1929
|
+
finalArgs.push("--scale", "node-exporter=0");
|
|
1930
|
+
}
|
|
1931
|
+
|
|
367
1932
|
return new Promise<number>((resolve) => {
|
|
368
|
-
const child = spawn(cmd[0], [...cmd.slice(1), "-f", composeFile, ...
|
|
1933
|
+
const child = spawn(cmd[0], [...cmd.slice(1), "-f", composeFile, ...finalArgs], {
|
|
369
1934
|
stdio: "inherit",
|
|
370
|
-
env: env
|
|
1935
|
+
env: env,
|
|
1936
|
+
cwd: projectDir
|
|
371
1937
|
});
|
|
372
1938
|
child.on("close", (code) => resolve(code || 0));
|
|
373
1939
|
});
|
|
@@ -381,18 +1947,64 @@ program.command("help", { isDefault: true }).description("show help").action(()
|
|
|
381
1947
|
const mon = program.command("mon").description("monitoring services management");
|
|
382
1948
|
|
|
383
1949
|
mon
|
|
384
|
-
.command("
|
|
385
|
-
.description("
|
|
1950
|
+
.command("local-install")
|
|
1951
|
+
.description("install local monitoring stack (generate config, start services)")
|
|
386
1952
|
.option("--demo", "demo mode with sample database", false)
|
|
387
1953
|
.option("--api-key <key>", "Postgres AI API key for automated report uploads")
|
|
388
1954
|
.option("--db-url <url>", "PostgreSQL connection URL to monitor")
|
|
1955
|
+
.option("--tag <tag>", "Docker image tag to use (e.g., 0.14.0, 0.14.0-dev.33)")
|
|
389
1956
|
.option("-y, --yes", "accept all defaults and skip interactive prompts", false)
|
|
390
|
-
.action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; yes: boolean }) => {
|
|
1957
|
+
.action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; tag?: string; yes: boolean }) => {
|
|
1958
|
+
// Get apiKey from global program options (--api-key is defined globally)
|
|
1959
|
+
// This is needed because Commander.js routes --api-key to the global option, not the subcommand's option
|
|
1960
|
+
const globalOpts = program.opts<CliOptions>();
|
|
1961
|
+
const apiKey = opts.apiKey || globalOpts.apiKey;
|
|
1962
|
+
|
|
391
1963
|
console.log("\n=================================");
|
|
392
|
-
console.log(" PostgresAI
|
|
1964
|
+
console.log(" PostgresAI monitoring local install");
|
|
393
1965
|
console.log("=================================\n");
|
|
394
1966
|
console.log("This will install, configure, and start the monitoring system\n");
|
|
395
1967
|
|
|
1968
|
+
// Ensure we have a project directory with docker-compose.yml even if running from elsewhere
|
|
1969
|
+
const { projectDir } = await resolveOrInitPaths();
|
|
1970
|
+
console.log(`Project directory: ${projectDir}\n`);
|
|
1971
|
+
|
|
1972
|
+
// Update .env with custom tag if provided
|
|
1973
|
+
const envFile = path.resolve(projectDir, ".env");
|
|
1974
|
+
|
|
1975
|
+
// Build .env content, preserving important existing values (registry, password)
|
|
1976
|
+
// Note: PGAI_TAG is intentionally NOT preserved - the CLI version should always match Docker images
|
|
1977
|
+
let existingRegistry: string | null = null;
|
|
1978
|
+
let existingPassword: string | null = null;
|
|
1979
|
+
|
|
1980
|
+
if (fs.existsSync(envFile)) {
|
|
1981
|
+
const existingEnv = fs.readFileSync(envFile, "utf8");
|
|
1982
|
+
// Extract existing values (except tag - always use CLI version)
|
|
1983
|
+
const registryMatch = existingEnv.match(/^PGAI_REGISTRY=(.+)$/m);
|
|
1984
|
+
if (registryMatch) existingRegistry = registryMatch[1].trim();
|
|
1985
|
+
const pwdMatch = existingEnv.match(/^GF_SECURITY_ADMIN_PASSWORD=(.+)$/m);
|
|
1986
|
+
if (pwdMatch) existingPassword = pwdMatch[1].trim();
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
// Priority: CLI --tag flag > package version
|
|
1990
|
+
// Note: We intentionally do NOT use process.env.PGAI_TAG here because Bun auto-loads .env files,
|
|
1991
|
+
// which would cause stale .env values to override the CLI version. The CLI version should always
|
|
1992
|
+
// match the Docker images. Users can override with --tag if needed.
|
|
1993
|
+
const imageTag = opts.tag || pkg.version;
|
|
1994
|
+
|
|
1995
|
+
const envLines: string[] = [`PGAI_TAG=${imageTag}`];
|
|
1996
|
+
if (existingRegistry) {
|
|
1997
|
+
envLines.push(`PGAI_REGISTRY=${existingRegistry}`);
|
|
1998
|
+
}
|
|
1999
|
+
if (existingPassword) {
|
|
2000
|
+
envLines.push(`GF_SECURITY_ADMIN_PASSWORD=${existingPassword}`);
|
|
2001
|
+
}
|
|
2002
|
+
fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
|
|
2003
|
+
|
|
2004
|
+
if (opts.tag) {
|
|
2005
|
+
console.log(`Using image tag: ${imageTag}\n`);
|
|
2006
|
+
}
|
|
2007
|
+
|
|
396
2008
|
// Validate conflicting options
|
|
397
2009
|
if (opts.demo && opts.dbUrl) {
|
|
398
2010
|
console.log("⚠ Both --demo and --db-url provided. Demo mode includes its own database.");
|
|
@@ -400,11 +2012,11 @@ mon
|
|
|
400
2012
|
opts.dbUrl = undefined;
|
|
401
2013
|
}
|
|
402
2014
|
|
|
403
|
-
if (opts.demo &&
|
|
2015
|
+
if (opts.demo && apiKey) {
|
|
404
2016
|
console.error("✗ Cannot use --api-key with --demo mode");
|
|
405
2017
|
console.error("✗ Demo mode is for testing only and does not support API key integration");
|
|
406
|
-
console.error("\nUse demo mode without API key: postgres-ai mon
|
|
407
|
-
console.error("Or use production mode with API key: postgres-ai mon
|
|
2018
|
+
console.error("\nUse demo mode without API key: postgres-ai mon local-install --demo");
|
|
2019
|
+
console.error("Or use production mode with API key: postgres-ai mon local-install --api-key=your_key");
|
|
408
2020
|
process.exitCode = 1;
|
|
409
2021
|
return;
|
|
410
2022
|
}
|
|
@@ -422,9 +2034,14 @@ mon
|
|
|
422
2034
|
console.log("Step 1: Postgres AI API Configuration (Optional)");
|
|
423
2035
|
console.log("An API key enables automatic upload of PostgreSQL reports to Postgres AI\n");
|
|
424
2036
|
|
|
425
|
-
if (
|
|
2037
|
+
if (apiKey) {
|
|
426
2038
|
console.log("Using API key provided via --api-key parameter");
|
|
427
|
-
config.writeConfig({ apiKey
|
|
2039
|
+
config.writeConfig({ apiKey });
|
|
2040
|
+
// Keep reporter compatibility (docker-compose mounts .pgwatch-config)
|
|
2041
|
+
fs.writeFileSync(path.resolve(projectDir, ".pgwatch-config"), `api_key=${apiKey}\n`, {
|
|
2042
|
+
encoding: "utf8",
|
|
2043
|
+
mode: 0o600
|
|
2044
|
+
});
|
|
428
2045
|
console.log("✓ API key saved\n");
|
|
429
2046
|
} else if (opts.yes) {
|
|
430
2047
|
// Auto-yes mode without API key - skip API key setup
|
|
@@ -432,43 +2049,36 @@ mon
|
|
|
432
2049
|
console.log("⚠ Reports will be generated locally only");
|
|
433
2050
|
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
434
2051
|
} else {
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
config.writeConfig({ apiKey: trimmedKey });
|
|
454
|
-
console.log("✓ API key saved\n");
|
|
455
|
-
break;
|
|
456
|
-
}
|
|
2052
|
+
const answer = await question("Do you have a Postgres AI API key? (Y/n): ");
|
|
2053
|
+
const proceedWithApiKey = !answer || answer.toLowerCase() === "y";
|
|
2054
|
+
|
|
2055
|
+
if (proceedWithApiKey) {
|
|
2056
|
+
while (true) {
|
|
2057
|
+
const inputApiKey = await question("Enter your Postgres AI API key: ");
|
|
2058
|
+
const trimmedKey = inputApiKey.trim();
|
|
2059
|
+
|
|
2060
|
+
if (trimmedKey) {
|
|
2061
|
+
config.writeConfig({ apiKey: trimmedKey });
|
|
2062
|
+
// Keep reporter compatibility (docker-compose mounts .pgwatch-config)
|
|
2063
|
+
fs.writeFileSync(path.resolve(projectDir, ".pgwatch-config"), `api_key=${trimmedKey}\n`, {
|
|
2064
|
+
encoding: "utf8",
|
|
2065
|
+
mode: 0o600
|
|
2066
|
+
});
|
|
2067
|
+
console.log("✓ API key saved\n");
|
|
2068
|
+
break;
|
|
2069
|
+
}
|
|
457
2070
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
}
|
|
2071
|
+
console.log("⚠ API key cannot be empty");
|
|
2072
|
+
const retry = await question("Try again or skip API key setup, retry? (Y/n): ");
|
|
2073
|
+
if (retry.toLowerCase() === "n") {
|
|
2074
|
+
console.log("⚠ Skipping API key setup - reports will be generated locally only");
|
|
2075
|
+
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
2076
|
+
break;
|
|
465
2077
|
}
|
|
466
|
-
} else {
|
|
467
|
-
console.log("⚠ Skipping API key setup - reports will be generated locally only");
|
|
468
|
-
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
469
2078
|
}
|
|
470
|
-
}
|
|
471
|
-
|
|
2079
|
+
} else {
|
|
2080
|
+
console.log("⚠ Skipping API key setup - reports will be generated locally only");
|
|
2081
|
+
console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
|
|
472
2082
|
}
|
|
473
2083
|
}
|
|
474
2084
|
} else {
|
|
@@ -481,9 +2091,11 @@ mon
|
|
|
481
2091
|
console.log("Step 2: Add PostgreSQL Instance to Monitor\n");
|
|
482
2092
|
|
|
483
2093
|
// Clear instances.yml in production mode (start fresh)
|
|
484
|
-
const instancesPath =
|
|
2094
|
+
const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
|
|
485
2095
|
const emptyInstancesContent = "# PostgreSQL instances to monitor\n# Add your instances using: postgres-ai mon targets add\n\n";
|
|
486
2096
|
fs.writeFileSync(instancesPath, emptyInstancesContent, "utf8");
|
|
2097
|
+
console.log(`Instances file: ${instancesPath}`);
|
|
2098
|
+
console.log(`Project directory: ${projectDir}\n`);
|
|
487
2099
|
|
|
488
2100
|
if (opts.dbUrl) {
|
|
489
2101
|
console.log("Using database URL provided via --db-url parameter");
|
|
@@ -512,7 +2124,6 @@ mon
|
|
|
512
2124
|
// Test connection
|
|
513
2125
|
console.log("Testing connection to the added instance...");
|
|
514
2126
|
try {
|
|
515
|
-
const { Client } = require("pg");
|
|
516
2127
|
const client = new Client({ connectionString: connStr });
|
|
517
2128
|
await client.connect();
|
|
518
2129
|
const result = await client.query("select version();");
|
|
@@ -529,63 +2140,50 @@ mon
|
|
|
529
2140
|
console.log("⚠ No PostgreSQL instance added");
|
|
530
2141
|
console.log("You can add one later with: postgres-ai mon targets add\n");
|
|
531
2142
|
} else {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
console.log(" 2. Press Enter to skip for now\n");
|
|
549
|
-
|
|
550
|
-
const connStr = await question("Enter connection string (or press Enter to skip): ");
|
|
551
|
-
|
|
552
|
-
if (connStr.trim()) {
|
|
553
|
-
const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
|
|
554
|
-
if (!m) {
|
|
555
|
-
console.error("✗ Invalid connection string format");
|
|
556
|
-
console.log("⚠ Continuing without adding instance\n");
|
|
557
|
-
} else {
|
|
558
|
-
const host = m[3];
|
|
559
|
-
const db = m[5];
|
|
560
|
-
const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
|
|
561
|
-
|
|
562
|
-
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`;
|
|
563
|
-
fs.appendFileSync(instancesPath, body, "utf8");
|
|
564
|
-
console.log(`✓ Monitoring target '${instanceName}' added\n`);
|
|
565
|
-
|
|
566
|
-
// Test connection
|
|
567
|
-
console.log("Testing connection to the added instance...");
|
|
568
|
-
try {
|
|
569
|
-
const { Client } = require("pg");
|
|
570
|
-
const client = new Client({ connectionString: connStr });
|
|
571
|
-
await client.connect();
|
|
572
|
-
const result = await client.query("select version();");
|
|
573
|
-
console.log("✓ Connection successful");
|
|
574
|
-
console.log(`${result.rows[0].version}\n`);
|
|
575
|
-
await client.end();
|
|
576
|
-
} catch (error) {
|
|
577
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
578
|
-
console.error(`✗ Connection failed: ${message}\n`);
|
|
579
|
-
}
|
|
580
|
-
}
|
|
2143
|
+
console.log("You need to add at least one PostgreSQL instance to monitor");
|
|
2144
|
+
const answer = await question("Do you want to add a PostgreSQL instance now? (Y/n): ");
|
|
2145
|
+
const proceedWithInstance = !answer || answer.toLowerCase() === "y";
|
|
2146
|
+
|
|
2147
|
+
if (proceedWithInstance) {
|
|
2148
|
+
console.log("\nYou can provide either:");
|
|
2149
|
+
console.log(" 1. A full connection string: postgresql://user:pass@host:port/database");
|
|
2150
|
+
console.log(" 2. Press Enter to skip for now\n");
|
|
2151
|
+
|
|
2152
|
+
const connStr = await question("Enter connection string (or press Enter to skip): ");
|
|
2153
|
+
|
|
2154
|
+
if (connStr.trim()) {
|
|
2155
|
+
const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
|
|
2156
|
+
if (!m) {
|
|
2157
|
+
console.error("✗ Invalid connection string format");
|
|
2158
|
+
console.log("⚠ Continuing without adding instance\n");
|
|
581
2159
|
} else {
|
|
582
|
-
|
|
2160
|
+
const host = m[3];
|
|
2161
|
+
const db = m[5];
|
|
2162
|
+
const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
|
|
2163
|
+
|
|
2164
|
+
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`;
|
|
2165
|
+
fs.appendFileSync(instancesPath, body, "utf8");
|
|
2166
|
+
console.log(`✓ Monitoring target '${instanceName}' added\n`);
|
|
2167
|
+
|
|
2168
|
+
// Test connection
|
|
2169
|
+
console.log("Testing connection to the added instance...");
|
|
2170
|
+
try {
|
|
2171
|
+
const client = new Client({ connectionString: connStr });
|
|
2172
|
+
await client.connect();
|
|
2173
|
+
const result = await client.query("select version();");
|
|
2174
|
+
console.log("✓ Connection successful");
|
|
2175
|
+
console.log(`${result.rows[0].version}\n`);
|
|
2176
|
+
await client.end();
|
|
2177
|
+
} catch (error) {
|
|
2178
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2179
|
+
console.error(`✗ Connection failed: ${message}\n`);
|
|
2180
|
+
}
|
|
583
2181
|
}
|
|
584
2182
|
} else {
|
|
585
2183
|
console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
|
|
586
2184
|
}
|
|
587
|
-
}
|
|
588
|
-
|
|
2185
|
+
} else {
|
|
2186
|
+
console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
|
|
589
2187
|
}
|
|
590
2188
|
}
|
|
591
2189
|
} else {
|
|
@@ -603,7 +2201,7 @@ mon
|
|
|
603
2201
|
|
|
604
2202
|
// Step 4: Ensure Grafana password is configured
|
|
605
2203
|
console.log(opts.demo ? "Step 4: Configuring Grafana security..." : "Step 4: Configuring Grafana security...");
|
|
606
|
-
const cfgPath = path.resolve(
|
|
2204
|
+
const cfgPath = path.resolve(projectDir, ".pgwatch-config");
|
|
607
2205
|
let grafanaPassword = "";
|
|
608
2206
|
|
|
609
2207
|
try {
|
|
@@ -644,8 +2242,8 @@ mon
|
|
|
644
2242
|
}
|
|
645
2243
|
|
|
646
2244
|
// Step 5: Start services
|
|
647
|
-
console.log(
|
|
648
|
-
const code2 = await runCompose(["up", "-d", "--force-recreate"]);
|
|
2245
|
+
console.log("Step 5: Starting monitoring services...");
|
|
2246
|
+
const code2 = await runCompose(["up", "-d", "--force-recreate"], grafanaPassword);
|
|
649
2247
|
if (code2 !== 0) {
|
|
650
2248
|
process.exitCode = code2;
|
|
651
2249
|
return;
|
|
@@ -654,7 +2252,7 @@ mon
|
|
|
654
2252
|
|
|
655
2253
|
// Final summary
|
|
656
2254
|
console.log("=================================");
|
|
657
|
-
console.log("
|
|
2255
|
+
console.log(" Local install completed!");
|
|
658
2256
|
console.log("=================================\n");
|
|
659
2257
|
|
|
660
2258
|
console.log("What's running:");
|
|
@@ -703,11 +2301,75 @@ mon
|
|
|
703
2301
|
if (code !== 0) process.exitCode = code;
|
|
704
2302
|
});
|
|
705
2303
|
|
|
2304
|
+
// Known container names for cleanup
|
|
2305
|
+
const MONITORING_CONTAINERS = [
|
|
2306
|
+
"postgres-ai-config-init",
|
|
2307
|
+
"node-exporter",
|
|
2308
|
+
"cadvisor",
|
|
2309
|
+
"grafana-with-datasources",
|
|
2310
|
+
"sink-postgres",
|
|
2311
|
+
"sink-prometheus",
|
|
2312
|
+
"target-db",
|
|
2313
|
+
"pgwatch-postgres",
|
|
2314
|
+
"pgwatch-prometheus",
|
|
2315
|
+
"postgres-exporter-sink",
|
|
2316
|
+
"flask-pgss-api",
|
|
2317
|
+
"sources-generator",
|
|
2318
|
+
"postgres-reports",
|
|
2319
|
+
];
|
|
2320
|
+
|
|
2321
|
+
/**
|
|
2322
|
+
* Network cleanup constants.
|
|
2323
|
+
* Docker Compose creates a default network named "{project}_default".
|
|
2324
|
+
* In CI environments, network cleanup can fail if containers are slow to disconnect.
|
|
2325
|
+
*/
|
|
2326
|
+
const COMPOSE_PROJECT_NAME = "postgres_ai";
|
|
2327
|
+
const DOCKER_NETWORK_NAME = `${COMPOSE_PROJECT_NAME}_default`;
|
|
2328
|
+
/** Delay before retrying network cleanup (allows container network disconnections to complete) */
|
|
2329
|
+
const NETWORK_CLEANUP_DELAY_MS = 2000;
|
|
2330
|
+
|
|
2331
|
+
/** Remove orphaned containers that docker compose down might miss */
|
|
2332
|
+
async function removeOrphanedContainers(): Promise<void> {
|
|
2333
|
+
for (const container of MONITORING_CONTAINERS) {
|
|
2334
|
+
try {
|
|
2335
|
+
await execFilePromise("docker", ["rm", "-f", container]);
|
|
2336
|
+
} catch {
|
|
2337
|
+
// Container doesn't exist, ignore
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
|
|
706
2342
|
mon
|
|
707
2343
|
.command("stop")
|
|
708
2344
|
.description("stop monitoring services")
|
|
709
2345
|
.action(async () => {
|
|
710
|
-
|
|
2346
|
+
// Multi-stage cleanup strategy for reliable shutdown in CI environments:
|
|
2347
|
+
// Stage 1: Standard compose down with orphan removal
|
|
2348
|
+
// Stage 2: Force remove any orphaned containers, then retry compose down
|
|
2349
|
+
// Stage 3: Force remove the Docker network directly
|
|
2350
|
+
// This handles edge cases where containers are slow to disconnect from networks.
|
|
2351
|
+
let code = await runCompose(["down", "--remove-orphans"]);
|
|
2352
|
+
|
|
2353
|
+
// Stage 2: If initial cleanup fails, try removing orphaned containers first
|
|
2354
|
+
if (code !== 0) {
|
|
2355
|
+
await removeOrphanedContainers();
|
|
2356
|
+
// Wait a moment for container network disconnections to complete
|
|
2357
|
+
await new Promise(resolve => setTimeout(resolve, NETWORK_CLEANUP_DELAY_MS));
|
|
2358
|
+
// Retry compose down
|
|
2359
|
+
code = await runCompose(["down", "--remove-orphans"]);
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
// Final cleanup: force remove the network if it still exists
|
|
2363
|
+
if (code !== 0) {
|
|
2364
|
+
try {
|
|
2365
|
+
await execFilePromise("docker", ["network", "rm", DOCKER_NETWORK_NAME]);
|
|
2366
|
+
// Network removal succeeded - cleanup is complete
|
|
2367
|
+
code = 0;
|
|
2368
|
+
} catch {
|
|
2369
|
+
// Network doesn't exist or couldn't be removed, ignore
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
|
|
711
2373
|
if (code !== 0) process.exitCode = code;
|
|
712
2374
|
});
|
|
713
2375
|
|
|
@@ -771,17 +2433,17 @@ mon
|
|
|
771
2433
|
allHealthy = true;
|
|
772
2434
|
for (const service of services) {
|
|
773
2435
|
try {
|
|
774
|
-
const
|
|
775
|
-
const status =
|
|
776
|
-
encoding: 'utf8',
|
|
777
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
778
|
-
}).trim();
|
|
2436
|
+
const result = spawnSync("docker", ["inspect", "-f", "{{.State.Status}}", service.container], { stdio: "pipe" });
|
|
2437
|
+
const status = result.stdout.trim();
|
|
779
2438
|
|
|
780
|
-
if (status === 'running') {
|
|
2439
|
+
if (result.status === 0 && status === 'running') {
|
|
781
2440
|
console.log(`✓ ${service.name}: healthy`);
|
|
782
|
-
} else {
|
|
2441
|
+
} else if (result.status === 0) {
|
|
783
2442
|
console.log(`✗ ${service.name}: unhealthy (status: ${status})`);
|
|
784
2443
|
allHealthy = false;
|
|
2444
|
+
} else {
|
|
2445
|
+
console.log(`✗ ${service.name}: unreachable`);
|
|
2446
|
+
allHealthy = false;
|
|
785
2447
|
}
|
|
786
2448
|
} catch (error) {
|
|
787
2449
|
console.log(`✗ ${service.name}: unreachable`);
|
|
@@ -810,7 +2472,7 @@ mon
|
|
|
810
2472
|
let composeFile: string;
|
|
811
2473
|
let instancesFile: string;
|
|
812
2474
|
try {
|
|
813
|
-
({ projectDir, composeFile, instancesFile } =
|
|
2475
|
+
({ projectDir, composeFile, instancesFile } = await resolveOrInitPaths());
|
|
814
2476
|
} catch (error) {
|
|
815
2477
|
const message = error instanceof Error ? error.message : String(error);
|
|
816
2478
|
console.error(message);
|
|
@@ -885,14 +2547,6 @@ mon
|
|
|
885
2547
|
.command("reset [service]")
|
|
886
2548
|
.description("reset all or specific monitoring service")
|
|
887
2549
|
.action(async (service?: string) => {
|
|
888
|
-
const rl = readline.createInterface({
|
|
889
|
-
input: process.stdin,
|
|
890
|
-
output: process.stdout,
|
|
891
|
-
});
|
|
892
|
-
|
|
893
|
-
const question = (prompt: string): Promise<string> =>
|
|
894
|
-
new Promise((resolve) => rl.question(prompt, resolve));
|
|
895
|
-
|
|
896
2550
|
try {
|
|
897
2551
|
if (service) {
|
|
898
2552
|
// Reset specific service
|
|
@@ -902,7 +2556,6 @@ mon
|
|
|
902
2556
|
const answer = await question("Continue? (y/N): ");
|
|
903
2557
|
if (answer.toLowerCase() !== "y") {
|
|
904
2558
|
console.log("Cancelled");
|
|
905
|
-
rl.close();
|
|
906
2559
|
return;
|
|
907
2560
|
}
|
|
908
2561
|
|
|
@@ -929,7 +2582,6 @@ mon
|
|
|
929
2582
|
const answer = await question("Continue? (y/N): ");
|
|
930
2583
|
if (answer.toLowerCase() !== "y") {
|
|
931
2584
|
console.log("Cancelled");
|
|
932
|
-
rl.close();
|
|
933
2585
|
return;
|
|
934
2586
|
}
|
|
935
2587
|
|
|
@@ -943,10 +2595,7 @@ mon
|
|
|
943
2595
|
process.exitCode = 1;
|
|
944
2596
|
}
|
|
945
2597
|
}
|
|
946
|
-
|
|
947
|
-
rl.close();
|
|
948
2598
|
} catch (error) {
|
|
949
|
-
rl.close();
|
|
950
2599
|
const message = error instanceof Error ? error.message : String(error);
|
|
951
2600
|
console.error(`Reset failed: ${message}`);
|
|
952
2601
|
process.exitCode = 1;
|
|
@@ -954,34 +2603,72 @@ mon
|
|
|
954
2603
|
});
|
|
955
2604
|
mon
|
|
956
2605
|
.command("clean")
|
|
957
|
-
.description("cleanup monitoring services artifacts")
|
|
958
|
-
.
|
|
959
|
-
|
|
2606
|
+
.description("cleanup monitoring services artifacts (stops services and removes volumes)")
|
|
2607
|
+
.option("--keep-volumes", "keep data volumes (only stop and remove containers)")
|
|
2608
|
+
.action(async (options: { keepVolumes?: boolean }) => {
|
|
2609
|
+
console.log("Cleaning up monitoring services...\n");
|
|
960
2610
|
|
|
961
2611
|
try {
|
|
962
|
-
//
|
|
963
|
-
const
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
2612
|
+
// First, use docker-compose down to properly stop and remove containers/volumes
|
|
2613
|
+
const downArgs = options.keepVolumes ? ["down"] : ["down", "-v"];
|
|
2614
|
+
console.log(options.keepVolumes
|
|
2615
|
+
? "Stopping and removing containers (keeping volumes)..."
|
|
2616
|
+
: "Stopping and removing containers and volumes...");
|
|
2617
|
+
|
|
2618
|
+
const downCode = await runCompose(downArgs);
|
|
2619
|
+
if (downCode === 0) {
|
|
2620
|
+
console.log("✓ Monitoring services stopped and removed");
|
|
968
2621
|
} else {
|
|
969
|
-
console.log("
|
|
2622
|
+
console.log("⚠ Could not stop services (may not be running)");
|
|
970
2623
|
}
|
|
971
2624
|
|
|
972
|
-
// Remove
|
|
973
|
-
await
|
|
974
|
-
console.log("✓ Removed
|
|
2625
|
+
// Remove any orphaned containers that docker compose down missed
|
|
2626
|
+
await removeOrphanedContainers();
|
|
2627
|
+
console.log("✓ Removed orphaned containers");
|
|
2628
|
+
|
|
2629
|
+
// Remove orphaned volumes from previous installs with different project names
|
|
2630
|
+
if (!options.keepVolumes) {
|
|
2631
|
+
const volumePatterns = [
|
|
2632
|
+
"monitoring_grafana_data",
|
|
2633
|
+
"monitoring_postgres_ai_configs",
|
|
2634
|
+
"monitoring_sink_postgres_data",
|
|
2635
|
+
"monitoring_target_db_data",
|
|
2636
|
+
"monitoring_victoria_metrics_data",
|
|
2637
|
+
"postgres_ai_configs_grafana_data",
|
|
2638
|
+
"postgres_ai_configs_sink_postgres_data",
|
|
2639
|
+
"postgres_ai_configs_target_db_data",
|
|
2640
|
+
"postgres_ai_configs_victoria_metrics_data",
|
|
2641
|
+
"postgres_ai_configs_postgres_ai_configs",
|
|
2642
|
+
];
|
|
2643
|
+
|
|
2644
|
+
const { stdout: existingVolumes } = await execFilePromise("docker", ["volume", "ls", "-q"]);
|
|
2645
|
+
const volumeList = existingVolumes.trim().split('\n').filter(Boolean);
|
|
2646
|
+
const orphanedVolumes = volumeList.filter(v => volumePatterns.includes(v));
|
|
2647
|
+
|
|
2648
|
+
if (orphanedVolumes.length > 0) {
|
|
2649
|
+
let removedCount = 0;
|
|
2650
|
+
for (const vol of orphanedVolumes) {
|
|
2651
|
+
try {
|
|
2652
|
+
await execFilePromise("docker", ["volume", "rm", vol]);
|
|
2653
|
+
removedCount++;
|
|
2654
|
+
} catch {
|
|
2655
|
+
// Volume might be in use, skip silently
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
if (removedCount > 0) {
|
|
2659
|
+
console.log(`✓ Removed ${removedCount} orphaned volume(s) from previous installs`);
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
975
2663
|
|
|
976
|
-
// Remove
|
|
2664
|
+
// Remove any dangling resources
|
|
977
2665
|
await execFilePromise("docker", ["network", "prune", "-f"]);
|
|
978
2666
|
console.log("✓ Removed unused networks");
|
|
979
2667
|
|
|
980
|
-
// Remove dangling images
|
|
981
2668
|
await execFilePromise("docker", ["image", "prune", "-f"]);
|
|
982
2669
|
console.log("✓ Removed dangling images");
|
|
983
2670
|
|
|
984
|
-
console.log("\
|
|
2671
|
+
console.log("\n✓ Cleanup completed - ready for fresh install");
|
|
985
2672
|
} catch (error) {
|
|
986
2673
|
const message = error instanceof Error ? error.message : String(error);
|
|
987
2674
|
console.error(`Error during cleanup: ${message}`);
|
|
@@ -1010,9 +2697,9 @@ targets
|
|
|
1010
2697
|
.command("list")
|
|
1011
2698
|
.description("list monitoring target databases")
|
|
1012
2699
|
.action(async () => {
|
|
1013
|
-
const instancesPath =
|
|
2700
|
+
const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
|
|
1014
2701
|
if (!fs.existsSync(instancesPath)) {
|
|
1015
|
-
console.error(`instances.yml not found in ${
|
|
2702
|
+
console.error(`instances.yml not found in ${projectDir}`);
|
|
1016
2703
|
process.exitCode = 1;
|
|
1017
2704
|
return;
|
|
1018
2705
|
}
|
|
@@ -1059,7 +2746,7 @@ targets
|
|
|
1059
2746
|
.command("add [connStr] [name]")
|
|
1060
2747
|
.description("add monitoring target database")
|
|
1061
2748
|
.action(async (connStr?: string, name?: string) => {
|
|
1062
|
-
const file =
|
|
2749
|
+
const { instancesFile: file } = await resolveOrInitPaths();
|
|
1063
2750
|
if (!connStr) {
|
|
1064
2751
|
console.error("Connection string required: postgresql://user:pass@host:port/db");
|
|
1065
2752
|
process.exitCode = 1;
|
|
@@ -1109,7 +2796,7 @@ targets
|
|
|
1109
2796
|
.command("remove <name>")
|
|
1110
2797
|
.description("remove monitoring target database")
|
|
1111
2798
|
.action(async (name: string) => {
|
|
1112
|
-
const file =
|
|
2799
|
+
const { instancesFile: file } = await resolveOrInitPaths();
|
|
1113
2800
|
if (!fs.existsSync(file)) {
|
|
1114
2801
|
console.error("instances.yml not found");
|
|
1115
2802
|
process.exitCode = 1;
|
|
@@ -1146,7 +2833,7 @@ targets
|
|
|
1146
2833
|
.command("test <name>")
|
|
1147
2834
|
.description("test monitoring target database connectivity")
|
|
1148
2835
|
.action(async (name: string) => {
|
|
1149
|
-
const instancesPath =
|
|
2836
|
+
const { instancesFile: instancesPath } = await resolveOrInitPaths();
|
|
1150
2837
|
if (!fs.existsSync(instancesPath)) {
|
|
1151
2838
|
console.error("instances.yml not found");
|
|
1152
2839
|
process.exitCode = 1;
|
|
@@ -1180,7 +2867,6 @@ targets
|
|
|
1180
2867
|
console.log(`Testing connection to monitoring target '${name}'...`);
|
|
1181
2868
|
|
|
1182
2869
|
// Use native pg client instead of requiring psql to be installed
|
|
1183
|
-
const { Client } = require('pg');
|
|
1184
2870
|
const client = new Client({ connectionString: instance.conn_str });
|
|
1185
2871
|
|
|
1186
2872
|
try {
|
|
@@ -1199,15 +2885,43 @@ targets
|
|
|
1199
2885
|
});
|
|
1200
2886
|
|
|
1201
2887
|
// Authentication and API key management
|
|
1202
|
-
program
|
|
1203
|
-
|
|
1204
|
-
|
|
2888
|
+
const auth = program.command("auth").description("authentication and API key management");
|
|
2889
|
+
|
|
2890
|
+
auth
|
|
2891
|
+
.command("login", { isDefault: true })
|
|
2892
|
+
.description("authenticate via browser (OAuth) or store API key directly")
|
|
2893
|
+
.option("--set-key <key>", "store API key directly without OAuth flow")
|
|
1205
2894
|
.option("--port <port>", "local callback server port (default: random)", parseInt)
|
|
1206
2895
|
.option("--debug", "enable debug output")
|
|
1207
|
-
.action(async (opts: { port?: number; debug?: boolean }) => {
|
|
1208
|
-
|
|
1209
|
-
|
|
2896
|
+
.action(async (opts: { setKey?: string; port?: number; debug?: boolean }) => {
|
|
2897
|
+
// If --set-key is provided, store it directly without OAuth
|
|
2898
|
+
if (opts.setKey) {
|
|
2899
|
+
const trimmedKey = opts.setKey.trim();
|
|
2900
|
+
if (!trimmedKey) {
|
|
2901
|
+
console.error("Error: API key cannot be empty");
|
|
2902
|
+
process.exitCode = 1;
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
// Read existing config to check for defaultProject before updating
|
|
2907
|
+
const existingConfig = config.readConfig();
|
|
2908
|
+
const existingProject = existingConfig.defaultProject;
|
|
2909
|
+
|
|
2910
|
+
config.writeConfig({ apiKey: trimmedKey });
|
|
2911
|
+
// When API key is set directly, only clear orgId (org selection may differ).
|
|
2912
|
+
// Preserve defaultProject to avoid orphaning historical reports.
|
|
2913
|
+
// If the new key lacks access to the project, upload will fail with a clear error.
|
|
2914
|
+
config.deleteConfigKeys(["orgId"]);
|
|
2915
|
+
|
|
2916
|
+
console.log(`API key saved to ${config.getConfigPath()}`);
|
|
2917
|
+
if (existingProject) {
|
|
2918
|
+
console.log(`Note: Your default project "${existingProject}" has been preserved.`);
|
|
2919
|
+
console.log(` If this key belongs to a different account, use --project to specify a new one.`);
|
|
2920
|
+
}
|
|
2921
|
+
return;
|
|
2922
|
+
}
|
|
1210
2923
|
|
|
2924
|
+
// Otherwise, proceed with OAuth flow
|
|
1211
2925
|
console.log("Starting authentication flow...\n");
|
|
1212
2926
|
|
|
1213
2927
|
// Generate PKCE parameters
|
|
@@ -1228,10 +2942,10 @@ program
|
|
|
1228
2942
|
const requestedPort = opts.port || 0; // 0 = OS assigns available port
|
|
1229
2943
|
const callbackServer = authServer.createCallbackServer(requestedPort, params.state, 120000); // 2 minute timeout
|
|
1230
2944
|
|
|
1231
|
-
// Wait
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
const redirectUri = `http://
|
|
2945
|
+
// Wait for server to start and get the actual port
|
|
2946
|
+
const actualPort = await callbackServer.ready;
|
|
2947
|
+
// Use 127.0.0.1 to match the server bind address (avoids IPv6 issues on some hosts)
|
|
2948
|
+
const redirectUri = `http://127.0.0.1:${actualPort}/callback`;
|
|
1235
2949
|
|
|
1236
2950
|
console.log(`Callback server listening on port ${actualPort}`);
|
|
1237
2951
|
|
|
@@ -1253,173 +2967,180 @@ program
|
|
|
1253
2967
|
console.log(`Debug: Request data: ${initData}`);
|
|
1254
2968
|
}
|
|
1255
2969
|
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
2970
|
+
// Step 2: Initialize OAuth session on backend using fetch
|
|
2971
|
+
let initResponse: Response;
|
|
2972
|
+
try {
|
|
2973
|
+
initResponse = await fetch(initUrl.toString(), {
|
|
1259
2974
|
method: "POST",
|
|
1260
2975
|
headers: {
|
|
1261
2976
|
"Content-Type": "application/json",
|
|
1262
|
-
"Content-Length": Buffer.byteLength(initData),
|
|
1263
2977
|
},
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
if (data.trim().startsWith("<!") || data.trim().startsWith("<html")) {
|
|
1274
|
-
console.error("Error: Received HTML response instead of JSON. This usually means:");
|
|
1275
|
-
console.error(" 1. The API endpoint URL is incorrect");
|
|
1276
|
-
console.error(" 2. The endpoint does not exist (404)");
|
|
1277
|
-
console.error(`\nAPI URL attempted: ${initUrl.toString()}`);
|
|
1278
|
-
console.error("\nPlease verify the --api-base-url parameter.");
|
|
1279
|
-
} else {
|
|
1280
|
-
console.error(data);
|
|
1281
|
-
}
|
|
2978
|
+
body: initData,
|
|
2979
|
+
});
|
|
2980
|
+
} catch (err) {
|
|
2981
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2982
|
+
console.error(`Failed to connect to API: ${message}`);
|
|
2983
|
+
callbackServer.server.stop();
|
|
2984
|
+
process.exit(1);
|
|
2985
|
+
return;
|
|
2986
|
+
}
|
|
1282
2987
|
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
2988
|
+
if (!initResponse.ok) {
|
|
2989
|
+
const data = await initResponse.text();
|
|
2990
|
+
console.error(`Failed to initialize auth session: ${initResponse.status}`);
|
|
2991
|
+
|
|
2992
|
+
// Check if response is HTML (common for 404 pages)
|
|
2993
|
+
if (data.trim().startsWith("<!") || data.trim().startsWith("<html")) {
|
|
2994
|
+
console.error("Error: Received HTML response instead of JSON. This usually means:");
|
|
2995
|
+
console.error(" 1. The API endpoint URL is incorrect");
|
|
2996
|
+
console.error(" 2. The endpoint does not exist (404)");
|
|
2997
|
+
console.error(`\nAPI URL attempted: ${initUrl.toString()}`);
|
|
2998
|
+
console.error("\nPlease verify the --api-base-url parameter.");
|
|
2999
|
+
} else {
|
|
3000
|
+
console.error(data);
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
callbackServer.server.stop();
|
|
3004
|
+
process.exit(1);
|
|
3005
|
+
return;
|
|
3006
|
+
}
|
|
1286
3007
|
|
|
1287
|
-
|
|
1288
|
-
|
|
3008
|
+
// Step 3: Open browser
|
|
3009
|
+
// Pass api_url so UI calls oauth_approve on the same backend where oauth_init created the session
|
|
3010
|
+
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)}`;
|
|
1289
3011
|
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
3012
|
+
if (opts.debug) {
|
|
3013
|
+
console.log(`Debug: Auth URL: ${authUrl}`);
|
|
3014
|
+
}
|
|
1293
3015
|
|
|
1294
|
-
|
|
1295
|
-
|
|
3016
|
+
console.log(`\nOpening browser for authentication...`);
|
|
3017
|
+
console.log(`If browser does not open automatically, visit:\n${authUrl}\n`);
|
|
1296
3018
|
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
3019
|
+
// Open browser (cross-platform)
|
|
3020
|
+
const openCommand = process.platform === "darwin" ? "open" :
|
|
3021
|
+
process.platform === "win32" ? "start" :
|
|
3022
|
+
"xdg-open";
|
|
3023
|
+
spawn(openCommand, [authUrl], { detached: true, stdio: "ignore" }).unref();
|
|
1302
3024
|
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
3025
|
+
// Step 4: Wait for callback
|
|
3026
|
+
console.log("Waiting for authorization...");
|
|
3027
|
+
console.log("(Press Ctrl+C to cancel)\n");
|
|
1306
3028
|
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
3029
|
+
// Handle Ctrl+C gracefully
|
|
3030
|
+
const cancelHandler = () => {
|
|
3031
|
+
console.log("\n\nAuthentication cancelled by user.");
|
|
3032
|
+
callbackServer.server.stop();
|
|
3033
|
+
process.exit(130); // Standard exit code for SIGINT
|
|
3034
|
+
};
|
|
3035
|
+
process.on("SIGINT", cancelHandler);
|
|
1314
3036
|
|
|
1315
|
-
|
|
1316
|
-
|
|
3037
|
+
try {
|
|
3038
|
+
const { code } = await callbackServer.promise;
|
|
1317
3039
|
|
|
1318
|
-
|
|
1319
|
-
|
|
3040
|
+
// Remove the cancel handler after successful auth
|
|
3041
|
+
process.off("SIGINT", cancelHandler);
|
|
1320
3042
|
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
const exchangeReq = http.request(
|
|
1330
|
-
exchangeUrl,
|
|
1331
|
-
{
|
|
1332
|
-
method: "POST",
|
|
1333
|
-
headers: {
|
|
1334
|
-
"Content-Type": "application/json",
|
|
1335
|
-
"Content-Length": Buffer.byteLength(exchangeData),
|
|
1336
|
-
},
|
|
1337
|
-
},
|
|
1338
|
-
(exchangeRes) => {
|
|
1339
|
-
let exchangeBody = "";
|
|
1340
|
-
exchangeRes.on("data", (chunk) => (exchangeBody += chunk));
|
|
1341
|
-
exchangeRes.on("end", () => {
|
|
1342
|
-
if (exchangeRes.statusCode !== 200) {
|
|
1343
|
-
console.error(`Failed to exchange code for token: ${exchangeRes.statusCode}`);
|
|
1344
|
-
|
|
1345
|
-
// Check if response is HTML (common for 404 pages)
|
|
1346
|
-
if (exchangeBody.trim().startsWith("<!") || exchangeBody.trim().startsWith("<html")) {
|
|
1347
|
-
console.error("Error: Received HTML response instead of JSON. This usually means:");
|
|
1348
|
-
console.error(" 1. The API endpoint URL is incorrect");
|
|
1349
|
-
console.error(" 2. The endpoint does not exist (404)");
|
|
1350
|
-
console.error(`\nAPI URL attempted: ${exchangeUrl.toString()}`);
|
|
1351
|
-
console.error("\nPlease verify the --api-base-url parameter.");
|
|
1352
|
-
} else {
|
|
1353
|
-
console.error(exchangeBody);
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
process.exit(1);
|
|
1357
|
-
return;
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
try {
|
|
1361
|
-
const result = JSON.parse(exchangeBody);
|
|
1362
|
-
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.
|
|
1363
|
-
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.
|
|
1364
|
-
|
|
1365
|
-
// Step 6: Save token to config
|
|
1366
|
-
config.writeConfig({
|
|
1367
|
-
apiKey: apiToken,
|
|
1368
|
-
baseUrl: apiBaseUrl,
|
|
1369
|
-
orgId: orgId,
|
|
1370
|
-
});
|
|
1371
|
-
|
|
1372
|
-
console.log("\nAuthentication successful!");
|
|
1373
|
-
console.log(`API key saved to: ${config.getConfigPath()}`);
|
|
1374
|
-
console.log(`Organization ID: ${orgId}`);
|
|
1375
|
-
console.log(`\nYou can now use the CLI without specifying an API key.`);
|
|
1376
|
-
process.exit(0);
|
|
1377
|
-
} catch (err) {
|
|
1378
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1379
|
-
console.error(`Failed to parse response: ${message}`);
|
|
1380
|
-
process.exit(1);
|
|
1381
|
-
}
|
|
1382
|
-
});
|
|
1383
|
-
}
|
|
1384
|
-
);
|
|
3043
|
+
// Step 5: Exchange code for token using fetch
|
|
3044
|
+
console.log("\nExchanging authorization code for API token...");
|
|
3045
|
+
const exchangeData = JSON.stringify({
|
|
3046
|
+
authorization_code: code,
|
|
3047
|
+
code_verifier: params.codeVerifier,
|
|
3048
|
+
state: params.state,
|
|
3049
|
+
});
|
|
3050
|
+
const exchangeUrl = new URL(`${apiBaseUrl}/rpc/oauth_token_exchange`);
|
|
1385
3051
|
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
3052
|
+
let exchangeResponse: Response;
|
|
3053
|
+
try {
|
|
3054
|
+
exchangeResponse = await fetch(exchangeUrl.toString(), {
|
|
3055
|
+
method: "POST",
|
|
3056
|
+
headers: {
|
|
3057
|
+
"Content-Type": "application/json",
|
|
3058
|
+
},
|
|
3059
|
+
body: exchangeData,
|
|
3060
|
+
});
|
|
3061
|
+
} catch (err) {
|
|
3062
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3063
|
+
console.error(`Exchange request failed: ${message}`);
|
|
3064
|
+
process.exit(1);
|
|
3065
|
+
return;
|
|
3066
|
+
}
|
|
1390
3067
|
|
|
1391
|
-
|
|
1392
|
-
exchangeReq.end();
|
|
3068
|
+
const exchangeBody = await exchangeResponse.text();
|
|
1393
3069
|
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
process.off("SIGINT", cancelHandler);
|
|
3070
|
+
if (!exchangeResponse.ok) {
|
|
3071
|
+
console.error(`Failed to exchange code for token: ${exchangeResponse.status}`);
|
|
1397
3072
|
|
|
1398
|
-
|
|
3073
|
+
// Check if response is HTML (common for 404 pages)
|
|
3074
|
+
if (exchangeBody.trim().startsWith("<!") || exchangeBody.trim().startsWith("<html")) {
|
|
3075
|
+
console.error("Error: Received HTML response instead of JSON. This usually means:");
|
|
3076
|
+
console.error(" 1. The API endpoint URL is incorrect");
|
|
3077
|
+
console.error(" 2. The endpoint does not exist (404)");
|
|
3078
|
+
console.error(`\nAPI URL attempted: ${exchangeUrl.toString()}`);
|
|
3079
|
+
console.error("\nPlease verify the --api-base-url parameter.");
|
|
3080
|
+
} else {
|
|
3081
|
+
console.error(exchangeBody);
|
|
3082
|
+
}
|
|
1399
3083
|
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
console.error(`This usually means you closed the browser window without completing authentication.`);
|
|
1404
|
-
console.error(`Please try again and complete the authentication flow.`);
|
|
1405
|
-
} else {
|
|
1406
|
-
console.error(`\nAuthentication failed: ${message}`);
|
|
1407
|
-
}
|
|
3084
|
+
process.exit(1);
|
|
3085
|
+
return;
|
|
3086
|
+
}
|
|
1408
3087
|
|
|
1409
|
-
|
|
1410
|
-
|
|
3088
|
+
try {
|
|
3089
|
+
const result = JSON.parse(exchangeBody);
|
|
3090
|
+
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.
|
|
3091
|
+
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.
|
|
3092
|
+
|
|
3093
|
+
// Step 6: Save token to config
|
|
3094
|
+
// Check if org changed to decide whether to preserve defaultProject
|
|
3095
|
+
const existingConfig = config.readConfig();
|
|
3096
|
+
const existingOrgId = existingConfig.orgId;
|
|
3097
|
+
const existingProject = existingConfig.defaultProject;
|
|
3098
|
+
const orgChanged = existingOrgId && existingOrgId !== orgId;
|
|
3099
|
+
|
|
3100
|
+
config.writeConfig({
|
|
3101
|
+
apiKey: apiToken,
|
|
3102
|
+
baseUrl: apiBaseUrl,
|
|
3103
|
+
orgId: orgId,
|
|
1411
3104
|
});
|
|
3105
|
+
|
|
3106
|
+
// Only clear defaultProject if org actually changed
|
|
3107
|
+
if (orgChanged && existingProject) {
|
|
3108
|
+
config.deleteConfigKeys(["defaultProject"]);
|
|
3109
|
+
console.log(`\nNote: Organization changed (${existingOrgId} → ${orgId}).`);
|
|
3110
|
+
console.log(` Default project "${existingProject}" has been cleared.`);
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3113
|
+
console.log("\nAuthentication successful!");
|
|
3114
|
+
console.log(`API key saved to: ${config.getConfigPath()}`);
|
|
3115
|
+
console.log(`Organization ID: ${orgId}`);
|
|
3116
|
+
if (!orgChanged && existingProject) {
|
|
3117
|
+
console.log(`Default project: ${existingProject} (preserved)`);
|
|
3118
|
+
}
|
|
3119
|
+
console.log(`\nYou can now use the CLI without specifying an API key.`);
|
|
3120
|
+
process.exit(0);
|
|
3121
|
+
} catch (err) {
|
|
3122
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3123
|
+
console.error(`Failed to parse response: ${message}`);
|
|
3124
|
+
process.exit(1);
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
} catch (err) {
|
|
3128
|
+
// Remove the cancel handler in error case too
|
|
3129
|
+
process.off("SIGINT", cancelHandler);
|
|
3130
|
+
|
|
3131
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3132
|
+
|
|
3133
|
+
// Provide more helpful error messages
|
|
3134
|
+
if (message.includes("timeout")) {
|
|
3135
|
+
console.error(`\nAuthentication timed out.`);
|
|
3136
|
+
console.error(`This usually means you closed the browser window without completing authentication.`);
|
|
3137
|
+
console.error(`Please try again and complete the authentication flow.`);
|
|
3138
|
+
} else {
|
|
3139
|
+
console.error(`\nAuthentication failed: ${message}`);
|
|
1412
3140
|
}
|
|
1413
|
-
);
|
|
1414
3141
|
|
|
1415
|
-
initReq.on("error", (err: Error) => {
|
|
1416
|
-
console.error(`Failed to connect to API: ${err.message}`);
|
|
1417
|
-
callbackServer.server.close();
|
|
1418
3142
|
process.exit(1);
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
initReq.write(initData);
|
|
1422
|
-
initReq.end();
|
|
3143
|
+
}
|
|
1423
3144
|
|
|
1424
3145
|
} catch (err) {
|
|
1425
3146
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1428,15 +3149,7 @@ program
|
|
|
1428
3149
|
}
|
|
1429
3150
|
});
|
|
1430
3151
|
|
|
1431
|
-
|
|
1432
|
-
.command("add-key <apiKey>")
|
|
1433
|
-
.description("store API key")
|
|
1434
|
-
.action(async (apiKey: string) => {
|
|
1435
|
-
config.writeConfig({ apiKey });
|
|
1436
|
-
console.log(`API key saved to ${config.getConfigPath()}`);
|
|
1437
|
-
});
|
|
1438
|
-
|
|
1439
|
-
program
|
|
3152
|
+
auth
|
|
1440
3153
|
.command("show-key")
|
|
1441
3154
|
.description("show API key (masked)")
|
|
1442
3155
|
.action(async () => {
|
|
@@ -1446,7 +3159,6 @@ program
|
|
|
1446
3159
|
console.log(`\nTo authenticate, run: pgai auth`);
|
|
1447
3160
|
return;
|
|
1448
3161
|
}
|
|
1449
|
-
const { maskSecret } = require("../lib/util");
|
|
1450
3162
|
console.log(`Current API key: ${maskSecret(cfg.apiKey)}`);
|
|
1451
3163
|
if (cfg.orgId) {
|
|
1452
3164
|
console.log(`Organization ID: ${cfg.orgId}`);
|
|
@@ -1454,14 +3166,20 @@ program
|
|
|
1454
3166
|
console.log(`Config location: ${config.getConfigPath()}`);
|
|
1455
3167
|
});
|
|
1456
3168
|
|
|
1457
|
-
|
|
3169
|
+
auth
|
|
1458
3170
|
.command("remove-key")
|
|
1459
3171
|
.description("remove API key")
|
|
1460
3172
|
.action(async () => {
|
|
1461
3173
|
// Check both new config and legacy config
|
|
1462
3174
|
const newConfigPath = config.getConfigPath();
|
|
1463
3175
|
const hasNewConfig = fs.existsSync(newConfigPath);
|
|
1464
|
-
|
|
3176
|
+
let legacyPath: string;
|
|
3177
|
+
try {
|
|
3178
|
+
const { projectDir } = await resolveOrInitPaths();
|
|
3179
|
+
legacyPath = path.resolve(projectDir, ".pgwatch-config");
|
|
3180
|
+
} catch {
|
|
3181
|
+
legacyPath = path.resolve(process.cwd(), ".pgwatch-config");
|
|
3182
|
+
}
|
|
1465
3183
|
const hasLegacyConfig = fs.existsSync(legacyPath) && fs.statSync(legacyPath).isFile();
|
|
1466
3184
|
|
|
1467
3185
|
if (!hasNewConfig && !hasLegacyConfig) {
|
|
@@ -1497,7 +3215,8 @@ mon
|
|
|
1497
3215
|
.command("generate-grafana-password")
|
|
1498
3216
|
.description("generate Grafana password for monitoring services")
|
|
1499
3217
|
.action(async () => {
|
|
1500
|
-
const
|
|
3218
|
+
const { projectDir } = await resolveOrInitPaths();
|
|
3219
|
+
const cfgPath = path.resolve(projectDir, ".pgwatch-config");
|
|
1501
3220
|
|
|
1502
3221
|
try {
|
|
1503
3222
|
// Generate secure password using openssl
|
|
@@ -1548,9 +3267,10 @@ mon
|
|
|
1548
3267
|
.command("show-grafana-credentials")
|
|
1549
3268
|
.description("show Grafana credentials for monitoring services")
|
|
1550
3269
|
.action(async () => {
|
|
1551
|
-
const
|
|
3270
|
+
const { projectDir } = await resolveOrInitPaths();
|
|
3271
|
+
const cfgPath = path.resolve(projectDir, ".pgwatch-config");
|
|
1552
3272
|
if (!fs.existsSync(cfgPath)) {
|
|
1553
|
-
console.error("Configuration file not found. Run 'postgres-ai mon
|
|
3273
|
+
console.error("Configuration file not found. Run 'postgres-ai mon local-install' first.");
|
|
1554
3274
|
process.exitCode = 1;
|
|
1555
3275
|
return;
|
|
1556
3276
|
}
|
|
@@ -1608,22 +3328,44 @@ const issues = program.command("issues").description("issues management");
|
|
|
1608
3328
|
issues
|
|
1609
3329
|
.command("list")
|
|
1610
3330
|
.description("list issues")
|
|
3331
|
+
.option("--status <status>", "filter by status: open, closed, or all (default: all)")
|
|
3332
|
+
.option("--limit <n>", "max number of issues to return (default: 20)", parseInt)
|
|
3333
|
+
.option("--offset <n>", "number of issues to skip (default: 0)", parseInt)
|
|
1611
3334
|
.option("--debug", "enable debug output")
|
|
1612
3335
|
.option("--json", "output raw JSON")
|
|
1613
|
-
.action(async (opts: { debug?: boolean; json?: boolean }) => {
|
|
3336
|
+
.action(async (opts: { status?: string; limit?: number; offset?: number; debug?: boolean; json?: boolean }) => {
|
|
3337
|
+
const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching issues...");
|
|
1614
3338
|
try {
|
|
1615
3339
|
const rootOpts = program.opts<CliOptions>();
|
|
1616
3340
|
const cfg = config.readConfig();
|
|
1617
3341
|
const { apiKey } = getConfig(rootOpts);
|
|
1618
3342
|
if (!apiKey) {
|
|
3343
|
+
spinner.stop();
|
|
1619
3344
|
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
1620
3345
|
process.exitCode = 1;
|
|
1621
3346
|
return;
|
|
1622
3347
|
}
|
|
3348
|
+
const orgId = cfg.orgId ?? undefined;
|
|
1623
3349
|
|
|
1624
3350
|
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
1625
3351
|
|
|
1626
|
-
|
|
3352
|
+
let statusFilter: "open" | "closed" | undefined;
|
|
3353
|
+
if (opts.status === "open") {
|
|
3354
|
+
statusFilter = "open";
|
|
3355
|
+
} else if (opts.status === "closed") {
|
|
3356
|
+
statusFilter = "closed";
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
const result = await fetchIssues({
|
|
3360
|
+
apiKey,
|
|
3361
|
+
apiBaseUrl,
|
|
3362
|
+
orgId,
|
|
3363
|
+
status: statusFilter,
|
|
3364
|
+
limit: opts.limit,
|
|
3365
|
+
offset: opts.offset,
|
|
3366
|
+
debug: !!opts.debug,
|
|
3367
|
+
});
|
|
3368
|
+
spinner.stop();
|
|
1627
3369
|
const trimmed = Array.isArray(result)
|
|
1628
3370
|
? (result as any[]).map((r) => ({
|
|
1629
3371
|
id: (r as any).id,
|
|
@@ -1634,6 +3376,7 @@ issues
|
|
|
1634
3376
|
: result;
|
|
1635
3377
|
printResult(trimmed, opts.json);
|
|
1636
3378
|
} catch (err) {
|
|
3379
|
+
spinner.stop();
|
|
1637
3380
|
const message = err instanceof Error ? err.message : String(err);
|
|
1638
3381
|
console.error(message);
|
|
1639
3382
|
process.exitCode = 1;
|
|
@@ -1646,11 +3389,13 @@ issues
|
|
|
1646
3389
|
.option("--debug", "enable debug output")
|
|
1647
3390
|
.option("--json", "output raw JSON")
|
|
1648
3391
|
.action(async (issueId: string, opts: { debug?: boolean; json?: boolean }) => {
|
|
3392
|
+
const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching issue...");
|
|
1649
3393
|
try {
|
|
1650
3394
|
const rootOpts = program.opts<CliOptions>();
|
|
1651
3395
|
const cfg = config.readConfig();
|
|
1652
3396
|
const { apiKey } = getConfig(rootOpts);
|
|
1653
3397
|
if (!apiKey) {
|
|
3398
|
+
spinner.stop();
|
|
1654
3399
|
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
1655
3400
|
process.exitCode = 1;
|
|
1656
3401
|
return;
|
|
@@ -1660,15 +3405,19 @@ issues
|
|
|
1660
3405
|
|
|
1661
3406
|
const issue = await fetchIssue({ apiKey, apiBaseUrl, issueId, debug: !!opts.debug });
|
|
1662
3407
|
if (!issue) {
|
|
3408
|
+
spinner.stop();
|
|
1663
3409
|
console.error("Issue not found");
|
|
1664
3410
|
process.exitCode = 1;
|
|
1665
3411
|
return;
|
|
1666
3412
|
}
|
|
1667
3413
|
|
|
3414
|
+
spinner.update("Fetching comments...");
|
|
1668
3415
|
const comments = await fetchIssueComments({ apiKey, apiBaseUrl, issueId, debug: !!opts.debug });
|
|
3416
|
+
spinner.stop();
|
|
1669
3417
|
const combined = { issue, comments };
|
|
1670
3418
|
printResult(combined, opts.json);
|
|
1671
3419
|
} catch (err) {
|
|
3420
|
+
spinner.stop();
|
|
1672
3421
|
const message = err instanceof Error ? err.message : String(err);
|
|
1673
3422
|
console.error(message);
|
|
1674
3423
|
process.exitCode = 1;
|
|
@@ -1676,28 +3425,30 @@ issues
|
|
|
1676
3425
|
});
|
|
1677
3426
|
|
|
1678
3427
|
issues
|
|
1679
|
-
.command("
|
|
3428
|
+
.command("post-comment <issueId> <content>")
|
|
1680
3429
|
.description("post a new comment to an issue")
|
|
1681
3430
|
.option("--parent <uuid>", "parent comment id")
|
|
1682
3431
|
.option("--debug", "enable debug output")
|
|
1683
3432
|
.option("--json", "output raw JSON")
|
|
1684
3433
|
.action(async (issueId: string, content: string, opts: { parent?: string; debug?: boolean; json?: boolean }) => {
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
}
|
|
3434
|
+
// Interpret escape sequences in content (e.g., \n -> newline)
|
|
3435
|
+
if (opts.debug) {
|
|
3436
|
+
// eslint-disable-next-line no-console
|
|
3437
|
+
console.log(`Debug: Original content: ${JSON.stringify(content)}`);
|
|
3438
|
+
}
|
|
3439
|
+
content = interpretEscapes(content);
|
|
3440
|
+
if (opts.debug) {
|
|
3441
|
+
// eslint-disable-next-line no-console
|
|
3442
|
+
console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
|
|
3443
|
+
}
|
|
1696
3444
|
|
|
3445
|
+
const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Posting comment...");
|
|
3446
|
+
try {
|
|
1697
3447
|
const rootOpts = program.opts<CliOptions>();
|
|
1698
3448
|
const cfg = config.readConfig();
|
|
1699
3449
|
const { apiKey } = getConfig(rootOpts);
|
|
1700
3450
|
if (!apiKey) {
|
|
3451
|
+
spinner.stop();
|
|
1701
3452
|
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
1702
3453
|
process.exitCode = 1;
|
|
1703
3454
|
return;
|
|
@@ -1713,8 +3464,431 @@ issues
|
|
|
1713
3464
|
parentCommentId: opts.parent,
|
|
1714
3465
|
debug: !!opts.debug,
|
|
1715
3466
|
});
|
|
3467
|
+
spinner.stop();
|
|
3468
|
+
printResult(result, opts.json);
|
|
3469
|
+
} catch (err) {
|
|
3470
|
+
spinner.stop();
|
|
3471
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3472
|
+
console.error(message);
|
|
3473
|
+
process.exitCode = 1;
|
|
3474
|
+
}
|
|
3475
|
+
});
|
|
3476
|
+
|
|
3477
|
+
issues
|
|
3478
|
+
.command("create <title>")
|
|
3479
|
+
.description("create a new issue")
|
|
3480
|
+
.option("--org-id <id>", "organization id (defaults to config orgId)", (v) => parseInt(v, 10))
|
|
3481
|
+
.option("--project-id <id>", "project id", (v) => parseInt(v, 10))
|
|
3482
|
+
.option("--description <text>", "issue description (use \\n for newlines)")
|
|
3483
|
+
.option(
|
|
3484
|
+
"--label <label>",
|
|
3485
|
+
"issue label (repeatable)",
|
|
3486
|
+
(value: string, previous: string[]) => {
|
|
3487
|
+
previous.push(value);
|
|
3488
|
+
return previous;
|
|
3489
|
+
},
|
|
3490
|
+
[] as string[]
|
|
3491
|
+
)
|
|
3492
|
+
.option("--debug", "enable debug output")
|
|
3493
|
+
.option("--json", "output raw JSON")
|
|
3494
|
+
.action(async (rawTitle: string, opts: { orgId?: number; projectId?: number; description?: string; label?: string[]; debug?: boolean; json?: boolean }) => {
|
|
3495
|
+
const rootOpts = program.opts<CliOptions>();
|
|
3496
|
+
const cfg = config.readConfig();
|
|
3497
|
+
const { apiKey } = getConfig(rootOpts);
|
|
3498
|
+
if (!apiKey) {
|
|
3499
|
+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
3500
|
+
process.exitCode = 1;
|
|
3501
|
+
return;
|
|
3502
|
+
}
|
|
3503
|
+
|
|
3504
|
+
const title = interpretEscapes(String(rawTitle || "").trim());
|
|
3505
|
+
if (!title) {
|
|
3506
|
+
console.error("title is required");
|
|
3507
|
+
process.exitCode = 1;
|
|
3508
|
+
return;
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
const orgId = typeof opts.orgId === "number" && !Number.isNaN(opts.orgId) ? opts.orgId : cfg.orgId;
|
|
3512
|
+
if (typeof orgId !== "number") {
|
|
3513
|
+
console.error("org_id is required. Either pass --org-id or run 'pgai auth' to store it in config.");
|
|
3514
|
+
process.exitCode = 1;
|
|
3515
|
+
return;
|
|
3516
|
+
}
|
|
3517
|
+
|
|
3518
|
+
const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
|
|
3519
|
+
const labels = Array.isArray(opts.label) && opts.label.length > 0 ? opts.label.map(String) : undefined;
|
|
3520
|
+
const projectId = typeof opts.projectId === "number" && !Number.isNaN(opts.projectId) ? opts.projectId : undefined;
|
|
3521
|
+
|
|
3522
|
+
const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Creating issue...");
|
|
3523
|
+
try {
|
|
3524
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
3525
|
+
const result = await createIssue({
|
|
3526
|
+
apiKey,
|
|
3527
|
+
apiBaseUrl,
|
|
3528
|
+
title,
|
|
3529
|
+
orgId,
|
|
3530
|
+
description,
|
|
3531
|
+
projectId,
|
|
3532
|
+
labels,
|
|
3533
|
+
debug: !!opts.debug,
|
|
3534
|
+
});
|
|
3535
|
+
spinner.stop();
|
|
3536
|
+
printResult(result, opts.json);
|
|
3537
|
+
} catch (err) {
|
|
3538
|
+
spinner.stop();
|
|
3539
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3540
|
+
console.error(message);
|
|
3541
|
+
process.exitCode = 1;
|
|
3542
|
+
}
|
|
3543
|
+
});
|
|
3544
|
+
|
|
3545
|
+
issues
|
|
3546
|
+
.command("update <issueId>")
|
|
3547
|
+
.description("update an existing issue (title/description/status/labels)")
|
|
3548
|
+
.option("--title <text>", "new title (use \\n for newlines)")
|
|
3549
|
+
.option("--description <text>", "new description (use \\n for newlines)")
|
|
3550
|
+
.option("--status <value>", "status: open|closed|0|1")
|
|
3551
|
+
.option(
|
|
3552
|
+
"--label <label>",
|
|
3553
|
+
"set labels (repeatable). If provided, replaces existing labels.",
|
|
3554
|
+
(value: string, previous: string[]) => {
|
|
3555
|
+
previous.push(value);
|
|
3556
|
+
return previous;
|
|
3557
|
+
},
|
|
3558
|
+
[] as string[]
|
|
3559
|
+
)
|
|
3560
|
+
.option("--clear-labels", "set labels to an empty list")
|
|
3561
|
+
.option("--debug", "enable debug output")
|
|
3562
|
+
.option("--json", "output raw JSON")
|
|
3563
|
+
.action(async (issueId: string, opts: { title?: string; description?: string; status?: string; label?: string[]; clearLabels?: boolean; debug?: boolean; json?: boolean }) => {
|
|
3564
|
+
const rootOpts = program.opts<CliOptions>();
|
|
3565
|
+
const cfg = config.readConfig();
|
|
3566
|
+
const { apiKey } = getConfig(rootOpts);
|
|
3567
|
+
if (!apiKey) {
|
|
3568
|
+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
3569
|
+
process.exitCode = 1;
|
|
3570
|
+
return;
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3573
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
3574
|
+
|
|
3575
|
+
const title = opts.title !== undefined ? interpretEscapes(String(opts.title)) : undefined;
|
|
3576
|
+
const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
|
|
3577
|
+
|
|
3578
|
+
let status: number | undefined = undefined;
|
|
3579
|
+
if (opts.status !== undefined) {
|
|
3580
|
+
const raw = String(opts.status).trim().toLowerCase();
|
|
3581
|
+
if (raw === "open") status = 0;
|
|
3582
|
+
else if (raw === "closed") status = 1;
|
|
3583
|
+
else {
|
|
3584
|
+
const n = Number(raw);
|
|
3585
|
+
if (!Number.isFinite(n)) {
|
|
3586
|
+
console.error("status must be open|closed|0|1");
|
|
3587
|
+
process.exitCode = 1;
|
|
3588
|
+
return;
|
|
3589
|
+
}
|
|
3590
|
+
status = n;
|
|
3591
|
+
}
|
|
3592
|
+
if (status !== 0 && status !== 1) {
|
|
3593
|
+
console.error("status must be 0 (open) or 1 (closed)");
|
|
3594
|
+
process.exitCode = 1;
|
|
3595
|
+
return;
|
|
3596
|
+
}
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
let labels: string[] | undefined = undefined;
|
|
3600
|
+
if (opts.clearLabels) {
|
|
3601
|
+
labels = [];
|
|
3602
|
+
} else if (Array.isArray(opts.label) && opts.label.length > 0) {
|
|
3603
|
+
labels = opts.label.map(String);
|
|
3604
|
+
}
|
|
3605
|
+
|
|
3606
|
+
const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Updating issue...");
|
|
3607
|
+
try {
|
|
3608
|
+
const result = await updateIssue({
|
|
3609
|
+
apiKey,
|
|
3610
|
+
apiBaseUrl,
|
|
3611
|
+
issueId,
|
|
3612
|
+
title,
|
|
3613
|
+
description,
|
|
3614
|
+
status,
|
|
3615
|
+
labels,
|
|
3616
|
+
debug: !!opts.debug,
|
|
3617
|
+
});
|
|
3618
|
+
spinner.stop();
|
|
3619
|
+
printResult(result, opts.json);
|
|
3620
|
+
} catch (err) {
|
|
3621
|
+
spinner.stop();
|
|
3622
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3623
|
+
console.error(message);
|
|
3624
|
+
process.exitCode = 1;
|
|
3625
|
+
}
|
|
3626
|
+
});
|
|
3627
|
+
|
|
3628
|
+
issues
|
|
3629
|
+
.command("update-comment <commentId> <content>")
|
|
3630
|
+
.description("update an existing issue comment")
|
|
3631
|
+
.option("--debug", "enable debug output")
|
|
3632
|
+
.option("--json", "output raw JSON")
|
|
3633
|
+
.action(async (commentId: string, content: string, opts: { debug?: boolean; json?: boolean }) => {
|
|
3634
|
+
if (opts.debug) {
|
|
3635
|
+
// eslint-disable-next-line no-console
|
|
3636
|
+
console.log(`Debug: Original content: ${JSON.stringify(content)}`);
|
|
3637
|
+
}
|
|
3638
|
+
content = interpretEscapes(content);
|
|
3639
|
+
if (opts.debug) {
|
|
3640
|
+
// eslint-disable-next-line no-console
|
|
3641
|
+
console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
const rootOpts = program.opts<CliOptions>();
|
|
3645
|
+
const cfg = config.readConfig();
|
|
3646
|
+
const { apiKey } = getConfig(rootOpts);
|
|
3647
|
+
if (!apiKey) {
|
|
3648
|
+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
3649
|
+
process.exitCode = 1;
|
|
3650
|
+
return;
|
|
3651
|
+
}
|
|
3652
|
+
|
|
3653
|
+
const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Updating comment...");
|
|
3654
|
+
try {
|
|
3655
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
3656
|
+
|
|
3657
|
+
const result = await updateIssueComment({
|
|
3658
|
+
apiKey,
|
|
3659
|
+
apiBaseUrl,
|
|
3660
|
+
commentId,
|
|
3661
|
+
content,
|
|
3662
|
+
debug: !!opts.debug,
|
|
3663
|
+
});
|
|
3664
|
+
spinner.stop();
|
|
3665
|
+
printResult(result, opts.json);
|
|
3666
|
+
} catch (err) {
|
|
3667
|
+
spinner.stop();
|
|
3668
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3669
|
+
console.error(message);
|
|
3670
|
+
process.exitCode = 1;
|
|
3671
|
+
}
|
|
3672
|
+
});
|
|
3673
|
+
|
|
3674
|
+
// Action Items management (subcommands of issues)
|
|
3675
|
+
issues
|
|
3676
|
+
.command("action-items <issueId>")
|
|
3677
|
+
.description("list action items for an issue")
|
|
3678
|
+
.option("--debug", "enable debug output")
|
|
3679
|
+
.option("--json", "output raw JSON")
|
|
3680
|
+
.action(async (issueId: string, opts: { debug?: boolean; json?: boolean }) => {
|
|
3681
|
+
const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching action items...");
|
|
3682
|
+
try {
|
|
3683
|
+
const rootOpts = program.opts<CliOptions>();
|
|
3684
|
+
const cfg = config.readConfig();
|
|
3685
|
+
const { apiKey } = getConfig(rootOpts);
|
|
3686
|
+
if (!apiKey) {
|
|
3687
|
+
spinner.stop();
|
|
3688
|
+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
3689
|
+
process.exitCode = 1;
|
|
3690
|
+
return;
|
|
3691
|
+
}
|
|
3692
|
+
|
|
3693
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
3694
|
+
|
|
3695
|
+
const result = await fetchActionItems({ apiKey, apiBaseUrl, issueId, debug: !!opts.debug });
|
|
3696
|
+
spinner.stop();
|
|
3697
|
+
printResult(result, opts.json);
|
|
3698
|
+
} catch (err) {
|
|
3699
|
+
spinner.stop();
|
|
3700
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3701
|
+
console.error(message);
|
|
3702
|
+
process.exitCode = 1;
|
|
3703
|
+
}
|
|
3704
|
+
});
|
|
3705
|
+
|
|
3706
|
+
issues
|
|
3707
|
+
.command("view-action-item <actionItemIds...>")
|
|
3708
|
+
.description("view action item(s) with all details (supports multiple IDs)")
|
|
3709
|
+
.option("--debug", "enable debug output")
|
|
3710
|
+
.option("--json", "output raw JSON")
|
|
3711
|
+
.action(async (actionItemIds: string[], opts: { debug?: boolean; json?: boolean }) => {
|
|
3712
|
+
const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching action item(s)...");
|
|
3713
|
+
try {
|
|
3714
|
+
const rootOpts = program.opts<CliOptions>();
|
|
3715
|
+
const cfg = config.readConfig();
|
|
3716
|
+
const { apiKey } = getConfig(rootOpts);
|
|
3717
|
+
if (!apiKey) {
|
|
3718
|
+
spinner.stop();
|
|
3719
|
+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
3720
|
+
process.exitCode = 1;
|
|
3721
|
+
return;
|
|
3722
|
+
}
|
|
3723
|
+
|
|
3724
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
3725
|
+
|
|
3726
|
+
const result = await fetchActionItem({ apiKey, apiBaseUrl, actionItemIds, debug: !!opts.debug });
|
|
3727
|
+
if (result.length === 0) {
|
|
3728
|
+
spinner.stop();
|
|
3729
|
+
console.error("Action item(s) not found");
|
|
3730
|
+
process.exitCode = 1;
|
|
3731
|
+
return;
|
|
3732
|
+
}
|
|
3733
|
+
spinner.stop();
|
|
1716
3734
|
printResult(result, opts.json);
|
|
1717
3735
|
} catch (err) {
|
|
3736
|
+
spinner.stop();
|
|
3737
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3738
|
+
console.error(message);
|
|
3739
|
+
process.exitCode = 1;
|
|
3740
|
+
}
|
|
3741
|
+
});
|
|
3742
|
+
|
|
3743
|
+
issues
|
|
3744
|
+
.command("create-action-item <issueId> <title>")
|
|
3745
|
+
.description("create a new action item for an issue")
|
|
3746
|
+
.option("--description <text>", "detailed description (use \\n for newlines)")
|
|
3747
|
+
.option("--sql-action <sql>", "SQL command to execute")
|
|
3748
|
+
.option("--config <json>", "config change as JSON: {\"parameter\":\"...\",\"value\":\"...\"} (repeatable)", (value: string, previous: ConfigChange[]) => {
|
|
3749
|
+
try {
|
|
3750
|
+
previous.push(JSON.parse(value) as ConfigChange);
|
|
3751
|
+
} catch {
|
|
3752
|
+
console.error(`Invalid JSON for --config: ${value}`);
|
|
3753
|
+
process.exit(1);
|
|
3754
|
+
}
|
|
3755
|
+
return previous;
|
|
3756
|
+
}, [] as ConfigChange[])
|
|
3757
|
+
.option("--debug", "enable debug output")
|
|
3758
|
+
.option("--json", "output raw JSON")
|
|
3759
|
+
.action(async (issueId: string, rawTitle: string, opts: { description?: string; sqlAction?: string; config?: ConfigChange[]; debug?: boolean; json?: boolean }) => {
|
|
3760
|
+
const rootOpts = program.opts<CliOptions>();
|
|
3761
|
+
const cfg = config.readConfig();
|
|
3762
|
+
const { apiKey } = getConfig(rootOpts);
|
|
3763
|
+
if (!apiKey) {
|
|
3764
|
+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
3765
|
+
process.exitCode = 1;
|
|
3766
|
+
return;
|
|
3767
|
+
}
|
|
3768
|
+
|
|
3769
|
+
const title = interpretEscapes(String(rawTitle || "").trim());
|
|
3770
|
+
if (!title) {
|
|
3771
|
+
console.error("title is required");
|
|
3772
|
+
process.exitCode = 1;
|
|
3773
|
+
return;
|
|
3774
|
+
}
|
|
3775
|
+
|
|
3776
|
+
const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
|
|
3777
|
+
const sqlAction = opts.sqlAction;
|
|
3778
|
+
const configs = Array.isArray(opts.config) && opts.config.length > 0 ? opts.config : undefined;
|
|
3779
|
+
|
|
3780
|
+
const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Creating action item...");
|
|
3781
|
+
try {
|
|
3782
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
3783
|
+
const result = await createActionItem({
|
|
3784
|
+
apiKey,
|
|
3785
|
+
apiBaseUrl,
|
|
3786
|
+
issueId,
|
|
3787
|
+
title,
|
|
3788
|
+
description,
|
|
3789
|
+
sqlAction,
|
|
3790
|
+
configs,
|
|
3791
|
+
debug: !!opts.debug,
|
|
3792
|
+
});
|
|
3793
|
+
spinner.stop();
|
|
3794
|
+
printResult({ id: result }, opts.json);
|
|
3795
|
+
} catch (err) {
|
|
3796
|
+
spinner.stop();
|
|
3797
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3798
|
+
console.error(message);
|
|
3799
|
+
process.exitCode = 1;
|
|
3800
|
+
}
|
|
3801
|
+
});
|
|
3802
|
+
|
|
3803
|
+
issues
|
|
3804
|
+
.command("update-action-item <actionItemId>")
|
|
3805
|
+
.description("update an action item (title, description, status, sql_action, configs)")
|
|
3806
|
+
.option("--title <text>", "new title (use \\n for newlines)")
|
|
3807
|
+
.option("--description <text>", "new description (use \\n for newlines)")
|
|
3808
|
+
.option("--done", "mark as done")
|
|
3809
|
+
.option("--not-done", "mark as not done")
|
|
3810
|
+
.option("--status <value>", "status: waiting_for_approval|approved|rejected")
|
|
3811
|
+
.option("--status-reason <text>", "reason for status change")
|
|
3812
|
+
.option("--sql-action <sql>", "SQL command (use empty string to clear)")
|
|
3813
|
+
.option("--config <json>", "config change as JSON (repeatable, replaces all configs)", (value: string, previous: ConfigChange[]) => {
|
|
3814
|
+
try {
|
|
3815
|
+
previous.push(JSON.parse(value) as ConfigChange);
|
|
3816
|
+
} catch {
|
|
3817
|
+
console.error(`Invalid JSON for --config: ${value}`);
|
|
3818
|
+
process.exit(1);
|
|
3819
|
+
}
|
|
3820
|
+
return previous;
|
|
3821
|
+
}, [] as ConfigChange[])
|
|
3822
|
+
.option("--clear-configs", "clear all config changes")
|
|
3823
|
+
.option("--debug", "enable debug output")
|
|
3824
|
+
.option("--json", "output raw JSON")
|
|
3825
|
+
.action(async (actionItemId: string, opts: { title?: string; description?: string; done?: boolean; notDone?: boolean; status?: string; statusReason?: string; sqlAction?: string; config?: ConfigChange[]; clearConfigs?: boolean; debug?: boolean; json?: boolean }) => {
|
|
3826
|
+
const rootOpts = program.opts<CliOptions>();
|
|
3827
|
+
const cfg = config.readConfig();
|
|
3828
|
+
const { apiKey } = getConfig(rootOpts);
|
|
3829
|
+
if (!apiKey) {
|
|
3830
|
+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
|
|
3831
|
+
process.exitCode = 1;
|
|
3832
|
+
return;
|
|
3833
|
+
}
|
|
3834
|
+
|
|
3835
|
+
const title = opts.title !== undefined ? interpretEscapes(String(opts.title)) : undefined;
|
|
3836
|
+
const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
|
|
3837
|
+
|
|
3838
|
+
let isDone: boolean | undefined = undefined;
|
|
3839
|
+
if (opts.done) isDone = true;
|
|
3840
|
+
else if (opts.notDone) isDone = false;
|
|
3841
|
+
|
|
3842
|
+
let status: string | undefined = undefined;
|
|
3843
|
+
if (opts.status !== undefined) {
|
|
3844
|
+
const validStatuses = ["waiting_for_approval", "approved", "rejected"];
|
|
3845
|
+
if (!validStatuses.includes(opts.status)) {
|
|
3846
|
+
console.error(`status must be one of: ${validStatuses.join(", ")}`);
|
|
3847
|
+
process.exitCode = 1;
|
|
3848
|
+
return;
|
|
3849
|
+
}
|
|
3850
|
+
status = opts.status;
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3853
|
+
const statusReason = opts.statusReason;
|
|
3854
|
+
const sqlAction = opts.sqlAction;
|
|
3855
|
+
|
|
3856
|
+
let configs: ConfigChange[] | undefined = undefined;
|
|
3857
|
+
if (opts.clearConfigs) {
|
|
3858
|
+
configs = [];
|
|
3859
|
+
} else if (Array.isArray(opts.config) && opts.config.length > 0) {
|
|
3860
|
+
configs = opts.config;
|
|
3861
|
+
}
|
|
3862
|
+
|
|
3863
|
+
// Check that at least one update field is provided
|
|
3864
|
+
if (title === undefined && description === undefined &&
|
|
3865
|
+
isDone === undefined && status === undefined && statusReason === undefined &&
|
|
3866
|
+
sqlAction === undefined && configs === undefined) {
|
|
3867
|
+
console.error("At least one update option is required");
|
|
3868
|
+
process.exitCode = 1;
|
|
3869
|
+
return;
|
|
3870
|
+
}
|
|
3871
|
+
|
|
3872
|
+
const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Updating action item...");
|
|
3873
|
+
try {
|
|
3874
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
3875
|
+
await updateActionItem({
|
|
3876
|
+
apiKey,
|
|
3877
|
+
apiBaseUrl,
|
|
3878
|
+
actionItemId,
|
|
3879
|
+
title,
|
|
3880
|
+
description,
|
|
3881
|
+
isDone,
|
|
3882
|
+
status,
|
|
3883
|
+
statusReason,
|
|
3884
|
+
sqlAction,
|
|
3885
|
+
configs,
|
|
3886
|
+
debug: !!opts.debug,
|
|
3887
|
+
});
|
|
3888
|
+
spinner.stop();
|
|
3889
|
+
printResult({ success: true }, opts.json);
|
|
3890
|
+
} catch (err) {
|
|
3891
|
+
spinner.stop();
|
|
1718
3892
|
const message = err instanceof Error ? err.message : String(err);
|
|
1719
3893
|
console.error(message);
|
|
1720
3894
|
process.exitCode = 1;
|
|
@@ -1748,15 +3922,7 @@ mcp
|
|
|
1748
3922
|
console.log(" 4. Codex");
|
|
1749
3923
|
console.log("");
|
|
1750
3924
|
|
|
1751
|
-
const
|
|
1752
|
-
input: process.stdin,
|
|
1753
|
-
output: process.stdout
|
|
1754
|
-
});
|
|
1755
|
-
|
|
1756
|
-
const answer = await new Promise<string>((resolve) => {
|
|
1757
|
-
rl.question("Select your AI coding tool (1-4): ", resolve);
|
|
1758
|
-
});
|
|
1759
|
-
rl.close();
|
|
3925
|
+
const answer = await question("Select your AI coding tool (1-4): ");
|
|
1760
3926
|
|
|
1761
3927
|
const choices: Record<string, string> = {
|
|
1762
3928
|
"1": "cursor",
|
|
@@ -1891,5 +4057,7 @@ mcp
|
|
|
1891
4057
|
}
|
|
1892
4058
|
});
|
|
1893
4059
|
|
|
1894
|
-
program.parseAsync(process.argv)
|
|
4060
|
+
program.parseAsync(process.argv).finally(() => {
|
|
4061
|
+
closeReadline();
|
|
4062
|
+
});
|
|
1895
4063
|
|