postgresai 0.14.0-beta.1 → 0.14.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +104 -61
  2. package/bin/postgres-ai.ts +1304 -417
  3. package/bun.lock +258 -0
  4. package/bunfig.toml +20 -0
  5. package/dist/bin/postgres-ai.js +28559 -1778
  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 +415 -220
  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/01.role.sql +8 -7
  32. package/sql/02.permissions.sql +9 -5
  33. package/sql/05.helpers.sql +439 -0
  34. package/test/auth.test.ts +258 -0
  35. package/test/checkup.integration.test.ts +321 -0
  36. package/test/checkup.test.ts +891 -0
  37. package/test/init.integration.test.ts +499 -0
  38. package/test/init.test.ts +417 -0
  39. package/test/issues.cli.test.ts +314 -0
  40. package/test/issues.test.ts +456 -0
  41. package/test/mcp-server.test.ts +988 -0
  42. package/test/schema-validation.test.ts +81 -0
  43. package/test/test-utils.ts +122 -0
  44. package/tsconfig.json +12 -20
  45. package/dist/bin/postgres-ai.d.ts +0 -3
  46. package/dist/bin/postgres-ai.d.ts.map +0 -1
  47. package/dist/bin/postgres-ai.js.map +0 -1
  48. package/dist/lib/auth-server.d.ts +0 -31
  49. package/dist/lib/auth-server.d.ts.map +0 -1
  50. package/dist/lib/auth-server.js +0 -263
  51. package/dist/lib/auth-server.js.map +0 -1
  52. package/dist/lib/config.d.ts +0 -45
  53. package/dist/lib/config.d.ts.map +0 -1
  54. package/dist/lib/config.js +0 -181
  55. package/dist/lib/config.js.map +0 -1
  56. package/dist/lib/init.d.ts +0 -75
  57. package/dist/lib/init.d.ts.map +0 -1
  58. package/dist/lib/init.js +0 -483
  59. package/dist/lib/init.js.map +0 -1
  60. package/dist/lib/issues.d.ts +0 -75
  61. package/dist/lib/issues.d.ts.map +0 -1
  62. package/dist/lib/issues.js +0 -336
  63. package/dist/lib/issues.js.map +0 -1
  64. package/dist/lib/mcp-server.d.ts +0 -9
  65. package/dist/lib/mcp-server.d.ts.map +0 -1
  66. package/dist/lib/mcp-server.js +0 -168
  67. package/dist/lib/mcp-server.js.map +0 -1
  68. package/dist/lib/pkce.d.ts +0 -32
  69. package/dist/lib/pkce.d.ts.map +0 -1
  70. package/dist/lib/pkce.js +0 -101
  71. package/dist/lib/pkce.js.map +0 -1
  72. package/dist/lib/util.d.ts +0 -27
  73. package/dist/lib/util.d.ts.map +0 -1
  74. package/dist/lib/util.js +0 -46
  75. package/dist/lib/util.js.map +0 -1
  76. package/dist/package.json +0 -46
  77. package/test/init.integration.test.cjs +0 -368
  78. package/test/init.test.cjs +0 -154
@@ -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, resolveAdminConnection, resolveMonitoringPassword, verifyInitSetup } from "../lib/init";
15
+ import { applyInitPlan, buildInitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, verifyInitSetup } from "../lib/init";
16
+ import * as pkce from "../lib/pkce";
17
+ import * as authServer from "../lib/auth-server";
18
+ import { maskSecret } from "../lib/util";
19
+ import { createInterface } from "readline";
20
+ import * as childProcess from "child_process";
21
+ import { REPORT_GENERATORS, CHECK_INFO, generateAllReports } from "../lib/checkup";
22
+ import { createCheckupReport, uploadCheckupReportJson, RpcError, formatRpcErrorForDisplay, withRetry } from "../lib/checkup-api";
23
+
24
+ // Singleton readline interface for stdin prompts
25
+ let rl: ReturnType<typeof createInterface> | null = null;
26
+ function getReadline() {
27
+ if (!rl) {
28
+ rl = createInterface({ input: process.stdin, output: process.stdout });
29
+ }
30
+ return rl;
31
+ }
32
+ function closeReadline() {
33
+ if (rl) {
34
+ rl.close();
35
+ rl = null;
36
+ }
37
+ }
38
+
39
+ // Helper functions for spawning processes - use Node.js child_process for compatibility
40
+ async function execPromise(command: string): Promise<{ stdout: string; stderr: string }> {
41
+ return new Promise((resolve, reject) => {
42
+ childProcess.exec(command, (error, stdout, stderr) => {
43
+ if (error) {
44
+ const err = error as Error & { code: number };
45
+ err.code = typeof error.code === "number" ? error.code : 1;
46
+ reject(err);
47
+ } else {
48
+ resolve({ stdout, stderr });
49
+ }
50
+ });
51
+ });
52
+ }
53
+
54
+ async function execFilePromise(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
55
+ return new Promise((resolve, reject) => {
56
+ childProcess.execFile(file, args, (error, stdout, stderr) => {
57
+ if (error) {
58
+ const err = error as Error & { code: number };
59
+ err.code = typeof error.code === "number" ? error.code : 1;
60
+ reject(err);
61
+ } else {
62
+ resolve({ stdout, stderr });
63
+ }
64
+ });
65
+ });
66
+ }
67
+
68
+ function spawnSync(cmd: string, args: string[], options?: { stdio?: "pipe" | "ignore" | "inherit"; encoding?: string; env?: Record<string, string | undefined>; cwd?: string }): { status: number | null; stdout: string; stderr: string } {
69
+ const result = childProcess.spawnSync(cmd, args, {
70
+ stdio: options?.stdio === "inherit" ? "inherit" : "pipe",
71
+ env: options?.env as NodeJS.ProcessEnv,
72
+ cwd: options?.cwd,
73
+ encoding: "utf8",
74
+ });
75
+ return {
76
+ status: result.status,
77
+ stdout: typeof result.stdout === "string" ? result.stdout : "",
78
+ stderr: typeof result.stderr === "string" ? result.stderr : "",
79
+ };
80
+ }
81
+
82
+ function spawn(cmd: string, args: string[], options?: { stdio?: "pipe" | "ignore" | "inherit"; env?: Record<string, string | undefined>; cwd?: string; detached?: boolean }): { on: (event: string, cb: (code: number | null, signal?: string) => void) => void; unref: () => void; pid?: number } {
83
+ const proc = childProcess.spawn(cmd, args, {
84
+ stdio: options?.stdio ?? "pipe",
85
+ env: options?.env as NodeJS.ProcessEnv,
86
+ cwd: options?.cwd,
87
+ detached: options?.detached,
88
+ });
89
+
90
+ return {
91
+ on(event: string, cb: (code: number | null, signal?: string) => void) {
92
+ if (event === "close" || event === "exit") {
93
+ proc.on(event, (code, signal) => cb(code, signal ?? undefined));
94
+ } else if (event === "error") {
95
+ proc.on("error", (err) => cb(null, String(err)));
96
+ }
97
+ return this;
98
+ },
99
+ unref() {
100
+ proc.unref();
101
+ },
102
+ pid: proc.pid,
103
+ };
104
+ }
105
+
106
+ // Simple readline-like interface for prompts using Bun
107
+ async function question(prompt: string): Promise<string> {
108
+ return new Promise((resolve) => {
109
+ getReadline().question(prompt, (answer) => {
110
+ resolve(answer);
111
+ });
112
+ });
113
+ }
20
114
 
21
- const execPromise = promisify(exec);
22
- const execFilePromise = promisify(execFile);
115
+ function expandHomePath(p: string): string {
116
+ const s = (p || "").trim();
117
+ if (!s) return s;
118
+ if (s === "~") return os.homedir();
119
+ if (s.startsWith("~/") || s.startsWith("~\\")) {
120
+ return path.join(os.homedir(), s.slice(2));
121
+ }
122
+ return s;
123
+ }
124
+
125
+ function createTtySpinner(
126
+ enabled: boolean,
127
+ initialText: string
128
+ ): { update: (text: string) => void; stop: (finalText?: string) => void } {
129
+ if (!enabled) {
130
+ return {
131
+ update: () => {},
132
+ stop: () => {},
133
+ };
134
+ }
135
+
136
+ const frames = ["|", "/", "-", "\\"];
137
+ const startTs = Date.now();
138
+ let text = initialText;
139
+ let frameIdx = 0;
140
+ let stopped = false;
141
+
142
+ const render = (): void => {
143
+ if (stopped) return;
144
+ const elapsedSec = ((Date.now() - startTs) / 1000).toFixed(1);
145
+ const frame = frames[frameIdx % frames.length] ?? frames[0] ?? "⠿";
146
+ frameIdx += 1;
147
+ process.stdout.write(`\r\x1b[2K${frame} ${text} (${elapsedSec}s)`);
148
+ };
149
+
150
+ const timer = setInterval(render, 120);
151
+ render(); // immediate feedback
152
+
153
+ return {
154
+ update: (t: string) => {
155
+ text = t;
156
+ render();
157
+ },
158
+ stop: (finalText?: string) => {
159
+ if (stopped) return;
160
+ // Set flag first so any queued render() calls exit early.
161
+ // JavaScript is single-threaded, so this is safe: queued callbacks
162
+ // run after stop() returns and will see stopped=true immediately.
163
+ stopped = true;
164
+ clearInterval(timer);
165
+ process.stdout.write("\r\x1b[2K");
166
+ if (finalText && finalText.trim()) {
167
+ process.stdout.write(finalText);
168
+ }
169
+ process.stdout.write("\n");
170
+ },
171
+ };
172
+ }
173
+
174
+ // ============================================================================
175
+ // Checkup command helpers
176
+ // ============================================================================
177
+
178
+ interface CheckupOptions {
179
+ checkId: string;
180
+ nodeName: string;
181
+ output?: string;
182
+ upload?: boolean;
183
+ project?: string;
184
+ json?: boolean;
185
+ }
186
+
187
+ interface UploadConfig {
188
+ apiKey: string;
189
+ apiBaseUrl: string;
190
+ project: string;
191
+ }
192
+
193
+ interface UploadSummary {
194
+ project: string;
195
+ reportId: number;
196
+ uploaded: Array<{ checkId: string; filename: string; chunkId: number }>;
197
+ }
198
+
199
+ /**
200
+ * Prepare and validate output directory for checkup reports.
201
+ * @returns Output path if valid, null if should exit with error
202
+ */
203
+ function prepareOutputDirectory(outputOpt: string | undefined): string | null | undefined {
204
+ if (!outputOpt) return undefined;
205
+
206
+ const outputDir = expandHomePath(outputOpt);
207
+ const outputPath = path.isAbsolute(outputDir) ? outputDir : path.resolve(process.cwd(), outputDir);
208
+
209
+ if (!fs.existsSync(outputPath)) {
210
+ try {
211
+ fs.mkdirSync(outputPath, { recursive: true });
212
+ } catch (e) {
213
+ const errAny = e as any;
214
+ const code = typeof errAny?.code === "string" ? errAny.code : "";
215
+ const msg = errAny instanceof Error ? errAny.message : String(errAny);
216
+ if (code === "EACCES" || code === "EPERM" || code === "ENOENT") {
217
+ console.error(`Error: Failed to create output directory: ${outputPath}`);
218
+ console.error(`Reason: ${msg}`);
219
+ console.error("Tip: choose a writable path, e.g. --output ./reports or --output ~/reports");
220
+ return null; // Signal to exit
221
+ }
222
+ throw e;
223
+ }
224
+ }
225
+ return outputPath;
226
+ }
227
+
228
+ /**
229
+ * Prepare upload configuration for checkup reports.
230
+ * @returns Upload config if valid, null if should exit, undefined if upload not needed
231
+ */
232
+ function prepareUploadConfig(
233
+ opts: CheckupOptions,
234
+ rootOpts: CliOptions,
235
+ shouldUpload: boolean,
236
+ uploadExplicitlyRequested: boolean
237
+ ): { config: UploadConfig; projectWasGenerated: boolean } | null | undefined {
238
+ if (!shouldUpload) return undefined;
239
+
240
+ const { apiKey } = getConfig(rootOpts);
241
+ if (!apiKey) {
242
+ if (uploadExplicitlyRequested) {
243
+ console.error("Error: API key is required for upload");
244
+ console.error("Tip: run 'postgresai auth' or pass --api-key / set PGAI_API_KEY");
245
+ return null; // Signal to exit
246
+ }
247
+ return undefined; // Skip upload silently
248
+ }
249
+
250
+ const cfg = config.readConfig();
251
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
252
+ let project = ((opts.project || cfg.defaultProject) || "").trim();
253
+ let projectWasGenerated = false;
254
+
255
+ if (!project) {
256
+ project = `project_${crypto.randomBytes(6).toString("hex")}`;
257
+ projectWasGenerated = true;
258
+ try {
259
+ config.writeConfig({ defaultProject: project });
260
+ } catch (e) {
261
+ const message = e instanceof Error ? e.message : String(e);
262
+ console.error(`Warning: Failed to save generated default project: ${message}`);
263
+ }
264
+ }
265
+
266
+ return {
267
+ config: { apiKey, apiBaseUrl, project },
268
+ projectWasGenerated,
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Upload checkup reports to PostgresAI API.
274
+ */
275
+ async function uploadCheckupReports(
276
+ uploadCfg: UploadConfig,
277
+ reports: Record<string, any>,
278
+ spinner: ReturnType<typeof createTtySpinner>,
279
+ logUpload: (msg: string) => void
280
+ ): Promise<UploadSummary> {
281
+ spinner.update("Creating remote checkup report");
282
+ const created = await withRetry(
283
+ () => createCheckupReport({
284
+ apiKey: uploadCfg.apiKey,
285
+ apiBaseUrl: uploadCfg.apiBaseUrl,
286
+ project: uploadCfg.project,
287
+ }),
288
+ { maxAttempts: 3 },
289
+ (attempt, err, delayMs) => {
290
+ const errMsg = err instanceof Error ? err.message : String(err);
291
+ logUpload(`[Retry ${attempt}/3] createCheckupReport failed: ${errMsg}, retrying in ${delayMs}ms...`);
292
+ }
293
+ );
294
+
295
+ const reportId = created.reportId;
296
+ logUpload(`Created remote checkup report: ${reportId}`);
297
+
298
+ const uploaded: Array<{ checkId: string; filename: string; chunkId: number }> = [];
299
+ for (const [checkId, report] of Object.entries(reports)) {
300
+ spinner.update(`Uploading ${checkId}.json`);
301
+ const jsonText = JSON.stringify(report, null, 2);
302
+ const r = await withRetry(
303
+ () => uploadCheckupReportJson({
304
+ apiKey: uploadCfg.apiKey,
305
+ apiBaseUrl: uploadCfg.apiBaseUrl,
306
+ reportId,
307
+ filename: `${checkId}.json`,
308
+ checkId,
309
+ jsonText,
310
+ }),
311
+ { maxAttempts: 3 },
312
+ (attempt, err, delayMs) => {
313
+ const errMsg = err instanceof Error ? err.message : String(err);
314
+ logUpload(`[Retry ${attempt}/3] Upload ${checkId}.json failed: ${errMsg}, retrying in ${delayMs}ms...`);
315
+ }
316
+ );
317
+ uploaded.push({ checkId, filename: `${checkId}.json`, chunkId: r.reportChunkId });
318
+ }
319
+ logUpload("Upload completed");
320
+
321
+ return { project: uploadCfg.project, reportId, uploaded };
322
+ }
323
+
324
+ /**
325
+ * Write checkup reports to files.
326
+ */
327
+ function writeReportFiles(reports: Record<string, any>, outputPath: string): void {
328
+ for (const [checkId, report] of Object.entries(reports)) {
329
+ const filePath = path.join(outputPath, `${checkId}.json`);
330
+ fs.writeFileSync(filePath, JSON.stringify(report, null, 2), "utf8");
331
+ console.log(`✓ ${checkId}: ${filePath}`);
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Print upload summary to console.
337
+ */
338
+ function printUploadSummary(
339
+ summary: UploadSummary,
340
+ projectWasGenerated: boolean,
341
+ useStderr: boolean
342
+ ): void {
343
+ const out = useStderr ? console.error : console.log;
344
+ out("\nCheckup report uploaded");
345
+ out("======================\n");
346
+ if (projectWasGenerated) {
347
+ out(`Project: ${summary.project} (generated and saved as default)`);
348
+ } else {
349
+ out(`Project: ${summary.project}`);
350
+ }
351
+ out(`Report ID: ${summary.reportId}`);
352
+ out("View in Console: console.postgres.ai → Support → checkup reports");
353
+ out("");
354
+ out("Files:");
355
+ for (const item of summary.uploaded) {
356
+ out(`- ${item.checkId}: ${item.filename}`);
357
+ }
358
+ }
359
+
360
+ // ============================================================================
361
+ // CLI configuration
362
+ // ============================================================================
23
363
 
24
364
  /**
25
365
  * CLI configuration options
@@ -61,6 +401,86 @@ interface PathResolution {
61
401
  instancesFile: string;
62
402
  }
63
403
 
404
+ function getDefaultMonitoringProjectDir(): string {
405
+ const override = process.env.PGAI_PROJECT_DIR;
406
+ if (override && override.trim()) return override.trim();
407
+ // Keep monitoring project next to user-level config (~/.config/postgresai)
408
+ return path.join(config.getConfigDir(), "monitoring");
409
+ }
410
+
411
+ async function downloadText(url: string): Promise<string> {
412
+ const controller = new AbortController();
413
+ const timeout = setTimeout(() => controller.abort(), 15_000);
414
+ try {
415
+ const response = await fetch(url, { signal: controller.signal });
416
+ if (!response.ok) {
417
+ throw new Error(`HTTP ${response.status} for ${url}`);
418
+ }
419
+ return await response.text();
420
+ } finally {
421
+ clearTimeout(timeout);
422
+ }
423
+ }
424
+
425
+ async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
426
+ const projectDir = getDefaultMonitoringProjectDir();
427
+ const composeFile = path.resolve(projectDir, "docker-compose.yml");
428
+ const instancesFile = path.resolve(projectDir, "instances.yml");
429
+
430
+ if (!fs.existsSync(projectDir)) {
431
+ fs.mkdirSync(projectDir, { recursive: true, mode: 0o700 });
432
+ }
433
+
434
+ if (!fs.existsSync(composeFile)) {
435
+ const refs = [
436
+ process.env.PGAI_PROJECT_REF,
437
+ pkg.version,
438
+ `v${pkg.version}`,
439
+ "main",
440
+ ].filter((v): v is string => Boolean(v && v.trim()));
441
+
442
+ let lastErr: unknown;
443
+ for (const ref of refs) {
444
+ const url = `https://gitlab.com/postgres-ai/postgres_ai/-/raw/${encodeURIComponent(ref)}/docker-compose.yml`;
445
+ try {
446
+ const text = await downloadText(url);
447
+ fs.writeFileSync(composeFile, text, { encoding: "utf8", mode: 0o600 });
448
+ break;
449
+ } catch (err) {
450
+ lastErr = err;
451
+ }
452
+ }
453
+
454
+ if (!fs.existsSync(composeFile)) {
455
+ const msg = lastErr instanceof Error ? lastErr.message : String(lastErr);
456
+ throw new Error(`Failed to bootstrap docker-compose.yml: ${msg}`);
457
+ }
458
+ }
459
+
460
+ // Ensure instances.yml exists as a FILE (avoid Docker creating a directory)
461
+ if (!fs.existsSync(instancesFile)) {
462
+ const header =
463
+ "# PostgreSQL instances to monitor\n" +
464
+ "# Add your instances using: pgai mon targets add <connection-string> <name>\n\n";
465
+ fs.writeFileSync(instancesFile, header, { encoding: "utf8", mode: 0o600 });
466
+ }
467
+
468
+ // Ensure .pgwatch-config exists as a FILE for reporter (may remain empty)
469
+ const pgwatchConfig = path.resolve(projectDir, ".pgwatch-config");
470
+ if (!fs.existsSync(pgwatchConfig)) {
471
+ fs.writeFileSync(pgwatchConfig, "", { encoding: "utf8", mode: 0o600 });
472
+ }
473
+
474
+ // Ensure .env exists and has PGAI_TAG (compose requires it)
475
+ const envFile = path.resolve(projectDir, ".env");
476
+ if (!fs.existsSync(envFile)) {
477
+ const envText = `PGAI_TAG=${pkg.version}\n# PGAI_REGISTRY=registry.gitlab.com/postgres-ai/postgres_ai\n`;
478
+ fs.writeFileSync(envFile, envText, { encoding: "utf8", mode: 0o600 });
479
+ }
480
+
481
+ return { fs, path, projectDir, composeFile, instancesFile };
482
+ }
483
+
64
484
  /**
65
485
  * Get configuration from various sources
66
486
  * @param opts - Command line options
@@ -119,30 +539,43 @@ program
119
539
  );
120
540
 
121
541
  program
122
- .command("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)")
127
561
  .option("-U, --username <username>", "PostgreSQL user (psql-like)")
128
562
  .option("-d, --dbname <dbname>", "PostgreSQL database name (psql-like)")
129
563
  .option("--admin-password <password>", "Admin connection password (otherwise uses PGPASSWORD if set)")
130
- .option("--monitoring-user <name>", "Monitoring role name to create/update", "postgres_ai_mon")
564
+ .option("--monitoring-user <name>", "Monitoring role name to create/update", DEFAULT_MONITORING_USER)
131
565
  .option("--password <password>", "Monitoring role password (overrides PGAI_MON_PASSWORD)")
132
566
  .option("--skip-optional-permissions", "Skip optional permissions (RDS/self-managed extras)", false)
133
567
  .option("--verify", "Verify that monitoring role/permissions are in place (no changes)", false)
134
568
  .option("--reset-password", "Reset monitoring role password only (no other changes)", false)
135
569
  .option("--print-sql", "Print SQL plan and exit (no changes applied)", false)
136
- .option("--show-secrets", "When printing SQL, do not redact secrets (DANGEROUS)", false)
137
570
  .option("--print-password", "Print generated monitoring password (DANGEROUS in CI logs)", false)
138
571
  .addHelpText(
139
572
  "after",
140
573
  [
141
574
  "",
142
575
  "Examples:",
143
- " postgresai init postgresql://admin@host:5432/dbname",
144
- " postgresai init \"dbname=dbname host=host user=admin\"",
145
- " 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",
146
579
  "",
147
580
  "Admin password:",
148
581
  " --admin-password <password> or PGPASSWORD=... (libpq standard)",
@@ -152,17 +585,28 @@ program
152
585
  " If auto-generated, it is printed only on TTY by default.",
153
586
  " To print it in non-interactive mode: --print-password",
154
587
  "",
588
+ "SSL connection (sslmode=prefer behavior):",
589
+ " Tries SSL first, falls back to non-SSL if server doesn't support it.",
590
+ " To force SSL: PGSSLMODE=require or ?sslmode=require in URL",
591
+ " To disable SSL: PGSSLMODE=disable or ?sslmode=disable in URL",
592
+ "",
593
+ "Environment variables (libpq standard):",
594
+ " PGHOST, PGPORT, PGUSER, PGDATABASE — connection defaults",
595
+ " PGPASSWORD — admin password",
596
+ " PGSSLMODE — SSL mode (disable, require, verify-full)",
597
+ " PGAI_MON_PASSWORD — monitoring password",
598
+ "",
155
599
  "Inspect SQL without applying changes:",
156
- " postgresai init <conn> --print-sql",
600
+ " postgresai prepare-db <conn> --print-sql",
157
601
  "",
158
602
  "Verify setup (no changes):",
159
- " postgresai init <conn> --verify",
603
+ " postgresai prepare-db <conn> --verify",
160
604
  "",
161
605
  "Reset monitoring password only:",
162
- " postgresai init <conn> --reset-password --password '...'",
606
+ " postgresai prepare-db <conn> --reset-password --password '...'",
163
607
  "",
164
608
  "Offline SQL plan (no DB connection):",
165
- " postgresai init --print-sql -d dbname --password '...' --show-secrets",
609
+ " postgresai prepare-db --print-sql",
166
610
  ].join("\n")
167
611
  )
168
612
  .action(async (conn: string | undefined, opts: {
@@ -178,7 +622,6 @@ program
178
622
  verify?: boolean;
179
623
  resetPassword?: boolean;
180
624
  printSql?: boolean;
181
- showSecrets?: boolean;
182
625
  printPassword?: boolean;
183
626
  }, cmd: Command) => {
184
627
  if (opts.verify && opts.resetPassword) {
@@ -193,30 +636,25 @@ program
193
636
  }
194
637
 
195
638
  const shouldPrintSql = !!opts.printSql;
196
- const shouldRedactSecrets = !opts.showSecrets;
197
- const redactPasswords = (sql: string): string => {
198
- if (!shouldRedactSecrets) return sql;
199
- // Replace PASSWORD '<literal>' (handles doubled quotes inside).
200
- return sql.replace(/password\s+'(?:''|[^'])*'/gi, "password '<redacted>'");
201
- };
639
+ const redactPasswords = (sql: string): string => redactPasswordsInSql(sql);
202
640
 
203
641
  // Offline mode: allow printing SQL without providing/using an admin connection.
204
- // Useful for audits/reviews; caller can provide -d/PGDATABASE and an explicit monitoring password.
642
+ // Useful for audits/reviews; caller can provide -d/PGDATABASE.
205
643
  if (!conn && !opts.dbUrl && !opts.host && !opts.port && !opts.username && !opts.adminPassword) {
206
644
  if (shouldPrintSql) {
207
645
  const database = (opts.dbname ?? process.env.PGDATABASE ?? "postgres").trim();
208
646
  const includeOptionalPermissions = !opts.skipOptionalPermissions;
209
647
 
210
- // Use explicit password/env if provided; otherwise use a placeholder (will be redacted unless --show-secrets).
648
+ // Use explicit password/env if provided; otherwise use a placeholder.
649
+ // Printed SQL always redacts secrets.
211
650
  const monPassword =
212
- (opts.password ?? process.env.PGAI_MON_PASSWORD ?? "CHANGE_ME").toString();
651
+ (opts.password ?? process.env.PGAI_MON_PASSWORD ?? "<redacted>").toString();
213
652
 
214
653
  const plan = await buildInitPlan({
215
654
  database,
216
655
  monitoringUser: opts.monitoringUser,
217
656
  monitoringPassword: monPassword,
218
657
  includeOptionalPermissions,
219
- roleExists: undefined,
220
658
  });
221
659
 
222
660
  console.log("\n--- SQL plan (offline; not connected) ---");
@@ -228,9 +666,7 @@ program
228
666
  console.log(redactPasswords(step.sql));
229
667
  }
230
668
  console.log("\n--- end SQL plan ---\n");
231
- if (shouldRedactSecrets) {
232
- console.log("Note: passwords are redacted in the printed SQL (use --show-secrets to print them).");
233
- }
669
+ console.log("Note: passwords are redacted in the printed SQL output.");
234
670
  return;
235
671
  }
236
672
  }
@@ -250,7 +686,7 @@ program
250
686
  });
251
687
  } catch (e) {
252
688
  const msg = e instanceof Error ? e.message : String(e);
253
- console.error(`✗ ${msg}`);
689
+ console.error(`Error: prepare-db: ${msg}`);
254
690
  // When connection details are missing, show full init help (options + examples).
255
691
  if (typeof msg === "string" && msg.startsWith("Connection is required.")) {
256
692
  console.error("");
@@ -267,15 +703,10 @@ program
267
703
  console.log(`Optional permissions: ${includeOptionalPermissions ? "enabled" : "skipped"}`);
268
704
 
269
705
  // Use native pg client instead of requiring psql to be installed
270
- const client = new Client(adminConn.clientConfig);
271
-
706
+ let client: Client | undefined;
272
707
  try {
273
- await client.connect();
274
-
275
- const roleRes = await client.query("select 1 from pg_catalog.pg_roles where rolname = $1", [
276
- opts.monitoringUser,
277
- ]);
278
- const roleExists = (roleRes.rowCount ?? 0) > 0;
708
+ const connResult = await connectWithSslFallback(Client, adminConn);
709
+ client = connResult.client;
279
710
 
280
711
  const dbRes = await client.query("select current_database() as db");
281
712
  const database = dbRes.rows?.[0]?.db;
@@ -291,14 +722,14 @@ program
291
722
  includeOptionalPermissions,
292
723
  });
293
724
  if (v.ok) {
294
- console.log("✓ init verify: OK");
725
+ console.log("✓ prepare-db verify: OK");
295
726
  if (v.missingOptional.length > 0) {
296
727
  console.log("⚠ Optional items missing:");
297
728
  for (const m of v.missingOptional) console.log(`- ${m}`);
298
729
  }
299
730
  return;
300
731
  }
301
- console.error("✗ init verify failed: missing required items");
732
+ console.error("✗ prepare-db verify failed: missing required items");
302
733
  for (const m of v.missingRequired) console.error(`- ${m}`);
303
734
  if (v.missingOptional.length > 0) {
304
735
  console.error("Optional items missing:");
@@ -319,10 +750,13 @@ program
319
750
  if (resolved.generated) {
320
751
  const canPrint = process.stdout.isTTY || !!opts.printPassword;
321
752
  if (canPrint) {
322
- console.log("");
323
- console.log(`Generated monitoring password for ${opts.monitoringUser} (copy/paste):`);
324
- console.log(`PGAI_MON_PASSWORD=${monPassword}`);
325
- console.log("");
753
+ // Print secrets to stderr to reduce the chance they end up in piped stdout logs.
754
+ const shellSafe = monPassword.replace(/'/g, "'\\''");
755
+ console.error("");
756
+ console.error(`Generated monitoring password for ${opts.monitoringUser} (copy/paste):`);
757
+ // Quote for shell copy/paste safety.
758
+ console.error(`PGAI_MON_PASSWORD='${shellSafe}'`);
759
+ console.error("");
326
760
  console.log("Store it securely (or rerun with --password / PGAI_MON_PASSWORD to set your own).");
327
761
  } else {
328
762
  console.error(
@@ -352,7 +786,6 @@ program
352
786
  monitoringUser: opts.monitoringUser,
353
787
  monitoringPassword: monPassword,
354
788
  includeOptionalPermissions,
355
- roleExists,
356
789
  });
357
790
 
358
791
  const effectivePlan = opts.resetPassword
@@ -366,15 +799,13 @@ program
366
799
  console.log(redactPasswords(step.sql));
367
800
  }
368
801
  console.log("\n--- end SQL plan ---\n");
369
- if (shouldRedactSecrets) {
370
- console.log("Note: passwords are redacted in the printed SQL (use --show-secrets to print them).");
371
- }
802
+ console.log("Note: passwords are redacted in the printed SQL output.");
372
803
  return;
373
804
  }
374
805
 
375
806
  const { applied, skippedOptional } = await applyInitPlan({ client, plan: effectivePlan });
376
807
 
377
- console.log(opts.resetPassword ? "✓ init password reset completed" : "✓ init completed");
808
+ console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
378
809
  if (skippedOptional.length > 0) {
379
810
  console.log("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
380
811
  for (const s of skippedOptional) console.log(`- ${s}`);
@@ -396,57 +827,191 @@ program
396
827
  if (!message || message === "[object Object]") {
397
828
  message = "Unknown error";
398
829
  }
399
- console.error(`✗ init failed: ${message}`);
830
+ console.error(`Error: prepare-db: ${message}`);
400
831
  // If this was a plan step failure, surface the step name explicitly to help users diagnose quickly.
401
832
  const stepMatch =
402
833
  typeof message === "string" ? message.match(/Failed at step "([^"]+)":/i) : null;
403
834
  const failedStep = stepMatch?.[1];
404
835
  if (failedStep) {
405
- console.error(`Step: ${failedStep}`);
836
+ console.error(` Step: ${failedStep}`);
406
837
  }
407
838
  if (errAny && typeof errAny === "object") {
408
839
  if (typeof errAny.code === "string" && errAny.code) {
409
- console.error(`Error code: ${errAny.code}`);
840
+ console.error(` Code: ${errAny.code}`);
410
841
  }
411
842
  if (typeof errAny.detail === "string" && errAny.detail) {
412
- console.error(`Detail: ${errAny.detail}`);
843
+ console.error(` Detail: ${errAny.detail}`);
413
844
  }
414
845
  if (typeof errAny.hint === "string" && errAny.hint) {
415
- console.error(`Hint: ${errAny.hint}`);
846
+ console.error(` Hint: ${errAny.hint}`);
416
847
  }
417
848
  }
418
849
  if (errAny && typeof errAny === "object" && typeof errAny.code === "string") {
419
850
  if (errAny.code === "42501") {
420
- console.error("");
421
- console.error("Permission error: your admin connection is not allowed to complete the setup.");
422
851
  if (failedStep === "01.role") {
423
- console.error("What failed: create/update the monitoring role (needs CREATEROLE or superuser).");
852
+ console.error(" Context: role creation/update requires CREATEROLE or superuser");
424
853
  } else if (failedStep === "02.permissions") {
425
- console.error("What failed: grant required permissions / create view / set role search_path.");
854
+ console.error(" Context: grants/view/search_path require sufficient GRANT/DDL privileges");
426
855
  }
427
- console.error("How to fix:");
428
- console.error("- Connect as a superuser (or a role with CREATEROLE and sufficient GRANT privileges).");
429
- console.error("- On managed Postgres, use the provider's admin/master user.");
430
- console.error("Tip: run with --print-sql to review the exact SQL plan.");
431
- console.error("");
432
- console.error("Hint: connect as a superuser (or a role with CREATEROLE and sufficient GRANT privileges).");
856
+ console.error(" Fix: connect as a superuser (or a role with CREATEROLE and sufficient GRANT privileges)");
857
+ console.error(" Fix: on managed Postgres, use the provider's admin/master user");
858
+ console.error(" Tip: run with --print-sql to review the exact SQL plan");
433
859
  }
434
860
  if (errAny.code === "ECONNREFUSED") {
435
- console.error("Hint: check host/port and ensure Postgres is reachable from this machine.");
861
+ console.error(" Hint: check host/port and ensure Postgres is reachable from this machine");
436
862
  }
437
863
  if (errAny.code === "ENOTFOUND") {
438
- console.error("Hint: DNS resolution failed; double-check the host name.");
864
+ console.error(" Hint: DNS resolution failed; double-check the host name");
439
865
  }
440
866
  if (errAny.code === "ETIMEDOUT") {
441
- console.error("Hint: connection timed out; check network/firewall rules.");
867
+ console.error(" Hint: connection timed out; check network/firewall rules");
442
868
  }
443
869
  }
444
870
  process.exitCode = 1;
445
871
  } finally {
446
- try {
872
+ if (client) {
873
+ try {
874
+ await client.end();
875
+ } catch {
876
+ // ignore
877
+ }
878
+ }
879
+ }
880
+ });
881
+
882
+ program
883
+ .command("checkup [conn]")
884
+ .description("generate health check reports directly from PostgreSQL (express mode)")
885
+ .option("--check-id <id>", `specific check to run: ${Object.keys(CHECK_INFO).join(", ")}, or ALL`, "ALL")
886
+ .option("--node-name <name>", "node name for reports", "node-01")
887
+ .option("--output <path>", "output directory for JSON files")
888
+ .option("--[no-]upload", "upload JSON results to PostgresAI (default: enabled; requires API key)", undefined)
889
+ .option(
890
+ "--project <project>",
891
+ "project name or ID for remote upload (used with --upload; defaults to config defaultProject; auto-generated on first run)"
892
+ )
893
+ .option("--json", "output JSON to stdout (implies --no-upload)")
894
+ .addHelpText(
895
+ "after",
896
+ [
897
+ "",
898
+ "Available checks:",
899
+ ...Object.entries(CHECK_INFO).map(([id, title]) => ` ${id}: ${title}`),
900
+ "",
901
+ "Examples:",
902
+ " postgresai checkup postgresql://user:pass@host:5432/db",
903
+ " postgresai checkup postgresql://user:pass@host:5432/db --check-id A003",
904
+ " postgresai checkup postgresql://user:pass@host:5432/db --output ./reports",
905
+ " postgresai checkup postgresql://user:pass@host:5432/db --project my_project",
906
+ " postgresai set-default-project my_project",
907
+ " postgresai checkup postgresql://user:pass@host:5432/db",
908
+ " postgresai checkup postgresql://user:pass@host:5432/db --no-upload --json",
909
+ ].join("\n")
910
+ )
911
+ .action(async (conn: string | undefined, opts: CheckupOptions, cmd: Command) => {
912
+ if (!conn) {
913
+ cmd.outputHelp();
914
+ process.exitCode = 1;
915
+ return;
916
+ }
917
+
918
+ const shouldPrintJson = !!opts.json;
919
+ const uploadExplicitlyRequested = opts.upload === true;
920
+ const uploadExplicitlyDisabled = opts.upload === false || shouldPrintJson;
921
+ let shouldUpload = !uploadExplicitlyDisabled;
922
+
923
+ // Preflight: validate/create output directory BEFORE connecting / running checks.
924
+ const outputPath = prepareOutputDirectory(opts.output);
925
+ if (outputPath === null) {
926
+ process.exitCode = 1;
927
+ return;
928
+ }
929
+
930
+ // Preflight: validate upload flags/credentials BEFORE connecting / running checks.
931
+ const rootOpts = program.opts() as CliOptions;
932
+ const uploadResult = prepareUploadConfig(opts, rootOpts, shouldUpload, uploadExplicitlyRequested);
933
+ if (uploadResult === null) {
934
+ process.exitCode = 1;
935
+ return;
936
+ }
937
+ const uploadCfg = uploadResult?.config;
938
+ const projectWasGenerated = uploadResult?.projectWasGenerated ?? false;
939
+ shouldUpload = !!uploadCfg;
940
+
941
+ // Connect and run checks
942
+ const adminConn = resolveAdminConnection({
943
+ conn,
944
+ envPassword: process.env.PGPASSWORD,
945
+ });
946
+ let client: Client | undefined;
947
+ const spinnerEnabled = !!process.stdout.isTTY && shouldUpload;
948
+ const spinner = createTtySpinner(spinnerEnabled, "Connecting to Postgres");
949
+
950
+ try {
951
+ spinner.update("Connecting to Postgres");
952
+ const connResult = await connectWithSslFallback(Client, adminConn);
953
+ client = connResult.client as Client;
954
+
955
+ // Generate reports
956
+ let reports: Record<string, any>;
957
+ if (opts.checkId === "ALL") {
958
+ reports = await generateAllReports(client, opts.nodeName, (p) => {
959
+ spinner.update(`Running ${p.checkId}: ${p.checkTitle} (${p.index}/${p.total})`);
960
+ });
961
+ } else {
962
+ const checkId = opts.checkId.toUpperCase();
963
+ const generator = REPORT_GENERATORS[checkId];
964
+ if (!generator) {
965
+ spinner.stop();
966
+ console.error(`Unknown check ID: ${opts.checkId}`);
967
+ console.error(`Available: ${Object.keys(CHECK_INFO).join(", ")}, ALL`);
968
+ process.exitCode = 1;
969
+ return;
970
+ }
971
+ spinner.update(`Running ${checkId}: ${CHECK_INFO[checkId] || checkId}`);
972
+ reports = { [checkId]: await generator(client, opts.nodeName) };
973
+ }
974
+
975
+ // Upload to PostgresAI API (if configured)
976
+ let uploadSummary: UploadSummary | undefined;
977
+ if (uploadCfg) {
978
+ const logUpload = (msg: string): void => {
979
+ (shouldPrintJson ? console.error : console.log)(msg);
980
+ };
981
+ uploadSummary = await uploadCheckupReports(uploadCfg, reports, spinner, logUpload);
982
+ }
983
+
984
+ spinner.stop();
985
+
986
+ // Write to files (if output path specified)
987
+ if (outputPath) {
988
+ writeReportFiles(reports, outputPath);
989
+ }
990
+
991
+ // Print upload summary
992
+ if (uploadSummary) {
993
+ printUploadSummary(uploadSummary, projectWasGenerated, shouldPrintJson);
994
+ }
995
+
996
+ // Output JSON to stdout
997
+ if (shouldPrintJson || (!shouldUpload && !opts.output)) {
998
+ console.log(JSON.stringify(reports, null, 2));
999
+ }
1000
+ } catch (error) {
1001
+ if (error instanceof RpcError) {
1002
+ for (const line of formatRpcErrorForDisplay(error)) {
1003
+ console.error(line);
1004
+ }
1005
+ } else {
1006
+ const message = error instanceof Error ? error.message : String(error);
1007
+ console.error(`Error: ${message}`);
1008
+ }
1009
+ process.exitCode = 1;
1010
+ } finally {
1011
+ // Always stop spinner to prevent interval leak (idempotent - safe to call multiple times)
1012
+ spinner.stop();
1013
+ if (client) {
447
1014
  await client.end();
448
- } catch {
449
- // ignore
450
1015
  }
451
1016
  }
452
1017
  });
@@ -484,6 +1049,14 @@ function resolvePaths(): PathResolution {
484
1049
  );
485
1050
  }
486
1051
 
1052
+ async function resolveOrInitPaths(): Promise<PathResolution> {
1053
+ try {
1054
+ return resolvePaths();
1055
+ } catch {
1056
+ return ensureDefaultMonitoringProject();
1057
+ }
1058
+ }
1059
+
487
1060
  /**
488
1061
  * Check if Docker daemon is running
489
1062
  */
@@ -531,11 +1104,11 @@ function checkRunningContainers(): { running: boolean; containers: string[] } {
531
1104
  /**
532
1105
  * Run docker compose command
533
1106
  */
534
- async function runCompose(args: string[]): Promise<number> {
1107
+ async function runCompose(args: string[], grafanaPassword?: string): Promise<number> {
535
1108
  let composeFile: string;
536
1109
  let projectDir: string;
537
1110
  try {
538
- ({ composeFile, projectDir } = resolvePaths());
1111
+ ({ composeFile, projectDir } = await resolveOrInitPaths());
539
1112
  } catch (error) {
540
1113
  const message = error instanceof Error ? error.message : String(error);
541
1114
  console.error(message);
@@ -557,28 +1130,42 @@ async function runCompose(args: string[]): Promise<number> {
557
1130
  return 1;
558
1131
  }
559
1132
 
560
- // Read Grafana password from .pgwatch-config and pass to Docker Compose
1133
+ // Set Grafana password from parameter or .pgwatch-config
561
1134
  const env = { ...process.env };
562
- const cfgPath = path.resolve(projectDir, ".pgwatch-config");
563
- if (fs.existsSync(cfgPath)) {
564
- try {
565
- const stats = fs.statSync(cfgPath);
566
- if (!stats.isDirectory()) {
567
- const content = fs.readFileSync(cfgPath, "utf8");
568
- const match = content.match(/^grafana_password=([^\r\n]+)/m);
569
- if (match) {
570
- env.GF_SECURITY_ADMIN_PASSWORD = match[1].trim();
1135
+ if (grafanaPassword) {
1136
+ env.GF_SECURITY_ADMIN_PASSWORD = grafanaPassword;
1137
+ } else {
1138
+ const cfgPath = path.resolve(projectDir, ".pgwatch-config");
1139
+ if (fs.existsSync(cfgPath)) {
1140
+ try {
1141
+ const stats = fs.statSync(cfgPath);
1142
+ if (!stats.isDirectory()) {
1143
+ const content = fs.readFileSync(cfgPath, "utf8");
1144
+ const match = content.match(/^grafana_password=([^\r\n]+)/m);
1145
+ if (match) {
1146
+ env.GF_SECURITY_ADMIN_PASSWORD = match[1].trim();
1147
+ }
1148
+ }
1149
+ } catch (err) {
1150
+ // If we can't read the config, log warning and continue without setting the password
1151
+ if (process.env.DEBUG) {
1152
+ console.warn(`Warning: Could not read Grafana password from config: ${err instanceof Error ? err.message : String(err)}`);
571
1153
  }
572
1154
  }
573
- } catch (err) {
574
- // If we can't read the config, continue without setting the password
575
1155
  }
576
1156
  }
577
1157
 
1158
+ // On macOS, node-exporter can't mount host root filesystem - skip it
1159
+ const finalArgs = [...args];
1160
+ if (process.platform === "darwin" && args.includes("up")) {
1161
+ finalArgs.push("--scale", "node-exporter=0");
1162
+ }
1163
+
578
1164
  return new Promise<number>((resolve) => {
579
- const child = spawn(cmd[0], [...cmd.slice(1), "-f", composeFile, ...args], {
1165
+ const child = spawn(cmd[0], [...cmd.slice(1), "-f", composeFile, ...finalArgs], {
580
1166
  stdio: "inherit",
581
- env: env
1167
+ env: env,
1168
+ cwd: projectDir
582
1169
  });
583
1170
  child.on("close", (code) => resolve(code || 0));
584
1171
  });
@@ -592,18 +1179,64 @@ program.command("help", { isDefault: true }).description("show help").action(()
592
1179
  const mon = program.command("mon").description("monitoring services management");
593
1180
 
594
1181
  mon
595
- .command("quickstart")
596
- .description("complete setup (generate config, start monitoring services)")
1182
+ .command("local-install")
1183
+ .description("install local monitoring stack (generate config, start services)")
597
1184
  .option("--demo", "demo mode with sample database", false)
598
1185
  .option("--api-key <key>", "Postgres AI API key for automated report uploads")
599
1186
  .option("--db-url <url>", "PostgreSQL connection URL to monitor")
1187
+ .option("--tag <tag>", "Docker image tag to use (e.g., 0.14.0, 0.14.0-dev.33)")
600
1188
  .option("-y, --yes", "accept all defaults and skip interactive prompts", false)
601
- .action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; yes: boolean }) => {
1189
+ .action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; tag?: string; yes: boolean }) => {
1190
+ // Get apiKey from global program options (--api-key is defined globally)
1191
+ // This is needed because Commander.js routes --api-key to the global option, not the subcommand's option
1192
+ const globalOpts = program.opts<CliOptions>();
1193
+ const apiKey = opts.apiKey || globalOpts.apiKey;
1194
+
602
1195
  console.log("\n=================================");
603
- console.log(" PostgresAI Monitoring Quickstart");
1196
+ console.log(" PostgresAI monitoring local install");
604
1197
  console.log("=================================\n");
605
1198
  console.log("This will install, configure, and start the monitoring system\n");
606
1199
 
1200
+ // Ensure we have a project directory with docker-compose.yml even if running from elsewhere
1201
+ const { projectDir } = await resolveOrInitPaths();
1202
+ console.log(`Project directory: ${projectDir}\n`);
1203
+
1204
+ // Update .env with custom tag if provided
1205
+ const envFile = path.resolve(projectDir, ".env");
1206
+
1207
+ // Build .env content, preserving important existing values
1208
+ // Read existing .env first to preserve CI/custom settings
1209
+ let existingTag: string | null = null;
1210
+ let existingRegistry: string | null = null;
1211
+ let existingPassword: string | null = null;
1212
+
1213
+ if (fs.existsSync(envFile)) {
1214
+ const existingEnv = fs.readFileSync(envFile, "utf8");
1215
+ // Extract existing values
1216
+ const tagMatch = existingEnv.match(/^PGAI_TAG=(.+)$/m);
1217
+ if (tagMatch) existingTag = tagMatch[1].trim();
1218
+ const registryMatch = existingEnv.match(/^PGAI_REGISTRY=(.+)$/m);
1219
+ if (registryMatch) existingRegistry = registryMatch[1].trim();
1220
+ const pwdMatch = existingEnv.match(/^GF_SECURITY_ADMIN_PASSWORD=(.+)$/m);
1221
+ if (pwdMatch) existingPassword = pwdMatch[1].trim();
1222
+ }
1223
+
1224
+ // Priority: CLI --tag flag > PGAI_TAG env var > existing .env > package version
1225
+ const imageTag = opts.tag || process.env.PGAI_TAG || existingTag || pkg.version;
1226
+
1227
+ const envLines: string[] = [`PGAI_TAG=${imageTag}`];
1228
+ if (existingRegistry) {
1229
+ envLines.push(`PGAI_REGISTRY=${existingRegistry}`);
1230
+ }
1231
+ if (existingPassword) {
1232
+ envLines.push(`GF_SECURITY_ADMIN_PASSWORD=${existingPassword}`);
1233
+ }
1234
+ fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
1235
+
1236
+ if (opts.tag) {
1237
+ console.log(`Using image tag: ${imageTag}\n`);
1238
+ }
1239
+
607
1240
  // Validate conflicting options
608
1241
  if (opts.demo && opts.dbUrl) {
609
1242
  console.log("⚠ Both --demo and --db-url provided. Demo mode includes its own database.");
@@ -611,11 +1244,11 @@ mon
611
1244
  opts.dbUrl = undefined;
612
1245
  }
613
1246
 
614
- if (opts.demo && opts.apiKey) {
1247
+ if (opts.demo && apiKey) {
615
1248
  console.error("✗ Cannot use --api-key with --demo mode");
616
1249
  console.error("✗ Demo mode is for testing only and does not support API key integration");
617
- console.error("\nUse demo mode without API key: postgres-ai mon quickstart --demo");
618
- console.error("Or use production mode with API key: postgres-ai mon quickstart --api-key=your_key");
1250
+ console.error("\nUse demo mode without API key: postgres-ai mon local-install --demo");
1251
+ console.error("Or use production mode with API key: postgres-ai mon local-install --api-key=your_key");
619
1252
  process.exitCode = 1;
620
1253
  return;
621
1254
  }
@@ -633,9 +1266,14 @@ mon
633
1266
  console.log("Step 1: Postgres AI API Configuration (Optional)");
634
1267
  console.log("An API key enables automatic upload of PostgreSQL reports to Postgres AI\n");
635
1268
 
636
- if (opts.apiKey) {
1269
+ if (apiKey) {
637
1270
  console.log("Using API key provided via --api-key parameter");
638
- config.writeConfig({ apiKey: opts.apiKey });
1271
+ config.writeConfig({ apiKey });
1272
+ // Keep reporter compatibility (docker-compose mounts .pgwatch-config)
1273
+ fs.writeFileSync(path.resolve(projectDir, ".pgwatch-config"), `api_key=${apiKey}\n`, {
1274
+ encoding: "utf8",
1275
+ mode: 0o600
1276
+ });
639
1277
  console.log("✓ API key saved\n");
640
1278
  } else if (opts.yes) {
641
1279
  // Auto-yes mode without API key - skip API key setup
@@ -643,43 +1281,36 @@ mon
643
1281
  console.log("⚠ Reports will be generated locally only");
644
1282
  console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
645
1283
  } else {
646
- const rl = readline.createInterface({
647
- input: process.stdin,
648
- output: process.stdout
649
- });
650
-
651
- const question = (prompt: string): Promise<string> =>
652
- new Promise((resolve) => rl.question(prompt, resolve));
653
-
654
- try {
655
- const answer = await question("Do you have a Postgres AI API key? (Y/n): ");
656
- const proceedWithApiKey = !answer || answer.toLowerCase() === "y";
657
-
658
- if (proceedWithApiKey) {
659
- while (true) {
660
- const inputApiKey = await question("Enter your Postgres AI API key: ");
661
- const trimmedKey = inputApiKey.trim();
662
-
663
- if (trimmedKey) {
664
- config.writeConfig({ apiKey: trimmedKey });
665
- console.log("✓ API key saved\n");
666
- break;
667
- }
1284
+ const answer = await question("Do you have a Postgres AI API key? (Y/n): ");
1285
+ const proceedWithApiKey = !answer || answer.toLowerCase() === "y";
1286
+
1287
+ if (proceedWithApiKey) {
1288
+ while (true) {
1289
+ const inputApiKey = await question("Enter your Postgres AI API key: ");
1290
+ const trimmedKey = inputApiKey.trim();
1291
+
1292
+ if (trimmedKey) {
1293
+ config.writeConfig({ apiKey: trimmedKey });
1294
+ // Keep reporter compatibility (docker-compose mounts .pgwatch-config)
1295
+ fs.writeFileSync(path.resolve(projectDir, ".pgwatch-config"), `api_key=${trimmedKey}\n`, {
1296
+ encoding: "utf8",
1297
+ mode: 0o600
1298
+ });
1299
+ console.log("✓ API key saved\n");
1300
+ break;
1301
+ }
668
1302
 
669
- console.log("⚠ API key cannot be empty");
670
- const retry = await question("Try again or skip API key setup, retry? (Y/n): ");
671
- if (retry.toLowerCase() === "n") {
672
- console.log("⚠ Skipping API key setup - reports will be generated locally only");
673
- console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
674
- break;
675
- }
1303
+ console.log("⚠ API key cannot be empty");
1304
+ const retry = await question("Try again or skip API key setup, retry? (Y/n): ");
1305
+ if (retry.toLowerCase() === "n") {
1306
+ console.log("⚠ Skipping API key setup - reports will be generated locally only");
1307
+ console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
1308
+ break;
676
1309
  }
677
- } else {
678
- console.log("⚠ Skipping API key setup - reports will be generated locally only");
679
- console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
680
1310
  }
681
- } finally {
682
- rl.close();
1311
+ } else {
1312
+ console.log("⚠ Skipping API key setup - reports will be generated locally only");
1313
+ console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
683
1314
  }
684
1315
  }
685
1316
  } else {
@@ -692,9 +1323,11 @@ mon
692
1323
  console.log("Step 2: Add PostgreSQL Instance to Monitor\n");
693
1324
 
694
1325
  // Clear instances.yml in production mode (start fresh)
695
- const instancesPath = path.resolve(process.cwd(), "instances.yml");
1326
+ const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
696
1327
  const emptyInstancesContent = "# PostgreSQL instances to monitor\n# Add your instances using: postgres-ai mon targets add\n\n";
697
1328
  fs.writeFileSync(instancesPath, emptyInstancesContent, "utf8");
1329
+ console.log(`Instances file: ${instancesPath}`);
1330
+ console.log(`Project directory: ${projectDir}\n`);
698
1331
 
699
1332
  if (opts.dbUrl) {
700
1333
  console.log("Using database URL provided via --db-url parameter");
@@ -723,7 +1356,6 @@ mon
723
1356
  // Test connection
724
1357
  console.log("Testing connection to the added instance...");
725
1358
  try {
726
- const { Client } = require("pg");
727
1359
  const client = new Client({ connectionString: connStr });
728
1360
  await client.connect();
729
1361
  const result = await client.query("select version();");
@@ -740,63 +1372,50 @@ mon
740
1372
  console.log("⚠ No PostgreSQL instance added");
741
1373
  console.log("You can add one later with: postgres-ai mon targets add\n");
742
1374
  } else {
743
- const rl = readline.createInterface({
744
- input: process.stdin,
745
- output: process.stdout
746
- });
747
-
748
- const question = (prompt: string): Promise<string> =>
749
- new Promise((resolve) => rl.question(prompt, resolve));
750
-
751
- try {
752
- console.log("You need to add at least one PostgreSQL instance to monitor");
753
- const answer = await question("Do you want to add a PostgreSQL instance now? (Y/n): ");
754
- const proceedWithInstance = !answer || answer.toLowerCase() === "y";
755
-
756
- if (proceedWithInstance) {
757
- console.log("\nYou can provide either:");
758
- console.log(" 1. A full connection string: postgresql://user:pass@host:port/database");
759
- console.log(" 2. Press Enter to skip for now\n");
760
-
761
- const connStr = await question("Enter connection string (or press Enter to skip): ");
762
-
763
- if (connStr.trim()) {
764
- const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
765
- if (!m) {
766
- console.error("✗ Invalid connection string format");
767
- console.log("⚠ Continuing without adding instance\n");
768
- } else {
769
- const host = m[3];
770
- const db = m[5];
771
- const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
772
-
773
- const body = `- name: ${instanceName}\n conn_str: ${connStr}\n preset_metrics: full\n custom_metrics:\n is_enabled: true\n group: default\n custom_tags:\n env: production\n cluster: default\n node_name: ${instanceName}\n sink_type: ~sink_type~\n`;
774
- fs.appendFileSync(instancesPath, body, "utf8");
775
- console.log(`✓ Monitoring target '${instanceName}' added\n`);
776
-
777
- // Test connection
778
- console.log("Testing connection to the added instance...");
779
- try {
780
- const { Client } = require("pg");
781
- const client = new Client({ connectionString: connStr });
782
- await client.connect();
783
- const result = await client.query("select version();");
784
- console.log("✓ Connection successful");
785
- console.log(`${result.rows[0].version}\n`);
786
- await client.end();
787
- } catch (error) {
788
- const message = error instanceof Error ? error.message : String(error);
789
- console.error(`✗ Connection failed: ${message}\n`);
790
- }
791
- }
1375
+ console.log("You need to add at least one PostgreSQL instance to monitor");
1376
+ const answer = await question("Do you want to add a PostgreSQL instance now? (Y/n): ");
1377
+ const proceedWithInstance = !answer || answer.toLowerCase() === "y";
1378
+
1379
+ if (proceedWithInstance) {
1380
+ console.log("\nYou can provide either:");
1381
+ console.log(" 1. A full connection string: postgresql://user:pass@host:port/database");
1382
+ console.log(" 2. Press Enter to skip for now\n");
1383
+
1384
+ const connStr = await question("Enter connection string (or press Enter to skip): ");
1385
+
1386
+ if (connStr.trim()) {
1387
+ const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
1388
+ if (!m) {
1389
+ console.error(" Invalid connection string format");
1390
+ console.log(" Continuing without adding instance\n");
792
1391
  } else {
793
- console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
1392
+ const host = m[3];
1393
+ const db = m[5];
1394
+ const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
1395
+
1396
+ const body = `- name: ${instanceName}\n conn_str: ${connStr}\n preset_metrics: full\n custom_metrics:\n is_enabled: true\n group: default\n custom_tags:\n env: production\n cluster: default\n node_name: ${instanceName}\n sink_type: ~sink_type~\n`;
1397
+ fs.appendFileSync(instancesPath, body, "utf8");
1398
+ console.log(`✓ Monitoring target '${instanceName}' added\n`);
1399
+
1400
+ // Test connection
1401
+ console.log("Testing connection to the added instance...");
1402
+ try {
1403
+ const client = new Client({ connectionString: connStr });
1404
+ await client.connect();
1405
+ const result = await client.query("select version();");
1406
+ console.log("✓ Connection successful");
1407
+ console.log(`${result.rows[0].version}\n`);
1408
+ await client.end();
1409
+ } catch (error) {
1410
+ const message = error instanceof Error ? error.message : String(error);
1411
+ console.error(`✗ Connection failed: ${message}\n`);
1412
+ }
794
1413
  }
795
1414
  } else {
796
1415
  console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
797
1416
  }
798
- } finally {
799
- rl.close();
1417
+ } else {
1418
+ console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
800
1419
  }
801
1420
  }
802
1421
  } else {
@@ -814,7 +1433,7 @@ mon
814
1433
 
815
1434
  // Step 4: Ensure Grafana password is configured
816
1435
  console.log(opts.demo ? "Step 4: Configuring Grafana security..." : "Step 4: Configuring Grafana security...");
817
- const cfgPath = path.resolve(process.cwd(), ".pgwatch-config");
1436
+ const cfgPath = path.resolve(projectDir, ".pgwatch-config");
818
1437
  let grafanaPassword = "";
819
1438
 
820
1439
  try {
@@ -855,8 +1474,8 @@ mon
855
1474
  }
856
1475
 
857
1476
  // Step 5: Start services
858
- console.log(opts.demo ? "Step 5: Starting monitoring services..." : "Step 5: Starting monitoring services...");
859
- const code2 = await runCompose(["up", "-d", "--force-recreate"]);
1477
+ console.log("Step 5: Starting monitoring services...");
1478
+ const code2 = await runCompose(["up", "-d", "--force-recreate"], grafanaPassword);
860
1479
  if (code2 !== 0) {
861
1480
  process.exitCode = code2;
862
1481
  return;
@@ -865,7 +1484,7 @@ mon
865
1484
 
866
1485
  // Final summary
867
1486
  console.log("=================================");
868
- console.log(" 🎉 Quickstart setup completed!");
1487
+ console.log(" Local install completed!");
869
1488
  console.log("=================================\n");
870
1489
 
871
1490
  console.log("What's running:");
@@ -914,6 +1533,34 @@ mon
914
1533
  if (code !== 0) process.exitCode = code;
915
1534
  });
916
1535
 
1536
+ // Known container names for cleanup
1537
+ const MONITORING_CONTAINERS = [
1538
+ "postgres-ai-config-init",
1539
+ "node-exporter",
1540
+ "cadvisor",
1541
+ "grafana-with-datasources",
1542
+ "sink-postgres",
1543
+ "sink-prometheus",
1544
+ "target-db",
1545
+ "pgwatch-postgres",
1546
+ "pgwatch-prometheus",
1547
+ "postgres-exporter-sink",
1548
+ "flask-pgss-api",
1549
+ "sources-generator",
1550
+ "postgres-reports",
1551
+ ];
1552
+
1553
+ /** Remove orphaned containers that docker compose down might miss */
1554
+ async function removeOrphanedContainers(): Promise<void> {
1555
+ for (const container of MONITORING_CONTAINERS) {
1556
+ try {
1557
+ await execFilePromise("docker", ["rm", "-f", container]);
1558
+ } catch {
1559
+ // Container doesn't exist, ignore
1560
+ }
1561
+ }
1562
+ }
1563
+
917
1564
  mon
918
1565
  .command("stop")
919
1566
  .description("stop monitoring services")
@@ -982,17 +1629,17 @@ mon
982
1629
  allHealthy = true;
983
1630
  for (const service of services) {
984
1631
  try {
985
- const { execSync } = require("child_process");
986
- const status = execSync(`docker inspect -f '{{.State.Status}}' ${service.container} 2>/dev/null`, {
987
- encoding: 'utf8',
988
- stdio: ['pipe', 'pipe', 'pipe']
989
- }).trim();
1632
+ const result = spawnSync("docker", ["inspect", "-f", "{{.State.Status}}", service.container], { stdio: "pipe" });
1633
+ const status = result.stdout.trim();
990
1634
 
991
- if (status === 'running') {
1635
+ if (result.status === 0 && status === 'running') {
992
1636
  console.log(`✓ ${service.name}: healthy`);
993
- } else {
1637
+ } else if (result.status === 0) {
994
1638
  console.log(`✗ ${service.name}: unhealthy (status: ${status})`);
995
1639
  allHealthy = false;
1640
+ } else {
1641
+ console.log(`✗ ${service.name}: unreachable`);
1642
+ allHealthy = false;
996
1643
  }
997
1644
  } catch (error) {
998
1645
  console.log(`✗ ${service.name}: unreachable`);
@@ -1021,7 +1668,7 @@ mon
1021
1668
  let composeFile: string;
1022
1669
  let instancesFile: string;
1023
1670
  try {
1024
- ({ projectDir, composeFile, instancesFile } = resolvePaths());
1671
+ ({ projectDir, composeFile, instancesFile } = await resolveOrInitPaths());
1025
1672
  } catch (error) {
1026
1673
  const message = error instanceof Error ? error.message : String(error);
1027
1674
  console.error(message);
@@ -1096,14 +1743,6 @@ mon
1096
1743
  .command("reset [service]")
1097
1744
  .description("reset all or specific monitoring service")
1098
1745
  .action(async (service?: string) => {
1099
- const rl = readline.createInterface({
1100
- input: process.stdin,
1101
- output: process.stdout,
1102
- });
1103
-
1104
- const question = (prompt: string): Promise<string> =>
1105
- new Promise((resolve) => rl.question(prompt, resolve));
1106
-
1107
1746
  try {
1108
1747
  if (service) {
1109
1748
  // Reset specific service
@@ -1113,7 +1752,6 @@ mon
1113
1752
  const answer = await question("Continue? (y/N): ");
1114
1753
  if (answer.toLowerCase() !== "y") {
1115
1754
  console.log("Cancelled");
1116
- rl.close();
1117
1755
  return;
1118
1756
  }
1119
1757
 
@@ -1140,7 +1778,6 @@ mon
1140
1778
  const answer = await question("Continue? (y/N): ");
1141
1779
  if (answer.toLowerCase() !== "y") {
1142
1780
  console.log("Cancelled");
1143
- rl.close();
1144
1781
  return;
1145
1782
  }
1146
1783
 
@@ -1154,10 +1791,7 @@ mon
1154
1791
  process.exitCode = 1;
1155
1792
  }
1156
1793
  }
1157
-
1158
- rl.close();
1159
1794
  } catch (error) {
1160
- rl.close();
1161
1795
  const message = error instanceof Error ? error.message : String(error);
1162
1796
  console.error(`Reset failed: ${message}`);
1163
1797
  process.exitCode = 1;
@@ -1165,34 +1799,72 @@ mon
1165
1799
  });
1166
1800
  mon
1167
1801
  .command("clean")
1168
- .description("cleanup monitoring services artifacts")
1169
- .action(async () => {
1170
- console.log("Cleaning up Docker resources...\n");
1802
+ .description("cleanup monitoring services artifacts (stops services and removes volumes)")
1803
+ .option("--keep-volumes", "keep data volumes (only stop and remove containers)")
1804
+ .action(async (options: { keepVolumes?: boolean }) => {
1805
+ console.log("Cleaning up monitoring services...\n");
1171
1806
 
1172
1807
  try {
1173
- // Remove stopped containers
1174
- const { stdout: containers } = await execFilePromise("docker", ["ps", "-aq", "--filter", "status=exited"]);
1175
- if (containers.trim()) {
1176
- const containerIds = containers.trim().split('\n');
1177
- await execFilePromise("docker", ["rm", ...containerIds]);
1178
- console.log("✓ Removed stopped containers");
1808
+ // First, use docker-compose down to properly stop and remove containers/volumes
1809
+ const downArgs = options.keepVolumes ? ["down"] : ["down", "-v"];
1810
+ console.log(options.keepVolumes
1811
+ ? "Stopping and removing containers (keeping volumes)..."
1812
+ : "Stopping and removing containers and volumes...");
1813
+
1814
+ const downCode = await runCompose(downArgs);
1815
+ if (downCode === 0) {
1816
+ console.log("✓ Monitoring services stopped and removed");
1179
1817
  } else {
1180
- console.log(" No stopped containers to remove");
1818
+ console.log(" Could not stop services (may not be running)");
1181
1819
  }
1182
1820
 
1183
- // Remove unused volumes
1184
- await execFilePromise("docker", ["volume", "prune", "-f"]);
1185
- console.log("✓ Removed unused volumes");
1821
+ // Remove any orphaned containers that docker compose down missed
1822
+ await removeOrphanedContainers();
1823
+ console.log("✓ Removed orphaned containers");
1824
+
1825
+ // Remove orphaned volumes from previous installs with different project names
1826
+ if (!options.keepVolumes) {
1827
+ const volumePatterns = [
1828
+ "monitoring_grafana_data",
1829
+ "monitoring_postgres_ai_configs",
1830
+ "monitoring_sink_postgres_data",
1831
+ "monitoring_target_db_data",
1832
+ "monitoring_victoria_metrics_data",
1833
+ "postgres_ai_configs_grafana_data",
1834
+ "postgres_ai_configs_sink_postgres_data",
1835
+ "postgres_ai_configs_target_db_data",
1836
+ "postgres_ai_configs_victoria_metrics_data",
1837
+ "postgres_ai_configs_postgres_ai_configs",
1838
+ ];
1839
+
1840
+ const { stdout: existingVolumes } = await execFilePromise("docker", ["volume", "ls", "-q"]);
1841
+ const volumeList = existingVolumes.trim().split('\n').filter(Boolean);
1842
+ const orphanedVolumes = volumeList.filter(v => volumePatterns.includes(v));
1843
+
1844
+ if (orphanedVolumes.length > 0) {
1845
+ let removedCount = 0;
1846
+ for (const vol of orphanedVolumes) {
1847
+ try {
1848
+ await execFilePromise("docker", ["volume", "rm", vol]);
1849
+ removedCount++;
1850
+ } catch {
1851
+ // Volume might be in use, skip silently
1852
+ }
1853
+ }
1854
+ if (removedCount > 0) {
1855
+ console.log(`✓ Removed ${removedCount} orphaned volume(s) from previous installs`);
1856
+ }
1857
+ }
1858
+ }
1186
1859
 
1187
- // Remove unused networks
1860
+ // Remove any dangling resources
1188
1861
  await execFilePromise("docker", ["network", "prune", "-f"]);
1189
1862
  console.log("✓ Removed unused networks");
1190
1863
 
1191
- // Remove dangling images
1192
1864
  await execFilePromise("docker", ["image", "prune", "-f"]);
1193
1865
  console.log("✓ Removed dangling images");
1194
1866
 
1195
- console.log("\nCleanup completed");
1867
+ console.log("\n✓ Cleanup completed - ready for fresh install");
1196
1868
  } catch (error) {
1197
1869
  const message = error instanceof Error ? error.message : String(error);
1198
1870
  console.error(`Error during cleanup: ${message}`);
@@ -1221,9 +1893,9 @@ targets
1221
1893
  .command("list")
1222
1894
  .description("list monitoring target databases")
1223
1895
  .action(async () => {
1224
- const instancesPath = path.resolve(process.cwd(), "instances.yml");
1896
+ const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
1225
1897
  if (!fs.existsSync(instancesPath)) {
1226
- console.error(`instances.yml not found in ${process.cwd()}`);
1898
+ console.error(`instances.yml not found in ${projectDir}`);
1227
1899
  process.exitCode = 1;
1228
1900
  return;
1229
1901
  }
@@ -1270,7 +1942,7 @@ targets
1270
1942
  .command("add [connStr] [name]")
1271
1943
  .description("add monitoring target database")
1272
1944
  .action(async (connStr?: string, name?: string) => {
1273
- const file = path.resolve(process.cwd(), "instances.yml");
1945
+ const { instancesFile: file } = await resolveOrInitPaths();
1274
1946
  if (!connStr) {
1275
1947
  console.error("Connection string required: postgresql://user:pass@host:port/db");
1276
1948
  process.exitCode = 1;
@@ -1320,7 +1992,7 @@ targets
1320
1992
  .command("remove <name>")
1321
1993
  .description("remove monitoring target database")
1322
1994
  .action(async (name: string) => {
1323
- const file = path.resolve(process.cwd(), "instances.yml");
1995
+ const { instancesFile: file } = await resolveOrInitPaths();
1324
1996
  if (!fs.existsSync(file)) {
1325
1997
  console.error("instances.yml not found");
1326
1998
  process.exitCode = 1;
@@ -1357,7 +2029,7 @@ targets
1357
2029
  .command("test <name>")
1358
2030
  .description("test monitoring target database connectivity")
1359
2031
  .action(async (name: string) => {
1360
- const instancesPath = path.resolve(process.cwd(), "instances.yml");
2032
+ const { instancesFile: instancesPath } = await resolveOrInitPaths();
1361
2033
  if (!fs.existsSync(instancesPath)) {
1362
2034
  console.error("instances.yml not found");
1363
2035
  process.exitCode = 1;
@@ -1391,7 +2063,6 @@ targets
1391
2063
  console.log(`Testing connection to monitoring target '${name}'...`);
1392
2064
 
1393
2065
  // Use native pg client instead of requiring psql to be installed
1394
- const { Client } = require('pg');
1395
2066
  const client = new Client({ connectionString: instance.conn_str });
1396
2067
 
1397
2068
  try {
@@ -1410,15 +2081,43 @@ targets
1410
2081
  });
1411
2082
 
1412
2083
  // Authentication and API key management
1413
- program
1414
- .command("auth")
1415
- .description("authenticate via browser and obtain API key")
2084
+ const auth = program.command("auth").description("authentication and API key management");
2085
+
2086
+ auth
2087
+ .command("login", { isDefault: true })
2088
+ .description("authenticate via browser (OAuth) or store API key directly")
2089
+ .option("--set-key <key>", "store API key directly without OAuth flow")
1416
2090
  .option("--port <port>", "local callback server port (default: random)", parseInt)
1417
2091
  .option("--debug", "enable debug output")
1418
- .action(async (opts: { port?: number; debug?: boolean }) => {
1419
- const pkce = require("../lib/pkce");
1420
- const authServer = require("../lib/auth-server");
2092
+ .action(async (opts: { setKey?: string; port?: number; debug?: boolean }) => {
2093
+ // If --set-key is provided, store it directly without OAuth
2094
+ if (opts.setKey) {
2095
+ const trimmedKey = opts.setKey.trim();
2096
+ if (!trimmedKey) {
2097
+ console.error("Error: API key cannot be empty");
2098
+ process.exitCode = 1;
2099
+ return;
2100
+ }
2101
+
2102
+ // Read existing config to check for defaultProject before updating
2103
+ const existingConfig = config.readConfig();
2104
+ const existingProject = existingConfig.defaultProject;
2105
+
2106
+ config.writeConfig({ apiKey: trimmedKey });
2107
+ // When API key is set directly, only clear orgId (org selection may differ).
2108
+ // Preserve defaultProject to avoid orphaning historical reports.
2109
+ // If the new key lacks access to the project, upload will fail with a clear error.
2110
+ config.deleteConfigKeys(["orgId"]);
2111
+
2112
+ console.log(`API key saved to ${config.getConfigPath()}`);
2113
+ if (existingProject) {
2114
+ console.log(`Note: Your default project "${existingProject}" has been preserved.`);
2115
+ console.log(` If this key belongs to a different account, use --project to specify a new one.`);
2116
+ }
2117
+ return;
2118
+ }
1421
2119
 
2120
+ // Otherwise, proceed with OAuth flow
1422
2121
  console.log("Starting authentication flow...\n");
1423
2122
 
1424
2123
  // Generate PKCE parameters
@@ -1439,10 +2138,10 @@ program
1439
2138
  const requestedPort = opts.port || 0; // 0 = OS assigns available port
1440
2139
  const callbackServer = authServer.createCallbackServer(requestedPort, params.state, 120000); // 2 minute timeout
1441
2140
 
1442
- // Wait a bit for server to start and get port
1443
- await new Promise(resolve => setTimeout(resolve, 100));
1444
- const actualPort = callbackServer.getPort();
1445
- const redirectUri = `http://localhost:${actualPort}/callback`;
2141
+ // Wait for server to start and get the actual port
2142
+ const actualPort = await callbackServer.ready;
2143
+ // Use 127.0.0.1 to match the server bind address (avoids IPv6 issues on some hosts)
2144
+ const redirectUri = `http://127.0.0.1:${actualPort}/callback`;
1446
2145
 
1447
2146
  console.log(`Callback server listening on port ${actualPort}`);
1448
2147
 
@@ -1464,173 +2163,180 @@ program
1464
2163
  console.log(`Debug: Request data: ${initData}`);
1465
2164
  }
1466
2165
 
1467
- const initReq = http.request(
1468
- initUrl,
1469
- {
2166
+ // Step 2: Initialize OAuth session on backend using fetch
2167
+ let initResponse: Response;
2168
+ try {
2169
+ initResponse = await fetch(initUrl.toString(), {
1470
2170
  method: "POST",
1471
2171
  headers: {
1472
2172
  "Content-Type": "application/json",
1473
- "Content-Length": Buffer.byteLength(initData),
1474
2173
  },
1475
- },
1476
- (res) => {
1477
- let data = "";
1478
- res.on("data", (chunk) => (data += chunk));
1479
- res.on("end", async () => {
1480
- if (res.statusCode !== 200) {
1481
- console.error(`Failed to initialize auth session: ${res.statusCode}`);
1482
-
1483
- // Check if response is HTML (common for 404 pages)
1484
- if (data.trim().startsWith("<!") || data.trim().startsWith("<html")) {
1485
- console.error("Error: Received HTML response instead of JSON. This usually means:");
1486
- console.error(" 1. The API endpoint URL is incorrect");
1487
- console.error(" 2. The endpoint does not exist (404)");
1488
- console.error(`\nAPI URL attempted: ${initUrl.toString()}`);
1489
- console.error("\nPlease verify the --api-base-url parameter.");
1490
- } else {
1491
- console.error(data);
1492
- }
2174
+ body: initData,
2175
+ });
2176
+ } catch (err) {
2177
+ const message = err instanceof Error ? err.message : String(err);
2178
+ console.error(`Failed to connect to API: ${message}`);
2179
+ callbackServer.server.stop();
2180
+ process.exit(1);
2181
+ return;
2182
+ }
1493
2183
 
1494
- callbackServer.server.close();
1495
- process.exit(1);
1496
- }
2184
+ if (!initResponse.ok) {
2185
+ const data = await initResponse.text();
2186
+ console.error(`Failed to initialize auth session: ${initResponse.status}`);
2187
+
2188
+ // Check if response is HTML (common for 404 pages)
2189
+ if (data.trim().startsWith("<!") || data.trim().startsWith("<html")) {
2190
+ console.error("Error: Received HTML response instead of JSON. This usually means:");
2191
+ console.error(" 1. The API endpoint URL is incorrect");
2192
+ console.error(" 2. The endpoint does not exist (404)");
2193
+ console.error(`\nAPI URL attempted: ${initUrl.toString()}`);
2194
+ console.error("\nPlease verify the --api-base-url parameter.");
2195
+ } else {
2196
+ console.error(data);
2197
+ }
1497
2198
 
1498
- // Step 3: Open browser
1499
- const authUrl = `${uiBaseUrl}/cli/auth?state=${encodeURIComponent(params.state)}&code_challenge=${encodeURIComponent(params.codeChallenge)}&code_challenge_method=S256&redirect_uri=${encodeURIComponent(redirectUri)}`;
2199
+ callbackServer.server.stop();
2200
+ process.exit(1);
2201
+ return;
2202
+ }
1500
2203
 
1501
- if (opts.debug) {
1502
- console.log(`Debug: Auth URL: ${authUrl}`);
1503
- }
2204
+ // Step 3: Open browser
2205
+ // Pass api_url so UI calls oauth_approve on the same backend where oauth_init created the session
2206
+ const authUrl = `${uiBaseUrl}/cli/auth?state=${encodeURIComponent(params.state)}&code_challenge=${encodeURIComponent(params.codeChallenge)}&code_challenge_method=S256&redirect_uri=${encodeURIComponent(redirectUri)}&api_url=${encodeURIComponent(apiBaseUrl)}`;
1504
2207
 
1505
- console.log(`\nOpening browser for authentication...`);
1506
- console.log(`If browser does not open automatically, visit:\n${authUrl}\n`);
2208
+ if (opts.debug) {
2209
+ console.log(`Debug: Auth URL: ${authUrl}`);
2210
+ }
1507
2211
 
1508
- // Open browser (cross-platform)
1509
- const openCommand = process.platform === "darwin" ? "open" :
1510
- process.platform === "win32" ? "start" :
1511
- "xdg-open";
1512
- spawn(openCommand, [authUrl], { detached: true, stdio: "ignore" }).unref();
2212
+ console.log(`\nOpening browser for authentication...`);
2213
+ console.log(`If browser does not open automatically, visit:\n${authUrl}\n`);
1513
2214
 
1514
- // Step 4: Wait for callback
1515
- console.log("Waiting for authorization...");
1516
- console.log("(Press Ctrl+C to cancel)\n");
2215
+ // Open browser (cross-platform)
2216
+ const openCommand = process.platform === "darwin" ? "open" :
2217
+ process.platform === "win32" ? "start" :
2218
+ "xdg-open";
2219
+ spawn(openCommand, [authUrl], { detached: true, stdio: "ignore" }).unref();
1517
2220
 
1518
- // Handle Ctrl+C gracefully
1519
- const cancelHandler = () => {
1520
- console.log("\n\nAuthentication cancelled by user.");
1521
- callbackServer.server.close();
1522
- process.exit(130); // Standard exit code for SIGINT
1523
- };
1524
- process.on("SIGINT", cancelHandler);
2221
+ // Step 4: Wait for callback
2222
+ console.log("Waiting for authorization...");
2223
+ console.log("(Press Ctrl+C to cancel)\n");
1525
2224
 
1526
- try {
1527
- const { code } = await callbackServer.promise;
2225
+ // Handle Ctrl+C gracefully
2226
+ const cancelHandler = () => {
2227
+ console.log("\n\nAuthentication cancelled by user.");
2228
+ callbackServer.server.stop();
2229
+ process.exit(130); // Standard exit code for SIGINT
2230
+ };
2231
+ process.on("SIGINT", cancelHandler);
1528
2232
 
1529
- // Remove the cancel handler after successful auth
1530
- process.off("SIGINT", cancelHandler);
2233
+ try {
2234
+ const { code } = await callbackServer.promise;
1531
2235
 
1532
- // Step 5: Exchange code for token
1533
- console.log("\nExchanging authorization code for API token...");
1534
- const exchangeData = JSON.stringify({
1535
- authorization_code: code,
1536
- code_verifier: params.codeVerifier,
1537
- state: params.state,
1538
- });
1539
- const exchangeUrl = new URL(`${apiBaseUrl}/rpc/oauth_token_exchange`);
1540
- const exchangeReq = http.request(
1541
- exchangeUrl,
1542
- {
1543
- method: "POST",
1544
- headers: {
1545
- "Content-Type": "application/json",
1546
- "Content-Length": Buffer.byteLength(exchangeData),
1547
- },
1548
- },
1549
- (exchangeRes) => {
1550
- let exchangeBody = "";
1551
- exchangeRes.on("data", (chunk) => (exchangeBody += chunk));
1552
- exchangeRes.on("end", () => {
1553
- if (exchangeRes.statusCode !== 200) {
1554
- console.error(`Failed to exchange code for token: ${exchangeRes.statusCode}`);
1555
-
1556
- // Check if response is HTML (common for 404 pages)
1557
- if (exchangeBody.trim().startsWith("<!") || exchangeBody.trim().startsWith("<html")) {
1558
- console.error("Error: Received HTML response instead of JSON. This usually means:");
1559
- console.error(" 1. The API endpoint URL is incorrect");
1560
- console.error(" 2. The endpoint does not exist (404)");
1561
- console.error(`\nAPI URL attempted: ${exchangeUrl.toString()}`);
1562
- console.error("\nPlease verify the --api-base-url parameter.");
1563
- } else {
1564
- console.error(exchangeBody);
1565
- }
1566
-
1567
- process.exit(1);
1568
- return;
1569
- }
1570
-
1571
- try {
1572
- const result = JSON.parse(exchangeBody);
1573
- const apiToken = result.api_token || result?.[0]?.result?.api_token; // There is a bug with PostgREST Caching that may return an array, not single object, it's a workaround to support both cases.
1574
- const orgId = result.org_id || result?.[0]?.result?.org_id; // There is a bug with PostgREST Caching that may return an array, not single object, it's a workaround to support both cases.
1575
-
1576
- // Step 6: Save token to config
1577
- config.writeConfig({
1578
- apiKey: apiToken,
1579
- baseUrl: apiBaseUrl,
1580
- orgId: orgId,
1581
- });
1582
-
1583
- console.log("\nAuthentication successful!");
1584
- console.log(`API key saved to: ${config.getConfigPath()}`);
1585
- console.log(`Organization ID: ${orgId}`);
1586
- console.log(`\nYou can now use the CLI without specifying an API key.`);
1587
- process.exit(0);
1588
- } catch (err) {
1589
- const message = err instanceof Error ? err.message : String(err);
1590
- console.error(`Failed to parse response: ${message}`);
1591
- process.exit(1);
1592
- }
1593
- });
1594
- }
1595
- );
1596
-
1597
- exchangeReq.on("error", (err: Error) => {
1598
- console.error(`Exchange request failed: ${err.message}`);
1599
- process.exit(1);
1600
- });
2236
+ // Remove the cancel handler after successful auth
2237
+ process.off("SIGINT", cancelHandler);
2238
+
2239
+ // Step 5: Exchange code for token using fetch
2240
+ console.log("\nExchanging authorization code for API token...");
2241
+ const exchangeData = JSON.stringify({
2242
+ authorization_code: code,
2243
+ code_verifier: params.codeVerifier,
2244
+ state: params.state,
2245
+ });
2246
+ const exchangeUrl = new URL(`${apiBaseUrl}/rpc/oauth_token_exchange`);
2247
+
2248
+ let exchangeResponse: Response;
2249
+ try {
2250
+ exchangeResponse = await fetch(exchangeUrl.toString(), {
2251
+ method: "POST",
2252
+ headers: {
2253
+ "Content-Type": "application/json",
2254
+ },
2255
+ body: exchangeData,
2256
+ });
2257
+ } catch (err) {
2258
+ const message = err instanceof Error ? err.message : String(err);
2259
+ console.error(`Exchange request failed: ${message}`);
2260
+ process.exit(1);
2261
+ return;
2262
+ }
1601
2263
 
1602
- exchangeReq.write(exchangeData);
1603
- exchangeReq.end();
2264
+ const exchangeBody = await exchangeResponse.text();
1604
2265
 
1605
- } catch (err) {
1606
- // Remove the cancel handler in error case too
1607
- process.off("SIGINT", cancelHandler);
2266
+ if (!exchangeResponse.ok) {
2267
+ console.error(`Failed to exchange code for token: ${exchangeResponse.status}`);
1608
2268
 
1609
- const message = err instanceof Error ? err.message : String(err);
2269
+ // Check if response is HTML (common for 404 pages)
2270
+ if (exchangeBody.trim().startsWith("<!") || exchangeBody.trim().startsWith("<html")) {
2271
+ console.error("Error: Received HTML response instead of JSON. This usually means:");
2272
+ console.error(" 1. The API endpoint URL is incorrect");
2273
+ console.error(" 2. The endpoint does not exist (404)");
2274
+ console.error(`\nAPI URL attempted: ${exchangeUrl.toString()}`);
2275
+ console.error("\nPlease verify the --api-base-url parameter.");
2276
+ } else {
2277
+ console.error(exchangeBody);
2278
+ }
1610
2279
 
1611
- // Provide more helpful error messages
1612
- if (message.includes("timeout")) {
1613
- console.error(`\nAuthentication timed out.`);
1614
- console.error(`This usually means you closed the browser window without completing authentication.`);
1615
- console.error(`Please try again and complete the authentication flow.`);
1616
- } else {
1617
- console.error(`\nAuthentication failed: ${message}`);
1618
- }
2280
+ process.exit(1);
2281
+ return;
2282
+ }
1619
2283
 
1620
- process.exit(1);
1621
- }
2284
+ try {
2285
+ const result = JSON.parse(exchangeBody);
2286
+ const apiToken = result.api_token || result?.[0]?.result?.api_token; // There is a bug with PostgREST Caching that may return an array, not single object, it's a workaround to support both cases.
2287
+ const orgId = result.org_id || result?.[0]?.result?.org_id; // There is a bug with PostgREST Caching that may return an array, not single object, it's a workaround to support both cases.
2288
+
2289
+ // Step 6: Save token to config
2290
+ // Check if org changed to decide whether to preserve defaultProject
2291
+ const existingConfig = config.readConfig();
2292
+ const existingOrgId = existingConfig.orgId;
2293
+ const existingProject = existingConfig.defaultProject;
2294
+ const orgChanged = existingOrgId && existingOrgId !== orgId;
2295
+
2296
+ config.writeConfig({
2297
+ apiKey: apiToken,
2298
+ baseUrl: apiBaseUrl,
2299
+ orgId: orgId,
1622
2300
  });
2301
+
2302
+ // Only clear defaultProject if org actually changed
2303
+ if (orgChanged && existingProject) {
2304
+ config.deleteConfigKeys(["defaultProject"]);
2305
+ console.log(`\nNote: Organization changed (${existingOrgId} → ${orgId}).`);
2306
+ console.log(` Default project "${existingProject}" has been cleared.`);
2307
+ }
2308
+
2309
+ console.log("\nAuthentication successful!");
2310
+ console.log(`API key saved to: ${config.getConfigPath()}`);
2311
+ console.log(`Organization ID: ${orgId}`);
2312
+ if (!orgChanged && existingProject) {
2313
+ console.log(`Default project: ${existingProject} (preserved)`);
2314
+ }
2315
+ console.log(`\nYou can now use the CLI without specifying an API key.`);
2316
+ process.exit(0);
2317
+ } catch (err) {
2318
+ const message = err instanceof Error ? err.message : String(err);
2319
+ console.error(`Failed to parse response: ${message}`);
2320
+ process.exit(1);
1623
2321
  }
1624
- );
1625
2322
 
1626
- initReq.on("error", (err: Error) => {
1627
- console.error(`Failed to connect to API: ${err.message}`);
1628
- callbackServer.server.close();
1629
- process.exit(1);
1630
- });
2323
+ } catch (err) {
2324
+ // Remove the cancel handler in error case too
2325
+ process.off("SIGINT", cancelHandler);
2326
+
2327
+ const message = err instanceof Error ? err.message : String(err);
2328
+
2329
+ // Provide more helpful error messages
2330
+ if (message.includes("timeout")) {
2331
+ console.error(`\nAuthentication timed out.`);
2332
+ console.error(`This usually means you closed the browser window without completing authentication.`);
2333
+ console.error(`Please try again and complete the authentication flow.`);
2334
+ } else {
2335
+ console.error(`\nAuthentication failed: ${message}`);
2336
+ }
1631
2337
 
1632
- initReq.write(initData);
1633
- initReq.end();
2338
+ process.exit(1);
2339
+ }
1634
2340
 
1635
2341
  } catch (err) {
1636
2342
  const message = err instanceof Error ? err.message : String(err);
@@ -1639,15 +2345,7 @@ program
1639
2345
  }
1640
2346
  });
1641
2347
 
1642
- program
1643
- .command("add-key <apiKey>")
1644
- .description("store API key")
1645
- .action(async (apiKey: string) => {
1646
- config.writeConfig({ apiKey });
1647
- console.log(`API key saved to ${config.getConfigPath()}`);
1648
- });
1649
-
1650
- program
2348
+ auth
1651
2349
  .command("show-key")
1652
2350
  .description("show API key (masked)")
1653
2351
  .action(async () => {
@@ -1657,7 +2355,6 @@ program
1657
2355
  console.log(`\nTo authenticate, run: pgai auth`);
1658
2356
  return;
1659
2357
  }
1660
- const { maskSecret } = require("../lib/util");
1661
2358
  console.log(`Current API key: ${maskSecret(cfg.apiKey)}`);
1662
2359
  if (cfg.orgId) {
1663
2360
  console.log(`Organization ID: ${cfg.orgId}`);
@@ -1665,14 +2362,20 @@ program
1665
2362
  console.log(`Config location: ${config.getConfigPath()}`);
1666
2363
  });
1667
2364
 
1668
- program
2365
+ auth
1669
2366
  .command("remove-key")
1670
2367
  .description("remove API key")
1671
2368
  .action(async () => {
1672
2369
  // Check both new config and legacy config
1673
2370
  const newConfigPath = config.getConfigPath();
1674
2371
  const hasNewConfig = fs.existsSync(newConfigPath);
1675
- const legacyPath = path.resolve(process.cwd(), ".pgwatch-config");
2372
+ let legacyPath: string;
2373
+ try {
2374
+ const { projectDir } = await resolveOrInitPaths();
2375
+ legacyPath = path.resolve(projectDir, ".pgwatch-config");
2376
+ } catch {
2377
+ legacyPath = path.resolve(process.cwd(), ".pgwatch-config");
2378
+ }
1676
2379
  const hasLegacyConfig = fs.existsSync(legacyPath) && fs.statSync(legacyPath).isFile();
1677
2380
 
1678
2381
  if (!hasNewConfig && !hasLegacyConfig) {
@@ -1708,7 +2411,8 @@ mon
1708
2411
  .command("generate-grafana-password")
1709
2412
  .description("generate Grafana password for monitoring services")
1710
2413
  .action(async () => {
1711
- const cfgPath = path.resolve(process.cwd(), ".pgwatch-config");
2414
+ const { projectDir } = await resolveOrInitPaths();
2415
+ const cfgPath = path.resolve(projectDir, ".pgwatch-config");
1712
2416
 
1713
2417
  try {
1714
2418
  // Generate secure password using openssl
@@ -1759,9 +2463,10 @@ mon
1759
2463
  .command("show-grafana-credentials")
1760
2464
  .description("show Grafana credentials for monitoring services")
1761
2465
  .action(async () => {
1762
- const cfgPath = path.resolve(process.cwd(), ".pgwatch-config");
2466
+ const { projectDir } = await resolveOrInitPaths();
2467
+ const cfgPath = path.resolve(projectDir, ".pgwatch-config");
1763
2468
  if (!fs.existsSync(cfgPath)) {
1764
- console.error("Configuration file not found. Run 'postgres-ai mon quickstart' first.");
2469
+ console.error("Configuration file not found. Run 'postgres-ai mon local-install' first.");
1765
2470
  process.exitCode = 1;
1766
2471
  return;
1767
2472
  }
@@ -1887,7 +2592,7 @@ issues
1887
2592
  });
1888
2593
 
1889
2594
  issues
1890
- .command("post_comment <issueId> <content>")
2595
+ .command("post-comment <issueId> <content>")
1891
2596
  .description("post a new comment to an issue")
1892
2597
  .option("--parent <uuid>", "parent comment id")
1893
2598
  .option("--debug", "enable debug output")
@@ -1932,6 +2637,194 @@ issues
1932
2637
  }
1933
2638
  });
1934
2639
 
2640
+ issues
2641
+ .command("create <title>")
2642
+ .description("create a new issue")
2643
+ .option("--org-id <id>", "organization id (defaults to config orgId)", (v) => parseInt(v, 10))
2644
+ .option("--project-id <id>", "project id", (v) => parseInt(v, 10))
2645
+ .option("--description <text>", "issue description (supports \\\\n)")
2646
+ .option(
2647
+ "--label <label>",
2648
+ "issue label (repeatable)",
2649
+ (value: string, previous: string[]) => {
2650
+ previous.push(value);
2651
+ return previous;
2652
+ },
2653
+ [] as string[]
2654
+ )
2655
+ .option("--debug", "enable debug output")
2656
+ .option("--json", "output raw JSON")
2657
+ .action(async (rawTitle: string, opts: { orgId?: number; projectId?: number; description?: string; label?: string[]; debug?: boolean; json?: boolean }) => {
2658
+ try {
2659
+ const rootOpts = program.opts<CliOptions>();
2660
+ const cfg = config.readConfig();
2661
+ const { apiKey } = getConfig(rootOpts);
2662
+ if (!apiKey) {
2663
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
2664
+ process.exitCode = 1;
2665
+ return;
2666
+ }
2667
+
2668
+ const title = interpretEscapes(String(rawTitle || "").trim());
2669
+ if (!title) {
2670
+ console.error("title is required");
2671
+ process.exitCode = 1;
2672
+ return;
2673
+ }
2674
+
2675
+ const orgId = typeof opts.orgId === "number" && !Number.isNaN(opts.orgId) ? opts.orgId : cfg.orgId;
2676
+ if (typeof orgId !== "number") {
2677
+ console.error("org_id is required. Either pass --org-id or run 'pgai auth' to store it in config.");
2678
+ process.exitCode = 1;
2679
+ return;
2680
+ }
2681
+
2682
+ const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
2683
+ const labels = Array.isArray(opts.label) && opts.label.length > 0 ? opts.label.map(String) : undefined;
2684
+ const projectId = typeof opts.projectId === "number" && !Number.isNaN(opts.projectId) ? opts.projectId : undefined;
2685
+
2686
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
2687
+ const result = await createIssue({
2688
+ apiKey,
2689
+ apiBaseUrl,
2690
+ title,
2691
+ orgId,
2692
+ description,
2693
+ projectId,
2694
+ labels,
2695
+ debug: !!opts.debug,
2696
+ });
2697
+ printResult(result, opts.json);
2698
+ } catch (err) {
2699
+ const message = err instanceof Error ? err.message : String(err);
2700
+ console.error(message);
2701
+ process.exitCode = 1;
2702
+ }
2703
+ });
2704
+
2705
+ issues
2706
+ .command("update <issueId>")
2707
+ .description("update an existing issue (title/description/status/labels)")
2708
+ .option("--title <text>", "new title (supports \\\\n)")
2709
+ .option("--description <text>", "new description (supports \\\\n)")
2710
+ .option("--status <value>", "status: open|closed|0|1")
2711
+ .option(
2712
+ "--label <label>",
2713
+ "set labels (repeatable). If provided, replaces existing labels.",
2714
+ (value: string, previous: string[]) => {
2715
+ previous.push(value);
2716
+ return previous;
2717
+ },
2718
+ [] as string[]
2719
+ )
2720
+ .option("--clear-labels", "set labels to an empty list")
2721
+ .option("--debug", "enable debug output")
2722
+ .option("--json", "output raw JSON")
2723
+ .action(async (issueId: string, opts: { title?: string; description?: string; status?: string; label?: string[]; clearLabels?: boolean; debug?: boolean; json?: boolean }) => {
2724
+ try {
2725
+ const rootOpts = program.opts<CliOptions>();
2726
+ const cfg = config.readConfig();
2727
+ const { apiKey } = getConfig(rootOpts);
2728
+ if (!apiKey) {
2729
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
2730
+ process.exitCode = 1;
2731
+ return;
2732
+ }
2733
+
2734
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
2735
+
2736
+ const title = opts.title !== undefined ? interpretEscapes(String(opts.title)) : undefined;
2737
+ const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
2738
+
2739
+ let status: number | undefined = undefined;
2740
+ if (opts.status !== undefined) {
2741
+ const raw = String(opts.status).trim().toLowerCase();
2742
+ if (raw === "open") status = 0;
2743
+ else if (raw === "closed") status = 1;
2744
+ else {
2745
+ const n = Number(raw);
2746
+ if (!Number.isFinite(n)) {
2747
+ console.error("status must be open|closed|0|1");
2748
+ process.exitCode = 1;
2749
+ return;
2750
+ }
2751
+ status = n;
2752
+ }
2753
+ if (status !== 0 && status !== 1) {
2754
+ console.error("status must be 0 (open) or 1 (closed)");
2755
+ process.exitCode = 1;
2756
+ return;
2757
+ }
2758
+ }
2759
+
2760
+ let labels: string[] | undefined = undefined;
2761
+ if (opts.clearLabels) {
2762
+ labels = [];
2763
+ } else if (Array.isArray(opts.label) && opts.label.length > 0) {
2764
+ labels = opts.label.map(String);
2765
+ }
2766
+
2767
+ const result = await updateIssue({
2768
+ apiKey,
2769
+ apiBaseUrl,
2770
+ issueId,
2771
+ title,
2772
+ description,
2773
+ status,
2774
+ labels,
2775
+ debug: !!opts.debug,
2776
+ });
2777
+ printResult(result, opts.json);
2778
+ } catch (err) {
2779
+ const message = err instanceof Error ? err.message : String(err);
2780
+ console.error(message);
2781
+ process.exitCode = 1;
2782
+ }
2783
+ });
2784
+
2785
+ issues
2786
+ .command("update-comment <commentId> <content>")
2787
+ .description("update an existing issue comment")
2788
+ .option("--debug", "enable debug output")
2789
+ .option("--json", "output raw JSON")
2790
+ .action(async (commentId: string, content: string, opts: { debug?: boolean; json?: boolean }) => {
2791
+ try {
2792
+ if (opts.debug) {
2793
+ // eslint-disable-next-line no-console
2794
+ console.log(`Debug: Original content: ${JSON.stringify(content)}`);
2795
+ }
2796
+ content = interpretEscapes(content);
2797
+ if (opts.debug) {
2798
+ // eslint-disable-next-line no-console
2799
+ console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
2800
+ }
2801
+
2802
+ const rootOpts = program.opts<CliOptions>();
2803
+ const cfg = config.readConfig();
2804
+ const { apiKey } = getConfig(rootOpts);
2805
+ if (!apiKey) {
2806
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
2807
+ process.exitCode = 1;
2808
+ return;
2809
+ }
2810
+
2811
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
2812
+
2813
+ const result = await updateIssueComment({
2814
+ apiKey,
2815
+ apiBaseUrl,
2816
+ commentId,
2817
+ content,
2818
+ debug: !!opts.debug,
2819
+ });
2820
+ printResult(result, opts.json);
2821
+ } catch (err) {
2822
+ const message = err instanceof Error ? err.message : String(err);
2823
+ console.error(message);
2824
+ process.exitCode = 1;
2825
+ }
2826
+ });
2827
+
1935
2828
  // MCP server
1936
2829
  const mcp = program.command("mcp").description("MCP server integration");
1937
2830
 
@@ -1959,15 +2852,7 @@ mcp
1959
2852
  console.log(" 4. Codex");
1960
2853
  console.log("");
1961
2854
 
1962
- const rl = readline.createInterface({
1963
- input: process.stdin,
1964
- output: process.stdout
1965
- });
1966
-
1967
- const answer = await new Promise<string>((resolve) => {
1968
- rl.question("Select your AI coding tool (1-4): ", resolve);
1969
- });
1970
- rl.close();
2855
+ const answer = await question("Select your AI coding tool (1-4): ");
1971
2856
 
1972
2857
  const choices: Record<string, string> = {
1973
2858
  "1": "cursor",
@@ -2102,5 +2987,7 @@ mcp
2102
2987
  }
2103
2988
  });
2104
2989
 
2105
- program.parseAsync(process.argv);
2990
+ program.parseAsync(process.argv).finally(() => {
2991
+ closeReadline();
2992
+ });
2106
2993