postgresai 0.14.0-dev.43 → 0.14.0-dev.45

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.
Files changed (58) hide show
  1. package/bin/postgres-ai.ts +649 -310
  2. package/bun.lock +258 -0
  3. package/dist/bin/postgres-ai.js +29491 -1910
  4. package/dist/sql/01.role.sql +16 -0
  5. package/dist/sql/02.permissions.sql +37 -0
  6. package/dist/sql/03.optional_rds.sql +6 -0
  7. package/dist/sql/04.optional_self_managed.sql +8 -0
  8. package/dist/sql/05.helpers.sql +415 -0
  9. package/lib/auth-server.ts +58 -97
  10. package/lib/checkup-api.ts +175 -0
  11. package/lib/checkup.ts +837 -0
  12. package/lib/config.ts +3 -0
  13. package/lib/init.ts +106 -74
  14. package/lib/issues.ts +121 -194
  15. package/lib/mcp-server.ts +6 -17
  16. package/lib/metrics-loader.ts +156 -0
  17. package/package.json +13 -9
  18. package/sql/02.permissions.sql +9 -5
  19. package/sql/05.helpers.sql +415 -0
  20. package/test/checkup.test.ts +953 -0
  21. package/test/init.integration.test.ts +396 -0
  22. package/test/init.test.ts +345 -0
  23. package/test/schema-validation.test.ts +188 -0
  24. package/tsconfig.json +12 -20
  25. package/dist/bin/postgres-ai.d.ts +0 -3
  26. package/dist/bin/postgres-ai.d.ts.map +0 -1
  27. package/dist/bin/postgres-ai.js.map +0 -1
  28. package/dist/lib/auth-server.d.ts +0 -31
  29. package/dist/lib/auth-server.d.ts.map +0 -1
  30. package/dist/lib/auth-server.js +0 -263
  31. package/dist/lib/auth-server.js.map +0 -1
  32. package/dist/lib/config.d.ts +0 -45
  33. package/dist/lib/config.d.ts.map +0 -1
  34. package/dist/lib/config.js +0 -181
  35. package/dist/lib/config.js.map +0 -1
  36. package/dist/lib/init.d.ts +0 -85
  37. package/dist/lib/init.d.ts.map +0 -1
  38. package/dist/lib/init.js +0 -644
  39. package/dist/lib/init.js.map +0 -1
  40. package/dist/lib/issues.d.ts +0 -75
  41. package/dist/lib/issues.d.ts.map +0 -1
  42. package/dist/lib/issues.js +0 -336
  43. package/dist/lib/issues.js.map +0 -1
  44. package/dist/lib/mcp-server.d.ts +0 -9
  45. package/dist/lib/mcp-server.d.ts.map +0 -1
  46. package/dist/lib/mcp-server.js +0 -168
  47. package/dist/lib/mcp-server.js.map +0 -1
  48. package/dist/lib/pkce.d.ts +0 -32
  49. package/dist/lib/pkce.d.ts.map +0 -1
  50. package/dist/lib/pkce.js +0 -101
  51. package/dist/lib/pkce.js.map +0 -1
  52. package/dist/lib/util.d.ts +0 -27
  53. package/dist/lib/util.d.ts.map +0 -1
  54. package/dist/lib/util.js +0 -46
  55. package/dist/lib/util.js.map +0 -1
  56. package/dist/package.json +0 -46
  57. package/test/init.integration.test.cjs +0 -382
  58. package/test/init.test.cjs +0 -392
@@ -1,25 +1,172 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env bun
2
2
 
3
3
  import { Command } from "commander";
4
- import * as pkg from "../package.json";
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 { spawn, spawnSync, exec, execFile } from "child_process";
11
- import { promisify } from "util";
12
- import * as readline from "readline";
13
- import * as http from "https";
14
- import { URL } from "url";
10
+ import * as crypto from "node:crypto";
15
11
  import { Client } from "pg";
16
12
  import { startMcpServer } from "../lib/mcp-server";
17
13
  import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue } from "../lib/issues";
18
14
  import { resolveBaseUrls } from "../lib/util";
19
15
  import { applyInitPlan, buildInitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, verifyInitSetup } from "../lib/init";
16
+ import * as pkce from "../lib/pkce";
17
+ import * as authServer from "../lib/auth-server";
18
+ import { maskSecret } from "../lib/util";
19
+ import { createInterface } from "readline";
20
+ import * as childProcess from "child_process";
21
+ import { REPORT_GENERATORS, CHECK_INFO, generateAllReports } from "../lib/checkup";
22
+ import { createCheckupReport, uploadCheckupReportJson, RpcError, formatRpcErrorForDisplay } from "../lib/checkup-api";
23
+
24
+ // Singleton readline interface for stdin prompts
25
+ let rl: ReturnType<typeof createInterface> | null = null;
26
+ function getReadline() {
27
+ if (!rl) {
28
+ rl = createInterface({ input: process.stdin, output: process.stdout });
29
+ }
30
+ return rl;
31
+ }
32
+ function closeReadline() {
33
+ if (rl) {
34
+ rl.close();
35
+ rl = null;
36
+ }
37
+ }
38
+
39
+ // Helper functions for spawning processes - use Node.js child_process for compatibility
40
+ async function execPromise(command: string): Promise<{ stdout: string; stderr: string }> {
41
+ return new Promise((resolve, reject) => {
42
+ childProcess.exec(command, (error, stdout, stderr) => {
43
+ if (error) {
44
+ const err = error as Error & { code: number };
45
+ err.code = error.code ?? 1;
46
+ reject(err);
47
+ } else {
48
+ resolve({ stdout, stderr });
49
+ }
50
+ });
51
+ });
52
+ }
53
+
54
+ async function execFilePromise(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
55
+ return new Promise((resolve, reject) => {
56
+ childProcess.execFile(file, args, (error, stdout, stderr) => {
57
+ if (error) {
58
+ const err = error as Error & { code: number };
59
+ err.code = error.code ?? 1;
60
+ reject(err);
61
+ } else {
62
+ resolve({ stdout, stderr });
63
+ }
64
+ });
65
+ });
66
+ }
67
+
68
+ function spawnSync(cmd: string, args: string[], options?: { stdio?: "pipe" | "ignore" | "inherit"; encoding?: string; env?: Record<string, string | undefined>; cwd?: string }): { status: number | null; stdout: string; stderr: string } {
69
+ const result = childProcess.spawnSync(cmd, args, {
70
+ stdio: options?.stdio === "inherit" ? "inherit" : "pipe",
71
+ env: options?.env as NodeJS.ProcessEnv,
72
+ cwd: options?.cwd,
73
+ encoding: "utf8",
74
+ });
75
+ return {
76
+ status: result.status,
77
+ stdout: typeof result.stdout === "string" ? result.stdout : "",
78
+ stderr: typeof result.stderr === "string" ? result.stderr : "",
79
+ };
80
+ }
81
+
82
+ function spawn(cmd: string, args: string[], options?: { stdio?: "pipe" | "ignore" | "inherit"; env?: Record<string, string | undefined>; cwd?: string; detached?: boolean }): { on: (event: string, cb: (code: number | null, signal?: string) => void) => void; unref: () => void; pid?: number } {
83
+ const proc = childProcess.spawn(cmd, args, {
84
+ stdio: options?.stdio ?? "pipe",
85
+ env: options?.env as NodeJS.ProcessEnv,
86
+ cwd: options?.cwd,
87
+ detached: options?.detached,
88
+ });
20
89
 
21
- const execPromise = promisify(exec);
22
- const execFilePromise = promisify(execFile);
90
+ return {
91
+ on(event: string, cb: (code: number | null, signal?: string) => void) {
92
+ if (event === "close" || event === "exit") {
93
+ proc.on(event, (code, signal) => cb(code, signal ?? undefined));
94
+ } else if (event === "error") {
95
+ proc.on("error", (err) => cb(null, String(err)));
96
+ }
97
+ return this;
98
+ },
99
+ unref() {
100
+ proc.unref();
101
+ },
102
+ pid: proc.pid,
103
+ };
104
+ }
105
+
106
+ // Simple readline-like interface for prompts using Bun
107
+ async function question(prompt: string): Promise<string> {
108
+ return new Promise((resolve) => {
109
+ getReadline().question(prompt, (answer) => {
110
+ resolve(answer);
111
+ });
112
+ });
113
+ }
114
+
115
+ function expandHomePath(p: string): string {
116
+ const s = (p || "").trim();
117
+ if (!s) return s;
118
+ if (s === "~") return os.homedir();
119
+ if (s.startsWith("~/") || s.startsWith("~\\")) {
120
+ return path.join(os.homedir(), s.slice(2));
121
+ }
122
+ return s;
123
+ }
124
+
125
+ function createTtySpinner(
126
+ enabled: boolean,
127
+ initialText: string
128
+ ): { update: (text: string) => void; stop: (finalText?: string) => void } {
129
+ if (!enabled) {
130
+ return {
131
+ update: () => {},
132
+ stop: () => {},
133
+ };
134
+ }
135
+
136
+ const frames = ["|", "/", "-", "\\"];
137
+ const startTs = Date.now();
138
+ let text = initialText;
139
+ let frameIdx = 0;
140
+ let stopped = false;
141
+
142
+ const render = (): void => {
143
+ if (stopped) return;
144
+ const elapsedSec = ((Date.now() - startTs) / 1000).toFixed(1);
145
+ const frame = frames[frameIdx % frames.length]!;
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
+ stopped = true;
161
+ clearInterval(timer);
162
+ process.stdout.write("\r\x1b[2K");
163
+ if (finalText && finalText.trim()) {
164
+ process.stdout.write(finalText);
165
+ }
166
+ process.stdout.write("\n");
167
+ },
168
+ };
169
+ }
23
170
 
24
171
  /**
25
172
  * CLI configuration options
@@ -69,21 +216,17 @@ function getDefaultMonitoringProjectDir(): string {
69
216
  }
70
217
 
71
218
  async function downloadText(url: string): Promise<string> {
72
- return new Promise<string>((resolve, reject) => {
73
- const req = http.get(new URL(url), (res) => {
74
- if (!res.statusCode || res.statusCode >= 400) {
75
- reject(new Error(`HTTP ${res.statusCode || "?"} for ${url}`));
76
- res.resume();
77
- return;
78
- }
79
- res.setEncoding("utf8");
80
- let data = "";
81
- res.on("data", (chunk) => (data += chunk));
82
- res.on("end", () => resolve(data));
83
- });
84
- req.on("error", reject);
85
- req.setTimeout(15_000, () => req.destroy(new Error(`Timeout fetching ${url}`)));
86
- });
219
+ const controller = new AbortController();
220
+ const timeout = setTimeout(() => controller.abort(), 15_000);
221
+ try {
222
+ const response = await fetch(url, { signal: controller.signal });
223
+ if (!response.ok) {
224
+ throw new Error(`HTTP ${response.status} for ${url}`);
225
+ }
226
+ return await response.text();
227
+ } finally {
228
+ clearTimeout(timeout);
229
+ }
87
230
  }
88
231
 
89
232
  async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
@@ -202,6 +345,20 @@ program
202
345
  "UI base URL for browser routes (overrides PGAI_UI_BASE_URL)"
203
346
  );
204
347
 
348
+ program
349
+ .command("set-default-project <project>")
350
+ .description("store default project for checkup uploads")
351
+ .action(async (project: string) => {
352
+ const value = (project || "").trim();
353
+ if (!value) {
354
+ console.error("Error: project is required");
355
+ process.exitCode = 1;
356
+ return;
357
+ }
358
+ config.writeConfig({ defaultProject: value });
359
+ console.log(`Default project saved: ${value}`);
360
+ });
361
+
205
362
  program
206
363
  .command("prepare-db [conn]")
207
364
  .description("prepare database for monitoring: create monitoring user, required view(s), and grant permissions (idempotent)")
@@ -529,6 +686,251 @@ program
529
686
  }
530
687
  });
531
688
 
689
+ program
690
+ .command("checkup [conn]")
691
+ .description("generate health check reports directly from PostgreSQL (express mode)")
692
+ .option("--check-id <id>", `specific check to run: ${Object.keys(CHECK_INFO).join(", ")}, or ALL`, "ALL")
693
+ .option("--node-name <name>", "node name for reports", "node-01")
694
+ .option("--output <path>", "output directory for JSON files")
695
+ .option("--[no-]upload", "upload JSON results to PostgresAI (default: enabled; requires API key)", undefined)
696
+ .option(
697
+ "--project <project>",
698
+ "project name or ID for remote upload (used with --upload; defaults to config defaultProject; auto-generated on first run)"
699
+ )
700
+ .option("--json", "output JSON to stdout (implies --no-upload)")
701
+ .addHelpText(
702
+ "after",
703
+ [
704
+ "",
705
+ "Available checks:",
706
+ ...Object.entries(CHECK_INFO).map(([id, title]) => ` ${id}: ${title}`),
707
+ "",
708
+ "Examples:",
709
+ " postgresai checkup postgresql://user:pass@host:5432/db",
710
+ " postgresai checkup postgresql://user:pass@host:5432/db --check-id A003",
711
+ " postgresai checkup postgresql://user:pass@host:5432/db --output ./reports",
712
+ " postgresai checkup postgresql://user:pass@host:5432/db --project my_project",
713
+ " postgresai set-default-project my_project",
714
+ " postgresai checkup postgresql://user:pass@host:5432/db",
715
+ " postgresai checkup postgresql://user:pass@host:5432/db --no-upload --json",
716
+ ].join("\n")
717
+ )
718
+ .action(async (conn: string | undefined, opts: {
719
+ checkId: string;
720
+ nodeName: string;
721
+ output?: string;
722
+ upload?: boolean;
723
+ project?: string;
724
+ json?: boolean;
725
+ }, cmd: Command) => {
726
+ if (!conn) {
727
+ // No args — show help like other commands do (instead of a bare error).
728
+ cmd.outputHelp();
729
+ process.exitCode = 1;
730
+ return;
731
+ }
732
+
733
+ const shouldPrintJson = !!opts.json;
734
+ // `--json` implies "local output" mode — do not upload by default.
735
+ const shouldUpload = opts.upload !== false && !shouldPrintJson;
736
+ const generateDefaultProjectName = (): string => {
737
+ // Must start with a letter; use only letters/numbers/underscores.
738
+ return `project_${crypto.randomBytes(6).toString("hex")}`;
739
+ };
740
+
741
+ // Preflight: validate/create output directory BEFORE connecting / running checks.
742
+ // This avoids waiting on network/DB work only to fail at the very end.
743
+ let outputPath: string | undefined;
744
+ if (opts.output) {
745
+ const outputDir = expandHomePath(opts.output);
746
+ outputPath = path.isAbsolute(outputDir) ? outputDir : path.resolve(process.cwd(), outputDir);
747
+ if (!fs.existsSync(outputPath)) {
748
+ try {
749
+ fs.mkdirSync(outputPath, { recursive: true });
750
+ } catch (e) {
751
+ const errAny = e as any;
752
+ const code = typeof errAny?.code === "string" ? errAny.code : "";
753
+ const msg = errAny instanceof Error ? errAny.message : String(errAny);
754
+ if (code === "EACCES" || code === "EPERM" || code === "ENOENT") {
755
+ console.error(`Error: Failed to create output directory: ${outputPath}`);
756
+ console.error(`Reason: ${msg}`);
757
+ console.error("Tip: choose a writable path, e.g. --output ./reports or --output ~/reports");
758
+ process.exitCode = 1;
759
+ return;
760
+ }
761
+ throw e;
762
+ }
763
+ }
764
+ }
765
+
766
+ // Preflight: validate upload flags/credentials BEFORE connecting / running checks.
767
+ // This allows "fast-fail" for missing API key / project name.
768
+ let uploadCfg:
769
+ | { apiKey: string; apiBaseUrl: string; project: string; epoch: number }
770
+ | undefined;
771
+ let projectWasGenerated = false;
772
+ if (shouldUpload) {
773
+ const rootOpts = program.opts() as CliOptions;
774
+ const { apiKey } = getConfig(rootOpts);
775
+ if (!apiKey) {
776
+ console.error("Error: API key is required for upload");
777
+ console.error("Tip: run 'postgresai auth' or pass --api-key / set PGAI_API_KEY");
778
+ process.exitCode = 1;
779
+ return;
780
+ }
781
+
782
+ const cfg = config.readConfig();
783
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
784
+ let project = ((opts.project || cfg.defaultProject) || "").trim();
785
+ if (!project) {
786
+ project = generateDefaultProjectName();
787
+ projectWasGenerated = true;
788
+ try {
789
+ config.writeConfig({ defaultProject: project });
790
+ } catch (e) {
791
+ const message = e instanceof Error ? e.message : String(e);
792
+ console.error(`Warning: Failed to save generated default project: ${message}`);
793
+ }
794
+ }
795
+ uploadCfg = {
796
+ apiKey,
797
+ apiBaseUrl,
798
+ project,
799
+ };
800
+ }
801
+
802
+ // Use the same SSL behavior as prepare-db:
803
+ // - Default: sslmode=prefer (try SSL first, fallback to non-SSL)
804
+ // - Respect PGSSLMODE env and ?sslmode=... in connection URI
805
+ const adminConn = resolveAdminConnection({
806
+ conn,
807
+ envPassword: process.env.PGPASSWORD,
808
+ });
809
+ let client: Client | undefined;
810
+ const spinnerEnabled = !!process.stdout.isTTY && !!shouldUpload;
811
+ const spinner = createTtySpinner(spinnerEnabled, "Connecting to Postgres");
812
+
813
+ try {
814
+ spinner.update("Connecting to Postgres");
815
+ const connResult = await connectWithSslFallback(Client, adminConn);
816
+ client = connResult.client as Client;
817
+
818
+ let reports: Record<string, any>;
819
+ let uploadSummary:
820
+ | { project: string; reportId: number; uploaded: Array<{ checkId: string; filename: string; chunkId: number }> }
821
+ | undefined;
822
+
823
+ if (opts.checkId === "ALL") {
824
+ reports = await generateAllReports(client, opts.nodeName, (p) => {
825
+ spinner.update(`Running ${p.checkId}: ${p.checkTitle} (${p.index}/${p.total})`);
826
+ });
827
+ } else {
828
+ const checkId = opts.checkId.toUpperCase();
829
+ const generator = REPORT_GENERATORS[checkId];
830
+ if (!generator) {
831
+ spinner.stop();
832
+ console.error(`Unknown check ID: ${opts.checkId}`);
833
+ console.error(`Available: ${Object.keys(CHECK_INFO).join(", ")}, ALL`);
834
+ process.exitCode = 1;
835
+ return;
836
+ }
837
+ spinner.update(`Running ${checkId}: ${CHECK_INFO[checkId] || checkId}`);
838
+ reports = { [checkId]: await generator(client, opts.nodeName) };
839
+ }
840
+
841
+ // Optional: upload to PostgresAI API.
842
+ if (uploadCfg) {
843
+ spinner.update("Creating remote checkup report");
844
+ const created = await createCheckupReport({
845
+ apiKey: uploadCfg.apiKey,
846
+ apiBaseUrl: uploadCfg.apiBaseUrl,
847
+ project: uploadCfg.project,
848
+ });
849
+
850
+ const reportId = created.reportId;
851
+ // Keep upload progress out of stdout when JSON is printed to stdout.
852
+ const logUpload = (msg: string): void => {
853
+ if (shouldPrintJson) {
854
+ console.error(msg);
855
+ } else {
856
+ console.log(msg);
857
+ }
858
+ };
859
+ logUpload(`Created remote checkup report: ${reportId}`);
860
+
861
+ const uploaded: Array<{ checkId: string; filename: string; chunkId: number }> = [];
862
+ for (const [checkId, report] of Object.entries(reports)) {
863
+ spinner.update(`Uploading ${checkId}.json`);
864
+ const jsonText = JSON.stringify(report, null, 2);
865
+ const r = await uploadCheckupReportJson({
866
+ apiKey: uploadCfg.apiKey,
867
+ apiBaseUrl: uploadCfg.apiBaseUrl,
868
+ reportId,
869
+ filename: `${checkId}.json`,
870
+ checkId,
871
+ jsonText,
872
+ });
873
+ uploaded.push({ checkId, filename: `${checkId}.json`, chunkId: r.reportChunkId });
874
+ }
875
+ logUpload("Upload completed");
876
+ uploadSummary = { project: uploadCfg.project, reportId, uploaded };
877
+ }
878
+
879
+ spinner.stop();
880
+ // Output results
881
+ if (opts.output) {
882
+ // Write to files
883
+ // outputPath is preflight-validated above
884
+ const outDir = outputPath || path.resolve(process.cwd(), expandHomePath(opts.output));
885
+ for (const [checkId, report] of Object.entries(reports)) {
886
+ const filePath = path.join(outDir, `${checkId}.json`);
887
+ fs.writeFileSync(filePath, JSON.stringify(report, null, 2), "utf8");
888
+ console.log(`✓ ${checkId}: ${filePath}`);
889
+ }
890
+ }
891
+
892
+ if (uploadSummary) {
893
+ const out = shouldPrintJson ? console.error : console.log;
894
+ out("\nCheckup report uploaded");
895
+ out("======================\n");
896
+ if (projectWasGenerated) {
897
+ out(`Project: ${uploadSummary.project} (generated and saved as default)`);
898
+ } else {
899
+ out(`Project: ${uploadSummary.project}`);
900
+ }
901
+ out(`Report ID: ${uploadSummary.reportId}`);
902
+ out("View in Console: console.postgres.ai → Support → checkup reports");
903
+ out("");
904
+ out("Files:");
905
+ for (const item of uploadSummary.uploaded) {
906
+ out(`- ${item.checkId}: ${item.filename}`);
907
+ }
908
+ }
909
+
910
+ if (shouldPrintJson) {
911
+ console.log(JSON.stringify(reports, null, 2));
912
+ } else if (!shouldUpload && !opts.output) {
913
+ // Offline mode keeps historical behavior: print JSON when not uploading and no --output.
914
+ console.log(JSON.stringify(reports, null, 2));
915
+ }
916
+ } catch (error) {
917
+ spinner.stop();
918
+ if (error instanceof RpcError) {
919
+ for (const line of formatRpcErrorForDisplay(error)) {
920
+ console.error(line);
921
+ }
922
+ } else {
923
+ const message = error instanceof Error ? error.message : String(error);
924
+ console.error(`Error: ${message}`);
925
+ }
926
+ process.exitCode = 1;
927
+ } finally {
928
+ if (client) {
929
+ await client.end();
930
+ }
931
+ }
932
+ });
933
+
532
934
  /**
533
935
  * Stub function for not implemented commands
534
936
  */
@@ -760,48 +1162,36 @@ mon
760
1162
  console.log("⚠ Reports will be generated locally only");
761
1163
  console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
762
1164
  } else {
763
- const rl = readline.createInterface({
764
- input: process.stdin,
765
- output: process.stdout
766
- });
767
-
768
- const question = (prompt: string): Promise<string> =>
769
- new Promise((resolve) => rl.question(prompt, resolve));
770
-
771
- try {
772
- const answer = await question("Do you have a Postgres AI API key? (Y/n): ");
773
- const proceedWithApiKey = !answer || answer.toLowerCase() === "y";
774
-
775
- if (proceedWithApiKey) {
776
- while (true) {
777
- const inputApiKey = await question("Enter your Postgres AI API key: ");
778
- const trimmedKey = inputApiKey.trim();
779
-
780
- if (trimmedKey) {
781
- config.writeConfig({ apiKey: trimmedKey });
782
- // Keep reporter compatibility (docker-compose mounts .pgwatch-config)
783
- fs.writeFileSync(path.resolve(projectDir, ".pgwatch-config"), `api_key=${trimmedKey}\n`, {
784
- encoding: "utf8",
785
- mode: 0o600
786
- });
787
- console.log("✓ API key saved\n");
788
- break;
789
- }
1165
+ const answer = await question("Do you have a Postgres AI API key? (Y/n): ");
1166
+ const proceedWithApiKey = !answer || answer.toLowerCase() === "y";
1167
+
1168
+ if (proceedWithApiKey) {
1169
+ while (true) {
1170
+ const inputApiKey = await question("Enter your Postgres AI API key: ");
1171
+ const trimmedKey = inputApiKey.trim();
1172
+
1173
+ if (trimmedKey) {
1174
+ config.writeConfig({ apiKey: trimmedKey });
1175
+ // Keep reporter compatibility (docker-compose mounts .pgwatch-config)
1176
+ fs.writeFileSync(path.resolve(projectDir, ".pgwatch-config"), `api_key=${trimmedKey}\n`, {
1177
+ encoding: "utf8",
1178
+ mode: 0o600
1179
+ });
1180
+ console.log("✓ API key saved\n");
1181
+ break;
1182
+ }
790
1183
 
791
- console.log("⚠ API key cannot be empty");
792
- const retry = await question("Try again or skip API key setup, retry? (Y/n): ");
793
- if (retry.toLowerCase() === "n") {
794
- console.log("⚠ Skipping API key setup - reports will be generated locally only");
795
- console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
796
- break;
797
- }
1184
+ console.log("⚠ API key cannot be empty");
1185
+ const retry = await question("Try again or skip API key setup, retry? (Y/n): ");
1186
+ if (retry.toLowerCase() === "n") {
1187
+ console.log("⚠ Skipping API key setup - reports will be generated locally only");
1188
+ console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
1189
+ break;
798
1190
  }
799
- } else {
800
- console.log("⚠ Skipping API key setup - reports will be generated locally only");
801
- console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
802
1191
  }
803
- } finally {
804
- rl.close();
1192
+ } else {
1193
+ console.log("⚠ Skipping API key setup - reports will be generated locally only");
1194
+ console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
805
1195
  }
806
1196
  }
807
1197
  } else {
@@ -847,7 +1237,6 @@ mon
847
1237
  // Test connection
848
1238
  console.log("Testing connection to the added instance...");
849
1239
  try {
850
- const { Client } = require("pg");
851
1240
  const client = new Client({ connectionString: connStr });
852
1241
  await client.connect();
853
1242
  const result = await client.query("select version();");
@@ -864,63 +1253,50 @@ mon
864
1253
  console.log("⚠ No PostgreSQL instance added");
865
1254
  console.log("You can add one later with: postgres-ai mon targets add\n");
866
1255
  } else {
867
- const rl = readline.createInterface({
868
- input: process.stdin,
869
- output: process.stdout
870
- });
871
-
872
- const question = (prompt: string): Promise<string> =>
873
- new Promise((resolve) => rl.question(prompt, resolve));
874
-
875
- try {
876
- console.log("You need to add at least one PostgreSQL instance to monitor");
877
- const answer = await question("Do you want to add a PostgreSQL instance now? (Y/n): ");
878
- const proceedWithInstance = !answer || answer.toLowerCase() === "y";
879
-
880
- if (proceedWithInstance) {
881
- console.log("\nYou can provide either:");
882
- console.log(" 1. A full connection string: postgresql://user:pass@host:port/database");
883
- console.log(" 2. Press Enter to skip for now\n");
884
-
885
- const connStr = await question("Enter connection string (or press Enter to skip): ");
886
-
887
- if (connStr.trim()) {
888
- const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
889
- if (!m) {
890
- console.error("✗ Invalid connection string format");
891
- console.log("⚠ Continuing without adding instance\n");
892
- } else {
893
- const host = m[3];
894
- const db = m[5];
895
- const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
896
-
897
- 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`;
898
- fs.appendFileSync(instancesPath, body, "utf8");
899
- console.log(`✓ Monitoring target '${instanceName}' added\n`);
900
-
901
- // Test connection
902
- console.log("Testing connection to the added instance...");
903
- try {
904
- const { Client } = require("pg");
905
- const client = new Client({ connectionString: connStr });
906
- await client.connect();
907
- const result = await client.query("select version();");
908
- console.log("✓ Connection successful");
909
- console.log(`${result.rows[0].version}\n`);
910
- await client.end();
911
- } catch (error) {
912
- const message = error instanceof Error ? error.message : String(error);
913
- console.error(`✗ Connection failed: ${message}\n`);
914
- }
915
- }
1256
+ console.log("You need to add at least one PostgreSQL instance to monitor");
1257
+ const answer = await question("Do you want to add a PostgreSQL instance now? (Y/n): ");
1258
+ const proceedWithInstance = !answer || answer.toLowerCase() === "y";
1259
+
1260
+ if (proceedWithInstance) {
1261
+ console.log("\nYou can provide either:");
1262
+ console.log(" 1. A full connection string: postgresql://user:pass@host:port/database");
1263
+ console.log(" 2. Press Enter to skip for now\n");
1264
+
1265
+ const connStr = await question("Enter connection string (or press Enter to skip): ");
1266
+
1267
+ if (connStr.trim()) {
1268
+ const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
1269
+ if (!m) {
1270
+ console.error(" Invalid connection string format");
1271
+ console.log(" Continuing without adding instance\n");
916
1272
  } else {
917
- console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
1273
+ const host = m[3];
1274
+ const db = m[5];
1275
+ const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
1276
+
1277
+ 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`;
1278
+ fs.appendFileSync(instancesPath, body, "utf8");
1279
+ console.log(`✓ Monitoring target '${instanceName}' added\n`);
1280
+
1281
+ // Test connection
1282
+ console.log("Testing connection to the added instance...");
1283
+ try {
1284
+ const client = new Client({ connectionString: connStr });
1285
+ await client.connect();
1286
+ const result = await client.query("select version();");
1287
+ console.log("✓ Connection successful");
1288
+ console.log(`${result.rows[0].version}\n`);
1289
+ await client.end();
1290
+ } catch (error) {
1291
+ const message = error instanceof Error ? error.message : String(error);
1292
+ console.error(`✗ Connection failed: ${message}\n`);
1293
+ }
918
1294
  }
919
1295
  } else {
920
1296
  console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
921
1297
  }
922
- } finally {
923
- rl.close();
1298
+ } else {
1299
+ console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
924
1300
  }
925
1301
  }
926
1302
  } else {
@@ -1106,17 +1482,17 @@ mon
1106
1482
  allHealthy = true;
1107
1483
  for (const service of services) {
1108
1484
  try {
1109
- const { execSync } = require("child_process");
1110
- const status = execSync(`docker inspect -f '{{.State.Status}}' ${service.container} 2>/dev/null`, {
1111
- encoding: 'utf8',
1112
- stdio: ['pipe', 'pipe', 'pipe']
1113
- }).trim();
1485
+ const result = spawnSync("docker", ["inspect", "-f", "{{.State.Status}}", service.container], { stdio: "pipe" });
1486
+ const status = result.stdout.trim();
1114
1487
 
1115
- if (status === 'running') {
1488
+ if (result.status === 0 && status === 'running') {
1116
1489
  console.log(`✓ ${service.name}: healthy`);
1117
- } else {
1490
+ } else if (result.status === 0) {
1118
1491
  console.log(`✗ ${service.name}: unhealthy (status: ${status})`);
1119
1492
  allHealthy = false;
1493
+ } else {
1494
+ console.log(`✗ ${service.name}: unreachable`);
1495
+ allHealthy = false;
1120
1496
  }
1121
1497
  } catch (error) {
1122
1498
  console.log(`✗ ${service.name}: unreachable`);
@@ -1220,14 +1596,6 @@ mon
1220
1596
  .command("reset [service]")
1221
1597
  .description("reset all or specific monitoring service")
1222
1598
  .action(async (service?: string) => {
1223
- const rl = readline.createInterface({
1224
- input: process.stdin,
1225
- output: process.stdout,
1226
- });
1227
-
1228
- const question = (prompt: string): Promise<string> =>
1229
- new Promise((resolve) => rl.question(prompt, resolve));
1230
-
1231
1599
  try {
1232
1600
  if (service) {
1233
1601
  // Reset specific service
@@ -1237,7 +1605,6 @@ mon
1237
1605
  const answer = await question("Continue? (y/N): ");
1238
1606
  if (answer.toLowerCase() !== "y") {
1239
1607
  console.log("Cancelled");
1240
- rl.close();
1241
1608
  return;
1242
1609
  }
1243
1610
 
@@ -1264,7 +1631,6 @@ mon
1264
1631
  const answer = await question("Continue? (y/N): ");
1265
1632
  if (answer.toLowerCase() !== "y") {
1266
1633
  console.log("Cancelled");
1267
- rl.close();
1268
1634
  return;
1269
1635
  }
1270
1636
 
@@ -1278,10 +1644,7 @@ mon
1278
1644
  process.exitCode = 1;
1279
1645
  }
1280
1646
  }
1281
-
1282
- rl.close();
1283
1647
  } catch (error) {
1284
- rl.close();
1285
1648
  const message = error instanceof Error ? error.message : String(error);
1286
1649
  console.error(`Reset failed: ${message}`);
1287
1650
  process.exitCode = 1;
@@ -1515,7 +1878,6 @@ targets
1515
1878
  console.log(`Testing connection to monitoring target '${name}'...`);
1516
1879
 
1517
1880
  // Use native pg client instead of requiring psql to be installed
1518
- const { Client } = require('pg');
1519
1881
  const client = new Client({ connectionString: instance.conn_str });
1520
1882
 
1521
1883
  try {
@@ -1553,14 +1915,14 @@ auth
1553
1915
  }
1554
1916
 
1555
1917
  config.writeConfig({ apiKey: trimmedKey });
1918
+ // When API key is set directly, invalidate org/project selection
1919
+ // as it likely belongs to a different account/session.
1920
+ config.deleteConfigKeys(["orgId", "defaultProject"]);
1556
1921
  console.log(`API key saved to ${config.getConfigPath()}`);
1557
1922
  return;
1558
1923
  }
1559
1924
 
1560
1925
  // Otherwise, proceed with OAuth flow
1561
- const pkce = require("../lib/pkce");
1562
- const authServer = require("../lib/auth-server");
1563
-
1564
1926
  console.log("Starting authentication flow...\n");
1565
1927
 
1566
1928
  // Generate PKCE parameters
@@ -1606,173 +1968,166 @@ auth
1606
1968
  console.log(`Debug: Request data: ${initData}`);
1607
1969
  }
1608
1970
 
1609
- const initReq = http.request(
1610
- initUrl,
1611
- {
1971
+ // Step 2: Initialize OAuth session on backend using fetch
1972
+ let initResponse: Response;
1973
+ try {
1974
+ initResponse = await fetch(initUrl.toString(), {
1612
1975
  method: "POST",
1613
1976
  headers: {
1614
1977
  "Content-Type": "application/json",
1615
- "Content-Length": Buffer.byteLength(initData),
1616
1978
  },
1617
- },
1618
- (res) => {
1619
- let data = "";
1620
- res.on("data", (chunk) => (data += chunk));
1621
- res.on("end", async () => {
1622
- if (res.statusCode !== 200) {
1623
- console.error(`Failed to initialize auth session: ${res.statusCode}`);
1624
-
1625
- // Check if response is HTML (common for 404 pages)
1626
- if (data.trim().startsWith("<!") || data.trim().startsWith("<html")) {
1627
- console.error("Error: Received HTML response instead of JSON. This usually means:");
1628
- console.error(" 1. The API endpoint URL is incorrect");
1629
- console.error(" 2. The endpoint does not exist (404)");
1630
- console.error(`\nAPI URL attempted: ${initUrl.toString()}`);
1631
- console.error("\nPlease verify the --api-base-url parameter.");
1632
- } else {
1633
- console.error(data);
1634
- }
1979
+ body: initData,
1980
+ });
1981
+ } catch (err) {
1982
+ const message = err instanceof Error ? err.message : String(err);
1983
+ console.error(`Failed to connect to API: ${message}`);
1984
+ callbackServer.server.stop();
1985
+ process.exit(1);
1986
+ return;
1987
+ }
1635
1988
 
1636
- callbackServer.server.close();
1637
- process.exit(1);
1638
- }
1989
+ if (!initResponse.ok) {
1990
+ const data = await initResponse.text();
1991
+ console.error(`Failed to initialize auth session: ${initResponse.status}`);
1992
+
1993
+ // Check if response is HTML (common for 404 pages)
1994
+ if (data.trim().startsWith("<!") || data.trim().startsWith("<html")) {
1995
+ console.error("Error: Received HTML response instead of JSON. This usually means:");
1996
+ console.error(" 1. The API endpoint URL is incorrect");
1997
+ console.error(" 2. The endpoint does not exist (404)");
1998
+ console.error(`\nAPI URL attempted: ${initUrl.toString()}`);
1999
+ console.error("\nPlease verify the --api-base-url parameter.");
2000
+ } else {
2001
+ console.error(data);
2002
+ }
1639
2003
 
1640
- // Step 3: Open browser
1641
- const authUrl = `${uiBaseUrl}/cli/auth?state=${encodeURIComponent(params.state)}&code_challenge=${encodeURIComponent(params.codeChallenge)}&code_challenge_method=S256&redirect_uri=${encodeURIComponent(redirectUri)}`;
2004
+ callbackServer.server.stop();
2005
+ process.exit(1);
2006
+ return;
2007
+ }
1642
2008
 
1643
- if (opts.debug) {
1644
- console.log(`Debug: Auth URL: ${authUrl}`);
1645
- }
2009
+ // Step 3: Open browser
2010
+ const authUrl = `${uiBaseUrl}/cli/auth?state=${encodeURIComponent(params.state)}&code_challenge=${encodeURIComponent(params.codeChallenge)}&code_challenge_method=S256&redirect_uri=${encodeURIComponent(redirectUri)}`;
1646
2011
 
1647
- console.log(`\nOpening browser for authentication...`);
1648
- console.log(`If browser does not open automatically, visit:\n${authUrl}\n`);
1649
-
1650
- // Open browser (cross-platform)
1651
- const openCommand = process.platform === "darwin" ? "open" :
1652
- process.platform === "win32" ? "start" :
1653
- "xdg-open";
1654
- spawn(openCommand, [authUrl], { detached: true, stdio: "ignore" }).unref();
1655
-
1656
- // Step 4: Wait for callback
1657
- console.log("Waiting for authorization...");
1658
- console.log("(Press Ctrl+C to cancel)\n");
1659
-
1660
- // Handle Ctrl+C gracefully
1661
- const cancelHandler = () => {
1662
- console.log("\n\nAuthentication cancelled by user.");
1663
- callbackServer.server.close();
1664
- process.exit(130); // Standard exit code for SIGINT
1665
- };
1666
- process.on("SIGINT", cancelHandler);
1667
-
1668
- try {
1669
- const { code } = await callbackServer.promise;
1670
-
1671
- // Remove the cancel handler after successful auth
1672
- process.off("SIGINT", cancelHandler);
1673
-
1674
- // Step 5: Exchange code for token
1675
- console.log("\nExchanging authorization code for API token...");
1676
- const exchangeData = JSON.stringify({
1677
- authorization_code: code,
1678
- code_verifier: params.codeVerifier,
1679
- state: params.state,
1680
- });
1681
- const exchangeUrl = new URL(`${apiBaseUrl}/rpc/oauth_token_exchange`);
1682
- const exchangeReq = http.request(
1683
- exchangeUrl,
1684
- {
1685
- method: "POST",
1686
- headers: {
1687
- "Content-Type": "application/json",
1688
- "Content-Length": Buffer.byteLength(exchangeData),
1689
- },
1690
- },
1691
- (exchangeRes) => {
1692
- let exchangeBody = "";
1693
- exchangeRes.on("data", (chunk) => (exchangeBody += chunk));
1694
- exchangeRes.on("end", () => {
1695
- if (exchangeRes.statusCode !== 200) {
1696
- console.error(`Failed to exchange code for token: ${exchangeRes.statusCode}`);
1697
-
1698
- // Check if response is HTML (common for 404 pages)
1699
- if (exchangeBody.trim().startsWith("<!") || exchangeBody.trim().startsWith("<html")) {
1700
- console.error("Error: Received HTML response instead of JSON. This usually means:");
1701
- console.error(" 1. The API endpoint URL is incorrect");
1702
- console.error(" 2. The endpoint does not exist (404)");
1703
- console.error(`\nAPI URL attempted: ${exchangeUrl.toString()}`);
1704
- console.error("\nPlease verify the --api-base-url parameter.");
1705
- } else {
1706
- console.error(exchangeBody);
1707
- }
1708
-
1709
- process.exit(1);
1710
- return;
1711
- }
1712
-
1713
- try {
1714
- const result = JSON.parse(exchangeBody);
1715
- 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.
1716
- 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.
1717
-
1718
- // Step 6: Save token to config
1719
- config.writeConfig({
1720
- apiKey: apiToken,
1721
- baseUrl: apiBaseUrl,
1722
- orgId: orgId,
1723
- });
1724
-
1725
- console.log("\nAuthentication successful!");
1726
- console.log(`API key saved to: ${config.getConfigPath()}`);
1727
- console.log(`Organization ID: ${orgId}`);
1728
- console.log(`\nYou can now use the CLI without specifying an API key.`);
1729
- process.exit(0);
1730
- } catch (err) {
1731
- const message = err instanceof Error ? err.message : String(err);
1732
- console.error(`Failed to parse response: ${message}`);
1733
- process.exit(1);
1734
- }
1735
- });
1736
- }
1737
- );
1738
-
1739
- exchangeReq.on("error", (err: Error) => {
1740
- console.error(`Exchange request failed: ${err.message}`);
1741
- process.exit(1);
1742
- });
2012
+ if (opts.debug) {
2013
+ console.log(`Debug: Auth URL: ${authUrl}`);
2014
+ }
1743
2015
 
1744
- exchangeReq.write(exchangeData);
1745
- exchangeReq.end();
2016
+ console.log(`\nOpening browser for authentication...`);
2017
+ console.log(`If browser does not open automatically, visit:\n${authUrl}\n`);
1746
2018
 
1747
- } catch (err) {
1748
- // Remove the cancel handler in error case too
1749
- process.off("SIGINT", cancelHandler);
2019
+ // Open browser (cross-platform)
2020
+ const openCommand = process.platform === "darwin" ? "open" :
2021
+ process.platform === "win32" ? "start" :
2022
+ "xdg-open";
2023
+ spawn(openCommand, [authUrl], { detached: true, stdio: "ignore" }).unref();
1750
2024
 
1751
- const message = err instanceof Error ? err.message : String(err);
2025
+ // Step 4: Wait for callback
2026
+ console.log("Waiting for authorization...");
2027
+ console.log("(Press Ctrl+C to cancel)\n");
1752
2028
 
1753
- // Provide more helpful error messages
1754
- if (message.includes("timeout")) {
1755
- console.error(`\nAuthentication timed out.`);
1756
- console.error(`This usually means you closed the browser window without completing authentication.`);
1757
- console.error(`Please try again and complete the authentication flow.`);
1758
- } else {
1759
- console.error(`\nAuthentication failed: ${message}`);
1760
- }
2029
+ // Handle Ctrl+C gracefully
2030
+ const cancelHandler = () => {
2031
+ console.log("\n\nAuthentication cancelled by user.");
2032
+ callbackServer.server.stop();
2033
+ process.exit(130); // Standard exit code for SIGINT
2034
+ };
2035
+ process.on("SIGINT", cancelHandler);
1761
2036
 
1762
- process.exit(1);
1763
- }
2037
+ try {
2038
+ const { code } = await callbackServer.promise;
2039
+
2040
+ // Remove the cancel handler after successful auth
2041
+ process.off("SIGINT", cancelHandler);
2042
+
2043
+ // Step 5: Exchange code for token using fetch
2044
+ console.log("\nExchanging authorization code for API token...");
2045
+ const exchangeData = JSON.stringify({
2046
+ authorization_code: code,
2047
+ code_verifier: params.codeVerifier,
2048
+ state: params.state,
2049
+ });
2050
+ const exchangeUrl = new URL(`${apiBaseUrl}/rpc/oauth_token_exchange`);
2051
+
2052
+ let exchangeResponse: Response;
2053
+ try {
2054
+ exchangeResponse = await fetch(exchangeUrl.toString(), {
2055
+ method: "POST",
2056
+ headers: {
2057
+ "Content-Type": "application/json",
2058
+ },
2059
+ body: exchangeData,
1764
2060
  });
2061
+ } catch (err) {
2062
+ const message = err instanceof Error ? err.message : String(err);
2063
+ console.error(`Exchange request failed: ${message}`);
2064
+ process.exit(1);
2065
+ return;
1765
2066
  }
1766
- );
1767
2067
 
1768
- initReq.on("error", (err: Error) => {
1769
- console.error(`Failed to connect to API: ${err.message}`);
1770
- callbackServer.server.close();
1771
- process.exit(1);
1772
- });
2068
+ const exchangeBody = await exchangeResponse.text();
2069
+
2070
+ if (!exchangeResponse.ok) {
2071
+ console.error(`Failed to exchange code for token: ${exchangeResponse.status}`);
1773
2072
 
1774
- initReq.write(initData);
1775
- initReq.end();
2073
+ // Check if response is HTML (common for 404 pages)
2074
+ if (exchangeBody.trim().startsWith("<!") || exchangeBody.trim().startsWith("<html")) {
2075
+ console.error("Error: Received HTML response instead of JSON. This usually means:");
2076
+ console.error(" 1. The API endpoint URL is incorrect");
2077
+ console.error(" 2. The endpoint does not exist (404)");
2078
+ console.error(`\nAPI URL attempted: ${exchangeUrl.toString()}`);
2079
+ console.error("\nPlease verify the --api-base-url parameter.");
2080
+ } else {
2081
+ console.error(exchangeBody);
2082
+ }
2083
+
2084
+ process.exit(1);
2085
+ return;
2086
+ }
2087
+
2088
+ try {
2089
+ const result = JSON.parse(exchangeBody);
2090
+ 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.
2091
+ 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.
2092
+
2093
+ // Step 6: Save token to config
2094
+ config.writeConfig({
2095
+ apiKey: apiToken,
2096
+ baseUrl: apiBaseUrl,
2097
+ orgId: orgId,
2098
+ });
2099
+ // When re-authing via OAuth, orgId will be refreshed,
2100
+ // but defaultProject may no longer be valid in the new org.
2101
+ config.deleteConfigKeys(["defaultProject"]);
2102
+
2103
+ console.log("\nAuthentication successful!");
2104
+ console.log(`API key saved to: ${config.getConfigPath()}`);
2105
+ console.log(`Organization ID: ${orgId}`);
2106
+ console.log(`\nYou can now use the CLI without specifying an API key.`);
2107
+ process.exit(0);
2108
+ } catch (err) {
2109
+ const message = err instanceof Error ? err.message : String(err);
2110
+ console.error(`Failed to parse response: ${message}`);
2111
+ process.exit(1);
2112
+ }
2113
+
2114
+ } catch (err) {
2115
+ // Remove the cancel handler in error case too
2116
+ process.off("SIGINT", cancelHandler);
2117
+
2118
+ const message = err instanceof Error ? err.message : String(err);
2119
+
2120
+ // Provide more helpful error messages
2121
+ if (message.includes("timeout")) {
2122
+ console.error(`\nAuthentication timed out.`);
2123
+ console.error(`This usually means you closed the browser window without completing authentication.`);
2124
+ console.error(`Please try again and complete the authentication flow.`);
2125
+ } else {
2126
+ console.error(`\nAuthentication failed: ${message}`);
2127
+ }
2128
+
2129
+ process.exit(1);
2130
+ }
1776
2131
 
1777
2132
  } catch (err) {
1778
2133
  const message = err instanceof Error ? err.message : String(err);
@@ -1781,15 +2136,6 @@ auth
1781
2136
  }
1782
2137
  });
1783
2138
 
1784
- auth
1785
- .command("add-key <apiKey>")
1786
- .description("store API key (deprecated: use 'auth --set-key' instead)")
1787
- .action(async (apiKey: string) => {
1788
- console.warn("Warning: 'add-key' is deprecated. Use 'auth --set-key <key>' instead.\n");
1789
- config.writeConfig({ apiKey });
1790
- console.log(`API key saved to ${config.getConfigPath()}`);
1791
- });
1792
-
1793
2139
  auth
1794
2140
  .command("show-key")
1795
2141
  .description("show API key (masked)")
@@ -1800,7 +2146,6 @@ auth
1800
2146
  console.log(`\nTo authenticate, run: pgai auth`);
1801
2147
  return;
1802
2148
  }
1803
- const { maskSecret } = require("../lib/util");
1804
2149
  console.log(`Current API key: ${maskSecret(cfg.apiKey)}`);
1805
2150
  if (cfg.orgId) {
1806
2151
  console.log(`Organization ID: ${cfg.orgId}`);
@@ -2110,15 +2455,7 @@ mcp
2110
2455
  console.log(" 4. Codex");
2111
2456
  console.log("");
2112
2457
 
2113
- const rl = readline.createInterface({
2114
- input: process.stdin,
2115
- output: process.stdout
2116
- });
2117
-
2118
- const answer = await new Promise<string>((resolve) => {
2119
- rl.question("Select your AI coding tool (1-4): ", resolve);
2120
- });
2121
- rl.close();
2458
+ const answer = await question("Select your AI coding tool (1-4): ");
2122
2459
 
2123
2460
  const choices: Record<string, string> = {
2124
2461
  "1": "cursor",
@@ -2253,5 +2590,7 @@ mcp
2253
2590
  }
2254
2591
  });
2255
2592
 
2256
- program.parseAsync(process.argv);
2593
+ program.parseAsync(process.argv).finally(() => {
2594
+ closeReadline();
2595
+ });
2257
2596