postgresai 0.14.0-dev.53 → 0.14.0-dev.55
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 +65 -38
- package/bin/postgres-ai.ts +461 -12
- package/bun.lock +3 -1
- package/bunfig.toml +19 -0
- package/dist/bin/postgres-ai.js +2208 -224
- package/lib/auth-server.ts +52 -5
- package/lib/checkup-api.ts +386 -0
- package/lib/checkup.ts +1327 -0
- package/lib/config.ts +3 -0
- package/lib/issues.ts +5 -41
- package/lib/metrics-embedded.ts +79 -0
- package/lib/metrics-loader.ts +127 -0
- package/lib/util.ts +61 -0
- package/package.json +14 -6
- package/packages/postgres-ai/README.md +26 -0
- package/packages/postgres-ai/bin/postgres-ai.js +27 -0
- package/packages/postgres-ai/package.json +27 -0
- package/scripts/embed-metrics.ts +154 -0
- package/test/auth.test.ts +258 -0
- package/test/checkup.integration.test.ts +273 -0
- package/test/checkup.test.ts +890 -0
- package/test/init.integration.test.ts +36 -33
- package/test/schema-validation.test.ts +81 -0
- package/test/test-utils.ts +122 -0
- package/dist/sql/01.role.sql +0 -16
- package/dist/sql/02.permissions.sql +0 -37
- package/dist/sql/03.optional_rds.sql +0 -6
- package/dist/sql/04.optional_self_managed.sql +0 -8
- package/dist/sql/05.helpers.sql +0 -415
package/bin/postgres-ai.ts
CHANGED
|
@@ -7,6 +7,7 @@ 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 * as crypto from "node:crypto";
|
|
10
11
|
import { Client } from "pg";
|
|
11
12
|
import { startMcpServer } from "../lib/mcp-server";
|
|
12
13
|
import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue } from "../lib/issues";
|
|
@@ -17,6 +18,8 @@ import * as authServer from "../lib/auth-server";
|
|
|
17
18
|
import { maskSecret } from "../lib/util";
|
|
18
19
|
import { createInterface } from "readline";
|
|
19
20
|
import * as childProcess from "child_process";
|
|
21
|
+
import { REPORT_GENERATORS, CHECK_INFO, generateAllReports } from "../lib/checkup";
|
|
22
|
+
import { createCheckupReport, uploadCheckupReportJson, RpcError, formatRpcErrorForDisplay, withRetry } from "../lib/checkup-api";
|
|
20
23
|
|
|
21
24
|
// Singleton readline interface for stdin prompts
|
|
22
25
|
let rl: ReturnType<typeof createInterface> | null = null;
|
|
@@ -109,6 +112,255 @@ async function question(prompt: string): Promise<string> {
|
|
|
109
112
|
});
|
|
110
113
|
}
|
|
111
114
|
|
|
115
|
+
function expandHomePath(p: string): string {
|
|
116
|
+
const s = (p || "").trim();
|
|
117
|
+
if (!s) return s;
|
|
118
|
+
if (s === "~") return os.homedir();
|
|
119
|
+
if (s.startsWith("~/") || s.startsWith("~\\")) {
|
|
120
|
+
return path.join(os.homedir(), s.slice(2));
|
|
121
|
+
}
|
|
122
|
+
return s;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function createTtySpinner(
|
|
126
|
+
enabled: boolean,
|
|
127
|
+
initialText: string
|
|
128
|
+
): { update: (text: string) => void; stop: (finalText?: string) => void } {
|
|
129
|
+
if (!enabled) {
|
|
130
|
+
return {
|
|
131
|
+
update: () => {},
|
|
132
|
+
stop: () => {},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const frames = ["|", "/", "-", "\\"];
|
|
137
|
+
const startTs = Date.now();
|
|
138
|
+
let text = initialText;
|
|
139
|
+
let frameIdx = 0;
|
|
140
|
+
let stopped = false;
|
|
141
|
+
|
|
142
|
+
const render = (): void => {
|
|
143
|
+
if (stopped) return;
|
|
144
|
+
const elapsedSec = ((Date.now() - startTs) / 1000).toFixed(1);
|
|
145
|
+
const frame = frames[frameIdx % frames.length] ?? frames[0] ?? "⠿";
|
|
146
|
+
frameIdx += 1;
|
|
147
|
+
process.stdout.write(`\r\x1b[2K${frame} ${text} (${elapsedSec}s)`);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const timer = setInterval(render, 120);
|
|
151
|
+
render(); // immediate feedback
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
update: (t: string) => {
|
|
155
|
+
text = t;
|
|
156
|
+
render();
|
|
157
|
+
},
|
|
158
|
+
stop: (finalText?: string) => {
|
|
159
|
+
if (stopped) return;
|
|
160
|
+
// Set flag first so any queued render() calls exit early.
|
|
161
|
+
// JavaScript is single-threaded, so this is safe: queued callbacks
|
|
162
|
+
// run after stop() returns and will see stopped=true immediately.
|
|
163
|
+
stopped = true;
|
|
164
|
+
clearInterval(timer);
|
|
165
|
+
process.stdout.write("\r\x1b[2K");
|
|
166
|
+
if (finalText && finalText.trim()) {
|
|
167
|
+
process.stdout.write(finalText);
|
|
168
|
+
}
|
|
169
|
+
process.stdout.write("\n");
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ============================================================================
|
|
175
|
+
// Checkup command helpers
|
|
176
|
+
// ============================================================================
|
|
177
|
+
|
|
178
|
+
interface CheckupOptions {
|
|
179
|
+
checkId: string;
|
|
180
|
+
nodeName: string;
|
|
181
|
+
output?: string;
|
|
182
|
+
upload?: boolean;
|
|
183
|
+
project?: string;
|
|
184
|
+
json?: boolean;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface UploadConfig {
|
|
188
|
+
apiKey: string;
|
|
189
|
+
apiBaseUrl: string;
|
|
190
|
+
project: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
interface UploadSummary {
|
|
194
|
+
project: string;
|
|
195
|
+
reportId: number;
|
|
196
|
+
uploaded: Array<{ checkId: string; filename: string; chunkId: number }>;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Prepare and validate output directory for checkup reports.
|
|
201
|
+
* @returns Output path if valid, null if should exit with error
|
|
202
|
+
*/
|
|
203
|
+
function prepareOutputDirectory(outputOpt: string | undefined): string | null | undefined {
|
|
204
|
+
if (!outputOpt) return undefined;
|
|
205
|
+
|
|
206
|
+
const outputDir = expandHomePath(outputOpt);
|
|
207
|
+
const outputPath = path.isAbsolute(outputDir) ? outputDir : path.resolve(process.cwd(), outputDir);
|
|
208
|
+
|
|
209
|
+
if (!fs.existsSync(outputPath)) {
|
|
210
|
+
try {
|
|
211
|
+
fs.mkdirSync(outputPath, { recursive: true });
|
|
212
|
+
} catch (e) {
|
|
213
|
+
const errAny = e as any;
|
|
214
|
+
const code = typeof errAny?.code === "string" ? errAny.code : "";
|
|
215
|
+
const msg = errAny instanceof Error ? errAny.message : String(errAny);
|
|
216
|
+
if (code === "EACCES" || code === "EPERM" || code === "ENOENT") {
|
|
217
|
+
console.error(`Error: Failed to create output directory: ${outputPath}`);
|
|
218
|
+
console.error(`Reason: ${msg}`);
|
|
219
|
+
console.error("Tip: choose a writable path, e.g. --output ./reports or --output ~/reports");
|
|
220
|
+
return null; // Signal to exit
|
|
221
|
+
}
|
|
222
|
+
throw e;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return outputPath;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Prepare upload configuration for checkup reports.
|
|
230
|
+
* @returns Upload config if valid, null if should exit, undefined if upload not needed
|
|
231
|
+
*/
|
|
232
|
+
function prepareUploadConfig(
|
|
233
|
+
opts: CheckupOptions,
|
|
234
|
+
rootOpts: CliOptions,
|
|
235
|
+
shouldUpload: boolean,
|
|
236
|
+
uploadExplicitlyRequested: boolean
|
|
237
|
+
): { config: UploadConfig; projectWasGenerated: boolean } | null | undefined {
|
|
238
|
+
if (!shouldUpload) return undefined;
|
|
239
|
+
|
|
240
|
+
const { apiKey } = getConfig(rootOpts);
|
|
241
|
+
if (!apiKey) {
|
|
242
|
+
if (uploadExplicitlyRequested) {
|
|
243
|
+
console.error("Error: API key is required for upload");
|
|
244
|
+
console.error("Tip: run 'postgresai auth' or pass --api-key / set PGAI_API_KEY");
|
|
245
|
+
return null; // Signal to exit
|
|
246
|
+
}
|
|
247
|
+
return undefined; // Skip upload silently
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const cfg = config.readConfig();
|
|
251
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
252
|
+
let project = ((opts.project || cfg.defaultProject) || "").trim();
|
|
253
|
+
let projectWasGenerated = false;
|
|
254
|
+
|
|
255
|
+
if (!project) {
|
|
256
|
+
project = `project_${crypto.randomBytes(6).toString("hex")}`;
|
|
257
|
+
projectWasGenerated = true;
|
|
258
|
+
try {
|
|
259
|
+
config.writeConfig({ defaultProject: project });
|
|
260
|
+
} catch (e) {
|
|
261
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
262
|
+
console.error(`Warning: Failed to save generated default project: ${message}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
config: { apiKey, apiBaseUrl, project },
|
|
268
|
+
projectWasGenerated,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Upload checkup reports to PostgresAI API.
|
|
274
|
+
*/
|
|
275
|
+
async function uploadCheckupReports(
|
|
276
|
+
uploadCfg: UploadConfig,
|
|
277
|
+
reports: Record<string, any>,
|
|
278
|
+
spinner: ReturnType<typeof createTtySpinner>,
|
|
279
|
+
logUpload: (msg: string) => void
|
|
280
|
+
): Promise<UploadSummary> {
|
|
281
|
+
spinner.update("Creating remote checkup report");
|
|
282
|
+
const created = await withRetry(
|
|
283
|
+
() => createCheckupReport({
|
|
284
|
+
apiKey: uploadCfg.apiKey,
|
|
285
|
+
apiBaseUrl: uploadCfg.apiBaseUrl,
|
|
286
|
+
project: uploadCfg.project,
|
|
287
|
+
}),
|
|
288
|
+
{ maxAttempts: 3 },
|
|
289
|
+
(attempt, err, delayMs) => {
|
|
290
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
291
|
+
logUpload(`[Retry ${attempt}/3] createCheckupReport failed: ${errMsg}, retrying in ${delayMs}ms...`);
|
|
292
|
+
}
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const reportId = created.reportId;
|
|
296
|
+
logUpload(`Created remote checkup report: ${reportId}`);
|
|
297
|
+
|
|
298
|
+
const uploaded: Array<{ checkId: string; filename: string; chunkId: number }> = [];
|
|
299
|
+
for (const [checkId, report] of Object.entries(reports)) {
|
|
300
|
+
spinner.update(`Uploading ${checkId}.json`);
|
|
301
|
+
const jsonText = JSON.stringify(report, null, 2);
|
|
302
|
+
const r = await withRetry(
|
|
303
|
+
() => uploadCheckupReportJson({
|
|
304
|
+
apiKey: uploadCfg.apiKey,
|
|
305
|
+
apiBaseUrl: uploadCfg.apiBaseUrl,
|
|
306
|
+
reportId,
|
|
307
|
+
filename: `${checkId}.json`,
|
|
308
|
+
checkId,
|
|
309
|
+
jsonText,
|
|
310
|
+
}),
|
|
311
|
+
{ maxAttempts: 3 },
|
|
312
|
+
(attempt, err, delayMs) => {
|
|
313
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
314
|
+
logUpload(`[Retry ${attempt}/3] Upload ${checkId}.json failed: ${errMsg}, retrying in ${delayMs}ms...`);
|
|
315
|
+
}
|
|
316
|
+
);
|
|
317
|
+
uploaded.push({ checkId, filename: `${checkId}.json`, chunkId: r.reportChunkId });
|
|
318
|
+
}
|
|
319
|
+
logUpload("Upload completed");
|
|
320
|
+
|
|
321
|
+
return { project: uploadCfg.project, reportId, uploaded };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Write checkup reports to files.
|
|
326
|
+
*/
|
|
327
|
+
function writeReportFiles(reports: Record<string, any>, outputPath: string): void {
|
|
328
|
+
for (const [checkId, report] of Object.entries(reports)) {
|
|
329
|
+
const filePath = path.join(outputPath, `${checkId}.json`);
|
|
330
|
+
fs.writeFileSync(filePath, JSON.stringify(report, null, 2), "utf8");
|
|
331
|
+
console.log(`✓ ${checkId}: ${filePath}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Print upload summary to console.
|
|
337
|
+
*/
|
|
338
|
+
function printUploadSummary(
|
|
339
|
+
summary: UploadSummary,
|
|
340
|
+
projectWasGenerated: boolean,
|
|
341
|
+
useStderr: boolean
|
|
342
|
+
): void {
|
|
343
|
+
const out = useStderr ? console.error : console.log;
|
|
344
|
+
out("\nCheckup report uploaded");
|
|
345
|
+
out("======================\n");
|
|
346
|
+
if (projectWasGenerated) {
|
|
347
|
+
out(`Project: ${summary.project} (generated and saved as default)`);
|
|
348
|
+
} else {
|
|
349
|
+
out(`Project: ${summary.project}`);
|
|
350
|
+
}
|
|
351
|
+
out(`Report ID: ${summary.reportId}`);
|
|
352
|
+
out("View in Console: console.postgres.ai → Support → checkup reports");
|
|
353
|
+
out("");
|
|
354
|
+
out("Files:");
|
|
355
|
+
for (const item of summary.uploaded) {
|
|
356
|
+
out(`- ${item.checkId}: ${item.filename}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ============================================================================
|
|
361
|
+
// CLI configuration
|
|
362
|
+
// ============================================================================
|
|
363
|
+
|
|
112
364
|
/**
|
|
113
365
|
* CLI configuration options
|
|
114
366
|
*/
|
|
@@ -286,6 +538,20 @@ program
|
|
|
286
538
|
"UI base URL for browser routes (overrides PGAI_UI_BASE_URL)"
|
|
287
539
|
);
|
|
288
540
|
|
|
541
|
+
program
|
|
542
|
+
.command("set-default-project <project>")
|
|
543
|
+
.description("store default project for checkup uploads")
|
|
544
|
+
.action(async (project: string) => {
|
|
545
|
+
const value = (project || "").trim();
|
|
546
|
+
if (!value) {
|
|
547
|
+
console.error("Error: project is required");
|
|
548
|
+
process.exitCode = 1;
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
config.writeConfig({ defaultProject: value });
|
|
552
|
+
console.log(`Default project saved: ${value}`);
|
|
553
|
+
});
|
|
554
|
+
|
|
289
555
|
program
|
|
290
556
|
.command("prepare-db [conn]")
|
|
291
557
|
.description("prepare database for monitoring: create monitoring user, required view(s), and grant permissions (idempotent)")
|
|
@@ -613,6 +879,143 @@ program
|
|
|
613
879
|
}
|
|
614
880
|
});
|
|
615
881
|
|
|
882
|
+
program
|
|
883
|
+
.command("checkup [conn]")
|
|
884
|
+
.description("generate health check reports directly from PostgreSQL (express mode)")
|
|
885
|
+
.option("--check-id <id>", `specific check to run: ${Object.keys(CHECK_INFO).join(", ")}, or ALL`, "ALL")
|
|
886
|
+
.option("--node-name <name>", "node name for reports", "node-01")
|
|
887
|
+
.option("--output <path>", "output directory for JSON files")
|
|
888
|
+
.option("--[no-]upload", "upload JSON results to PostgresAI (default: enabled; requires API key)", undefined)
|
|
889
|
+
.option(
|
|
890
|
+
"--project <project>",
|
|
891
|
+
"project name or ID for remote upload (used with --upload; defaults to config defaultProject; auto-generated on first run)"
|
|
892
|
+
)
|
|
893
|
+
.option("--json", "output JSON to stdout (implies --no-upload)")
|
|
894
|
+
.addHelpText(
|
|
895
|
+
"after",
|
|
896
|
+
[
|
|
897
|
+
"",
|
|
898
|
+
"Available checks:",
|
|
899
|
+
...Object.entries(CHECK_INFO).map(([id, title]) => ` ${id}: ${title}`),
|
|
900
|
+
"",
|
|
901
|
+
"Examples:",
|
|
902
|
+
" postgresai checkup postgresql://user:pass@host:5432/db",
|
|
903
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --check-id A003",
|
|
904
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --output ./reports",
|
|
905
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --project my_project",
|
|
906
|
+
" postgresai set-default-project my_project",
|
|
907
|
+
" postgresai checkup postgresql://user:pass@host:5432/db",
|
|
908
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --no-upload --json",
|
|
909
|
+
].join("\n")
|
|
910
|
+
)
|
|
911
|
+
.action(async (conn: string | undefined, opts: CheckupOptions, cmd: Command) => {
|
|
912
|
+
if (!conn) {
|
|
913
|
+
cmd.outputHelp();
|
|
914
|
+
process.exitCode = 1;
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const shouldPrintJson = !!opts.json;
|
|
919
|
+
const uploadExplicitlyRequested = opts.upload === true;
|
|
920
|
+
const uploadExplicitlyDisabled = opts.upload === false || shouldPrintJson;
|
|
921
|
+
let shouldUpload = !uploadExplicitlyDisabled;
|
|
922
|
+
|
|
923
|
+
// Preflight: validate/create output directory BEFORE connecting / running checks.
|
|
924
|
+
const outputPath = prepareOutputDirectory(opts.output);
|
|
925
|
+
if (outputPath === null) {
|
|
926
|
+
process.exitCode = 1;
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Preflight: validate upload flags/credentials BEFORE connecting / running checks.
|
|
931
|
+
const rootOpts = program.opts() as CliOptions;
|
|
932
|
+
const uploadResult = prepareUploadConfig(opts, rootOpts, shouldUpload, uploadExplicitlyRequested);
|
|
933
|
+
if (uploadResult === null) {
|
|
934
|
+
process.exitCode = 1;
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
const uploadCfg = uploadResult?.config;
|
|
938
|
+
const projectWasGenerated = uploadResult?.projectWasGenerated ?? false;
|
|
939
|
+
shouldUpload = !!uploadCfg;
|
|
940
|
+
|
|
941
|
+
// Connect and run checks
|
|
942
|
+
const adminConn = resolveAdminConnection({
|
|
943
|
+
conn,
|
|
944
|
+
envPassword: process.env.PGPASSWORD,
|
|
945
|
+
});
|
|
946
|
+
let client: Client | undefined;
|
|
947
|
+
const spinnerEnabled = !!process.stdout.isTTY && shouldUpload;
|
|
948
|
+
const spinner = createTtySpinner(spinnerEnabled, "Connecting to Postgres");
|
|
949
|
+
|
|
950
|
+
try {
|
|
951
|
+
spinner.update("Connecting to Postgres");
|
|
952
|
+
const connResult = await connectWithSslFallback(Client, adminConn);
|
|
953
|
+
client = connResult.client as Client;
|
|
954
|
+
|
|
955
|
+
// Generate reports
|
|
956
|
+
let reports: Record<string, any>;
|
|
957
|
+
if (opts.checkId === "ALL") {
|
|
958
|
+
reports = await generateAllReports(client, opts.nodeName, (p) => {
|
|
959
|
+
spinner.update(`Running ${p.checkId}: ${p.checkTitle} (${p.index}/${p.total})`);
|
|
960
|
+
});
|
|
961
|
+
} else {
|
|
962
|
+
const checkId = opts.checkId.toUpperCase();
|
|
963
|
+
const generator = REPORT_GENERATORS[checkId];
|
|
964
|
+
if (!generator) {
|
|
965
|
+
spinner.stop();
|
|
966
|
+
console.error(`Unknown check ID: ${opts.checkId}`);
|
|
967
|
+
console.error(`Available: ${Object.keys(CHECK_INFO).join(", ")}, ALL`);
|
|
968
|
+
process.exitCode = 1;
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
spinner.update(`Running ${checkId}: ${CHECK_INFO[checkId] || checkId}`);
|
|
972
|
+
reports = { [checkId]: await generator(client, opts.nodeName) };
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// Upload to PostgresAI API (if configured)
|
|
976
|
+
let uploadSummary: UploadSummary | undefined;
|
|
977
|
+
if (uploadCfg) {
|
|
978
|
+
const logUpload = (msg: string): void => {
|
|
979
|
+
(shouldPrintJson ? console.error : console.log)(msg);
|
|
980
|
+
};
|
|
981
|
+
uploadSummary = await uploadCheckupReports(uploadCfg, reports, spinner, logUpload);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
spinner.stop();
|
|
985
|
+
|
|
986
|
+
// Write to files (if output path specified)
|
|
987
|
+
if (outputPath) {
|
|
988
|
+
writeReportFiles(reports, outputPath);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Print upload summary
|
|
992
|
+
if (uploadSummary) {
|
|
993
|
+
printUploadSummary(uploadSummary, projectWasGenerated, shouldPrintJson);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Output JSON to stdout
|
|
997
|
+
if (shouldPrintJson || (!shouldUpload && !opts.output)) {
|
|
998
|
+
console.log(JSON.stringify(reports, null, 2));
|
|
999
|
+
}
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
if (error instanceof RpcError) {
|
|
1002
|
+
for (const line of formatRpcErrorForDisplay(error)) {
|
|
1003
|
+
console.error(line);
|
|
1004
|
+
}
|
|
1005
|
+
} else {
|
|
1006
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1007
|
+
console.error(`Error: ${message}`);
|
|
1008
|
+
}
|
|
1009
|
+
process.exitCode = 1;
|
|
1010
|
+
} finally {
|
|
1011
|
+
// Always stop spinner to prevent interval leak (idempotent - safe to call multiple times)
|
|
1012
|
+
spinner.stop();
|
|
1013
|
+
if (client) {
|
|
1014
|
+
await client.end();
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
});
|
|
1018
|
+
|
|
616
1019
|
/**
|
|
617
1020
|
* Stub function for not implemented commands
|
|
618
1021
|
*/
|
|
@@ -782,17 +1185,33 @@ mon
|
|
|
782
1185
|
|
|
783
1186
|
// Update .env with custom tag if provided
|
|
784
1187
|
const envFile = path.resolve(projectDir, ".env");
|
|
785
|
-
const imageTag = opts.tag || pkg.version;
|
|
786
1188
|
|
|
787
|
-
// Build .env content
|
|
788
|
-
|
|
789
|
-
|
|
1189
|
+
// Build .env content, preserving important existing values
|
|
1190
|
+
// Read existing .env first to preserve CI/custom settings
|
|
1191
|
+
let existingTag: string | null = null;
|
|
1192
|
+
let existingRegistry: string | null = null;
|
|
1193
|
+
let existingPassword: string | null = null;
|
|
1194
|
+
|
|
790
1195
|
if (fs.existsSync(envFile)) {
|
|
791
1196
|
const existingEnv = fs.readFileSync(envFile, "utf8");
|
|
1197
|
+
// Extract existing values
|
|
1198
|
+
const tagMatch = existingEnv.match(/^PGAI_TAG=(.+)$/m);
|
|
1199
|
+
if (tagMatch) existingTag = tagMatch[1].trim();
|
|
1200
|
+
const registryMatch = existingEnv.match(/^PGAI_REGISTRY=(.+)$/m);
|
|
1201
|
+
if (registryMatch) existingRegistry = registryMatch[1].trim();
|
|
792
1202
|
const pwdMatch = existingEnv.match(/^GF_SECURITY_ADMIN_PASSWORD=(.+)$/m);
|
|
793
|
-
if (pwdMatch)
|
|
794
|
-
|
|
795
|
-
|
|
1203
|
+
if (pwdMatch) existingPassword = pwdMatch[1].trim();
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// Priority: CLI --tag flag > existing .env > package version
|
|
1207
|
+
const imageTag = opts.tag || existingTag || pkg.version;
|
|
1208
|
+
|
|
1209
|
+
const envLines: string[] = [`PGAI_TAG=${imageTag}`];
|
|
1210
|
+
if (existingRegistry) {
|
|
1211
|
+
envLines.push(`PGAI_REGISTRY=${existingRegistry}`);
|
|
1212
|
+
}
|
|
1213
|
+
if (existingPassword) {
|
|
1214
|
+
envLines.push(`GF_SECURITY_ADMIN_PASSWORD=${existingPassword}`);
|
|
796
1215
|
}
|
|
797
1216
|
fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
|
|
798
1217
|
|
|
@@ -1596,8 +2015,21 @@ auth
|
|
|
1596
2015
|
return;
|
|
1597
2016
|
}
|
|
1598
2017
|
|
|
2018
|
+
// Read existing config to check for defaultProject before updating
|
|
2019
|
+
const existingConfig = config.readConfig();
|
|
2020
|
+
const existingProject = existingConfig.defaultProject;
|
|
2021
|
+
|
|
1599
2022
|
config.writeConfig({ apiKey: trimmedKey });
|
|
2023
|
+
// When API key is set directly, only clear orgId (org selection may differ).
|
|
2024
|
+
// Preserve defaultProject to avoid orphaning historical reports.
|
|
2025
|
+
// If the new key lacks access to the project, upload will fail with a clear error.
|
|
2026
|
+
config.deleteConfigKeys(["orgId"]);
|
|
2027
|
+
|
|
1600
2028
|
console.log(`API key saved to ${config.getConfigPath()}`);
|
|
2029
|
+
if (existingProject) {
|
|
2030
|
+
console.log(`Note: Your default project "${existingProject}" has been preserved.`);
|
|
2031
|
+
console.log(` If this key belongs to a different account, use --project to specify a new one.`);
|
|
2032
|
+
}
|
|
1601
2033
|
return;
|
|
1602
2034
|
}
|
|
1603
2035
|
|
|
@@ -1622,10 +2054,10 @@ auth
|
|
|
1622
2054
|
const requestedPort = opts.port || 0; // 0 = OS assigns available port
|
|
1623
2055
|
const callbackServer = authServer.createCallbackServer(requestedPort, params.state, 120000); // 2 minute timeout
|
|
1624
2056
|
|
|
1625
|
-
// Wait
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
const redirectUri = `http://
|
|
2057
|
+
// Wait for server to start and get the actual port
|
|
2058
|
+
const actualPort = await callbackServer.ready;
|
|
2059
|
+
// Use 127.0.0.1 to match the server bind address (avoids IPv6 issues on some hosts)
|
|
2060
|
+
const redirectUri = `http://127.0.0.1:${actualPort}/callback`;
|
|
1629
2061
|
|
|
1630
2062
|
console.log(`Callback server listening on port ${actualPort}`);
|
|
1631
2063
|
|
|
@@ -1686,7 +2118,8 @@ auth
|
|
|
1686
2118
|
}
|
|
1687
2119
|
|
|
1688
2120
|
// Step 3: Open browser
|
|
1689
|
-
|
|
2121
|
+
// Pass api_url so UI calls oauth_approve on the same backend where oauth_init created the session
|
|
2122
|
+
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)}`;
|
|
1690
2123
|
|
|
1691
2124
|
if (opts.debug) {
|
|
1692
2125
|
console.log(`Debug: Auth URL: ${authUrl}`);
|
|
@@ -1770,15 +2203,31 @@ auth
|
|
|
1770
2203
|
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.
|
|
1771
2204
|
|
|
1772
2205
|
// Step 6: Save token to config
|
|
2206
|
+
// Check if org changed to decide whether to preserve defaultProject
|
|
2207
|
+
const existingConfig = config.readConfig();
|
|
2208
|
+
const existingOrgId = existingConfig.orgId;
|
|
2209
|
+
const existingProject = existingConfig.defaultProject;
|
|
2210
|
+
const orgChanged = existingOrgId && existingOrgId !== orgId;
|
|
2211
|
+
|
|
1773
2212
|
config.writeConfig({
|
|
1774
2213
|
apiKey: apiToken,
|
|
1775
2214
|
baseUrl: apiBaseUrl,
|
|
1776
2215
|
orgId: orgId,
|
|
1777
2216
|
});
|
|
2217
|
+
|
|
2218
|
+
// Only clear defaultProject if org actually changed
|
|
2219
|
+
if (orgChanged && existingProject) {
|
|
2220
|
+
config.deleteConfigKeys(["defaultProject"]);
|
|
2221
|
+
console.log(`\nNote: Organization changed (${existingOrgId} → ${orgId}).`);
|
|
2222
|
+
console.log(` Default project "${existingProject}" has been cleared.`);
|
|
2223
|
+
}
|
|
1778
2224
|
|
|
1779
2225
|
console.log("\nAuthentication successful!");
|
|
1780
2226
|
console.log(`API key saved to: ${config.getConfigPath()}`);
|
|
1781
2227
|
console.log(`Organization ID: ${orgId}`);
|
|
2228
|
+
if (!orgChanged && existingProject) {
|
|
2229
|
+
console.log(`Default project: ${existingProject} (preserved)`);
|
|
2230
|
+
}
|
|
1782
2231
|
console.log(`\nYou can now use the CLI without specifying an API key.`);
|
|
1783
2232
|
process.exit(0);
|
|
1784
2233
|
} catch (err) {
|
package/bun.lock
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
"@types/bun": "^1.1.14",
|
|
15
15
|
"@types/js-yaml": "^4.0.9",
|
|
16
16
|
"@types/pg": "^8.15.6",
|
|
17
|
+
"ajv": "^8.17.1",
|
|
18
|
+
"ajv-formats": "^3.0.1",
|
|
17
19
|
"typescript": "^5.3.3",
|
|
18
20
|
},
|
|
19
21
|
},
|
|
@@ -129,7 +131,7 @@
|
|
|
129
131
|
|
|
130
132
|
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
|
|
131
133
|
|
|
132
|
-
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
|
134
|
+
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
|
133
135
|
|
|
134
136
|
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
|
135
137
|
|
package/bunfig.toml
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Bun configuration for postgres_ai CLI
|
|
2
|
+
# https://bun.sh/docs/runtime/bunfig
|
|
3
|
+
|
|
4
|
+
[test]
|
|
5
|
+
# Default timeout for all tests (30 seconds)
|
|
6
|
+
# Integration tests that connect to databases need longer timeouts
|
|
7
|
+
timeout = 30000
|
|
8
|
+
|
|
9
|
+
# Coverage settings - enabled by default for test runs
|
|
10
|
+
coverage = true
|
|
11
|
+
coverageDir = "coverage"
|
|
12
|
+
|
|
13
|
+
# Skip coverage for test files and node_modules
|
|
14
|
+
coverageSkipTestFiles = true
|
|
15
|
+
|
|
16
|
+
# Reporter format for CI integration
|
|
17
|
+
# - text: console output with summary table
|
|
18
|
+
# - lcov: standard format for coverage tools
|
|
19
|
+
coverageReporter = ["text", "lcov"]
|