postgresai 0.14.0-beta.3 → 0.14.0-beta.5

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 (77) hide show
  1. package/README.md +76 -48
  2. package/bin/postgres-ai.ts +1161 -341
  3. package/bun.lock +258 -0
  4. package/bunfig.toml +19 -0
  5. package/dist/bin/postgres-ai.js +28499 -1771
  6. package/dist/sql/01.role.sql +16 -0
  7. package/dist/sql/02.permissions.sql +37 -0
  8. package/dist/sql/03.optional_rds.sql +6 -0
  9. package/dist/sql/04.optional_self_managed.sql +8 -0
  10. package/dist/sql/05.helpers.sql +439 -0
  11. package/dist/sql/sql/01.role.sql +16 -0
  12. package/dist/sql/sql/02.permissions.sql +37 -0
  13. package/dist/sql/sql/03.optional_rds.sql +6 -0
  14. package/dist/sql/sql/04.optional_self_managed.sql +8 -0
  15. package/dist/sql/sql/05.helpers.sql +439 -0
  16. package/lib/auth-server.ts +124 -106
  17. package/lib/checkup-api.ts +386 -0
  18. package/lib/checkup.ts +1330 -0
  19. package/lib/config.ts +6 -3
  20. package/lib/init.ts +289 -79
  21. package/lib/issues.ts +400 -191
  22. package/lib/mcp-server.ts +213 -90
  23. package/lib/metrics-embedded.ts +79 -0
  24. package/lib/metrics-loader.ts +127 -0
  25. package/lib/util.ts +61 -0
  26. package/package.json +20 -10
  27. package/packages/postgres-ai/README.md +26 -0
  28. package/packages/postgres-ai/bin/postgres-ai.js +27 -0
  29. package/packages/postgres-ai/package.json +27 -0
  30. package/scripts/embed-metrics.ts +154 -0
  31. package/sql/02.permissions.sql +9 -5
  32. package/sql/05.helpers.sql +439 -0
  33. package/test/auth.test.ts +258 -0
  34. package/test/checkup.integration.test.ts +319 -0
  35. package/test/checkup.test.ts +891 -0
  36. package/test/init.integration.test.ts +497 -0
  37. package/test/init.test.ts +417 -0
  38. package/test/issues.cli.test.ts +314 -0
  39. package/test/issues.test.ts +456 -0
  40. package/test/mcp-server.test.ts +988 -0
  41. package/test/schema-validation.test.ts +81 -0
  42. package/test/test-utils.ts +122 -0
  43. package/tsconfig.json +12 -20
  44. package/dist/bin/postgres-ai.d.ts +0 -3
  45. package/dist/bin/postgres-ai.d.ts.map +0 -1
  46. package/dist/bin/postgres-ai.js.map +0 -1
  47. package/dist/lib/auth-server.d.ts +0 -31
  48. package/dist/lib/auth-server.d.ts.map +0 -1
  49. package/dist/lib/auth-server.js +0 -263
  50. package/dist/lib/auth-server.js.map +0 -1
  51. package/dist/lib/config.d.ts +0 -45
  52. package/dist/lib/config.d.ts.map +0 -1
  53. package/dist/lib/config.js +0 -181
  54. package/dist/lib/config.js.map +0 -1
  55. package/dist/lib/init.d.ts +0 -75
  56. package/dist/lib/init.d.ts.map +0 -1
  57. package/dist/lib/init.js +0 -482
  58. package/dist/lib/init.js.map +0 -1
  59. package/dist/lib/issues.d.ts +0 -75
  60. package/dist/lib/issues.d.ts.map +0 -1
  61. package/dist/lib/issues.js +0 -336
  62. package/dist/lib/issues.js.map +0 -1
  63. package/dist/lib/mcp-server.d.ts +0 -9
  64. package/dist/lib/mcp-server.d.ts.map +0 -1
  65. package/dist/lib/mcp-server.js +0 -168
  66. package/dist/lib/mcp-server.js.map +0 -1
  67. package/dist/lib/pkce.d.ts +0 -32
  68. package/dist/lib/pkce.d.ts.map +0 -1
  69. package/dist/lib/pkce.js +0 -101
  70. package/dist/lib/pkce.js.map +0 -1
  71. package/dist/lib/util.d.ts +0 -27
  72. package/dist/lib/util.d.ts.map +0 -1
  73. package/dist/lib/util.js +0 -46
  74. package/dist/lib/util.js.map +0 -1
  75. package/dist/package.json +0 -46
  76. package/test/init.integration.test.cjs +0 -382
  77. package/test/init.test.cjs +0 -323
@@ -1,25 +1,365 @@
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
- import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue } from "../lib/issues";
13
+ import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment } from "../lib/issues";
18
14
  import { resolveBaseUrls } from "../lib/util";
19
- import { applyInitPlan, buildInitPlan, DEFAULT_MONITORING_USER, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, verifyInitSetup } from "../lib/init";
15
+ import { applyInitPlan, buildInitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, verifyInitSetup } from "../lib/init";
16
+ import * as pkce from "../lib/pkce";
17
+ import * as authServer from "../lib/auth-server";
18
+ import { maskSecret } from "../lib/util";
19
+ import { createInterface } from "readline";
20
+ import * as childProcess from "child_process";
21
+ import { REPORT_GENERATORS, CHECK_INFO, generateAllReports } from "../lib/checkup";
22
+ import { createCheckupReport, uploadCheckupReportJson, RpcError, formatRpcErrorForDisplay, withRetry } from "../lib/checkup-api";
23
+
24
+ // Singleton readline interface for stdin prompts
25
+ let rl: ReturnType<typeof createInterface> | null = null;
26
+ function getReadline() {
27
+ if (!rl) {
28
+ rl = createInterface({ input: process.stdin, output: process.stdout });
29
+ }
30
+ return rl;
31
+ }
32
+ function closeReadline() {
33
+ if (rl) {
34
+ rl.close();
35
+ rl = null;
36
+ }
37
+ }
38
+
39
+ // Helper functions for spawning processes - use Node.js child_process for compatibility
40
+ async function execPromise(command: string): Promise<{ stdout: string; stderr: string }> {
41
+ return new Promise((resolve, reject) => {
42
+ childProcess.exec(command, (error, stdout, stderr) => {
43
+ if (error) {
44
+ const err = error as Error & { code: number };
45
+ err.code = typeof error.code === "number" ? error.code : 1;
46
+ reject(err);
47
+ } else {
48
+ resolve({ stdout, stderr });
49
+ }
50
+ });
51
+ });
52
+ }
53
+
54
+ async function execFilePromise(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
55
+ return new Promise((resolve, reject) => {
56
+ childProcess.execFile(file, args, (error, stdout, stderr) => {
57
+ if (error) {
58
+ const err = error as Error & { code: number };
59
+ err.code = typeof error.code === "number" ? error.code : 1;
60
+ reject(err);
61
+ } else {
62
+ resolve({ stdout, stderr });
63
+ }
64
+ });
65
+ });
66
+ }
67
+
68
+ function spawnSync(cmd: string, args: string[], options?: { stdio?: "pipe" | "ignore" | "inherit"; encoding?: string; env?: Record<string, string | undefined>; cwd?: string }): { status: number | null; stdout: string; stderr: string } {
69
+ const result = childProcess.spawnSync(cmd, args, {
70
+ stdio: options?.stdio === "inherit" ? "inherit" : "pipe",
71
+ env: options?.env as NodeJS.ProcessEnv,
72
+ cwd: options?.cwd,
73
+ encoding: "utf8",
74
+ });
75
+ return {
76
+ status: result.status,
77
+ stdout: typeof result.stdout === "string" ? result.stdout : "",
78
+ stderr: typeof result.stderr === "string" ? result.stderr : "",
79
+ };
80
+ }
81
+
82
+ function spawn(cmd: string, args: string[], options?: { stdio?: "pipe" | "ignore" | "inherit"; env?: Record<string, string | undefined>; cwd?: string; detached?: boolean }): { on: (event: string, cb: (code: number | null, signal?: string) => void) => void; unref: () => void; pid?: number } {
83
+ const proc = childProcess.spawn(cmd, args, {
84
+ stdio: options?.stdio ?? "pipe",
85
+ env: options?.env as NodeJS.ProcessEnv,
86
+ cwd: options?.cwd,
87
+ detached: options?.detached,
88
+ });
89
+
90
+ return {
91
+ on(event: string, cb: (code: number | null, signal?: string) => void) {
92
+ if (event === "close" || event === "exit") {
93
+ proc.on(event, (code, signal) => cb(code, signal ?? undefined));
94
+ } else if (event === "error") {
95
+ proc.on("error", (err) => cb(null, String(err)));
96
+ }
97
+ return this;
98
+ },
99
+ unref() {
100
+ proc.unref();
101
+ },
102
+ pid: proc.pid,
103
+ };
104
+ }
105
+
106
+ // Simple readline-like interface for prompts using Bun
107
+ async function question(prompt: string): Promise<string> {
108
+ return new Promise((resolve) => {
109
+ getReadline().question(prompt, (answer) => {
110
+ resolve(answer);
111
+ });
112
+ });
113
+ }
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
+ }
20
192
 
21
- const execPromise = promisify(exec);
22
- const execFilePromise = promisify(execFile);
193
+ interface UploadSummary {
194
+ project: string;
195
+ reportId: number;
196
+ uploaded: Array<{ checkId: string; filename: string; chunkId: number }>;
197
+ }
198
+
199
+ /**
200
+ * Prepare and validate output directory for checkup reports.
201
+ * @returns Output path if valid, null if should exit with error
202
+ */
203
+ function prepareOutputDirectory(outputOpt: string | undefined): string | null | undefined {
204
+ if (!outputOpt) return undefined;
205
+
206
+ const outputDir = expandHomePath(outputOpt);
207
+ const outputPath = path.isAbsolute(outputDir) ? outputDir : path.resolve(process.cwd(), outputDir);
208
+
209
+ if (!fs.existsSync(outputPath)) {
210
+ try {
211
+ fs.mkdirSync(outputPath, { recursive: true });
212
+ } catch (e) {
213
+ const errAny = e as any;
214
+ const code = typeof errAny?.code === "string" ? errAny.code : "";
215
+ const msg = errAny instanceof Error ? errAny.message : String(errAny);
216
+ if (code === "EACCES" || code === "EPERM" || code === "ENOENT") {
217
+ console.error(`Error: Failed to create output directory: ${outputPath}`);
218
+ console.error(`Reason: ${msg}`);
219
+ console.error("Tip: choose a writable path, e.g. --output ./reports or --output ~/reports");
220
+ return null; // Signal to exit
221
+ }
222
+ throw e;
223
+ }
224
+ }
225
+ return outputPath;
226
+ }
227
+
228
+ /**
229
+ * Prepare upload configuration for checkup reports.
230
+ * @returns Upload config if valid, null if should exit, undefined if upload not needed
231
+ */
232
+ function prepareUploadConfig(
233
+ opts: CheckupOptions,
234
+ rootOpts: CliOptions,
235
+ shouldUpload: boolean,
236
+ uploadExplicitlyRequested: boolean
237
+ ): { config: UploadConfig; projectWasGenerated: boolean } | null | undefined {
238
+ if (!shouldUpload) return undefined;
239
+
240
+ const { apiKey } = getConfig(rootOpts);
241
+ if (!apiKey) {
242
+ if (uploadExplicitlyRequested) {
243
+ console.error("Error: API key is required for upload");
244
+ console.error("Tip: run 'postgresai auth' or pass --api-key / set PGAI_API_KEY");
245
+ return null; // Signal to exit
246
+ }
247
+ return undefined; // Skip upload silently
248
+ }
249
+
250
+ const cfg = config.readConfig();
251
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
252
+ let project = ((opts.project || cfg.defaultProject) || "").trim();
253
+ let projectWasGenerated = false;
254
+
255
+ if (!project) {
256
+ project = `project_${crypto.randomBytes(6).toString("hex")}`;
257
+ projectWasGenerated = true;
258
+ try {
259
+ config.writeConfig({ defaultProject: project });
260
+ } catch (e) {
261
+ const message = e instanceof Error ? e.message : String(e);
262
+ console.error(`Warning: Failed to save generated default project: ${message}`);
263
+ }
264
+ }
265
+
266
+ return {
267
+ config: { apiKey, apiBaseUrl, project },
268
+ projectWasGenerated,
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Upload checkup reports to PostgresAI API.
274
+ */
275
+ async function uploadCheckupReports(
276
+ uploadCfg: UploadConfig,
277
+ reports: Record<string, any>,
278
+ spinner: ReturnType<typeof createTtySpinner>,
279
+ logUpload: (msg: string) => void
280
+ ): Promise<UploadSummary> {
281
+ spinner.update("Creating remote checkup report");
282
+ const created = await withRetry(
283
+ () => createCheckupReport({
284
+ apiKey: uploadCfg.apiKey,
285
+ apiBaseUrl: uploadCfg.apiBaseUrl,
286
+ project: uploadCfg.project,
287
+ }),
288
+ { maxAttempts: 3 },
289
+ (attempt, err, delayMs) => {
290
+ const errMsg = err instanceof Error ? err.message : String(err);
291
+ logUpload(`[Retry ${attempt}/3] createCheckupReport failed: ${errMsg}, retrying in ${delayMs}ms...`);
292
+ }
293
+ );
294
+
295
+ const reportId = created.reportId;
296
+ logUpload(`Created remote checkup report: ${reportId}`);
297
+
298
+ const uploaded: Array<{ checkId: string; filename: string; chunkId: number }> = [];
299
+ for (const [checkId, report] of Object.entries(reports)) {
300
+ spinner.update(`Uploading ${checkId}.json`);
301
+ const jsonText = JSON.stringify(report, null, 2);
302
+ const r = await withRetry(
303
+ () => uploadCheckupReportJson({
304
+ apiKey: uploadCfg.apiKey,
305
+ apiBaseUrl: uploadCfg.apiBaseUrl,
306
+ reportId,
307
+ filename: `${checkId}.json`,
308
+ checkId,
309
+ jsonText,
310
+ }),
311
+ { maxAttempts: 3 },
312
+ (attempt, err, delayMs) => {
313
+ const errMsg = err instanceof Error ? err.message : String(err);
314
+ logUpload(`[Retry ${attempt}/3] Upload ${checkId}.json failed: ${errMsg}, retrying in ${delayMs}ms...`);
315
+ }
316
+ );
317
+ uploaded.push({ checkId, filename: `${checkId}.json`, chunkId: r.reportChunkId });
318
+ }
319
+ logUpload("Upload completed");
320
+
321
+ return { project: uploadCfg.project, reportId, uploaded };
322
+ }
323
+
324
+ /**
325
+ * Write checkup reports to files.
326
+ */
327
+ function writeReportFiles(reports: Record<string, any>, outputPath: string): void {
328
+ for (const [checkId, report] of Object.entries(reports)) {
329
+ const filePath = path.join(outputPath, `${checkId}.json`);
330
+ fs.writeFileSync(filePath, JSON.stringify(report, null, 2), "utf8");
331
+ console.log(`✓ ${checkId}: ${filePath}`);
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Print upload summary to console.
337
+ */
338
+ function printUploadSummary(
339
+ summary: UploadSummary,
340
+ projectWasGenerated: boolean,
341
+ useStderr: boolean
342
+ ): void {
343
+ const out = useStderr ? console.error : console.log;
344
+ out("\nCheckup report uploaded");
345
+ out("======================\n");
346
+ if (projectWasGenerated) {
347
+ out(`Project: ${summary.project} (generated and saved as default)`);
348
+ } else {
349
+ out(`Project: ${summary.project}`);
350
+ }
351
+ out(`Report ID: ${summary.reportId}`);
352
+ out("View in Console: console.postgres.ai → Support → checkup reports");
353
+ out("");
354
+ out("Files:");
355
+ for (const item of summary.uploaded) {
356
+ out(`- ${item.checkId}: ${item.filename}`);
357
+ }
358
+ }
359
+
360
+ // ============================================================================
361
+ // CLI configuration
362
+ // ============================================================================
23
363
 
24
364
  /**
25
365
  * CLI configuration options
@@ -61,6 +401,86 @@ interface PathResolution {
61
401
  instancesFile: string;
62
402
  }
63
403
 
404
+ function getDefaultMonitoringProjectDir(): string {
405
+ const override = process.env.PGAI_PROJECT_DIR;
406
+ if (override && override.trim()) return override.trim();
407
+ // Keep monitoring project next to user-level config (~/.config/postgresai)
408
+ return path.join(config.getConfigDir(), "monitoring");
409
+ }
410
+
411
+ async function downloadText(url: string): Promise<string> {
412
+ const controller = new AbortController();
413
+ const timeout = setTimeout(() => controller.abort(), 15_000);
414
+ try {
415
+ const response = await fetch(url, { signal: controller.signal });
416
+ if (!response.ok) {
417
+ throw new Error(`HTTP ${response.status} for ${url}`);
418
+ }
419
+ return await response.text();
420
+ } finally {
421
+ clearTimeout(timeout);
422
+ }
423
+ }
424
+
425
+ async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
426
+ const projectDir = getDefaultMonitoringProjectDir();
427
+ const composeFile = path.resolve(projectDir, "docker-compose.yml");
428
+ const instancesFile = path.resolve(projectDir, "instances.yml");
429
+
430
+ if (!fs.existsSync(projectDir)) {
431
+ fs.mkdirSync(projectDir, { recursive: true, mode: 0o700 });
432
+ }
433
+
434
+ if (!fs.existsSync(composeFile)) {
435
+ const refs = [
436
+ process.env.PGAI_PROJECT_REF,
437
+ pkg.version,
438
+ `v${pkg.version}`,
439
+ "main",
440
+ ].filter((v): v is string => Boolean(v && v.trim()));
441
+
442
+ let lastErr: unknown;
443
+ for (const ref of refs) {
444
+ const url = `https://gitlab.com/postgres-ai/postgres_ai/-/raw/${encodeURIComponent(ref)}/docker-compose.yml`;
445
+ try {
446
+ const text = await downloadText(url);
447
+ fs.writeFileSync(composeFile, text, { encoding: "utf8", mode: 0o600 });
448
+ break;
449
+ } catch (err) {
450
+ lastErr = err;
451
+ }
452
+ }
453
+
454
+ if (!fs.existsSync(composeFile)) {
455
+ const msg = lastErr instanceof Error ? lastErr.message : String(lastErr);
456
+ throw new Error(`Failed to bootstrap docker-compose.yml: ${msg}`);
457
+ }
458
+ }
459
+
460
+ // Ensure instances.yml exists as a FILE (avoid Docker creating a directory)
461
+ if (!fs.existsSync(instancesFile)) {
462
+ const header =
463
+ "# PostgreSQL instances to monitor\n" +
464
+ "# Add your instances using: pgai mon targets add <connection-string> <name>\n\n";
465
+ fs.writeFileSync(instancesFile, header, { encoding: "utf8", mode: 0o600 });
466
+ }
467
+
468
+ // Ensure .pgwatch-config exists as a FILE for reporter (may remain empty)
469
+ const pgwatchConfig = path.resolve(projectDir, ".pgwatch-config");
470
+ if (!fs.existsSync(pgwatchConfig)) {
471
+ fs.writeFileSync(pgwatchConfig, "", { encoding: "utf8", mode: 0o600 });
472
+ }
473
+
474
+ // Ensure .env exists and has PGAI_TAG (compose requires it)
475
+ const envFile = path.resolve(projectDir, ".env");
476
+ if (!fs.existsSync(envFile)) {
477
+ const envText = `PGAI_TAG=${pkg.version}\n# PGAI_REGISTRY=registry.gitlab.com/postgres-ai/postgres_ai\n`;
478
+ fs.writeFileSync(envFile, envText, { encoding: "utf8", mode: 0o600 });
479
+ }
480
+
481
+ return { fs, path, projectDir, composeFile, instancesFile };
482
+ }
483
+
64
484
  /**
65
485
  * Get configuration from various sources
66
486
  * @param opts - Command line options
@@ -119,8 +539,22 @@ program
119
539
  );
120
540
 
121
541
  program
122
- .command("init [conn]")
123
- .description("Create a monitoring user and grant all required permissions (idempotent)")
542
+ .command("set-default-project <project>")
543
+ .description("store default project for checkup uploads")
544
+ .action(async (project: string) => {
545
+ const value = (project || "").trim();
546
+ if (!value) {
547
+ console.error("Error: project is required");
548
+ process.exitCode = 1;
549
+ return;
550
+ }
551
+ config.writeConfig({ defaultProject: value });
552
+ console.log(`Default project saved: ${value}`);
553
+ });
554
+
555
+ program
556
+ .command("prepare-db [conn]")
557
+ .description("prepare database for monitoring: create monitoring user, required view(s), and grant permissions (idempotent)")
124
558
  .option("--db-url <url>", "PostgreSQL connection URL (admin) to run the setup against (deprecated; pass it as positional arg)")
125
559
  .option("-h, --host <host>", "PostgreSQL host (psql-like)")
126
560
  .option("-p, --port <port>", "PostgreSQL port (psql-like)")
@@ -139,9 +573,9 @@ program
139
573
  [
140
574
  "",
141
575
  "Examples:",
142
- " postgresai init postgresql://admin@host:5432/dbname",
143
- " postgresai init \"dbname=dbname host=host user=admin\"",
144
- " postgresai init -h host -p 5432 -U admin -d dbname",
576
+ " postgresai prepare-db postgresql://admin@host:5432/dbname",
577
+ " postgresai prepare-db \"dbname=dbname host=host user=admin\"",
578
+ " postgresai prepare-db -h host -p 5432 -U admin -d dbname",
145
579
  "",
146
580
  "Admin password:",
147
581
  " --admin-password <password> or PGPASSWORD=... (libpq standard)",
@@ -151,22 +585,28 @@ program
151
585
  " If auto-generated, it is printed only on TTY by default.",
152
586
  " To print it in non-interactive mode: --print-password",
153
587
  "",
588
+ "SSL connection (sslmode=prefer behavior):",
589
+ " Tries SSL first, falls back to non-SSL if server doesn't support it.",
590
+ " To force SSL: PGSSLMODE=require or ?sslmode=require in URL",
591
+ " To disable SSL: PGSSLMODE=disable or ?sslmode=disable in URL",
592
+ "",
154
593
  "Environment variables (libpq standard):",
155
594
  " PGHOST, PGPORT, PGUSER, PGDATABASE — connection defaults",
156
595
  " PGPASSWORD — admin password",
596
+ " PGSSLMODE — SSL mode (disable, require, verify-full)",
157
597
  " PGAI_MON_PASSWORD — monitoring password",
158
598
  "",
159
599
  "Inspect SQL without applying changes:",
160
- " postgresai init <conn> --print-sql",
600
+ " postgresai prepare-db <conn> --print-sql",
161
601
  "",
162
602
  "Verify setup (no changes):",
163
- " postgresai init <conn> --verify",
603
+ " postgresai prepare-db <conn> --verify",
164
604
  "",
165
605
  "Reset monitoring password only:",
166
- " postgresai init <conn> --reset-password --password '...'",
606
+ " postgresai prepare-db <conn> --reset-password --password '...'",
167
607
  "",
168
608
  "Offline SQL plan (no DB connection):",
169
- " postgresai init --print-sql",
609
+ " postgresai prepare-db --print-sql",
170
610
  ].join("\n")
171
611
  )
172
612
  .action(async (conn: string | undefined, opts: {
@@ -246,7 +686,7 @@ program
246
686
  });
247
687
  } catch (e) {
248
688
  const msg = e instanceof Error ? e.message : String(e);
249
- console.error(`Error: init: ${msg}`);
689
+ console.error(`Error: prepare-db: ${msg}`);
250
690
  // When connection details are missing, show full init help (options + examples).
251
691
  if (typeof msg === "string" && msg.startsWith("Connection is required.")) {
252
692
  console.error("");
@@ -265,8 +705,8 @@ program
265
705
  // Use native pg client instead of requiring psql to be installed
266
706
  let client: Client | undefined;
267
707
  try {
268
- client = new Client(adminConn.clientConfig);
269
- await client.connect();
708
+ const connResult = await connectWithSslFallback(Client, adminConn);
709
+ client = connResult.client;
270
710
 
271
711
  const dbRes = await client.query("select current_database() as db");
272
712
  const database = dbRes.rows?.[0]?.db;
@@ -282,14 +722,14 @@ program
282
722
  includeOptionalPermissions,
283
723
  });
284
724
  if (v.ok) {
285
- console.log("✓ init verify: OK");
725
+ console.log("✓ prepare-db verify: OK");
286
726
  if (v.missingOptional.length > 0) {
287
727
  console.log("⚠ Optional items missing:");
288
728
  for (const m of v.missingOptional) console.log(`- ${m}`);
289
729
  }
290
730
  return;
291
731
  }
292
- console.error("✗ init verify failed: missing required items");
732
+ console.error("✗ prepare-db verify failed: missing required items");
293
733
  for (const m of v.missingRequired) console.error(`- ${m}`);
294
734
  if (v.missingOptional.length > 0) {
295
735
  console.error("Optional items missing:");
@@ -365,7 +805,7 @@ program
365
805
 
366
806
  const { applied, skippedOptional } = await applyInitPlan({ client, plan: effectivePlan });
367
807
 
368
- console.log(opts.resetPassword ? "✓ init password reset completed" : "✓ init completed");
808
+ console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
369
809
  if (skippedOptional.length > 0) {
370
810
  console.log("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
371
811
  for (const s of skippedOptional) console.log(`- ${s}`);
@@ -387,7 +827,7 @@ program
387
827
  if (!message || message === "[object Object]") {
388
828
  message = "Unknown error";
389
829
  }
390
- console.error(`Error: init: ${message}`);
830
+ console.error(`Error: prepare-db: ${message}`);
391
831
  // If this was a plan step failure, surface the step name explicitly to help users diagnose quickly.
392
832
  const stepMatch =
393
833
  typeof message === "string" ? message.match(/Failed at step "([^"]+)":/i) : null;
@@ -439,6 +879,143 @@ program
439
879
  }
440
880
  });
441
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
+
442
1019
  /**
443
1020
  * Stub function for not implemented commands
444
1021
  */
@@ -472,6 +1049,14 @@ function resolvePaths(): PathResolution {
472
1049
  );
473
1050
  }
474
1051
 
1052
+ async function resolveOrInitPaths(): Promise<PathResolution> {
1053
+ try {
1054
+ return resolvePaths();
1055
+ } catch {
1056
+ return ensureDefaultMonitoringProject();
1057
+ }
1058
+ }
1059
+
475
1060
  /**
476
1061
  * Check if Docker daemon is running
477
1062
  */
@@ -523,7 +1108,7 @@ async function runCompose(args: string[]): Promise<number> {
523
1108
  let composeFile: string;
524
1109
  let projectDir: string;
525
1110
  try {
526
- ({ composeFile, projectDir } = resolvePaths());
1111
+ ({ composeFile, projectDir } = await resolveOrInitPaths());
527
1112
  } catch (error) {
528
1113
  const message = error instanceof Error ? error.message : String(error);
529
1114
  console.error(message);
@@ -566,7 +1151,8 @@ async function runCompose(args: string[]): Promise<number> {
566
1151
  return new Promise<number>((resolve) => {
567
1152
  const child = spawn(cmd[0], [...cmd.slice(1), "-f", composeFile, ...args], {
568
1153
  stdio: "inherit",
569
- env: env
1154
+ env: env,
1155
+ cwd: projectDir
570
1156
  });
571
1157
  child.on("close", (code) => resolve(code || 0));
572
1158
  });
@@ -580,18 +1166,64 @@ program.command("help", { isDefault: true }).description("show help").action(()
580
1166
  const mon = program.command("mon").description("monitoring services management");
581
1167
 
582
1168
  mon
583
- .command("quickstart")
584
- .description("complete setup (generate config, start monitoring services)")
1169
+ .command("local-install")
1170
+ .description("install local monitoring stack (generate config, start services)")
585
1171
  .option("--demo", "demo mode with sample database", false)
586
1172
  .option("--api-key <key>", "Postgres AI API key for automated report uploads")
587
1173
  .option("--db-url <url>", "PostgreSQL connection URL to monitor")
1174
+ .option("--tag <tag>", "Docker image tag to use (e.g., 0.14.0, 0.14.0-dev.33)")
588
1175
  .option("-y, --yes", "accept all defaults and skip interactive prompts", false)
589
- .action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; yes: boolean }) => {
1176
+ .action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; tag?: string; yes: boolean }) => {
1177
+ // Get apiKey from global program options (--api-key is defined globally)
1178
+ // This is needed because Commander.js routes --api-key to the global option, not the subcommand's option
1179
+ const globalOpts = program.opts<CliOptions>();
1180
+ const apiKey = opts.apiKey || globalOpts.apiKey;
1181
+
590
1182
  console.log("\n=================================");
591
- console.log(" PostgresAI Monitoring Quickstart");
1183
+ console.log(" PostgresAI monitoring local install");
592
1184
  console.log("=================================\n");
593
1185
  console.log("This will install, configure, and start the monitoring system\n");
594
1186
 
1187
+ // Ensure we have a project directory with docker-compose.yml even if running from elsewhere
1188
+ const { projectDir } = await resolveOrInitPaths();
1189
+ console.log(`Project directory: ${projectDir}\n`);
1190
+
1191
+ // Update .env with custom tag if provided
1192
+ const envFile = path.resolve(projectDir, ".env");
1193
+
1194
+ // Build .env content, preserving important existing values
1195
+ // Read existing .env first to preserve CI/custom settings
1196
+ let existingTag: string | null = null;
1197
+ let existingRegistry: string | null = null;
1198
+ let existingPassword: string | null = null;
1199
+
1200
+ if (fs.existsSync(envFile)) {
1201
+ const existingEnv = fs.readFileSync(envFile, "utf8");
1202
+ // Extract existing values
1203
+ const tagMatch = existingEnv.match(/^PGAI_TAG=(.+)$/m);
1204
+ if (tagMatch) existingTag = tagMatch[1].trim();
1205
+ const registryMatch = existingEnv.match(/^PGAI_REGISTRY=(.+)$/m);
1206
+ if (registryMatch) existingRegistry = registryMatch[1].trim();
1207
+ const pwdMatch = existingEnv.match(/^GF_SECURITY_ADMIN_PASSWORD=(.+)$/m);
1208
+ if (pwdMatch) existingPassword = pwdMatch[1].trim();
1209
+ }
1210
+
1211
+ // Priority: CLI --tag flag > existing .env > package version
1212
+ const imageTag = opts.tag || existingTag || pkg.version;
1213
+
1214
+ const envLines: string[] = [`PGAI_TAG=${imageTag}`];
1215
+ if (existingRegistry) {
1216
+ envLines.push(`PGAI_REGISTRY=${existingRegistry}`);
1217
+ }
1218
+ if (existingPassword) {
1219
+ envLines.push(`GF_SECURITY_ADMIN_PASSWORD=${existingPassword}`);
1220
+ }
1221
+ fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
1222
+
1223
+ if (opts.tag) {
1224
+ console.log(`Using image tag: ${imageTag}\n`);
1225
+ }
1226
+
595
1227
  // Validate conflicting options
596
1228
  if (opts.demo && opts.dbUrl) {
597
1229
  console.log("⚠ Both --demo and --db-url provided. Demo mode includes its own database.");
@@ -599,11 +1231,11 @@ mon
599
1231
  opts.dbUrl = undefined;
600
1232
  }
601
1233
 
602
- if (opts.demo && opts.apiKey) {
1234
+ if (opts.demo && apiKey) {
603
1235
  console.error("✗ Cannot use --api-key with --demo mode");
604
1236
  console.error("✗ Demo mode is for testing only and does not support API key integration");
605
- console.error("\nUse demo mode without API key: postgres-ai mon quickstart --demo");
606
- console.error("Or use production mode with API key: postgres-ai mon quickstart --api-key=your_key");
1237
+ console.error("\nUse demo mode without API key: postgres-ai mon local-install --demo");
1238
+ console.error("Or use production mode with API key: postgres-ai mon local-install --api-key=your_key");
607
1239
  process.exitCode = 1;
608
1240
  return;
609
1241
  }
@@ -621,9 +1253,14 @@ mon
621
1253
  console.log("Step 1: Postgres AI API Configuration (Optional)");
622
1254
  console.log("An API key enables automatic upload of PostgreSQL reports to Postgres AI\n");
623
1255
 
624
- if (opts.apiKey) {
1256
+ if (apiKey) {
625
1257
  console.log("Using API key provided via --api-key parameter");
626
- config.writeConfig({ apiKey: opts.apiKey });
1258
+ config.writeConfig({ apiKey });
1259
+ // Keep reporter compatibility (docker-compose mounts .pgwatch-config)
1260
+ fs.writeFileSync(path.resolve(projectDir, ".pgwatch-config"), `api_key=${apiKey}\n`, {
1261
+ encoding: "utf8",
1262
+ mode: 0o600
1263
+ });
627
1264
  console.log("✓ API key saved\n");
628
1265
  } else if (opts.yes) {
629
1266
  // Auto-yes mode without API key - skip API key setup
@@ -631,43 +1268,36 @@ mon
631
1268
  console.log("⚠ Reports will be generated locally only");
632
1269
  console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
633
1270
  } else {
634
- const rl = readline.createInterface({
635
- input: process.stdin,
636
- output: process.stdout
637
- });
638
-
639
- const question = (prompt: string): Promise<string> =>
640
- new Promise((resolve) => rl.question(prompt, resolve));
641
-
642
- try {
643
- const answer = await question("Do you have a Postgres AI API key? (Y/n): ");
644
- const proceedWithApiKey = !answer || answer.toLowerCase() === "y";
645
-
646
- if (proceedWithApiKey) {
647
- while (true) {
648
- const inputApiKey = await question("Enter your Postgres AI API key: ");
649
- const trimmedKey = inputApiKey.trim();
650
-
651
- if (trimmedKey) {
652
- config.writeConfig({ apiKey: trimmedKey });
653
- console.log("✓ API key saved\n");
654
- break;
655
- }
1271
+ const answer = await question("Do you have a Postgres AI API key? (Y/n): ");
1272
+ const proceedWithApiKey = !answer || answer.toLowerCase() === "y";
1273
+
1274
+ if (proceedWithApiKey) {
1275
+ while (true) {
1276
+ const inputApiKey = await question("Enter your Postgres AI API key: ");
1277
+ const trimmedKey = inputApiKey.trim();
1278
+
1279
+ if (trimmedKey) {
1280
+ config.writeConfig({ apiKey: trimmedKey });
1281
+ // Keep reporter compatibility (docker-compose mounts .pgwatch-config)
1282
+ fs.writeFileSync(path.resolve(projectDir, ".pgwatch-config"), `api_key=${trimmedKey}\n`, {
1283
+ encoding: "utf8",
1284
+ mode: 0o600
1285
+ });
1286
+ console.log("✓ API key saved\n");
1287
+ break;
1288
+ }
656
1289
 
657
- console.log("⚠ API key cannot be empty");
658
- const retry = await question("Try again or skip API key setup, retry? (Y/n): ");
659
- if (retry.toLowerCase() === "n") {
660
- console.log("⚠ Skipping API key setup - reports will be generated locally only");
661
- console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
662
- break;
663
- }
1290
+ console.log("⚠ API key cannot be empty");
1291
+ const retry = await question("Try again or skip API key setup, retry? (Y/n): ");
1292
+ if (retry.toLowerCase() === "n") {
1293
+ console.log("⚠ Skipping API key setup - reports will be generated locally only");
1294
+ console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
1295
+ break;
664
1296
  }
665
- } else {
666
- console.log("⚠ Skipping API key setup - reports will be generated locally only");
667
- console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
668
1297
  }
669
- } finally {
670
- rl.close();
1298
+ } else {
1299
+ console.log("⚠ Skipping API key setup - reports will be generated locally only");
1300
+ console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
671
1301
  }
672
1302
  }
673
1303
  } else {
@@ -680,9 +1310,11 @@ mon
680
1310
  console.log("Step 2: Add PostgreSQL Instance to Monitor\n");
681
1311
 
682
1312
  // Clear instances.yml in production mode (start fresh)
683
- const instancesPath = path.resolve(process.cwd(), "instances.yml");
1313
+ const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
684
1314
  const emptyInstancesContent = "# PostgreSQL instances to monitor\n# Add your instances using: postgres-ai mon targets add\n\n";
685
1315
  fs.writeFileSync(instancesPath, emptyInstancesContent, "utf8");
1316
+ console.log(`Instances file: ${instancesPath}`);
1317
+ console.log(`Project directory: ${projectDir}\n`);
686
1318
 
687
1319
  if (opts.dbUrl) {
688
1320
  console.log("Using database URL provided via --db-url parameter");
@@ -711,7 +1343,6 @@ mon
711
1343
  // Test connection
712
1344
  console.log("Testing connection to the added instance...");
713
1345
  try {
714
- const { Client } = require("pg");
715
1346
  const client = new Client({ connectionString: connStr });
716
1347
  await client.connect();
717
1348
  const result = await client.query("select version();");
@@ -728,63 +1359,50 @@ mon
728
1359
  console.log("⚠ No PostgreSQL instance added");
729
1360
  console.log("You can add one later with: postgres-ai mon targets add\n");
730
1361
  } else {
731
- const rl = readline.createInterface({
732
- input: process.stdin,
733
- output: process.stdout
734
- });
735
-
736
- const question = (prompt: string): Promise<string> =>
737
- new Promise((resolve) => rl.question(prompt, resolve));
738
-
739
- try {
740
- console.log("You need to add at least one PostgreSQL instance to monitor");
741
- const answer = await question("Do you want to add a PostgreSQL instance now? (Y/n): ");
742
- const proceedWithInstance = !answer || answer.toLowerCase() === "y";
743
-
744
- if (proceedWithInstance) {
745
- console.log("\nYou can provide either:");
746
- console.log(" 1. A full connection string: postgresql://user:pass@host:port/database");
747
- console.log(" 2. Press Enter to skip for now\n");
748
-
749
- const connStr = await question("Enter connection string (or press Enter to skip): ");
750
-
751
- if (connStr.trim()) {
752
- const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
753
- if (!m) {
754
- console.error("✗ Invalid connection string format");
755
- console.log("⚠ Continuing without adding instance\n");
756
- } else {
757
- const host = m[3];
758
- const db = m[5];
759
- const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
760
-
761
- 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`;
762
- fs.appendFileSync(instancesPath, body, "utf8");
763
- console.log(`✓ Monitoring target '${instanceName}' added\n`);
764
-
765
- // Test connection
766
- console.log("Testing connection to the added instance...");
767
- try {
768
- const { Client } = require("pg");
769
- const client = new Client({ connectionString: connStr });
770
- await client.connect();
771
- const result = await client.query("select version();");
772
- console.log("✓ Connection successful");
773
- console.log(`${result.rows[0].version}\n`);
774
- await client.end();
775
- } catch (error) {
776
- const message = error instanceof Error ? error.message : String(error);
777
- console.error(`✗ Connection failed: ${message}\n`);
778
- }
779
- }
1362
+ console.log("You need to add at least one PostgreSQL instance to monitor");
1363
+ const answer = await question("Do you want to add a PostgreSQL instance now? (Y/n): ");
1364
+ const proceedWithInstance = !answer || answer.toLowerCase() === "y";
1365
+
1366
+ if (proceedWithInstance) {
1367
+ console.log("\nYou can provide either:");
1368
+ console.log(" 1. A full connection string: postgresql://user:pass@host:port/database");
1369
+ console.log(" 2. Press Enter to skip for now\n");
1370
+
1371
+ const connStr = await question("Enter connection string (or press Enter to skip): ");
1372
+
1373
+ if (connStr.trim()) {
1374
+ const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
1375
+ if (!m) {
1376
+ console.error(" Invalid connection string format");
1377
+ console.log(" Continuing without adding instance\n");
780
1378
  } else {
781
- console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
1379
+ const host = m[3];
1380
+ const db = m[5];
1381
+ const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
1382
+
1383
+ 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`;
1384
+ fs.appendFileSync(instancesPath, body, "utf8");
1385
+ console.log(`✓ Monitoring target '${instanceName}' added\n`);
1386
+
1387
+ // Test connection
1388
+ console.log("Testing connection to the added instance...");
1389
+ try {
1390
+ const client = new Client({ connectionString: connStr });
1391
+ await client.connect();
1392
+ const result = await client.query("select version();");
1393
+ console.log("✓ Connection successful");
1394
+ console.log(`${result.rows[0].version}\n`);
1395
+ await client.end();
1396
+ } catch (error) {
1397
+ const message = error instanceof Error ? error.message : String(error);
1398
+ console.error(`✗ Connection failed: ${message}\n`);
1399
+ }
782
1400
  }
783
1401
  } else {
784
1402
  console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
785
1403
  }
786
- } finally {
787
- rl.close();
1404
+ } else {
1405
+ console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
788
1406
  }
789
1407
  }
790
1408
  } else {
@@ -802,7 +1420,7 @@ mon
802
1420
 
803
1421
  // Step 4: Ensure Grafana password is configured
804
1422
  console.log(opts.demo ? "Step 4: Configuring Grafana security..." : "Step 4: Configuring Grafana security...");
805
- const cfgPath = path.resolve(process.cwd(), ".pgwatch-config");
1423
+ const cfgPath = path.resolve(projectDir, ".pgwatch-config");
806
1424
  let grafanaPassword = "";
807
1425
 
808
1426
  try {
@@ -853,7 +1471,7 @@ mon
853
1471
 
854
1472
  // Final summary
855
1473
  console.log("=================================");
856
- console.log(" 🎉 Quickstart setup completed!");
1474
+ console.log(" Local install completed!");
857
1475
  console.log("=================================\n");
858
1476
 
859
1477
  console.log("What's running:");
@@ -970,17 +1588,17 @@ mon
970
1588
  allHealthy = true;
971
1589
  for (const service of services) {
972
1590
  try {
973
- const { execSync } = require("child_process");
974
- const status = execSync(`docker inspect -f '{{.State.Status}}' ${service.container} 2>/dev/null`, {
975
- encoding: 'utf8',
976
- stdio: ['pipe', 'pipe', 'pipe']
977
- }).trim();
1591
+ const result = spawnSync("docker", ["inspect", "-f", "{{.State.Status}}", service.container], { stdio: "pipe" });
1592
+ const status = result.stdout.trim();
978
1593
 
979
- if (status === 'running') {
1594
+ if (result.status === 0 && status === 'running') {
980
1595
  console.log(`✓ ${service.name}: healthy`);
981
- } else {
1596
+ } else if (result.status === 0) {
982
1597
  console.log(`✗ ${service.name}: unhealthy (status: ${status})`);
983
1598
  allHealthy = false;
1599
+ } else {
1600
+ console.log(`✗ ${service.name}: unreachable`);
1601
+ allHealthy = false;
984
1602
  }
985
1603
  } catch (error) {
986
1604
  console.log(`✗ ${service.name}: unreachable`);
@@ -1009,7 +1627,7 @@ mon
1009
1627
  let composeFile: string;
1010
1628
  let instancesFile: string;
1011
1629
  try {
1012
- ({ projectDir, composeFile, instancesFile } = resolvePaths());
1630
+ ({ projectDir, composeFile, instancesFile } = await resolveOrInitPaths());
1013
1631
  } catch (error) {
1014
1632
  const message = error instanceof Error ? error.message : String(error);
1015
1633
  console.error(message);
@@ -1084,14 +1702,6 @@ mon
1084
1702
  .command("reset [service]")
1085
1703
  .description("reset all or specific monitoring service")
1086
1704
  .action(async (service?: string) => {
1087
- const rl = readline.createInterface({
1088
- input: process.stdin,
1089
- output: process.stdout,
1090
- });
1091
-
1092
- const question = (prompt: string): Promise<string> =>
1093
- new Promise((resolve) => rl.question(prompt, resolve));
1094
-
1095
1705
  try {
1096
1706
  if (service) {
1097
1707
  // Reset specific service
@@ -1101,7 +1711,6 @@ mon
1101
1711
  const answer = await question("Continue? (y/N): ");
1102
1712
  if (answer.toLowerCase() !== "y") {
1103
1713
  console.log("Cancelled");
1104
- rl.close();
1105
1714
  return;
1106
1715
  }
1107
1716
 
@@ -1128,7 +1737,6 @@ mon
1128
1737
  const answer = await question("Continue? (y/N): ");
1129
1738
  if (answer.toLowerCase() !== "y") {
1130
1739
  console.log("Cancelled");
1131
- rl.close();
1132
1740
  return;
1133
1741
  }
1134
1742
 
@@ -1142,10 +1750,7 @@ mon
1142
1750
  process.exitCode = 1;
1143
1751
  }
1144
1752
  }
1145
-
1146
- rl.close();
1147
1753
  } catch (error) {
1148
- rl.close();
1149
1754
  const message = error instanceof Error ? error.message : String(error);
1150
1755
  console.error(`Reset failed: ${message}`);
1151
1756
  process.exitCode = 1;
@@ -1209,9 +1814,9 @@ targets
1209
1814
  .command("list")
1210
1815
  .description("list monitoring target databases")
1211
1816
  .action(async () => {
1212
- const instancesPath = path.resolve(process.cwd(), "instances.yml");
1817
+ const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
1213
1818
  if (!fs.existsSync(instancesPath)) {
1214
- console.error(`instances.yml not found in ${process.cwd()}`);
1819
+ console.error(`instances.yml not found in ${projectDir}`);
1215
1820
  process.exitCode = 1;
1216
1821
  return;
1217
1822
  }
@@ -1258,7 +1863,7 @@ targets
1258
1863
  .command("add [connStr] [name]")
1259
1864
  .description("add monitoring target database")
1260
1865
  .action(async (connStr?: string, name?: string) => {
1261
- const file = path.resolve(process.cwd(), "instances.yml");
1866
+ const { instancesFile: file } = await resolveOrInitPaths();
1262
1867
  if (!connStr) {
1263
1868
  console.error("Connection string required: postgresql://user:pass@host:port/db");
1264
1869
  process.exitCode = 1;
@@ -1308,7 +1913,7 @@ targets
1308
1913
  .command("remove <name>")
1309
1914
  .description("remove monitoring target database")
1310
1915
  .action(async (name: string) => {
1311
- const file = path.resolve(process.cwd(), "instances.yml");
1916
+ const { instancesFile: file } = await resolveOrInitPaths();
1312
1917
  if (!fs.existsSync(file)) {
1313
1918
  console.error("instances.yml not found");
1314
1919
  process.exitCode = 1;
@@ -1345,7 +1950,7 @@ targets
1345
1950
  .command("test <name>")
1346
1951
  .description("test monitoring target database connectivity")
1347
1952
  .action(async (name: string) => {
1348
- const instancesPath = path.resolve(process.cwd(), "instances.yml");
1953
+ const { instancesFile: instancesPath } = await resolveOrInitPaths();
1349
1954
  if (!fs.existsSync(instancesPath)) {
1350
1955
  console.error("instances.yml not found");
1351
1956
  process.exitCode = 1;
@@ -1379,7 +1984,6 @@ targets
1379
1984
  console.log(`Testing connection to monitoring target '${name}'...`);
1380
1985
 
1381
1986
  // Use native pg client instead of requiring psql to be installed
1382
- const { Client } = require('pg');
1383
1987
  const client = new Client({ connectionString: instance.conn_str });
1384
1988
 
1385
1989
  try {
@@ -1398,15 +2002,43 @@ targets
1398
2002
  });
1399
2003
 
1400
2004
  // Authentication and API key management
1401
- program
1402
- .command("auth")
1403
- .description("authenticate via browser and obtain API key")
2005
+ const auth = program.command("auth").description("authentication and API key management");
2006
+
2007
+ auth
2008
+ .command("login", { isDefault: true })
2009
+ .description("authenticate via browser (OAuth) or store API key directly")
2010
+ .option("--set-key <key>", "store API key directly without OAuth flow")
1404
2011
  .option("--port <port>", "local callback server port (default: random)", parseInt)
1405
2012
  .option("--debug", "enable debug output")
1406
- .action(async (opts: { port?: number; debug?: boolean }) => {
1407
- const pkce = require("../lib/pkce");
1408
- const authServer = require("../lib/auth-server");
2013
+ .action(async (opts: { setKey?: string; port?: number; debug?: boolean }) => {
2014
+ // If --set-key is provided, store it directly without OAuth
2015
+ if (opts.setKey) {
2016
+ const trimmedKey = opts.setKey.trim();
2017
+ if (!trimmedKey) {
2018
+ console.error("Error: API key cannot be empty");
2019
+ process.exitCode = 1;
2020
+ return;
2021
+ }
2022
+
2023
+ // Read existing config to check for defaultProject before updating
2024
+ const existingConfig = config.readConfig();
2025
+ const existingProject = existingConfig.defaultProject;
2026
+
2027
+ config.writeConfig({ apiKey: trimmedKey });
2028
+ // When API key is set directly, only clear orgId (org selection may differ).
2029
+ // Preserve defaultProject to avoid orphaning historical reports.
2030
+ // If the new key lacks access to the project, upload will fail with a clear error.
2031
+ config.deleteConfigKeys(["orgId"]);
2032
+
2033
+ console.log(`API key saved to ${config.getConfigPath()}`);
2034
+ if (existingProject) {
2035
+ console.log(`Note: Your default project "${existingProject}" has been preserved.`);
2036
+ console.log(` If this key belongs to a different account, use --project to specify a new one.`);
2037
+ }
2038
+ return;
2039
+ }
1409
2040
 
2041
+ // Otherwise, proceed with OAuth flow
1410
2042
  console.log("Starting authentication flow...\n");
1411
2043
 
1412
2044
  // Generate PKCE parameters
@@ -1427,10 +2059,10 @@ program
1427
2059
  const requestedPort = opts.port || 0; // 0 = OS assigns available port
1428
2060
  const callbackServer = authServer.createCallbackServer(requestedPort, params.state, 120000); // 2 minute timeout
1429
2061
 
1430
- // Wait a bit for server to start and get port
1431
- await new Promise(resolve => setTimeout(resolve, 100));
1432
- const actualPort = callbackServer.getPort();
1433
- const redirectUri = `http://localhost:${actualPort}/callback`;
2062
+ // Wait for server to start and get the actual port
2063
+ const actualPort = await callbackServer.ready;
2064
+ // Use 127.0.0.1 to match the server bind address (avoids IPv6 issues on some hosts)
2065
+ const redirectUri = `http://127.0.0.1:${actualPort}/callback`;
1434
2066
 
1435
2067
  console.log(`Callback server listening on port ${actualPort}`);
1436
2068
 
@@ -1452,173 +2084,180 @@ program
1452
2084
  console.log(`Debug: Request data: ${initData}`);
1453
2085
  }
1454
2086
 
1455
- const initReq = http.request(
1456
- initUrl,
1457
- {
2087
+ // Step 2: Initialize OAuth session on backend using fetch
2088
+ let initResponse: Response;
2089
+ try {
2090
+ initResponse = await fetch(initUrl.toString(), {
1458
2091
  method: "POST",
1459
2092
  headers: {
1460
2093
  "Content-Type": "application/json",
1461
- "Content-Length": Buffer.byteLength(initData),
1462
2094
  },
1463
- },
1464
- (res) => {
1465
- let data = "";
1466
- res.on("data", (chunk) => (data += chunk));
1467
- res.on("end", async () => {
1468
- if (res.statusCode !== 200) {
1469
- console.error(`Failed to initialize auth session: ${res.statusCode}`);
1470
-
1471
- // Check if response is HTML (common for 404 pages)
1472
- if (data.trim().startsWith("<!") || data.trim().startsWith("<html")) {
1473
- console.error("Error: Received HTML response instead of JSON. This usually means:");
1474
- console.error(" 1. The API endpoint URL is incorrect");
1475
- console.error(" 2. The endpoint does not exist (404)");
1476
- console.error(`\nAPI URL attempted: ${initUrl.toString()}`);
1477
- console.error("\nPlease verify the --api-base-url parameter.");
1478
- } else {
1479
- console.error(data);
1480
- }
2095
+ body: initData,
2096
+ });
2097
+ } catch (err) {
2098
+ const message = err instanceof Error ? err.message : String(err);
2099
+ console.error(`Failed to connect to API: ${message}`);
2100
+ callbackServer.server.stop();
2101
+ process.exit(1);
2102
+ return;
2103
+ }
1481
2104
 
1482
- callbackServer.server.close();
1483
- process.exit(1);
1484
- }
2105
+ if (!initResponse.ok) {
2106
+ const data = await initResponse.text();
2107
+ console.error(`Failed to initialize auth session: ${initResponse.status}`);
2108
+
2109
+ // Check if response is HTML (common for 404 pages)
2110
+ if (data.trim().startsWith("<!") || data.trim().startsWith("<html")) {
2111
+ console.error("Error: Received HTML response instead of JSON. This usually means:");
2112
+ console.error(" 1. The API endpoint URL is incorrect");
2113
+ console.error(" 2. The endpoint does not exist (404)");
2114
+ console.error(`\nAPI URL attempted: ${initUrl.toString()}`);
2115
+ console.error("\nPlease verify the --api-base-url parameter.");
2116
+ } else {
2117
+ console.error(data);
2118
+ }
1485
2119
 
1486
- // Step 3: Open browser
1487
- const authUrl = `${uiBaseUrl}/cli/auth?state=${encodeURIComponent(params.state)}&code_challenge=${encodeURIComponent(params.codeChallenge)}&code_challenge_method=S256&redirect_uri=${encodeURIComponent(redirectUri)}`;
2120
+ callbackServer.server.stop();
2121
+ process.exit(1);
2122
+ return;
2123
+ }
1488
2124
 
1489
- if (opts.debug) {
1490
- console.log(`Debug: Auth URL: ${authUrl}`);
1491
- }
2125
+ // Step 3: Open browser
2126
+ // Pass api_url so UI calls oauth_approve on the same backend where oauth_init created the session
2127
+ 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)}`;
1492
2128
 
1493
- console.log(`\nOpening browser for authentication...`);
1494
- console.log(`If browser does not open automatically, visit:\n${authUrl}\n`);
1495
-
1496
- // Open browser (cross-platform)
1497
- const openCommand = process.platform === "darwin" ? "open" :
1498
- process.platform === "win32" ? "start" :
1499
- "xdg-open";
1500
- spawn(openCommand, [authUrl], { detached: true, stdio: "ignore" }).unref();
1501
-
1502
- // Step 4: Wait for callback
1503
- console.log("Waiting for authorization...");
1504
- console.log("(Press Ctrl+C to cancel)\n");
1505
-
1506
- // Handle Ctrl+C gracefully
1507
- const cancelHandler = () => {
1508
- console.log("\n\nAuthentication cancelled by user.");
1509
- callbackServer.server.close();
1510
- process.exit(130); // Standard exit code for SIGINT
1511
- };
1512
- process.on("SIGINT", cancelHandler);
1513
-
1514
- try {
1515
- const { code } = await callbackServer.promise;
1516
-
1517
- // Remove the cancel handler after successful auth
1518
- process.off("SIGINT", cancelHandler);
1519
-
1520
- // Step 5: Exchange code for token
1521
- console.log("\nExchanging authorization code for API token...");
1522
- const exchangeData = JSON.stringify({
1523
- authorization_code: code,
1524
- code_verifier: params.codeVerifier,
1525
- state: params.state,
1526
- });
1527
- const exchangeUrl = new URL(`${apiBaseUrl}/rpc/oauth_token_exchange`);
1528
- const exchangeReq = http.request(
1529
- exchangeUrl,
1530
- {
1531
- method: "POST",
1532
- headers: {
1533
- "Content-Type": "application/json",
1534
- "Content-Length": Buffer.byteLength(exchangeData),
1535
- },
1536
- },
1537
- (exchangeRes) => {
1538
- let exchangeBody = "";
1539
- exchangeRes.on("data", (chunk) => (exchangeBody += chunk));
1540
- exchangeRes.on("end", () => {
1541
- if (exchangeRes.statusCode !== 200) {
1542
- console.error(`Failed to exchange code for token: ${exchangeRes.statusCode}`);
1543
-
1544
- // Check if response is HTML (common for 404 pages)
1545
- if (exchangeBody.trim().startsWith("<!") || exchangeBody.trim().startsWith("<html")) {
1546
- console.error("Error: Received HTML response instead of JSON. This usually means:");
1547
- console.error(" 1. The API endpoint URL is incorrect");
1548
- console.error(" 2. The endpoint does not exist (404)");
1549
- console.error(`\nAPI URL attempted: ${exchangeUrl.toString()}`);
1550
- console.error("\nPlease verify the --api-base-url parameter.");
1551
- } else {
1552
- console.error(exchangeBody);
1553
- }
1554
-
1555
- process.exit(1);
1556
- return;
1557
- }
1558
-
1559
- try {
1560
- const result = JSON.parse(exchangeBody);
1561
- 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.
1562
- 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.
1563
-
1564
- // Step 6: Save token to config
1565
- config.writeConfig({
1566
- apiKey: apiToken,
1567
- baseUrl: apiBaseUrl,
1568
- orgId: orgId,
1569
- });
1570
-
1571
- console.log("\nAuthentication successful!");
1572
- console.log(`API key saved to: ${config.getConfigPath()}`);
1573
- console.log(`Organization ID: ${orgId}`);
1574
- console.log(`\nYou can now use the CLI without specifying an API key.`);
1575
- process.exit(0);
1576
- } catch (err) {
1577
- const message = err instanceof Error ? err.message : String(err);
1578
- console.error(`Failed to parse response: ${message}`);
1579
- process.exit(1);
1580
- }
1581
- });
1582
- }
1583
- );
1584
-
1585
- exchangeReq.on("error", (err: Error) => {
1586
- console.error(`Exchange request failed: ${err.message}`);
1587
- process.exit(1);
1588
- });
2129
+ if (opts.debug) {
2130
+ console.log(`Debug: Auth URL: ${authUrl}`);
2131
+ }
1589
2132
 
1590
- exchangeReq.write(exchangeData);
1591
- exchangeReq.end();
2133
+ console.log(`\nOpening browser for authentication...`);
2134
+ console.log(`If browser does not open automatically, visit:\n${authUrl}\n`);
1592
2135
 
1593
- } catch (err) {
1594
- // Remove the cancel handler in error case too
1595
- process.off("SIGINT", cancelHandler);
2136
+ // Open browser (cross-platform)
2137
+ const openCommand = process.platform === "darwin" ? "open" :
2138
+ process.platform === "win32" ? "start" :
2139
+ "xdg-open";
2140
+ spawn(openCommand, [authUrl], { detached: true, stdio: "ignore" }).unref();
1596
2141
 
1597
- const message = err instanceof Error ? err.message : String(err);
2142
+ // Step 4: Wait for callback
2143
+ console.log("Waiting for authorization...");
2144
+ console.log("(Press Ctrl+C to cancel)\n");
1598
2145
 
1599
- // Provide more helpful error messages
1600
- if (message.includes("timeout")) {
1601
- console.error(`\nAuthentication timed out.`);
1602
- console.error(`This usually means you closed the browser window without completing authentication.`);
1603
- console.error(`Please try again and complete the authentication flow.`);
1604
- } else {
1605
- console.error(`\nAuthentication failed: ${message}`);
1606
- }
2146
+ // Handle Ctrl+C gracefully
2147
+ const cancelHandler = () => {
2148
+ console.log("\n\nAuthentication cancelled by user.");
2149
+ callbackServer.server.stop();
2150
+ process.exit(130); // Standard exit code for SIGINT
2151
+ };
2152
+ process.on("SIGINT", cancelHandler);
1607
2153
 
1608
- process.exit(1);
1609
- }
2154
+ try {
2155
+ const { code } = await callbackServer.promise;
2156
+
2157
+ // Remove the cancel handler after successful auth
2158
+ process.off("SIGINT", cancelHandler);
2159
+
2160
+ // Step 5: Exchange code for token using fetch
2161
+ console.log("\nExchanging authorization code for API token...");
2162
+ const exchangeData = JSON.stringify({
2163
+ authorization_code: code,
2164
+ code_verifier: params.codeVerifier,
2165
+ state: params.state,
2166
+ });
2167
+ const exchangeUrl = new URL(`${apiBaseUrl}/rpc/oauth_token_exchange`);
2168
+
2169
+ let exchangeResponse: Response;
2170
+ try {
2171
+ exchangeResponse = await fetch(exchangeUrl.toString(), {
2172
+ method: "POST",
2173
+ headers: {
2174
+ "Content-Type": "application/json",
2175
+ },
2176
+ body: exchangeData,
1610
2177
  });
2178
+ } catch (err) {
2179
+ const message = err instanceof Error ? err.message : String(err);
2180
+ console.error(`Exchange request failed: ${message}`);
2181
+ process.exit(1);
2182
+ return;
1611
2183
  }
1612
- );
1613
2184
 
1614
- initReq.on("error", (err: Error) => {
1615
- console.error(`Failed to connect to API: ${err.message}`);
1616
- callbackServer.server.close();
1617
- process.exit(1);
1618
- });
2185
+ const exchangeBody = await exchangeResponse.text();
2186
+
2187
+ if (!exchangeResponse.ok) {
2188
+ console.error(`Failed to exchange code for token: ${exchangeResponse.status}`);
2189
+
2190
+ // Check if response is HTML (common for 404 pages)
2191
+ if (exchangeBody.trim().startsWith("<!") || exchangeBody.trim().startsWith("<html")) {
2192
+ console.error("Error: Received HTML response instead of JSON. This usually means:");
2193
+ console.error(" 1. The API endpoint URL is incorrect");
2194
+ console.error(" 2. The endpoint does not exist (404)");
2195
+ console.error(`\nAPI URL attempted: ${exchangeUrl.toString()}`);
2196
+ console.error("\nPlease verify the --api-base-url parameter.");
2197
+ } else {
2198
+ console.error(exchangeBody);
2199
+ }
2200
+
2201
+ process.exit(1);
2202
+ return;
2203
+ }
2204
+
2205
+ try {
2206
+ const result = JSON.parse(exchangeBody);
2207
+ 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.
2208
+ 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.
2209
+
2210
+ // Step 6: Save token to config
2211
+ // Check if org changed to decide whether to preserve defaultProject
2212
+ const existingConfig = config.readConfig();
2213
+ const existingOrgId = existingConfig.orgId;
2214
+ const existingProject = existingConfig.defaultProject;
2215
+ const orgChanged = existingOrgId && existingOrgId !== orgId;
2216
+
2217
+ config.writeConfig({
2218
+ apiKey: apiToken,
2219
+ baseUrl: apiBaseUrl,
2220
+ orgId: orgId,
2221
+ });
2222
+
2223
+ // Only clear defaultProject if org actually changed
2224
+ if (orgChanged && existingProject) {
2225
+ config.deleteConfigKeys(["defaultProject"]);
2226
+ console.log(`\nNote: Organization changed (${existingOrgId} → ${orgId}).`);
2227
+ console.log(` Default project "${existingProject}" has been cleared.`);
2228
+ }
1619
2229
 
1620
- initReq.write(initData);
1621
- initReq.end();
2230
+ console.log("\nAuthentication successful!");
2231
+ console.log(`API key saved to: ${config.getConfigPath()}`);
2232
+ console.log(`Organization ID: ${orgId}`);
2233
+ if (!orgChanged && existingProject) {
2234
+ console.log(`Default project: ${existingProject} (preserved)`);
2235
+ }
2236
+ console.log(`\nYou can now use the CLI without specifying an API key.`);
2237
+ process.exit(0);
2238
+ } catch (err) {
2239
+ const message = err instanceof Error ? err.message : String(err);
2240
+ console.error(`Failed to parse response: ${message}`);
2241
+ process.exit(1);
2242
+ }
2243
+
2244
+ } catch (err) {
2245
+ // Remove the cancel handler in error case too
2246
+ process.off("SIGINT", cancelHandler);
2247
+
2248
+ const message = err instanceof Error ? err.message : String(err);
2249
+
2250
+ // Provide more helpful error messages
2251
+ if (message.includes("timeout")) {
2252
+ console.error(`\nAuthentication timed out.`);
2253
+ console.error(`This usually means you closed the browser window without completing authentication.`);
2254
+ console.error(`Please try again and complete the authentication flow.`);
2255
+ } else {
2256
+ console.error(`\nAuthentication failed: ${message}`);
2257
+ }
2258
+
2259
+ process.exit(1);
2260
+ }
1622
2261
 
1623
2262
  } catch (err) {
1624
2263
  const message = err instanceof Error ? err.message : String(err);
@@ -1627,15 +2266,7 @@ program
1627
2266
  }
1628
2267
  });
1629
2268
 
1630
- program
1631
- .command("add-key <apiKey>")
1632
- .description("store API key")
1633
- .action(async (apiKey: string) => {
1634
- config.writeConfig({ apiKey });
1635
- console.log(`API key saved to ${config.getConfigPath()}`);
1636
- });
1637
-
1638
- program
2269
+ auth
1639
2270
  .command("show-key")
1640
2271
  .description("show API key (masked)")
1641
2272
  .action(async () => {
@@ -1645,7 +2276,6 @@ program
1645
2276
  console.log(`\nTo authenticate, run: pgai auth`);
1646
2277
  return;
1647
2278
  }
1648
- const { maskSecret } = require("../lib/util");
1649
2279
  console.log(`Current API key: ${maskSecret(cfg.apiKey)}`);
1650
2280
  if (cfg.orgId) {
1651
2281
  console.log(`Organization ID: ${cfg.orgId}`);
@@ -1653,14 +2283,20 @@ program
1653
2283
  console.log(`Config location: ${config.getConfigPath()}`);
1654
2284
  });
1655
2285
 
1656
- program
2286
+ auth
1657
2287
  .command("remove-key")
1658
2288
  .description("remove API key")
1659
2289
  .action(async () => {
1660
2290
  // Check both new config and legacy config
1661
2291
  const newConfigPath = config.getConfigPath();
1662
2292
  const hasNewConfig = fs.existsSync(newConfigPath);
1663
- const legacyPath = path.resolve(process.cwd(), ".pgwatch-config");
2293
+ let legacyPath: string;
2294
+ try {
2295
+ const { projectDir } = await resolveOrInitPaths();
2296
+ legacyPath = path.resolve(projectDir, ".pgwatch-config");
2297
+ } catch {
2298
+ legacyPath = path.resolve(process.cwd(), ".pgwatch-config");
2299
+ }
1664
2300
  const hasLegacyConfig = fs.existsSync(legacyPath) && fs.statSync(legacyPath).isFile();
1665
2301
 
1666
2302
  if (!hasNewConfig && !hasLegacyConfig) {
@@ -1696,7 +2332,8 @@ mon
1696
2332
  .command("generate-grafana-password")
1697
2333
  .description("generate Grafana password for monitoring services")
1698
2334
  .action(async () => {
1699
- const cfgPath = path.resolve(process.cwd(), ".pgwatch-config");
2335
+ const { projectDir } = await resolveOrInitPaths();
2336
+ const cfgPath = path.resolve(projectDir, ".pgwatch-config");
1700
2337
 
1701
2338
  try {
1702
2339
  // Generate secure password using openssl
@@ -1747,9 +2384,10 @@ mon
1747
2384
  .command("show-grafana-credentials")
1748
2385
  .description("show Grafana credentials for monitoring services")
1749
2386
  .action(async () => {
1750
- const cfgPath = path.resolve(process.cwd(), ".pgwatch-config");
2387
+ const { projectDir } = await resolveOrInitPaths();
2388
+ const cfgPath = path.resolve(projectDir, ".pgwatch-config");
1751
2389
  if (!fs.existsSync(cfgPath)) {
1752
- console.error("Configuration file not found. Run 'postgres-ai mon quickstart' first.");
2390
+ console.error("Configuration file not found. Run 'postgres-ai mon local-install' first.");
1753
2391
  process.exitCode = 1;
1754
2392
  return;
1755
2393
  }
@@ -1875,7 +2513,7 @@ issues
1875
2513
  });
1876
2514
 
1877
2515
  issues
1878
- .command("post_comment <issueId> <content>")
2516
+ .command("post-comment <issueId> <content>")
1879
2517
  .description("post a new comment to an issue")
1880
2518
  .option("--parent <uuid>", "parent comment id")
1881
2519
  .option("--debug", "enable debug output")
@@ -1920,6 +2558,194 @@ issues
1920
2558
  }
1921
2559
  });
1922
2560
 
2561
+ issues
2562
+ .command("create <title>")
2563
+ .description("create a new issue")
2564
+ .option("--org-id <id>", "organization id (defaults to config orgId)", (v) => parseInt(v, 10))
2565
+ .option("--project-id <id>", "project id", (v) => parseInt(v, 10))
2566
+ .option("--description <text>", "issue description (supports \\\\n)")
2567
+ .option(
2568
+ "--label <label>",
2569
+ "issue label (repeatable)",
2570
+ (value: string, previous: string[]) => {
2571
+ previous.push(value);
2572
+ return previous;
2573
+ },
2574
+ [] as string[]
2575
+ )
2576
+ .option("--debug", "enable debug output")
2577
+ .option("--json", "output raw JSON")
2578
+ .action(async (rawTitle: string, opts: { orgId?: number; projectId?: number; description?: string; label?: string[]; debug?: boolean; json?: boolean }) => {
2579
+ try {
2580
+ const rootOpts = program.opts<CliOptions>();
2581
+ const cfg = config.readConfig();
2582
+ const { apiKey } = getConfig(rootOpts);
2583
+ if (!apiKey) {
2584
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
2585
+ process.exitCode = 1;
2586
+ return;
2587
+ }
2588
+
2589
+ const title = interpretEscapes(String(rawTitle || "").trim());
2590
+ if (!title) {
2591
+ console.error("title is required");
2592
+ process.exitCode = 1;
2593
+ return;
2594
+ }
2595
+
2596
+ const orgId = typeof opts.orgId === "number" && !Number.isNaN(opts.orgId) ? opts.orgId : cfg.orgId;
2597
+ if (typeof orgId !== "number") {
2598
+ console.error("org_id is required. Either pass --org-id or run 'pgai auth' to store it in config.");
2599
+ process.exitCode = 1;
2600
+ return;
2601
+ }
2602
+
2603
+ const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
2604
+ const labels = Array.isArray(opts.label) && opts.label.length > 0 ? opts.label.map(String) : undefined;
2605
+ const projectId = typeof opts.projectId === "number" && !Number.isNaN(opts.projectId) ? opts.projectId : undefined;
2606
+
2607
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
2608
+ const result = await createIssue({
2609
+ apiKey,
2610
+ apiBaseUrl,
2611
+ title,
2612
+ orgId,
2613
+ description,
2614
+ projectId,
2615
+ labels,
2616
+ debug: !!opts.debug,
2617
+ });
2618
+ printResult(result, opts.json);
2619
+ } catch (err) {
2620
+ const message = err instanceof Error ? err.message : String(err);
2621
+ console.error(message);
2622
+ process.exitCode = 1;
2623
+ }
2624
+ });
2625
+
2626
+ issues
2627
+ .command("update <issueId>")
2628
+ .description("update an existing issue (title/description/status/labels)")
2629
+ .option("--title <text>", "new title (supports \\\\n)")
2630
+ .option("--description <text>", "new description (supports \\\\n)")
2631
+ .option("--status <value>", "status: open|closed|0|1")
2632
+ .option(
2633
+ "--label <label>",
2634
+ "set labels (repeatable). If provided, replaces existing labels.",
2635
+ (value: string, previous: string[]) => {
2636
+ previous.push(value);
2637
+ return previous;
2638
+ },
2639
+ [] as string[]
2640
+ )
2641
+ .option("--clear-labels", "set labels to an empty list")
2642
+ .option("--debug", "enable debug output")
2643
+ .option("--json", "output raw JSON")
2644
+ .action(async (issueId: string, opts: { title?: string; description?: string; status?: string; label?: string[]; clearLabels?: boolean; debug?: boolean; json?: boolean }) => {
2645
+ try {
2646
+ const rootOpts = program.opts<CliOptions>();
2647
+ const cfg = config.readConfig();
2648
+ const { apiKey } = getConfig(rootOpts);
2649
+ if (!apiKey) {
2650
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
2651
+ process.exitCode = 1;
2652
+ return;
2653
+ }
2654
+
2655
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
2656
+
2657
+ const title = opts.title !== undefined ? interpretEscapes(String(opts.title)) : undefined;
2658
+ const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
2659
+
2660
+ let status: number | undefined = undefined;
2661
+ if (opts.status !== undefined) {
2662
+ const raw = String(opts.status).trim().toLowerCase();
2663
+ if (raw === "open") status = 0;
2664
+ else if (raw === "closed") status = 1;
2665
+ else {
2666
+ const n = Number(raw);
2667
+ if (!Number.isFinite(n)) {
2668
+ console.error("status must be open|closed|0|1");
2669
+ process.exitCode = 1;
2670
+ return;
2671
+ }
2672
+ status = n;
2673
+ }
2674
+ if (status !== 0 && status !== 1) {
2675
+ console.error("status must be 0 (open) or 1 (closed)");
2676
+ process.exitCode = 1;
2677
+ return;
2678
+ }
2679
+ }
2680
+
2681
+ let labels: string[] | undefined = undefined;
2682
+ if (opts.clearLabels) {
2683
+ labels = [];
2684
+ } else if (Array.isArray(opts.label) && opts.label.length > 0) {
2685
+ labels = opts.label.map(String);
2686
+ }
2687
+
2688
+ const result = await updateIssue({
2689
+ apiKey,
2690
+ apiBaseUrl,
2691
+ issueId,
2692
+ title,
2693
+ description,
2694
+ status,
2695
+ labels,
2696
+ debug: !!opts.debug,
2697
+ });
2698
+ printResult(result, opts.json);
2699
+ } catch (err) {
2700
+ const message = err instanceof Error ? err.message : String(err);
2701
+ console.error(message);
2702
+ process.exitCode = 1;
2703
+ }
2704
+ });
2705
+
2706
+ issues
2707
+ .command("update-comment <commentId> <content>")
2708
+ .description("update an existing issue comment")
2709
+ .option("--debug", "enable debug output")
2710
+ .option("--json", "output raw JSON")
2711
+ .action(async (commentId: string, content: string, opts: { debug?: boolean; json?: boolean }) => {
2712
+ try {
2713
+ if (opts.debug) {
2714
+ // eslint-disable-next-line no-console
2715
+ console.log(`Debug: Original content: ${JSON.stringify(content)}`);
2716
+ }
2717
+ content = interpretEscapes(content);
2718
+ if (opts.debug) {
2719
+ // eslint-disable-next-line no-console
2720
+ console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
2721
+ }
2722
+
2723
+ const rootOpts = program.opts<CliOptions>();
2724
+ const cfg = config.readConfig();
2725
+ const { apiKey } = getConfig(rootOpts);
2726
+ if (!apiKey) {
2727
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
2728
+ process.exitCode = 1;
2729
+ return;
2730
+ }
2731
+
2732
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
2733
+
2734
+ const result = await updateIssueComment({
2735
+ apiKey,
2736
+ apiBaseUrl,
2737
+ commentId,
2738
+ content,
2739
+ debug: !!opts.debug,
2740
+ });
2741
+ printResult(result, opts.json);
2742
+ } catch (err) {
2743
+ const message = err instanceof Error ? err.message : String(err);
2744
+ console.error(message);
2745
+ process.exitCode = 1;
2746
+ }
2747
+ });
2748
+
1923
2749
  // MCP server
1924
2750
  const mcp = program.command("mcp").description("MCP server integration");
1925
2751
 
@@ -1947,15 +2773,7 @@ mcp
1947
2773
  console.log(" 4. Codex");
1948
2774
  console.log("");
1949
2775
 
1950
- const rl = readline.createInterface({
1951
- input: process.stdin,
1952
- output: process.stdout
1953
- });
1954
-
1955
- const answer = await new Promise<string>((resolve) => {
1956
- rl.question("Select your AI coding tool (1-4): ", resolve);
1957
- });
1958
- rl.close();
2776
+ const answer = await question("Select your AI coding tool (1-4): ");
1959
2777
 
1960
2778
  const choices: Record<string, string> = {
1961
2779
  "1": "cursor",
@@ -2090,5 +2908,7 @@ mcp
2090
2908
  }
2091
2909
  });
2092
2910
 
2093
- program.parseAsync(process.argv);
2911
+ program.parseAsync(process.argv).finally(() => {
2912
+ closeReadline();
2913
+ });
2094
2914