postgresai 0.14.0-dev.7 → 0.14.0-dev.71

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 (83) hide show
  1. package/README.md +161 -61
  2. package/bin/postgres-ai.ts +1982 -404
  3. package/bun.lock +258 -0
  4. package/bunfig.toml +20 -0
  5. package/dist/bin/postgres-ai.js +29395 -1576
  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 +1396 -0
  19. package/lib/config.ts +6 -3
  20. package/lib/init.ts +568 -155
  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/supabase.ts +769 -0
  26. package/lib/util.ts +61 -0
  27. package/package.json +20 -10
  28. package/packages/postgres-ai/README.md +26 -0
  29. package/packages/postgres-ai/bin/postgres-ai.js +27 -0
  30. package/packages/postgres-ai/package.json +27 -0
  31. package/scripts/embed-metrics.ts +154 -0
  32. package/sql/01.role.sql +16 -0
  33. package/sql/02.permissions.sql +37 -0
  34. package/sql/03.optional_rds.sql +6 -0
  35. package/sql/04.optional_self_managed.sql +8 -0
  36. package/sql/05.helpers.sql +439 -0
  37. package/test/auth.test.ts +258 -0
  38. package/test/checkup.integration.test.ts +321 -0
  39. package/test/checkup.test.ts +1117 -0
  40. package/test/config-consistency.test.ts +36 -0
  41. package/test/init.integration.test.ts +500 -0
  42. package/test/init.test.ts +682 -0
  43. package/test/issues.cli.test.ts +314 -0
  44. package/test/issues.test.ts +456 -0
  45. package/test/mcp-server.test.ts +988 -0
  46. package/test/schema-validation.test.ts +81 -0
  47. package/test/supabase.test.ts +568 -0
  48. package/test/test-utils.ts +128 -0
  49. package/tsconfig.json +12 -20
  50. package/dist/bin/postgres-ai.d.ts +0 -3
  51. package/dist/bin/postgres-ai.d.ts.map +0 -1
  52. package/dist/bin/postgres-ai.js.map +0 -1
  53. package/dist/lib/auth-server.d.ts +0 -31
  54. package/dist/lib/auth-server.d.ts.map +0 -1
  55. package/dist/lib/auth-server.js +0 -263
  56. package/dist/lib/auth-server.js.map +0 -1
  57. package/dist/lib/config.d.ts +0 -45
  58. package/dist/lib/config.d.ts.map +0 -1
  59. package/dist/lib/config.js +0 -181
  60. package/dist/lib/config.js.map +0 -1
  61. package/dist/lib/init.d.ts +0 -61
  62. package/dist/lib/init.d.ts.map +0 -1
  63. package/dist/lib/init.js +0 -359
  64. package/dist/lib/init.js.map +0 -1
  65. package/dist/lib/issues.d.ts +0 -75
  66. package/dist/lib/issues.d.ts.map +0 -1
  67. package/dist/lib/issues.js +0 -336
  68. package/dist/lib/issues.js.map +0 -1
  69. package/dist/lib/mcp-server.d.ts +0 -9
  70. package/dist/lib/mcp-server.d.ts.map +0 -1
  71. package/dist/lib/mcp-server.js +0 -168
  72. package/dist/lib/mcp-server.js.map +0 -1
  73. package/dist/lib/pkce.d.ts +0 -32
  74. package/dist/lib/pkce.d.ts.map +0 -1
  75. package/dist/lib/pkce.js +0 -101
  76. package/dist/lib/pkce.js.map +0 -1
  77. package/dist/lib/util.d.ts +0 -27
  78. package/dist/lib/util.d.ts.map +0 -1
  79. package/dist/lib/util.js +0 -46
  80. package/dist/lib/util.js.map +0 -1
  81. package/dist/package.json +0 -46
  82. package/test/init.integration.test.cjs +0 -269
  83. package/test/init.test.cjs +0 -69
@@ -1,24 +1,366 @@
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";
11
+ import { Client } from "pg";
15
12
  import { startMcpServer } from "../lib/mcp-server";
16
- import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue } from "../lib/issues";
13
+ import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment } from "../lib/issues";
17
14
  import { resolveBaseUrls } from "../lib/util";
18
- import { applyInitPlan, buildInitPlan, resolveAdminConnection, resolveMonitoringPassword } from "../lib/init";
15
+ import { applyInitPlan, buildInitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, KNOWN_PROVIDERS, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, validateProvider, verifyInitSetup } from "../lib/init";
16
+ import { SupabaseClient, resolveSupabaseConfig, extractProjectRefFromUrl, applyInitPlanViaSupabase, verifyInitSetupViaSupabase, type PgCompatibleError } from "../lib/supabase";
17
+ import * as pkce from "../lib/pkce";
18
+ import * as authServer from "../lib/auth-server";
19
+ import { maskSecret } from "../lib/util";
20
+ import { createInterface } from "readline";
21
+ import * as childProcess from "child_process";
22
+ import { REPORT_GENERATORS, CHECK_INFO, generateAllReports } from "../lib/checkup";
23
+ import { createCheckupReport, uploadCheckupReportJson, RpcError, formatRpcErrorForDisplay, withRetry } from "../lib/checkup-api";
24
+
25
+ // Singleton readline interface for stdin prompts
26
+ let rl: ReturnType<typeof createInterface> | null = null;
27
+ function getReadline() {
28
+ if (!rl) {
29
+ rl = createInterface({ input: process.stdin, output: process.stdout });
30
+ }
31
+ return rl;
32
+ }
33
+ function closeReadline() {
34
+ if (rl) {
35
+ rl.close();
36
+ rl = null;
37
+ }
38
+ }
39
+
40
+ // Helper functions for spawning processes - use Node.js child_process for compatibility
41
+ async function execPromise(command: string): Promise<{ stdout: string; stderr: string }> {
42
+ return new Promise((resolve, reject) => {
43
+ childProcess.exec(command, (error, stdout, stderr) => {
44
+ if (error) {
45
+ const err = error as Error & { code: number };
46
+ err.code = typeof error.code === "number" ? error.code : 1;
47
+ reject(err);
48
+ } else {
49
+ resolve({ stdout, stderr });
50
+ }
51
+ });
52
+ });
53
+ }
54
+
55
+ async function execFilePromise(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
56
+ return new Promise((resolve, reject) => {
57
+ childProcess.execFile(file, args, (error, stdout, stderr) => {
58
+ if (error) {
59
+ const err = error as Error & { code: number };
60
+ err.code = typeof error.code === "number" ? error.code : 1;
61
+ reject(err);
62
+ } else {
63
+ resolve({ stdout, stderr });
64
+ }
65
+ });
66
+ });
67
+ }
68
+
69
+ 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 } {
70
+ const result = childProcess.spawnSync(cmd, args, {
71
+ stdio: options?.stdio === "inherit" ? "inherit" : "pipe",
72
+ env: options?.env as NodeJS.ProcessEnv,
73
+ cwd: options?.cwd,
74
+ encoding: "utf8",
75
+ });
76
+ return {
77
+ status: result.status,
78
+ stdout: typeof result.stdout === "string" ? result.stdout : "",
79
+ stderr: typeof result.stderr === "string" ? result.stderr : "",
80
+ };
81
+ }
82
+
83
+ 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 } {
84
+ const proc = childProcess.spawn(cmd, args, {
85
+ stdio: options?.stdio ?? "pipe",
86
+ env: options?.env as NodeJS.ProcessEnv,
87
+ cwd: options?.cwd,
88
+ detached: options?.detached,
89
+ });
90
+
91
+ return {
92
+ on(event: string, cb: (code: number | null, signal?: string) => void) {
93
+ if (event === "close" || event === "exit") {
94
+ proc.on(event, (code, signal) => cb(code, signal ?? undefined));
95
+ } else if (event === "error") {
96
+ proc.on("error", (err) => cb(null, String(err)));
97
+ }
98
+ return this;
99
+ },
100
+ unref() {
101
+ proc.unref();
102
+ },
103
+ pid: proc.pid,
104
+ };
105
+ }
106
+
107
+ // Simple readline-like interface for prompts using Bun
108
+ async function question(prompt: string): Promise<string> {
109
+ return new Promise((resolve) => {
110
+ getReadline().question(prompt, (answer) => {
111
+ resolve(answer);
112
+ });
113
+ });
114
+ }
115
+
116
+ function expandHomePath(p: string): string {
117
+ const s = (p || "").trim();
118
+ if (!s) return s;
119
+ if (s === "~") return os.homedir();
120
+ if (s.startsWith("~/") || s.startsWith("~\\")) {
121
+ return path.join(os.homedir(), s.slice(2));
122
+ }
123
+ return s;
124
+ }
125
+
126
+ function createTtySpinner(
127
+ enabled: boolean,
128
+ initialText: string
129
+ ): { update: (text: string) => void; stop: (finalText?: string) => void } {
130
+ if (!enabled) {
131
+ return {
132
+ update: () => {},
133
+ stop: () => {},
134
+ };
135
+ }
136
+
137
+ const frames = ["|", "/", "-", "\\"];
138
+ const startTs = Date.now();
139
+ let text = initialText;
140
+ let frameIdx = 0;
141
+ let stopped = false;
142
+
143
+ const render = (): void => {
144
+ if (stopped) return;
145
+ const elapsedSec = ((Date.now() - startTs) / 1000).toFixed(1);
146
+ const frame = frames[frameIdx % frames.length] ?? frames[0] ?? "⠿";
147
+ frameIdx += 1;
148
+ process.stdout.write(`\r\x1b[2K${frame} ${text} (${elapsedSec}s)`);
149
+ };
150
+
151
+ const timer = setInterval(render, 120);
152
+ render(); // immediate feedback
153
+
154
+ return {
155
+ update: (t: string) => {
156
+ text = t;
157
+ render();
158
+ },
159
+ stop: (finalText?: string) => {
160
+ if (stopped) return;
161
+ // Set flag first so any queued render() calls exit early.
162
+ // JavaScript is single-threaded, so this is safe: queued callbacks
163
+ // run after stop() returns and will see stopped=true immediately.
164
+ stopped = true;
165
+ clearInterval(timer);
166
+ process.stdout.write("\r\x1b[2K");
167
+ if (finalText && finalText.trim()) {
168
+ process.stdout.write(finalText);
169
+ }
170
+ process.stdout.write("\n");
171
+ },
172
+ };
173
+ }
174
+
175
+ // ============================================================================
176
+ // Checkup command helpers
177
+ // ============================================================================
178
+
179
+ interface CheckupOptions {
180
+ checkId: string;
181
+ nodeName: string;
182
+ output?: string;
183
+ upload?: boolean;
184
+ project?: string;
185
+ json?: boolean;
186
+ }
187
+
188
+ interface UploadConfig {
189
+ apiKey: string;
190
+ apiBaseUrl: string;
191
+ project: string;
192
+ }
193
+
194
+ interface UploadSummary {
195
+ project: string;
196
+ reportId: number;
197
+ uploaded: Array<{ checkId: string; filename: string; chunkId: number }>;
198
+ }
199
+
200
+ /**
201
+ * Prepare and validate output directory for checkup reports.
202
+ * @returns Output path if valid, null if should exit with error
203
+ */
204
+ function prepareOutputDirectory(outputOpt: string | undefined): string | null | undefined {
205
+ if (!outputOpt) return undefined;
206
+
207
+ const outputDir = expandHomePath(outputOpt);
208
+ const outputPath = path.isAbsolute(outputDir) ? outputDir : path.resolve(process.cwd(), outputDir);
19
209
 
20
- const execPromise = promisify(exec);
21
- const execFilePromise = promisify(execFile);
210
+ if (!fs.existsSync(outputPath)) {
211
+ try {
212
+ fs.mkdirSync(outputPath, { recursive: true });
213
+ } catch (e) {
214
+ const errAny = e as any;
215
+ const code = typeof errAny?.code === "string" ? errAny.code : "";
216
+ const msg = errAny instanceof Error ? errAny.message : String(errAny);
217
+ if (code === "EACCES" || code === "EPERM" || code === "ENOENT") {
218
+ console.error(`Error: Failed to create output directory: ${outputPath}`);
219
+ console.error(`Reason: ${msg}`);
220
+ console.error("Tip: choose a writable path, e.g. --output ./reports or --output ~/reports");
221
+ return null; // Signal to exit
222
+ }
223
+ throw e;
224
+ }
225
+ }
226
+ return outputPath;
227
+ }
228
+
229
+ /**
230
+ * Prepare upload configuration for checkup reports.
231
+ * @returns Upload config if valid, null if should exit, undefined if upload not needed
232
+ */
233
+ function prepareUploadConfig(
234
+ opts: CheckupOptions,
235
+ rootOpts: CliOptions,
236
+ shouldUpload: boolean,
237
+ uploadExplicitlyRequested: boolean
238
+ ): { config: UploadConfig; projectWasGenerated: boolean } | null | undefined {
239
+ if (!shouldUpload) return undefined;
240
+
241
+ const { apiKey } = getConfig(rootOpts);
242
+ if (!apiKey) {
243
+ if (uploadExplicitlyRequested) {
244
+ console.error("Error: API key is required for upload");
245
+ console.error("Tip: run 'postgresai auth' or pass --api-key / set PGAI_API_KEY");
246
+ return null; // Signal to exit
247
+ }
248
+ return undefined; // Skip upload silently
249
+ }
250
+
251
+ const cfg = config.readConfig();
252
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
253
+ let project = ((opts.project || cfg.defaultProject) || "").trim();
254
+ let projectWasGenerated = false;
255
+
256
+ if (!project) {
257
+ project = `project_${crypto.randomBytes(6).toString("hex")}`;
258
+ projectWasGenerated = true;
259
+ try {
260
+ config.writeConfig({ defaultProject: project });
261
+ } catch (e) {
262
+ const message = e instanceof Error ? e.message : String(e);
263
+ console.error(`Warning: Failed to save generated default project: ${message}`);
264
+ }
265
+ }
266
+
267
+ return {
268
+ config: { apiKey, apiBaseUrl, project },
269
+ projectWasGenerated,
270
+ };
271
+ }
272
+
273
+ /**
274
+ * Upload checkup reports to PostgresAI API.
275
+ */
276
+ async function uploadCheckupReports(
277
+ uploadCfg: UploadConfig,
278
+ reports: Record<string, any>,
279
+ spinner: ReturnType<typeof createTtySpinner>,
280
+ logUpload: (msg: string) => void
281
+ ): Promise<UploadSummary> {
282
+ spinner.update("Creating remote checkup report");
283
+ const created = await withRetry(
284
+ () => createCheckupReport({
285
+ apiKey: uploadCfg.apiKey,
286
+ apiBaseUrl: uploadCfg.apiBaseUrl,
287
+ project: uploadCfg.project,
288
+ }),
289
+ { maxAttempts: 3 },
290
+ (attempt, err, delayMs) => {
291
+ const errMsg = err instanceof Error ? err.message : String(err);
292
+ logUpload(`[Retry ${attempt}/3] createCheckupReport failed: ${errMsg}, retrying in ${delayMs}ms...`);
293
+ }
294
+ );
295
+
296
+ const reportId = created.reportId;
297
+ logUpload(`Created remote checkup report: ${reportId}`);
298
+
299
+ const uploaded: Array<{ checkId: string; filename: string; chunkId: number }> = [];
300
+ for (const [checkId, report] of Object.entries(reports)) {
301
+ spinner.update(`Uploading ${checkId}.json`);
302
+ const jsonText = JSON.stringify(report, null, 2);
303
+ const r = await withRetry(
304
+ () => uploadCheckupReportJson({
305
+ apiKey: uploadCfg.apiKey,
306
+ apiBaseUrl: uploadCfg.apiBaseUrl,
307
+ reportId,
308
+ filename: `${checkId}.json`,
309
+ checkId,
310
+ jsonText,
311
+ }),
312
+ { maxAttempts: 3 },
313
+ (attempt, err, delayMs) => {
314
+ const errMsg = err instanceof Error ? err.message : String(err);
315
+ logUpload(`[Retry ${attempt}/3] Upload ${checkId}.json failed: ${errMsg}, retrying in ${delayMs}ms...`);
316
+ }
317
+ );
318
+ uploaded.push({ checkId, filename: `${checkId}.json`, chunkId: r.reportChunkId });
319
+ }
320
+ logUpload("Upload completed");
321
+
322
+ return { project: uploadCfg.project, reportId, uploaded };
323
+ }
324
+
325
+ /**
326
+ * Write checkup reports to files.
327
+ */
328
+ function writeReportFiles(reports: Record<string, any>, outputPath: string): void {
329
+ for (const [checkId, report] of Object.entries(reports)) {
330
+ const filePath = path.join(outputPath, `${checkId}.json`);
331
+ fs.writeFileSync(filePath, JSON.stringify(report, null, 2), "utf8");
332
+ console.log(`✓ ${checkId}: ${filePath}`);
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Print upload summary to console.
338
+ */
339
+ function printUploadSummary(
340
+ summary: UploadSummary,
341
+ projectWasGenerated: boolean,
342
+ useStderr: boolean
343
+ ): void {
344
+ const out = useStderr ? console.error : console.log;
345
+ out("\nCheckup report uploaded");
346
+ out("======================\n");
347
+ if (projectWasGenerated) {
348
+ out(`Project: ${summary.project} (generated and saved as default)`);
349
+ } else {
350
+ out(`Project: ${summary.project}`);
351
+ }
352
+ out(`Report ID: ${summary.reportId}`);
353
+ out("View in Console: console.postgres.ai → Support → checkup reports");
354
+ out("");
355
+ out("Files:");
356
+ for (const item of summary.uploaded) {
357
+ out(`- ${item.checkId}: ${item.filename}`);
358
+ }
359
+ }
360
+
361
+ // ============================================================================
362
+ // CLI configuration
363
+ // ============================================================================
22
364
 
23
365
  /**
24
366
  * CLI configuration options
@@ -60,6 +402,86 @@ interface PathResolution {
60
402
  instancesFile: string;
61
403
  }
62
404
 
405
+ function getDefaultMonitoringProjectDir(): string {
406
+ const override = process.env.PGAI_PROJECT_DIR;
407
+ if (override && override.trim()) return override.trim();
408
+ // Keep monitoring project next to user-level config (~/.config/postgresai)
409
+ return path.join(config.getConfigDir(), "monitoring");
410
+ }
411
+
412
+ async function downloadText(url: string): Promise<string> {
413
+ const controller = new AbortController();
414
+ const timeout = setTimeout(() => controller.abort(), 15_000);
415
+ try {
416
+ const response = await fetch(url, { signal: controller.signal });
417
+ if (!response.ok) {
418
+ throw new Error(`HTTP ${response.status} for ${url}`);
419
+ }
420
+ return await response.text();
421
+ } finally {
422
+ clearTimeout(timeout);
423
+ }
424
+ }
425
+
426
+ async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
427
+ const projectDir = getDefaultMonitoringProjectDir();
428
+ const composeFile = path.resolve(projectDir, "docker-compose.yml");
429
+ const instancesFile = path.resolve(projectDir, "instances.yml");
430
+
431
+ if (!fs.existsSync(projectDir)) {
432
+ fs.mkdirSync(projectDir, { recursive: true, mode: 0o700 });
433
+ }
434
+
435
+ if (!fs.existsSync(composeFile)) {
436
+ const refs = [
437
+ process.env.PGAI_PROJECT_REF,
438
+ pkg.version,
439
+ `v${pkg.version}`,
440
+ "main",
441
+ ].filter((v): v is string => Boolean(v && v.trim()));
442
+
443
+ let lastErr: unknown;
444
+ for (const ref of refs) {
445
+ const url = `https://gitlab.com/postgres-ai/postgres_ai/-/raw/${encodeURIComponent(ref)}/docker-compose.yml`;
446
+ try {
447
+ const text = await downloadText(url);
448
+ fs.writeFileSync(composeFile, text, { encoding: "utf8", mode: 0o600 });
449
+ break;
450
+ } catch (err) {
451
+ lastErr = err;
452
+ }
453
+ }
454
+
455
+ if (!fs.existsSync(composeFile)) {
456
+ const msg = lastErr instanceof Error ? lastErr.message : String(lastErr);
457
+ throw new Error(`Failed to bootstrap docker-compose.yml: ${msg}`);
458
+ }
459
+ }
460
+
461
+ // Ensure instances.yml exists as a FILE (avoid Docker creating a directory)
462
+ if (!fs.existsSync(instancesFile)) {
463
+ const header =
464
+ "# PostgreSQL instances to monitor\n" +
465
+ "# Add your instances using: pgai mon targets add <connection-string> <name>\n\n";
466
+ fs.writeFileSync(instancesFile, header, { encoding: "utf8", mode: 0o600 });
467
+ }
468
+
469
+ // Ensure .pgwatch-config exists as a FILE for reporter (may remain empty)
470
+ const pgwatchConfig = path.resolve(projectDir, ".pgwatch-config");
471
+ if (!fs.existsSync(pgwatchConfig)) {
472
+ fs.writeFileSync(pgwatchConfig, "", { encoding: "utf8", mode: 0o600 });
473
+ }
474
+
475
+ // Ensure .env exists and has PGAI_TAG (compose requires it)
476
+ const envFile = path.resolve(projectDir, ".env");
477
+ if (!fs.existsSync(envFile)) {
478
+ const envText = `PGAI_TAG=${pkg.version}\n# PGAI_REGISTRY=registry.gitlab.com/postgres-ai/postgres_ai\n`;
479
+ fs.writeFileSync(envFile, envText, { encoding: "utf8", mode: 0o600 });
480
+ }
481
+
482
+ return { fs, path, projectDir, composeFile, instancesFile };
483
+ }
484
+
63
485
  /**
64
486
  * Get configuration from various sources
65
487
  * @param opts - Command line options
@@ -118,17 +540,92 @@ program
118
540
  );
119
541
 
120
542
  program
121
- .command("init [conn]")
122
- .description("Create a monitoring user and grant all required permissions (idempotent)")
543
+ .command("set-default-project <project>")
544
+ .description("store default project for checkup uploads")
545
+ .action(async (project: string) => {
546
+ const value = (project || "").trim();
547
+ if (!value) {
548
+ console.error("Error: project is required");
549
+ process.exitCode = 1;
550
+ return;
551
+ }
552
+ config.writeConfig({ defaultProject: value });
553
+ console.log(`Default project saved: ${value}`);
554
+ });
555
+
556
+ program
557
+ .command("prepare-db [conn]")
558
+ .description("prepare database for monitoring: create monitoring user, required view(s), and grant permissions (idempotent)")
123
559
  .option("--db-url <url>", "PostgreSQL connection URL (admin) to run the setup against (deprecated; pass it as positional arg)")
124
560
  .option("-h, --host <host>", "PostgreSQL host (psql-like)")
125
561
  .option("-p, --port <port>", "PostgreSQL port (psql-like)")
126
562
  .option("-U, --username <username>", "PostgreSQL user (psql-like)")
127
563
  .option("-d, --dbname <dbname>", "PostgreSQL database name (psql-like)")
128
564
  .option("--admin-password <password>", "Admin connection password (otherwise uses PGPASSWORD if set)")
129
- .option("--monitoring-user <name>", "Monitoring role name to create/update", "postgres_ai_mon")
565
+ .option("--monitoring-user <name>", "Monitoring role name to create/update", DEFAULT_MONITORING_USER)
130
566
  .option("--password <password>", "Monitoring role password (overrides PGAI_MON_PASSWORD)")
131
567
  .option("--skip-optional-permissions", "Skip optional permissions (RDS/self-managed extras)", false)
568
+ .option("--provider <provider>", "Database provider (e.g., supabase). Affects which steps are executed.")
569
+ .option("--verify", "Verify that monitoring role/permissions are in place (no changes)", false)
570
+ .option("--reset-password", "Reset monitoring role password only (no other changes)", false)
571
+ .option("--print-sql", "Print SQL plan and exit (no changes applied)", false)
572
+ .option("--print-password", "Print generated monitoring password (DANGEROUS in CI logs)", false)
573
+ .option("--supabase", "Use Supabase Management API instead of direct PostgreSQL connection", false)
574
+ .option("--supabase-access-token <token>", "Supabase Management API access token (or SUPABASE_ACCESS_TOKEN env)")
575
+ .option("--supabase-project-ref <ref>", "Supabase project reference (or SUPABASE_PROJECT_REF env)")
576
+ .option("--json", "Output result as JSON (machine-readable)", false)
577
+ .addHelpText(
578
+ "after",
579
+ [
580
+ "",
581
+ "Examples:",
582
+ " postgresai prepare-db postgresql://admin@host:5432/dbname",
583
+ " postgresai prepare-db \"dbname=dbname host=host user=admin\"",
584
+ " postgresai prepare-db -h host -p 5432 -U admin -d dbname",
585
+ "",
586
+ "Admin password:",
587
+ " --admin-password <password> or PGPASSWORD=... (libpq standard)",
588
+ "",
589
+ "Monitoring password:",
590
+ " --password <password> or PGAI_MON_PASSWORD=... (otherwise auto-generated)",
591
+ " If auto-generated, it is printed only on TTY by default.",
592
+ " To print it in non-interactive mode: --print-password",
593
+ "",
594
+ "SSL connection (sslmode=prefer behavior):",
595
+ " Tries SSL first, falls back to non-SSL if server doesn't support it.",
596
+ " To force SSL: PGSSLMODE=require or ?sslmode=require in URL",
597
+ " To disable SSL: PGSSLMODE=disable or ?sslmode=disable in URL",
598
+ "",
599
+ "Environment variables (libpq standard):",
600
+ " PGHOST, PGPORT, PGUSER, PGDATABASE — connection defaults",
601
+ " PGPASSWORD — admin password",
602
+ " PGSSLMODE — SSL mode (disable, require, verify-full)",
603
+ " PGAI_MON_PASSWORD — monitoring password",
604
+ "",
605
+ "Inspect SQL without applying changes:",
606
+ " postgresai prepare-db <conn> --print-sql",
607
+ "",
608
+ "Verify setup (no changes):",
609
+ " postgresai prepare-db <conn> --verify",
610
+ "",
611
+ "Reset monitoring password only:",
612
+ " postgresai prepare-db <conn> --reset-password --password '...'",
613
+ "",
614
+ "Offline SQL plan (no DB connection):",
615
+ " postgresai prepare-db --print-sql",
616
+ "",
617
+ "Supabase mode (use Management API instead of direct connection):",
618
+ " postgresai prepare-db --supabase --supabase-project-ref <ref>",
619
+ " SUPABASE_ACCESS_TOKEN=... postgresai prepare-db --supabase --supabase-project-ref <ref>",
620
+ "",
621
+ " Generate a token at: https://supabase.com/dashboard/account/tokens",
622
+ " Find your project ref in: https://supabase.com/dashboard/project/<ref>",
623
+ "",
624
+ "Provider-specific behavior (for direct connections):",
625
+ " --provider supabase Skip role creation (create user in Supabase dashboard)",
626
+ " Skip ALTER USER (restricted by Supabase)",
627
+ ].join("\n")
628
+ )
132
629
  .action(async (conn: string | undefined, opts: {
133
630
  dbUrl?: string;
134
631
  host?: string;
@@ -139,57 +636,430 @@ program
139
636
  monitoringUser: string;
140
637
  password?: string;
141
638
  skipOptionalPermissions?: boolean;
142
- }) => {
639
+ provider?: string;
640
+ verify?: boolean;
641
+ resetPassword?: boolean;
642
+ printSql?: boolean;
643
+ printPassword?: boolean;
644
+ supabase?: boolean;
645
+ supabaseAccessToken?: string;
646
+ supabaseProjectRef?: string;
647
+ json?: boolean;
648
+ }, cmd: Command) => {
649
+ // JSON output helper
650
+ const jsonOutput = opts.json;
651
+ const outputJson = (data: Record<string, unknown>) => {
652
+ console.log(JSON.stringify(data, null, 2));
653
+ };
654
+ const outputError = (error: {
655
+ message: string;
656
+ step?: string;
657
+ code?: string;
658
+ detail?: string;
659
+ hint?: string;
660
+ httpStatus?: number;
661
+ }) => {
662
+ if (jsonOutput) {
663
+ outputJson({
664
+ success: false,
665
+ mode: opts.supabase ? "supabase" : "direct",
666
+ error,
667
+ });
668
+ } else {
669
+ console.error(`Error: prepare-db${opts.supabase ? " (Supabase)" : ""}: ${error.message}`);
670
+ if (error.step) console.error(` Step: ${error.step}`);
671
+ if (error.code) console.error(` Code: ${error.code}`);
672
+ if (error.detail) console.error(` Detail: ${error.detail}`);
673
+ if (error.hint) console.error(` Hint: ${error.hint}`);
674
+ if (error.httpStatus) console.error(` HTTP Status: ${error.httpStatus}`);
675
+ }
676
+ process.exitCode = 1;
677
+ };
678
+ if (opts.verify && opts.resetPassword) {
679
+ outputError({ message: "Provide only one of --verify or --reset-password" });
680
+ return;
681
+ }
682
+ if (opts.verify && opts.printSql) {
683
+ outputError({ message: "--verify cannot be combined with --print-sql" });
684
+ return;
685
+ }
686
+
687
+ const shouldPrintSql = !!opts.printSql;
688
+ const redactPasswords = (sql: string): string => redactPasswordsInSql(sql);
689
+
690
+ // Validate provider and warn if unknown
691
+ const providerWarning = validateProvider(opts.provider);
692
+ if (providerWarning) {
693
+ console.warn(`⚠ ${providerWarning}`);
694
+ }
695
+
696
+ // Offline mode: allow printing SQL without providing/using an admin connection.
697
+ // Useful for audits/reviews; caller can provide -d/PGDATABASE.
698
+ if (!conn && !opts.dbUrl && !opts.host && !opts.port && !opts.username && !opts.adminPassword) {
699
+ if (shouldPrintSql) {
700
+ const database = (opts.dbname ?? process.env.PGDATABASE ?? "postgres").trim();
701
+ const includeOptionalPermissions = !opts.skipOptionalPermissions;
702
+
703
+ // Use explicit password/env if provided; otherwise use a placeholder.
704
+ // Printed SQL always redacts secrets.
705
+ const monPassword =
706
+ (opts.password ?? process.env.PGAI_MON_PASSWORD ?? "<redacted>").toString();
707
+
708
+ const plan = await buildInitPlan({
709
+ database,
710
+ monitoringUser: opts.monitoringUser,
711
+ monitoringPassword: monPassword,
712
+ includeOptionalPermissions,
713
+ provider: opts.provider,
714
+ });
715
+
716
+ console.log("\n--- SQL plan (offline; not connected) ---");
717
+ console.log(`-- database: ${database}`);
718
+ console.log(`-- monitoring user: ${opts.monitoringUser}`);
719
+ console.log(`-- provider: ${opts.provider ?? "self-managed"}`);
720
+ console.log(`-- optional permissions: ${includeOptionalPermissions ? "enabled" : "skipped"}`);
721
+ for (const step of plan.steps) {
722
+ console.log(`\n-- ${step.name}${step.optional ? " (optional)" : ""}`);
723
+ console.log(redactPasswords(step.sql));
724
+ }
725
+ console.log("\n--- end SQL plan ---\n");
726
+ console.log("Note: passwords are redacted in the printed SQL output.");
727
+ return;
728
+ }
729
+ }
730
+
731
+ // Supabase mode: use Supabase Management API instead of direct PG connection
732
+ if (opts.supabase) {
733
+ let supabaseConfig;
734
+ try {
735
+ // Try to extract project ref from connection URL if provided
736
+ let projectRef = opts.supabaseProjectRef;
737
+ if (!projectRef && conn) {
738
+ projectRef = extractProjectRefFromUrl(conn);
739
+ }
740
+ supabaseConfig = resolveSupabaseConfig({
741
+ accessToken: opts.supabaseAccessToken,
742
+ projectRef,
743
+ });
744
+ } catch (e) {
745
+ const msg = e instanceof Error ? e.message : String(e);
746
+ outputError({ message: msg });
747
+ return;
748
+ }
749
+
750
+ const includeOptionalPermissions = !opts.skipOptionalPermissions;
751
+
752
+ if (!jsonOutput) {
753
+ console.log(`Supabase mode: project ref ${supabaseConfig.projectRef}`);
754
+ console.log(`Monitoring user: ${opts.monitoringUser}`);
755
+ console.log(`Optional permissions: ${includeOptionalPermissions ? "enabled" : "skipped"}`);
756
+ }
757
+
758
+ const supabaseClient = new SupabaseClient(supabaseConfig);
759
+
760
+ try {
761
+ // Get current database name
762
+ const database = await supabaseClient.getCurrentDatabase();
763
+ if (!database) {
764
+ throw new Error("Failed to resolve current database name");
765
+ }
766
+ if (!jsonOutput) {
767
+ console.log(`Database: ${database}`);
768
+ }
769
+
770
+ if (opts.verify) {
771
+ const v = await verifyInitSetupViaSupabase({
772
+ client: supabaseClient,
773
+ database,
774
+ monitoringUser: opts.monitoringUser,
775
+ includeOptionalPermissions,
776
+ });
777
+ if (v.ok) {
778
+ if (jsonOutput) {
779
+ outputJson({
780
+ success: true,
781
+ mode: "supabase",
782
+ action: "verify",
783
+ database,
784
+ monitoringUser: opts.monitoringUser,
785
+ verified: true,
786
+ missingOptional: v.missingOptional,
787
+ });
788
+ } else {
789
+ console.log("✓ prepare-db verify: OK");
790
+ if (v.missingOptional.length > 0) {
791
+ console.log("⚠ Optional items missing:");
792
+ for (const m of v.missingOptional) console.log(`- ${m}`);
793
+ }
794
+ }
795
+ return;
796
+ }
797
+ if (jsonOutput) {
798
+ outputJson({
799
+ success: false,
800
+ mode: "supabase",
801
+ action: "verify",
802
+ database,
803
+ monitoringUser: opts.monitoringUser,
804
+ verified: false,
805
+ missingRequired: v.missingRequired,
806
+ missingOptional: v.missingOptional,
807
+ });
808
+ } else {
809
+ console.error("✗ prepare-db verify failed: missing required items");
810
+ for (const m of v.missingRequired) console.error(`- ${m}`);
811
+ if (v.missingOptional.length > 0) {
812
+ console.error("Optional items missing:");
813
+ for (const m of v.missingOptional) console.error(`- ${m}`);
814
+ }
815
+ }
816
+ process.exitCode = 1;
817
+ return;
818
+ }
819
+
820
+ let monPassword: string;
821
+ let passwordGenerated = false;
822
+ try {
823
+ const resolved = await resolveMonitoringPassword({
824
+ passwordFlag: opts.password,
825
+ passwordEnv: process.env.PGAI_MON_PASSWORD,
826
+ monitoringUser: opts.monitoringUser,
827
+ });
828
+ monPassword = resolved.password;
829
+ passwordGenerated = resolved.generated;
830
+ if (resolved.generated) {
831
+ const canPrint = process.stdout.isTTY || !!opts.printPassword || jsonOutput;
832
+ if (canPrint) {
833
+ if (!jsonOutput) {
834
+ const shellSafe = monPassword.replace(/'/g, "'\\''");
835
+ console.error("");
836
+ console.error(`Generated monitoring password for ${opts.monitoringUser} (copy/paste):`);
837
+ console.error(`PGAI_MON_PASSWORD='${shellSafe}'`);
838
+ console.error("");
839
+ console.log("Store it securely (or rerun with --password / PGAI_MON_PASSWORD to set your own).");
840
+ }
841
+ // For JSON mode, password will be included in the success output below
842
+ } else {
843
+ console.error(
844
+ [
845
+ `✗ Monitoring password was auto-generated for ${opts.monitoringUser} but not printed in non-interactive mode.`,
846
+ "",
847
+ "Provide it explicitly:",
848
+ " --password <password> or PGAI_MON_PASSWORD=...",
849
+ "",
850
+ "Or (NOT recommended) print the generated password:",
851
+ " --print-password",
852
+ ].join("\n")
853
+ );
854
+ process.exitCode = 1;
855
+ return;
856
+ }
857
+ }
858
+ } catch (e) {
859
+ const msg = e instanceof Error ? e.message : String(e);
860
+ outputError({ message: msg });
861
+ return;
862
+ }
863
+
864
+ const plan = await buildInitPlan({
865
+ database,
866
+ monitoringUser: opts.monitoringUser,
867
+ monitoringPassword: monPassword,
868
+ includeOptionalPermissions,
869
+ });
870
+
871
+ // For Supabase mode, skip RDS and self-managed steps (they don't apply)
872
+ const supabaseApplicableSteps = plan.steps.filter(
873
+ (s) => s.name !== "03.optional_rds" && s.name !== "04.optional_self_managed"
874
+ );
875
+
876
+ const effectivePlan = opts.resetPassword
877
+ ? { ...plan, steps: supabaseApplicableSteps.filter((s) => s.name === "01.role") }
878
+ : { ...plan, steps: supabaseApplicableSteps };
879
+
880
+ if (shouldPrintSql) {
881
+ console.log("\n--- SQL plan ---");
882
+ for (const step of effectivePlan.steps) {
883
+ console.log(`\n-- ${step.name}${step.optional ? " (optional)" : ""}`);
884
+ console.log(redactPasswords(step.sql));
885
+ }
886
+ console.log("\n--- end SQL plan ---\n");
887
+ console.log("Note: passwords are redacted in the printed SQL output.");
888
+ return;
889
+ }
890
+
891
+ const { applied, skippedOptional } = await applyInitPlanViaSupabase({
892
+ client: supabaseClient,
893
+ plan: effectivePlan,
894
+ });
895
+
896
+ if (jsonOutput) {
897
+ const result: Record<string, unknown> = {
898
+ success: true,
899
+ mode: "supabase",
900
+ action: opts.resetPassword ? "reset-password" : "apply",
901
+ database,
902
+ monitoringUser: opts.monitoringUser,
903
+ applied,
904
+ skippedOptional,
905
+ warnings: skippedOptional.length > 0
906
+ ? ["Some optional steps were skipped (not supported or insufficient privileges)"]
907
+ : [],
908
+ };
909
+ if (passwordGenerated) {
910
+ result.generatedPassword = monPassword;
911
+ }
912
+ outputJson(result);
913
+ } else {
914
+ console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
915
+ if (skippedOptional.length > 0) {
916
+ console.log("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
917
+ for (const s of skippedOptional) console.log(`- ${s}`);
918
+ }
919
+ if (process.stdout.isTTY) {
920
+ console.log(`Applied ${applied.length} steps`);
921
+ }
922
+ }
923
+ } catch (error) {
924
+ const errAny = error as PgCompatibleError;
925
+ let message = "";
926
+ if (error instanceof Error && error.message) {
927
+ message = error.message;
928
+ } else if (errAny && typeof errAny === "object" && typeof errAny.message === "string" && errAny.message) {
929
+ message = errAny.message;
930
+ } else {
931
+ message = String(error);
932
+ }
933
+ if (!message || message === "[object Object]") {
934
+ message = "Unknown error";
935
+ }
936
+
937
+ // Surface step name if this was a plan step failure
938
+ const stepMatch = typeof message === "string" ? message.match(/Failed at step "([^"]+)":/i) : null;
939
+ const failedStep = stepMatch?.[1];
940
+
941
+ // Build error object for JSON output
942
+ const errorObj: {
943
+ message: string;
944
+ step?: string;
945
+ code?: string;
946
+ detail?: string;
947
+ hint?: string;
948
+ httpStatus?: number;
949
+ } = { message };
950
+
951
+ if (failedStep) errorObj.step = failedStep;
952
+ if (errAny && typeof errAny === "object") {
953
+ if (typeof errAny.code === "string" && errAny.code) errorObj.code = errAny.code;
954
+ if (typeof errAny.detail === "string" && errAny.detail) errorObj.detail = errAny.detail;
955
+ if (typeof errAny.hint === "string" && errAny.hint) errorObj.hint = errAny.hint;
956
+ if (typeof errAny.httpStatus === "number") errorObj.httpStatus = errAny.httpStatus;
957
+ }
958
+
959
+ if (jsonOutput) {
960
+ outputJson({
961
+ success: false,
962
+ mode: "supabase",
963
+ error: errorObj,
964
+ });
965
+ process.exitCode = 1;
966
+ } else {
967
+ console.error(`Error: prepare-db (Supabase): ${message}`);
968
+
969
+ if (failedStep) {
970
+ console.error(` Step: ${failedStep}`);
971
+ }
972
+
973
+ // Surface PostgreSQL-compatible error details
974
+ if (errAny && typeof errAny === "object") {
975
+ if (typeof errAny.code === "string" && errAny.code) {
976
+ console.error(` Code: ${errAny.code}`);
977
+ }
978
+ if (typeof errAny.detail === "string" && errAny.detail) {
979
+ console.error(` Detail: ${errAny.detail}`);
980
+ }
981
+ if (typeof errAny.hint === "string" && errAny.hint) {
982
+ console.error(` Hint: ${errAny.hint}`);
983
+ }
984
+ if (typeof errAny.httpStatus === "number") {
985
+ console.error(` HTTP Status: ${errAny.httpStatus}`);
986
+ }
987
+ }
988
+
989
+ // Provide context hints for common errors
990
+ if (errAny && typeof errAny === "object" && typeof errAny.code === "string") {
991
+ if (errAny.code === "42501") {
992
+ if (failedStep === "01.role") {
993
+ console.error(" Context: role creation/update requires CREATEROLE or superuser");
994
+ } else if (failedStep === "02.permissions") {
995
+ console.error(" Context: grants/view/search_path require sufficient GRANT/DDL privileges");
996
+ }
997
+ console.error(" Fix: ensure your Supabase access token has sufficient permissions");
998
+ console.error(" Tip: run with --print-sql to review the exact SQL plan");
999
+ }
1000
+ }
1001
+ if (errAny && typeof errAny === "object" && typeof errAny.httpStatus === "number") {
1002
+ if (errAny.httpStatus === 401) {
1003
+ console.error(" Hint: invalid or expired access token; generate a new one at https://supabase.com/dashboard/account/tokens");
1004
+ }
1005
+ if (errAny.httpStatus === 403) {
1006
+ console.error(" Hint: access denied; check your token permissions and project access");
1007
+ }
1008
+ if (errAny.httpStatus === 404) {
1009
+ console.error(" Hint: project not found; verify the project reference is correct");
1010
+ }
1011
+ if (errAny.httpStatus === 429) {
1012
+ console.error(" Hint: rate limited; wait a moment and try again");
1013
+ }
1014
+ }
1015
+ process.exitCode = 1;
1016
+ }
1017
+ }
1018
+ return;
1019
+ }
1020
+
143
1021
  let adminConn;
144
1022
  try {
145
1023
  adminConn = resolveAdminConnection({
146
1024
  conn,
147
1025
  dbUrlFlag: opts.dbUrl,
148
- host: opts.host,
149
- port: opts.port,
150
- username: opts.username,
151
- dbname: opts.dbname,
1026
+ // Allow libpq standard env vars as implicit defaults (common UX).
1027
+ host: opts.host ?? process.env.PGHOST,
1028
+ port: opts.port ?? process.env.PGPORT,
1029
+ username: opts.username ?? process.env.PGUSER,
1030
+ dbname: opts.dbname ?? process.env.PGDATABASE,
152
1031
  adminPassword: opts.adminPassword,
153
1032
  envPassword: process.env.PGPASSWORD,
154
1033
  });
155
1034
  } catch (e) {
156
1035
  const msg = e instanceof Error ? e.message : String(e);
157
- console.error(`✗ ${msg}`);
158
- process.exitCode = 1;
159
- return;
160
- }
161
-
162
- let monPassword: string;
163
- try {
164
- monPassword = await resolveMonitoringPassword({
165
- passwordFlag: opts.password,
166
- passwordEnv: process.env.PGAI_MON_PASSWORD,
167
- monitoringUser: opts.monitoringUser,
168
- });
169
- } catch (e) {
170
- const msg = e instanceof Error ? e.message : String(e);
171
- console.error(`✗ ${msg}`);
172
- process.exitCode = 1;
1036
+ if (jsonOutput) {
1037
+ outputError({ message: msg });
1038
+ } else {
1039
+ console.error(`Error: prepare-db: ${msg}`);
1040
+ // When connection details are missing, show full init help (options + examples).
1041
+ if (typeof msg === "string" && msg.startsWith("Connection is required.")) {
1042
+ console.error("");
1043
+ cmd.outputHelp({ error: true });
1044
+ }
1045
+ process.exitCode = 1;
1046
+ }
173
1047
  return;
174
1048
  }
175
1049
 
176
1050
  const includeOptionalPermissions = !opts.skipOptionalPermissions;
177
1051
 
178
- console.log(`Connecting to: ${adminConn.display}`);
179
- console.log(`Monitoring user: ${opts.monitoringUser}`);
180
- console.log(`Optional permissions: ${includeOptionalPermissions ? "enabled" : "skipped"}`);
1052
+ if (!jsonOutput) {
1053
+ console.log(`Connecting to: ${adminConn.display}`);
1054
+ console.log(`Monitoring user: ${opts.monitoringUser}`);
1055
+ console.log(`Optional permissions: ${includeOptionalPermissions ? "enabled" : "skipped"}`);
1056
+ }
181
1057
 
182
1058
  // Use native pg client instead of requiring psql to be installed
183
- const { Client } = require("pg");
184
- const client = new Client(adminConn.clientConfig);
185
-
1059
+ let client: Client | undefined;
186
1060
  try {
187
- await client.connect();
188
-
189
- const roleRes = await client.query("select 1 from pg_catalog.pg_roles where rolname = $1", [
190
- opts.monitoringUser,
191
- ]);
192
- const roleExists = roleRes.rowCount > 0;
1061
+ const connResult = await connectWithSslFallback(Client, adminConn);
1062
+ client = connResult.client;
193
1063
 
194
1064
  const dbRes = await client.query("select current_database() as db");
195
1065
  const database = dbRes.rows?.[0]?.db;
@@ -197,40 +1067,390 @@ program
197
1067
  throw new Error("Failed to resolve current database name");
198
1068
  }
199
1069
 
1070
+ if (opts.verify) {
1071
+ const v = await verifyInitSetup({
1072
+ client,
1073
+ database,
1074
+ monitoringUser: opts.monitoringUser,
1075
+ includeOptionalPermissions,
1076
+ provider: opts.provider,
1077
+ });
1078
+ if (v.ok) {
1079
+ if (jsonOutput) {
1080
+ outputJson({
1081
+ success: true,
1082
+ mode: "direct",
1083
+ action: "verify",
1084
+ database,
1085
+ monitoringUser: opts.monitoringUser,
1086
+ provider: opts.provider,
1087
+ verified: true,
1088
+ missingOptional: v.missingOptional,
1089
+ });
1090
+ } else {
1091
+ console.log(`✓ prepare-db verify: OK${opts.provider ? ` (provider: ${opts.provider})` : ""}`);
1092
+ if (v.missingOptional.length > 0) {
1093
+ console.log("⚠ Optional items missing:");
1094
+ for (const m of v.missingOptional) console.log(`- ${m}`);
1095
+ }
1096
+ }
1097
+ return;
1098
+ }
1099
+ if (jsonOutput) {
1100
+ outputJson({
1101
+ success: false,
1102
+ mode: "direct",
1103
+ action: "verify",
1104
+ database,
1105
+ monitoringUser: opts.monitoringUser,
1106
+ verified: false,
1107
+ missingRequired: v.missingRequired,
1108
+ missingOptional: v.missingOptional,
1109
+ });
1110
+ } else {
1111
+ console.error("✗ prepare-db verify failed: missing required items");
1112
+ for (const m of v.missingRequired) console.error(`- ${m}`);
1113
+ if (v.missingOptional.length > 0) {
1114
+ console.error("Optional items missing:");
1115
+ for (const m of v.missingOptional) console.error(`- ${m}`);
1116
+ }
1117
+ }
1118
+ process.exitCode = 1;
1119
+ return;
1120
+ }
1121
+
1122
+ let monPassword: string;
1123
+ let passwordGenerated = false;
1124
+ try {
1125
+ const resolved = await resolveMonitoringPassword({
1126
+ passwordFlag: opts.password,
1127
+ passwordEnv: process.env.PGAI_MON_PASSWORD,
1128
+ monitoringUser: opts.monitoringUser,
1129
+ });
1130
+ monPassword = resolved.password;
1131
+ passwordGenerated = resolved.generated;
1132
+ if (resolved.generated) {
1133
+ const canPrint = process.stdout.isTTY || !!opts.printPassword || jsonOutput;
1134
+ if (canPrint) {
1135
+ if (!jsonOutput) {
1136
+ // Print secrets to stderr to reduce the chance they end up in piped stdout logs.
1137
+ const shellSafe = monPassword.replace(/'/g, "'\\''");
1138
+ console.error("");
1139
+ console.error(`Generated monitoring password for ${opts.monitoringUser} (copy/paste):`);
1140
+ // Quote for shell copy/paste safety.
1141
+ console.error(`PGAI_MON_PASSWORD='${shellSafe}'`);
1142
+ console.error("");
1143
+ console.log("Store it securely (or rerun with --password / PGAI_MON_PASSWORD to set your own).");
1144
+ }
1145
+ // For JSON mode, password will be included in the success output below
1146
+ } else {
1147
+ console.error(
1148
+ [
1149
+ `✗ Monitoring password was auto-generated for ${opts.monitoringUser} but not printed in non-interactive mode.`,
1150
+ "",
1151
+ "Provide it explicitly:",
1152
+ " --password <password> or PGAI_MON_PASSWORD=...",
1153
+ "",
1154
+ "Or (NOT recommended) print the generated password:",
1155
+ " --print-password",
1156
+ ].join("\n")
1157
+ );
1158
+ process.exitCode = 1;
1159
+ return;
1160
+ }
1161
+ }
1162
+ } catch (e) {
1163
+ const msg = e instanceof Error ? e.message : String(e);
1164
+ outputError({ message: msg });
1165
+ return;
1166
+ }
1167
+
200
1168
  const plan = await buildInitPlan({
201
1169
  database,
202
1170
  monitoringUser: opts.monitoringUser,
203
1171
  monitoringPassword: monPassword,
204
1172
  includeOptionalPermissions,
205
- roleExists,
1173
+ provider: opts.provider,
206
1174
  });
207
1175
 
208
- const { applied, skippedOptional } = await applyInitPlan({ client, plan });
1176
+ // For reset-password, we only want the role step. But if provider skips role creation,
1177
+ // reset-password doesn't make sense - warn the user.
1178
+ const effectivePlan = opts.resetPassword
1179
+ ? { ...plan, steps: plan.steps.filter((s) => s.name === "01.role") }
1180
+ : plan;
209
1181
 
210
- console.log("✓ init completed");
211
- if (skippedOptional.length > 0) {
212
- console.log("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
213
- for (const s of skippedOptional) console.log(`- ${s}`);
1182
+ if (opts.resetPassword && effectivePlan.steps.length === 0) {
1183
+ console.error(`✗ --reset-password not supported for provider "${opts.provider}" (role creation is skipped)`);
1184
+ process.exitCode = 1;
1185
+ return;
214
1186
  }
215
- // Keep output compact but still useful
216
- if (process.stdout.isTTY) {
217
- console.log(`Applied ${applied.length} steps`);
1187
+
1188
+ if (shouldPrintSql) {
1189
+ console.log("\n--- SQL plan ---");
1190
+ for (const step of effectivePlan.steps) {
1191
+ console.log(`\n-- ${step.name}${step.optional ? " (optional)" : ""}`);
1192
+ console.log(redactPasswords(step.sql));
1193
+ }
1194
+ console.log("\n--- end SQL plan ---\n");
1195
+ console.log("Note: passwords are redacted in the printed SQL output.");
1196
+ return;
1197
+ }
1198
+
1199
+ const { applied, skippedOptional } = await applyInitPlan({ client, plan: effectivePlan });
1200
+
1201
+ if (jsonOutput) {
1202
+ const result: Record<string, unknown> = {
1203
+ success: true,
1204
+ mode: "direct",
1205
+ action: opts.resetPassword ? "reset-password" : "apply",
1206
+ database,
1207
+ monitoringUser: opts.monitoringUser,
1208
+ applied,
1209
+ skippedOptional,
1210
+ warnings: skippedOptional.length > 0
1211
+ ? ["Some optional steps were skipped (not supported or insufficient privileges)"]
1212
+ : [],
1213
+ };
1214
+ if (passwordGenerated) {
1215
+ result.generatedPassword = monPassword;
1216
+ }
1217
+ outputJson(result);
1218
+ } else {
1219
+ console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
1220
+ if (skippedOptional.length > 0) {
1221
+ console.log("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
1222
+ for (const s of skippedOptional) console.log(`- ${s}`);
1223
+ }
1224
+ // Keep output compact but still useful
1225
+ if (process.stdout.isTTY) {
1226
+ console.log(`Applied ${applied.length} steps`);
1227
+ }
218
1228
  }
219
1229
  } catch (error) {
220
1230
  const errAny = error as any;
221
- const message = error instanceof Error ? error.message : String(error);
222
- console.error(`✗ init failed: ${message}`);
223
- if (errAny && typeof errAny === "object" && typeof errAny.code === "string") {
224
- if (errAny.code === "42501") {
225
- console.error("Hint: connect as a superuser (or a role with CREATEROLE and sufficient GRANT privileges).");
1231
+ let message = "";
1232
+ if (error instanceof Error && error.message) {
1233
+ message = error.message;
1234
+ } else if (errAny && typeof errAny === "object" && typeof errAny.message === "string" && errAny.message) {
1235
+ message = errAny.message;
1236
+ } else {
1237
+ message = String(error);
1238
+ }
1239
+ if (!message || message === "[object Object]") {
1240
+ message = "Unknown error";
1241
+ }
1242
+
1243
+ // If this was a plan step failure, surface the step name explicitly to help users diagnose quickly.
1244
+ const stepMatch =
1245
+ typeof message === "string" ? message.match(/Failed at step "([^"]+)":/i) : null;
1246
+ const failedStep = stepMatch?.[1];
1247
+
1248
+ // Build error object for JSON output
1249
+ const errorObj: {
1250
+ message: string;
1251
+ step?: string;
1252
+ code?: string;
1253
+ detail?: string;
1254
+ hint?: string;
1255
+ } = { message };
1256
+
1257
+ if (failedStep) errorObj.step = failedStep;
1258
+ if (errAny && typeof errAny === "object") {
1259
+ if (typeof errAny.code === "string" && errAny.code) errorObj.code = errAny.code;
1260
+ if (typeof errAny.detail === "string" && errAny.detail) errorObj.detail = errAny.detail;
1261
+ if (typeof errAny.hint === "string" && errAny.hint) errorObj.hint = errAny.hint;
1262
+ }
1263
+
1264
+ if (jsonOutput) {
1265
+ outputJson({
1266
+ success: false,
1267
+ mode: "direct",
1268
+ error: errorObj,
1269
+ });
1270
+ process.exitCode = 1;
1271
+ } else {
1272
+ console.error(`Error: prepare-db: ${message}`);
1273
+ if (failedStep) {
1274
+ console.error(` Step: ${failedStep}`);
1275
+ }
1276
+ if (errAny && typeof errAny === "object") {
1277
+ if (typeof errAny.code === "string" && errAny.code) {
1278
+ console.error(` Code: ${errAny.code}`);
1279
+ }
1280
+ if (typeof errAny.detail === "string" && errAny.detail) {
1281
+ console.error(` Detail: ${errAny.detail}`);
1282
+ }
1283
+ if (typeof errAny.hint === "string" && errAny.hint) {
1284
+ console.error(` Hint: ${errAny.hint}`);
1285
+ }
1286
+ }
1287
+ if (errAny && typeof errAny === "object" && typeof errAny.code === "string") {
1288
+ if (errAny.code === "42501") {
1289
+ if (failedStep === "01.role") {
1290
+ console.error(" Context: role creation/update requires CREATEROLE or superuser");
1291
+ } else if (failedStep === "02.permissions") {
1292
+ console.error(" Context: grants/view/search_path require sufficient GRANT/DDL privileges");
1293
+ }
1294
+ console.error(" Fix: connect as a superuser (or a role with CREATEROLE and sufficient GRANT privileges)");
1295
+ console.error(" Fix: on managed Postgres, use the provider's admin/master user");
1296
+ console.error(" Tip: run with --print-sql to review the exact SQL plan");
1297
+ }
1298
+ if (errAny.code === "ECONNREFUSED") {
1299
+ console.error(" Hint: check host/port and ensure Postgres is reachable from this machine");
1300
+ }
1301
+ if (errAny.code === "ENOTFOUND") {
1302
+ console.error(" Hint: DNS resolution failed; double-check the host name");
1303
+ }
1304
+ if (errAny.code === "ETIMEDOUT") {
1305
+ console.error(" Hint: connection timed out; check network/firewall rules");
1306
+ }
1307
+ }
1308
+ process.exitCode = 1;
1309
+ }
1310
+ } finally {
1311
+ if (client) {
1312
+ try {
1313
+ await client.end();
1314
+ } catch {
1315
+ // ignore
226
1316
  }
227
1317
  }
1318
+ }
1319
+ });
1320
+
1321
+ program
1322
+ .command("checkup [conn]")
1323
+ .description("generate health check reports directly from PostgreSQL (express mode)")
1324
+ .option("--check-id <id>", `specific check to run: ${Object.keys(CHECK_INFO).join(", ")}, or ALL`, "ALL")
1325
+ .option("--node-name <name>", "node name for reports", "node-01")
1326
+ .option("--output <path>", "output directory for JSON files")
1327
+ .option("--[no-]upload", "upload JSON results to PostgresAI (default: enabled; requires API key)", undefined)
1328
+ .option(
1329
+ "--project <project>",
1330
+ "project name or ID for remote upload (used with --upload; defaults to config defaultProject; auto-generated on first run)"
1331
+ )
1332
+ .option("--json", "output JSON to stdout (implies --no-upload)")
1333
+ .addHelpText(
1334
+ "after",
1335
+ [
1336
+ "",
1337
+ "Available checks:",
1338
+ ...Object.entries(CHECK_INFO).map(([id, title]) => ` ${id}: ${title}`),
1339
+ "",
1340
+ "Examples:",
1341
+ " postgresai checkup postgresql://user:pass@host:5432/db",
1342
+ " postgresai checkup postgresql://user:pass@host:5432/db --check-id A003",
1343
+ " postgresai checkup postgresql://user:pass@host:5432/db --output ./reports",
1344
+ " postgresai checkup postgresql://user:pass@host:5432/db --project my_project",
1345
+ " postgresai set-default-project my_project",
1346
+ " postgresai checkup postgresql://user:pass@host:5432/db",
1347
+ " postgresai checkup postgresql://user:pass@host:5432/db --no-upload --json",
1348
+ ].join("\n")
1349
+ )
1350
+ .action(async (conn: string | undefined, opts: CheckupOptions, cmd: Command) => {
1351
+ if (!conn) {
1352
+ cmd.outputHelp();
1353
+ process.exitCode = 1;
1354
+ return;
1355
+ }
1356
+
1357
+ const shouldPrintJson = !!opts.json;
1358
+ const uploadExplicitlyRequested = opts.upload === true;
1359
+ const uploadExplicitlyDisabled = opts.upload === false || shouldPrintJson;
1360
+ let shouldUpload = !uploadExplicitlyDisabled;
1361
+
1362
+ // Preflight: validate/create output directory BEFORE connecting / running checks.
1363
+ const outputPath = prepareOutputDirectory(opts.output);
1364
+ if (outputPath === null) {
1365
+ process.exitCode = 1;
1366
+ return;
1367
+ }
1368
+
1369
+ // Preflight: validate upload flags/credentials BEFORE connecting / running checks.
1370
+ const rootOpts = program.opts() as CliOptions;
1371
+ const uploadResult = prepareUploadConfig(opts, rootOpts, shouldUpload, uploadExplicitlyRequested);
1372
+ if (uploadResult === null) {
1373
+ process.exitCode = 1;
1374
+ return;
1375
+ }
1376
+ const uploadCfg = uploadResult?.config;
1377
+ const projectWasGenerated = uploadResult?.projectWasGenerated ?? false;
1378
+ shouldUpload = !!uploadCfg;
1379
+
1380
+ // Connect and run checks
1381
+ const adminConn = resolveAdminConnection({
1382
+ conn,
1383
+ envPassword: process.env.PGPASSWORD,
1384
+ });
1385
+ let client: Client | undefined;
1386
+ const spinnerEnabled = !!process.stdout.isTTY && shouldUpload;
1387
+ const spinner = createTtySpinner(spinnerEnabled, "Connecting to Postgres");
1388
+
1389
+ try {
1390
+ spinner.update("Connecting to Postgres");
1391
+ const connResult = await connectWithSslFallback(Client, adminConn);
1392
+ client = connResult.client as Client;
1393
+
1394
+ // Generate reports
1395
+ let reports: Record<string, any>;
1396
+ if (opts.checkId === "ALL") {
1397
+ reports = await generateAllReports(client, opts.nodeName, (p) => {
1398
+ spinner.update(`Running ${p.checkId}: ${p.checkTitle} (${p.index}/${p.total})`);
1399
+ });
1400
+ } else {
1401
+ const checkId = opts.checkId.toUpperCase();
1402
+ const generator = REPORT_GENERATORS[checkId];
1403
+ if (!generator) {
1404
+ spinner.stop();
1405
+ console.error(`Unknown check ID: ${opts.checkId}`);
1406
+ console.error(`Available: ${Object.keys(CHECK_INFO).join(", ")}, ALL`);
1407
+ process.exitCode = 1;
1408
+ return;
1409
+ }
1410
+ spinner.update(`Running ${checkId}: ${CHECK_INFO[checkId] || checkId}`);
1411
+ reports = { [checkId]: await generator(client, opts.nodeName) };
1412
+ }
1413
+
1414
+ // Upload to PostgresAI API (if configured)
1415
+ let uploadSummary: UploadSummary | undefined;
1416
+ if (uploadCfg) {
1417
+ const logUpload = (msg: string): void => {
1418
+ (shouldPrintJson ? console.error : console.log)(msg);
1419
+ };
1420
+ uploadSummary = await uploadCheckupReports(uploadCfg, reports, spinner, logUpload);
1421
+ }
1422
+
1423
+ spinner.stop();
1424
+
1425
+ // Write to files (if output path specified)
1426
+ if (outputPath) {
1427
+ writeReportFiles(reports, outputPath);
1428
+ }
1429
+
1430
+ // Print upload summary
1431
+ if (uploadSummary) {
1432
+ printUploadSummary(uploadSummary, projectWasGenerated, shouldPrintJson);
1433
+ }
1434
+
1435
+ // Output JSON to stdout
1436
+ if (shouldPrintJson || (!shouldUpload && !opts.output)) {
1437
+ console.log(JSON.stringify(reports, null, 2));
1438
+ }
1439
+ } catch (error) {
1440
+ if (error instanceof RpcError) {
1441
+ for (const line of formatRpcErrorForDisplay(error)) {
1442
+ console.error(line);
1443
+ }
1444
+ } else {
1445
+ const message = error instanceof Error ? error.message : String(error);
1446
+ console.error(`Error: ${message}`);
1447
+ }
228
1448
  process.exitCode = 1;
229
1449
  } finally {
230
- try {
1450
+ // Always stop spinner to prevent interval leak (idempotent - safe to call multiple times)
1451
+ spinner.stop();
1452
+ if (client) {
231
1453
  await client.end();
232
- } catch {
233
- // ignore
234
1454
  }
235
1455
  }
236
1456
  });
@@ -268,6 +1488,14 @@ function resolvePaths(): PathResolution {
268
1488
  );
269
1489
  }
270
1490
 
1491
+ async function resolveOrInitPaths(): Promise<PathResolution> {
1492
+ try {
1493
+ return resolvePaths();
1494
+ } catch {
1495
+ return ensureDefaultMonitoringProject();
1496
+ }
1497
+ }
1498
+
271
1499
  /**
272
1500
  * Check if Docker daemon is running
273
1501
  */
@@ -315,11 +1543,11 @@ function checkRunningContainers(): { running: boolean; containers: string[] } {
315
1543
  /**
316
1544
  * Run docker compose command
317
1545
  */
318
- async function runCompose(args: string[]): Promise<number> {
1546
+ async function runCompose(args: string[], grafanaPassword?: string): Promise<number> {
319
1547
  let composeFile: string;
320
1548
  let projectDir: string;
321
1549
  try {
322
- ({ composeFile, projectDir } = resolvePaths());
1550
+ ({ composeFile, projectDir } = await resolveOrInitPaths());
323
1551
  } catch (error) {
324
1552
  const message = error instanceof Error ? error.message : String(error);
325
1553
  console.error(message);
@@ -341,28 +1569,42 @@ async function runCompose(args: string[]): Promise<number> {
341
1569
  return 1;
342
1570
  }
343
1571
 
344
- // Read Grafana password from .pgwatch-config and pass to Docker Compose
1572
+ // Set Grafana password from parameter or .pgwatch-config
345
1573
  const env = { ...process.env };
346
- const cfgPath = path.resolve(projectDir, ".pgwatch-config");
347
- if (fs.existsSync(cfgPath)) {
348
- try {
349
- const stats = fs.statSync(cfgPath);
350
- if (!stats.isDirectory()) {
351
- const content = fs.readFileSync(cfgPath, "utf8");
352
- const match = content.match(/^grafana_password=([^\r\n]+)/m);
353
- if (match) {
354
- env.GF_SECURITY_ADMIN_PASSWORD = match[1].trim();
1574
+ if (grafanaPassword) {
1575
+ env.GF_SECURITY_ADMIN_PASSWORD = grafanaPassword;
1576
+ } else {
1577
+ const cfgPath = path.resolve(projectDir, ".pgwatch-config");
1578
+ if (fs.existsSync(cfgPath)) {
1579
+ try {
1580
+ const stats = fs.statSync(cfgPath);
1581
+ if (!stats.isDirectory()) {
1582
+ const content = fs.readFileSync(cfgPath, "utf8");
1583
+ const match = content.match(/^grafana_password=([^\r\n]+)/m);
1584
+ if (match) {
1585
+ env.GF_SECURITY_ADMIN_PASSWORD = match[1].trim();
1586
+ }
1587
+ }
1588
+ } catch (err) {
1589
+ // If we can't read the config, log warning and continue without setting the password
1590
+ if (process.env.DEBUG) {
1591
+ console.warn(`Warning: Could not read Grafana password from config: ${err instanceof Error ? err.message : String(err)}`);
355
1592
  }
356
1593
  }
357
- } catch (err) {
358
- // If we can't read the config, continue without setting the password
359
1594
  }
360
1595
  }
361
1596
 
1597
+ // On macOS, node-exporter can't mount host root filesystem - skip it
1598
+ const finalArgs = [...args];
1599
+ if (process.platform === "darwin" && args.includes("up")) {
1600
+ finalArgs.push("--scale", "node-exporter=0");
1601
+ }
1602
+
362
1603
  return new Promise<number>((resolve) => {
363
- const child = spawn(cmd[0], [...cmd.slice(1), "-f", composeFile, ...args], {
1604
+ const child = spawn(cmd[0], [...cmd.slice(1), "-f", composeFile, ...finalArgs], {
364
1605
  stdio: "inherit",
365
- env: env
1606
+ env: env,
1607
+ cwd: projectDir
366
1608
  });
367
1609
  child.on("close", (code) => resolve(code || 0));
368
1610
  });
@@ -376,18 +1618,64 @@ program.command("help", { isDefault: true }).description("show help").action(()
376
1618
  const mon = program.command("mon").description("monitoring services management");
377
1619
 
378
1620
  mon
379
- .command("quickstart")
380
- .description("complete setup (generate config, start monitoring services)")
1621
+ .command("local-install")
1622
+ .description("install local monitoring stack (generate config, start services)")
381
1623
  .option("--demo", "demo mode with sample database", false)
382
1624
  .option("--api-key <key>", "Postgres AI API key for automated report uploads")
383
1625
  .option("--db-url <url>", "PostgreSQL connection URL to monitor")
1626
+ .option("--tag <tag>", "Docker image tag to use (e.g., 0.14.0, 0.14.0-dev.33)")
384
1627
  .option("-y, --yes", "accept all defaults and skip interactive prompts", false)
385
- .action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; yes: boolean }) => {
1628
+ .action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; tag?: string; yes: boolean }) => {
1629
+ // Get apiKey from global program options (--api-key is defined globally)
1630
+ // This is needed because Commander.js routes --api-key to the global option, not the subcommand's option
1631
+ const globalOpts = program.opts<CliOptions>();
1632
+ const apiKey = opts.apiKey || globalOpts.apiKey;
1633
+
386
1634
  console.log("\n=================================");
387
- console.log(" PostgresAI Monitoring Quickstart");
1635
+ console.log(" PostgresAI monitoring local install");
388
1636
  console.log("=================================\n");
389
1637
  console.log("This will install, configure, and start the monitoring system\n");
390
1638
 
1639
+ // Ensure we have a project directory with docker-compose.yml even if running from elsewhere
1640
+ const { projectDir } = await resolveOrInitPaths();
1641
+ console.log(`Project directory: ${projectDir}\n`);
1642
+
1643
+ // Update .env with custom tag if provided
1644
+ const envFile = path.resolve(projectDir, ".env");
1645
+
1646
+ // Build .env content, preserving important existing values (registry, password)
1647
+ // Note: PGAI_TAG is intentionally NOT preserved - the CLI version should always match Docker images
1648
+ let existingRegistry: string | null = null;
1649
+ let existingPassword: string | null = null;
1650
+
1651
+ if (fs.existsSync(envFile)) {
1652
+ const existingEnv = fs.readFileSync(envFile, "utf8");
1653
+ // Extract existing values (except tag - always use CLI version)
1654
+ const registryMatch = existingEnv.match(/^PGAI_REGISTRY=(.+)$/m);
1655
+ if (registryMatch) existingRegistry = registryMatch[1].trim();
1656
+ const pwdMatch = existingEnv.match(/^GF_SECURITY_ADMIN_PASSWORD=(.+)$/m);
1657
+ if (pwdMatch) existingPassword = pwdMatch[1].trim();
1658
+ }
1659
+
1660
+ // Priority: CLI --tag flag > package version
1661
+ // Note: We intentionally do NOT use process.env.PGAI_TAG here because Bun auto-loads .env files,
1662
+ // which would cause stale .env values to override the CLI version. The CLI version should always
1663
+ // match the Docker images. Users can override with --tag if needed.
1664
+ const imageTag = opts.tag || pkg.version;
1665
+
1666
+ const envLines: string[] = [`PGAI_TAG=${imageTag}`];
1667
+ if (existingRegistry) {
1668
+ envLines.push(`PGAI_REGISTRY=${existingRegistry}`);
1669
+ }
1670
+ if (existingPassword) {
1671
+ envLines.push(`GF_SECURITY_ADMIN_PASSWORD=${existingPassword}`);
1672
+ }
1673
+ fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
1674
+
1675
+ if (opts.tag) {
1676
+ console.log(`Using image tag: ${imageTag}\n`);
1677
+ }
1678
+
391
1679
  // Validate conflicting options
392
1680
  if (opts.demo && opts.dbUrl) {
393
1681
  console.log("⚠ Both --demo and --db-url provided. Demo mode includes its own database.");
@@ -395,11 +1683,11 @@ mon
395
1683
  opts.dbUrl = undefined;
396
1684
  }
397
1685
 
398
- if (opts.demo && opts.apiKey) {
1686
+ if (opts.demo && apiKey) {
399
1687
  console.error("✗ Cannot use --api-key with --demo mode");
400
1688
  console.error("✗ Demo mode is for testing only and does not support API key integration");
401
- console.error("\nUse demo mode without API key: postgres-ai mon quickstart --demo");
402
- console.error("Or use production mode with API key: postgres-ai mon quickstart --api-key=your_key");
1689
+ console.error("\nUse demo mode without API key: postgres-ai mon local-install --demo");
1690
+ console.error("Or use production mode with API key: postgres-ai mon local-install --api-key=your_key");
403
1691
  process.exitCode = 1;
404
1692
  return;
405
1693
  }
@@ -417,9 +1705,14 @@ mon
417
1705
  console.log("Step 1: Postgres AI API Configuration (Optional)");
418
1706
  console.log("An API key enables automatic upload of PostgreSQL reports to Postgres AI\n");
419
1707
 
420
- if (opts.apiKey) {
1708
+ if (apiKey) {
421
1709
  console.log("Using API key provided via --api-key parameter");
422
- config.writeConfig({ apiKey: opts.apiKey });
1710
+ config.writeConfig({ apiKey });
1711
+ // Keep reporter compatibility (docker-compose mounts .pgwatch-config)
1712
+ fs.writeFileSync(path.resolve(projectDir, ".pgwatch-config"), `api_key=${apiKey}\n`, {
1713
+ encoding: "utf8",
1714
+ mode: 0o600
1715
+ });
423
1716
  console.log("✓ API key saved\n");
424
1717
  } else if (opts.yes) {
425
1718
  // Auto-yes mode without API key - skip API key setup
@@ -427,43 +1720,36 @@ mon
427
1720
  console.log("⚠ Reports will be generated locally only");
428
1721
  console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
429
1722
  } else {
430
- const rl = readline.createInterface({
431
- input: process.stdin,
432
- output: process.stdout
433
- });
434
-
435
- const question = (prompt: string): Promise<string> =>
436
- new Promise((resolve) => rl.question(prompt, resolve));
437
-
438
- try {
439
- const answer = await question("Do you have a Postgres AI API key? (Y/n): ");
440
- const proceedWithApiKey = !answer || answer.toLowerCase() === "y";
441
-
442
- if (proceedWithApiKey) {
443
- while (true) {
444
- const inputApiKey = await question("Enter your Postgres AI API key: ");
445
- const trimmedKey = inputApiKey.trim();
446
-
447
- if (trimmedKey) {
448
- config.writeConfig({ apiKey: trimmedKey });
449
- console.log("✓ API key saved\n");
450
- break;
451
- }
1723
+ const answer = await question("Do you have a Postgres AI API key? (Y/n): ");
1724
+ const proceedWithApiKey = !answer || answer.toLowerCase() === "y";
1725
+
1726
+ if (proceedWithApiKey) {
1727
+ while (true) {
1728
+ const inputApiKey = await question("Enter your Postgres AI API key: ");
1729
+ const trimmedKey = inputApiKey.trim();
1730
+
1731
+ if (trimmedKey) {
1732
+ config.writeConfig({ apiKey: trimmedKey });
1733
+ // Keep reporter compatibility (docker-compose mounts .pgwatch-config)
1734
+ fs.writeFileSync(path.resolve(projectDir, ".pgwatch-config"), `api_key=${trimmedKey}\n`, {
1735
+ encoding: "utf8",
1736
+ mode: 0o600
1737
+ });
1738
+ console.log("✓ API key saved\n");
1739
+ break;
1740
+ }
452
1741
 
453
- console.log("⚠ API key cannot be empty");
454
- const retry = await question("Try again or skip API key setup, retry? (Y/n): ");
455
- if (retry.toLowerCase() === "n") {
456
- console.log("⚠ Skipping API key setup - reports will be generated locally only");
457
- console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
458
- break;
459
- }
1742
+ console.log("⚠ API key cannot be empty");
1743
+ const retry = await question("Try again or skip API key setup, retry? (Y/n): ");
1744
+ if (retry.toLowerCase() === "n") {
1745
+ console.log("⚠ Skipping API key setup - reports will be generated locally only");
1746
+ console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
1747
+ break;
460
1748
  }
461
- } else {
462
- console.log("⚠ Skipping API key setup - reports will be generated locally only");
463
- console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
464
1749
  }
465
- } finally {
466
- rl.close();
1750
+ } else {
1751
+ console.log("⚠ Skipping API key setup - reports will be generated locally only");
1752
+ console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
467
1753
  }
468
1754
  }
469
1755
  } else {
@@ -476,9 +1762,11 @@ mon
476
1762
  console.log("Step 2: Add PostgreSQL Instance to Monitor\n");
477
1763
 
478
1764
  // Clear instances.yml in production mode (start fresh)
479
- const instancesPath = path.resolve(process.cwd(), "instances.yml");
1765
+ const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
480
1766
  const emptyInstancesContent = "# PostgreSQL instances to monitor\n# Add your instances using: postgres-ai mon targets add\n\n";
481
1767
  fs.writeFileSync(instancesPath, emptyInstancesContent, "utf8");
1768
+ console.log(`Instances file: ${instancesPath}`);
1769
+ console.log(`Project directory: ${projectDir}\n`);
482
1770
 
483
1771
  if (opts.dbUrl) {
484
1772
  console.log("Using database URL provided via --db-url parameter");
@@ -507,7 +1795,6 @@ mon
507
1795
  // Test connection
508
1796
  console.log("Testing connection to the added instance...");
509
1797
  try {
510
- const { Client } = require("pg");
511
1798
  const client = new Client({ connectionString: connStr });
512
1799
  await client.connect();
513
1800
  const result = await client.query("select version();");
@@ -524,63 +1811,50 @@ mon
524
1811
  console.log("⚠ No PostgreSQL instance added");
525
1812
  console.log("You can add one later with: postgres-ai mon targets add\n");
526
1813
  } else {
527
- const rl = readline.createInterface({
528
- input: process.stdin,
529
- output: process.stdout
530
- });
531
-
532
- const question = (prompt: string): Promise<string> =>
533
- new Promise((resolve) => rl.question(prompt, resolve));
534
-
535
- try {
536
- console.log("You need to add at least one PostgreSQL instance to monitor");
537
- const answer = await question("Do you want to add a PostgreSQL instance now? (Y/n): ");
538
- const proceedWithInstance = !answer || answer.toLowerCase() === "y";
539
-
540
- if (proceedWithInstance) {
541
- console.log("\nYou can provide either:");
542
- console.log(" 1. A full connection string: postgresql://user:pass@host:port/database");
543
- console.log(" 2. Press Enter to skip for now\n");
544
-
545
- const connStr = await question("Enter connection string (or press Enter to skip): ");
546
-
547
- if (connStr.trim()) {
548
- const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
549
- if (!m) {
550
- console.error("✗ Invalid connection string format");
551
- console.log("⚠ Continuing without adding instance\n");
552
- } else {
553
- const host = m[3];
554
- const db = m[5];
555
- const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
556
-
557
- 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`;
558
- fs.appendFileSync(instancesPath, body, "utf8");
559
- console.log(`✓ Monitoring target '${instanceName}' added\n`);
560
-
561
- // Test connection
562
- console.log("Testing connection to the added instance...");
563
- try {
564
- const { Client } = require("pg");
565
- const client = new Client({ connectionString: connStr });
566
- await client.connect();
567
- const result = await client.query("select version();");
568
- console.log("✓ Connection successful");
569
- console.log(`${result.rows[0].version}\n`);
570
- await client.end();
571
- } catch (error) {
572
- const message = error instanceof Error ? error.message : String(error);
573
- console.error(`✗ Connection failed: ${message}\n`);
574
- }
575
- }
1814
+ console.log("You need to add at least one PostgreSQL instance to monitor");
1815
+ const answer = await question("Do you want to add a PostgreSQL instance now? (Y/n): ");
1816
+ const proceedWithInstance = !answer || answer.toLowerCase() === "y";
1817
+
1818
+ if (proceedWithInstance) {
1819
+ console.log("\nYou can provide either:");
1820
+ console.log(" 1. A full connection string: postgresql://user:pass@host:port/database");
1821
+ console.log(" 2. Press Enter to skip for now\n");
1822
+
1823
+ const connStr = await question("Enter connection string (or press Enter to skip): ");
1824
+
1825
+ if (connStr.trim()) {
1826
+ const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
1827
+ if (!m) {
1828
+ console.error(" Invalid connection string format");
1829
+ console.log(" Continuing without adding instance\n");
576
1830
  } else {
577
- console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
1831
+ const host = m[3];
1832
+ const db = m[5];
1833
+ const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
1834
+
1835
+ 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`;
1836
+ fs.appendFileSync(instancesPath, body, "utf8");
1837
+ console.log(`✓ Monitoring target '${instanceName}' added\n`);
1838
+
1839
+ // Test connection
1840
+ console.log("Testing connection to the added instance...");
1841
+ try {
1842
+ const client = new Client({ connectionString: connStr });
1843
+ await client.connect();
1844
+ const result = await client.query("select version();");
1845
+ console.log("✓ Connection successful");
1846
+ console.log(`${result.rows[0].version}\n`);
1847
+ await client.end();
1848
+ } catch (error) {
1849
+ const message = error instanceof Error ? error.message : String(error);
1850
+ console.error(`✗ Connection failed: ${message}\n`);
1851
+ }
578
1852
  }
579
1853
  } else {
580
1854
  console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
581
1855
  }
582
- } finally {
583
- rl.close();
1856
+ } else {
1857
+ console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
584
1858
  }
585
1859
  }
586
1860
  } else {
@@ -598,7 +1872,7 @@ mon
598
1872
 
599
1873
  // Step 4: Ensure Grafana password is configured
600
1874
  console.log(opts.demo ? "Step 4: Configuring Grafana security..." : "Step 4: Configuring Grafana security...");
601
- const cfgPath = path.resolve(process.cwd(), ".pgwatch-config");
1875
+ const cfgPath = path.resolve(projectDir, ".pgwatch-config");
602
1876
  let grafanaPassword = "";
603
1877
 
604
1878
  try {
@@ -639,8 +1913,8 @@ mon
639
1913
  }
640
1914
 
641
1915
  // Step 5: Start services
642
- console.log(opts.demo ? "Step 5: Starting monitoring services..." : "Step 5: Starting monitoring services...");
643
- const code2 = await runCompose(["up", "-d", "--force-recreate"]);
1916
+ console.log("Step 5: Starting monitoring services...");
1917
+ const code2 = await runCompose(["up", "-d", "--force-recreate"], grafanaPassword);
644
1918
  if (code2 !== 0) {
645
1919
  process.exitCode = code2;
646
1920
  return;
@@ -649,7 +1923,7 @@ mon
649
1923
 
650
1924
  // Final summary
651
1925
  console.log("=================================");
652
- console.log(" 🎉 Quickstart setup completed!");
1926
+ console.log(" Local install completed!");
653
1927
  console.log("=================================\n");
654
1928
 
655
1929
  console.log("What's running:");
@@ -698,11 +1972,75 @@ mon
698
1972
  if (code !== 0) process.exitCode = code;
699
1973
  });
700
1974
 
1975
+ // Known container names for cleanup
1976
+ const MONITORING_CONTAINERS = [
1977
+ "postgres-ai-config-init",
1978
+ "node-exporter",
1979
+ "cadvisor",
1980
+ "grafana-with-datasources",
1981
+ "sink-postgres",
1982
+ "sink-prometheus",
1983
+ "target-db",
1984
+ "pgwatch-postgres",
1985
+ "pgwatch-prometheus",
1986
+ "postgres-exporter-sink",
1987
+ "flask-pgss-api",
1988
+ "sources-generator",
1989
+ "postgres-reports",
1990
+ ];
1991
+
1992
+ /**
1993
+ * Network cleanup constants.
1994
+ * Docker Compose creates a default network named "{project}_default".
1995
+ * In CI environments, network cleanup can fail if containers are slow to disconnect.
1996
+ */
1997
+ const COMPOSE_PROJECT_NAME = "postgres_ai";
1998
+ const DOCKER_NETWORK_NAME = `${COMPOSE_PROJECT_NAME}_default`;
1999
+ /** Delay before retrying network cleanup (allows container network disconnections to complete) */
2000
+ const NETWORK_CLEANUP_DELAY_MS = 2000;
2001
+
2002
+ /** Remove orphaned containers that docker compose down might miss */
2003
+ async function removeOrphanedContainers(): Promise<void> {
2004
+ for (const container of MONITORING_CONTAINERS) {
2005
+ try {
2006
+ await execFilePromise("docker", ["rm", "-f", container]);
2007
+ } catch {
2008
+ // Container doesn't exist, ignore
2009
+ }
2010
+ }
2011
+ }
2012
+
701
2013
  mon
702
2014
  .command("stop")
703
2015
  .description("stop monitoring services")
704
2016
  .action(async () => {
705
- const code = await runCompose(["down"]);
2017
+ // Multi-stage cleanup strategy for reliable shutdown in CI environments:
2018
+ // Stage 1: Standard compose down with orphan removal
2019
+ // Stage 2: Force remove any orphaned containers, then retry compose down
2020
+ // Stage 3: Force remove the Docker network directly
2021
+ // This handles edge cases where containers are slow to disconnect from networks.
2022
+ let code = await runCompose(["down", "--remove-orphans"]);
2023
+
2024
+ // Stage 2: If initial cleanup fails, try removing orphaned containers first
2025
+ if (code !== 0) {
2026
+ await removeOrphanedContainers();
2027
+ // Wait a moment for container network disconnections to complete
2028
+ await new Promise(resolve => setTimeout(resolve, NETWORK_CLEANUP_DELAY_MS));
2029
+ // Retry compose down
2030
+ code = await runCompose(["down", "--remove-orphans"]);
2031
+ }
2032
+
2033
+ // Final cleanup: force remove the network if it still exists
2034
+ if (code !== 0) {
2035
+ try {
2036
+ await execFilePromise("docker", ["network", "rm", DOCKER_NETWORK_NAME]);
2037
+ // Network removal succeeded - cleanup is complete
2038
+ code = 0;
2039
+ } catch {
2040
+ // Network doesn't exist or couldn't be removed, ignore
2041
+ }
2042
+ }
2043
+
706
2044
  if (code !== 0) process.exitCode = code;
707
2045
  });
708
2046
 
@@ -766,17 +2104,17 @@ mon
766
2104
  allHealthy = true;
767
2105
  for (const service of services) {
768
2106
  try {
769
- const { execSync } = require("child_process");
770
- const status = execSync(`docker inspect -f '{{.State.Status}}' ${service.container} 2>/dev/null`, {
771
- encoding: 'utf8',
772
- stdio: ['pipe', 'pipe', 'pipe']
773
- }).trim();
2107
+ const result = spawnSync("docker", ["inspect", "-f", "{{.State.Status}}", service.container], { stdio: "pipe" });
2108
+ const status = result.stdout.trim();
774
2109
 
775
- if (status === 'running') {
2110
+ if (result.status === 0 && status === 'running') {
776
2111
  console.log(`✓ ${service.name}: healthy`);
777
- } else {
2112
+ } else if (result.status === 0) {
778
2113
  console.log(`✗ ${service.name}: unhealthy (status: ${status})`);
779
2114
  allHealthy = false;
2115
+ } else {
2116
+ console.log(`✗ ${service.name}: unreachable`);
2117
+ allHealthy = false;
780
2118
  }
781
2119
  } catch (error) {
782
2120
  console.log(`✗ ${service.name}: unreachable`);
@@ -805,7 +2143,7 @@ mon
805
2143
  let composeFile: string;
806
2144
  let instancesFile: string;
807
2145
  try {
808
- ({ projectDir, composeFile, instancesFile } = resolvePaths());
2146
+ ({ projectDir, composeFile, instancesFile } = await resolveOrInitPaths());
809
2147
  } catch (error) {
810
2148
  const message = error instanceof Error ? error.message : String(error);
811
2149
  console.error(message);
@@ -880,14 +2218,6 @@ mon
880
2218
  .command("reset [service]")
881
2219
  .description("reset all or specific monitoring service")
882
2220
  .action(async (service?: string) => {
883
- const rl = readline.createInterface({
884
- input: process.stdin,
885
- output: process.stdout,
886
- });
887
-
888
- const question = (prompt: string): Promise<string> =>
889
- new Promise((resolve) => rl.question(prompt, resolve));
890
-
891
2221
  try {
892
2222
  if (service) {
893
2223
  // Reset specific service
@@ -897,7 +2227,6 @@ mon
897
2227
  const answer = await question("Continue? (y/N): ");
898
2228
  if (answer.toLowerCase() !== "y") {
899
2229
  console.log("Cancelled");
900
- rl.close();
901
2230
  return;
902
2231
  }
903
2232
 
@@ -924,7 +2253,6 @@ mon
924
2253
  const answer = await question("Continue? (y/N): ");
925
2254
  if (answer.toLowerCase() !== "y") {
926
2255
  console.log("Cancelled");
927
- rl.close();
928
2256
  return;
929
2257
  }
930
2258
 
@@ -938,10 +2266,7 @@ mon
938
2266
  process.exitCode = 1;
939
2267
  }
940
2268
  }
941
-
942
- rl.close();
943
2269
  } catch (error) {
944
- rl.close();
945
2270
  const message = error instanceof Error ? error.message : String(error);
946
2271
  console.error(`Reset failed: ${message}`);
947
2272
  process.exitCode = 1;
@@ -949,34 +2274,72 @@ mon
949
2274
  });
950
2275
  mon
951
2276
  .command("clean")
952
- .description("cleanup monitoring services artifacts")
953
- .action(async () => {
954
- console.log("Cleaning up Docker resources...\n");
2277
+ .description("cleanup monitoring services artifacts (stops services and removes volumes)")
2278
+ .option("--keep-volumes", "keep data volumes (only stop and remove containers)")
2279
+ .action(async (options: { keepVolumes?: boolean }) => {
2280
+ console.log("Cleaning up monitoring services...\n");
955
2281
 
956
2282
  try {
957
- // Remove stopped containers
958
- const { stdout: containers } = await execFilePromise("docker", ["ps", "-aq", "--filter", "status=exited"]);
959
- if (containers.trim()) {
960
- const containerIds = containers.trim().split('\n');
961
- await execFilePromise("docker", ["rm", ...containerIds]);
962
- console.log("✓ Removed stopped containers");
2283
+ // First, use docker-compose down to properly stop and remove containers/volumes
2284
+ const downArgs = options.keepVolumes ? ["down"] : ["down", "-v"];
2285
+ console.log(options.keepVolumes
2286
+ ? "Stopping and removing containers (keeping volumes)..."
2287
+ : "Stopping and removing containers and volumes...");
2288
+
2289
+ const downCode = await runCompose(downArgs);
2290
+ if (downCode === 0) {
2291
+ console.log("✓ Monitoring services stopped and removed");
963
2292
  } else {
964
- console.log(" No stopped containers to remove");
2293
+ console.log(" Could not stop services (may not be running)");
965
2294
  }
966
2295
 
967
- // Remove unused volumes
968
- await execFilePromise("docker", ["volume", "prune", "-f"]);
969
- console.log("✓ Removed unused volumes");
2296
+ // Remove any orphaned containers that docker compose down missed
2297
+ await removeOrphanedContainers();
2298
+ console.log("✓ Removed orphaned containers");
2299
+
2300
+ // Remove orphaned volumes from previous installs with different project names
2301
+ if (!options.keepVolumes) {
2302
+ const volumePatterns = [
2303
+ "monitoring_grafana_data",
2304
+ "monitoring_postgres_ai_configs",
2305
+ "monitoring_sink_postgres_data",
2306
+ "monitoring_target_db_data",
2307
+ "monitoring_victoria_metrics_data",
2308
+ "postgres_ai_configs_grafana_data",
2309
+ "postgres_ai_configs_sink_postgres_data",
2310
+ "postgres_ai_configs_target_db_data",
2311
+ "postgres_ai_configs_victoria_metrics_data",
2312
+ "postgres_ai_configs_postgres_ai_configs",
2313
+ ];
2314
+
2315
+ const { stdout: existingVolumes } = await execFilePromise("docker", ["volume", "ls", "-q"]);
2316
+ const volumeList = existingVolumes.trim().split('\n').filter(Boolean);
2317
+ const orphanedVolumes = volumeList.filter(v => volumePatterns.includes(v));
2318
+
2319
+ if (orphanedVolumes.length > 0) {
2320
+ let removedCount = 0;
2321
+ for (const vol of orphanedVolumes) {
2322
+ try {
2323
+ await execFilePromise("docker", ["volume", "rm", vol]);
2324
+ removedCount++;
2325
+ } catch {
2326
+ // Volume might be in use, skip silently
2327
+ }
2328
+ }
2329
+ if (removedCount > 0) {
2330
+ console.log(`✓ Removed ${removedCount} orphaned volume(s) from previous installs`);
2331
+ }
2332
+ }
2333
+ }
970
2334
 
971
- // Remove unused networks
2335
+ // Remove any dangling resources
972
2336
  await execFilePromise("docker", ["network", "prune", "-f"]);
973
2337
  console.log("✓ Removed unused networks");
974
2338
 
975
- // Remove dangling images
976
2339
  await execFilePromise("docker", ["image", "prune", "-f"]);
977
2340
  console.log("✓ Removed dangling images");
978
2341
 
979
- console.log("\nCleanup completed");
2342
+ console.log("\n✓ Cleanup completed - ready for fresh install");
980
2343
  } catch (error) {
981
2344
  const message = error instanceof Error ? error.message : String(error);
982
2345
  console.error(`Error during cleanup: ${message}`);
@@ -1005,9 +2368,9 @@ targets
1005
2368
  .command("list")
1006
2369
  .description("list monitoring target databases")
1007
2370
  .action(async () => {
1008
- const instancesPath = path.resolve(process.cwd(), "instances.yml");
2371
+ const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
1009
2372
  if (!fs.existsSync(instancesPath)) {
1010
- console.error(`instances.yml not found in ${process.cwd()}`);
2373
+ console.error(`instances.yml not found in ${projectDir}`);
1011
2374
  process.exitCode = 1;
1012
2375
  return;
1013
2376
  }
@@ -1054,7 +2417,7 @@ targets
1054
2417
  .command("add [connStr] [name]")
1055
2418
  .description("add monitoring target database")
1056
2419
  .action(async (connStr?: string, name?: string) => {
1057
- const file = path.resolve(process.cwd(), "instances.yml");
2420
+ const { instancesFile: file } = await resolveOrInitPaths();
1058
2421
  if (!connStr) {
1059
2422
  console.error("Connection string required: postgresql://user:pass@host:port/db");
1060
2423
  process.exitCode = 1;
@@ -1104,7 +2467,7 @@ targets
1104
2467
  .command("remove <name>")
1105
2468
  .description("remove monitoring target database")
1106
2469
  .action(async (name: string) => {
1107
- const file = path.resolve(process.cwd(), "instances.yml");
2470
+ const { instancesFile: file } = await resolveOrInitPaths();
1108
2471
  if (!fs.existsSync(file)) {
1109
2472
  console.error("instances.yml not found");
1110
2473
  process.exitCode = 1;
@@ -1141,7 +2504,7 @@ targets
1141
2504
  .command("test <name>")
1142
2505
  .description("test monitoring target database connectivity")
1143
2506
  .action(async (name: string) => {
1144
- const instancesPath = path.resolve(process.cwd(), "instances.yml");
2507
+ const { instancesFile: instancesPath } = await resolveOrInitPaths();
1145
2508
  if (!fs.existsSync(instancesPath)) {
1146
2509
  console.error("instances.yml not found");
1147
2510
  process.exitCode = 1;
@@ -1175,7 +2538,6 @@ targets
1175
2538
  console.log(`Testing connection to monitoring target '${name}'...`);
1176
2539
 
1177
2540
  // Use native pg client instead of requiring psql to be installed
1178
- const { Client } = require('pg');
1179
2541
  const client = new Client({ connectionString: instance.conn_str });
1180
2542
 
1181
2543
  try {
@@ -1194,15 +2556,43 @@ targets
1194
2556
  });
1195
2557
 
1196
2558
  // Authentication and API key management
1197
- program
1198
- .command("auth")
1199
- .description("authenticate via browser and obtain API key")
2559
+ const auth = program.command("auth").description("authentication and API key management");
2560
+
2561
+ auth
2562
+ .command("login", { isDefault: true })
2563
+ .description("authenticate via browser (OAuth) or store API key directly")
2564
+ .option("--set-key <key>", "store API key directly without OAuth flow")
1200
2565
  .option("--port <port>", "local callback server port (default: random)", parseInt)
1201
2566
  .option("--debug", "enable debug output")
1202
- .action(async (opts: { port?: number; debug?: boolean }) => {
1203
- const pkce = require("../lib/pkce");
1204
- const authServer = require("../lib/auth-server");
2567
+ .action(async (opts: { setKey?: string; port?: number; debug?: boolean }) => {
2568
+ // If --set-key is provided, store it directly without OAuth
2569
+ if (opts.setKey) {
2570
+ const trimmedKey = opts.setKey.trim();
2571
+ if (!trimmedKey) {
2572
+ console.error("Error: API key cannot be empty");
2573
+ process.exitCode = 1;
2574
+ return;
2575
+ }
2576
+
2577
+ // Read existing config to check for defaultProject before updating
2578
+ const existingConfig = config.readConfig();
2579
+ const existingProject = existingConfig.defaultProject;
2580
+
2581
+ config.writeConfig({ apiKey: trimmedKey });
2582
+ // When API key is set directly, only clear orgId (org selection may differ).
2583
+ // Preserve defaultProject to avoid orphaning historical reports.
2584
+ // If the new key lacks access to the project, upload will fail with a clear error.
2585
+ config.deleteConfigKeys(["orgId"]);
2586
+
2587
+ console.log(`API key saved to ${config.getConfigPath()}`);
2588
+ if (existingProject) {
2589
+ console.log(`Note: Your default project "${existingProject}" has been preserved.`);
2590
+ console.log(` If this key belongs to a different account, use --project to specify a new one.`);
2591
+ }
2592
+ return;
2593
+ }
1205
2594
 
2595
+ // Otherwise, proceed with OAuth flow
1206
2596
  console.log("Starting authentication flow...\n");
1207
2597
 
1208
2598
  // Generate PKCE parameters
@@ -1223,10 +2613,10 @@ program
1223
2613
  const requestedPort = opts.port || 0; // 0 = OS assigns available port
1224
2614
  const callbackServer = authServer.createCallbackServer(requestedPort, params.state, 120000); // 2 minute timeout
1225
2615
 
1226
- // Wait a bit for server to start and get port
1227
- await new Promise(resolve => setTimeout(resolve, 100));
1228
- const actualPort = callbackServer.getPort();
1229
- const redirectUri = `http://localhost:${actualPort}/callback`;
2616
+ // Wait for server to start and get the actual port
2617
+ const actualPort = await callbackServer.ready;
2618
+ // Use 127.0.0.1 to match the server bind address (avoids IPv6 issues on some hosts)
2619
+ const redirectUri = `http://127.0.0.1:${actualPort}/callback`;
1230
2620
 
1231
2621
  console.log(`Callback server listening on port ${actualPort}`);
1232
2622
 
@@ -1248,173 +2638,180 @@ program
1248
2638
  console.log(`Debug: Request data: ${initData}`);
1249
2639
  }
1250
2640
 
1251
- const initReq = http.request(
1252
- initUrl,
1253
- {
2641
+ // Step 2: Initialize OAuth session on backend using fetch
2642
+ let initResponse: Response;
2643
+ try {
2644
+ initResponse = await fetch(initUrl.toString(), {
1254
2645
  method: "POST",
1255
2646
  headers: {
1256
2647
  "Content-Type": "application/json",
1257
- "Content-Length": Buffer.byteLength(initData),
1258
2648
  },
1259
- },
1260
- (res) => {
1261
- let data = "";
1262
- res.on("data", (chunk) => (data += chunk));
1263
- res.on("end", async () => {
1264
- if (res.statusCode !== 200) {
1265
- console.error(`Failed to initialize auth session: ${res.statusCode}`);
1266
-
1267
- // Check if response is HTML (common for 404 pages)
1268
- if (data.trim().startsWith("<!") || data.trim().startsWith("<html")) {
1269
- console.error("Error: Received HTML response instead of JSON. This usually means:");
1270
- console.error(" 1. The API endpoint URL is incorrect");
1271
- console.error(" 2. The endpoint does not exist (404)");
1272
- console.error(`\nAPI URL attempted: ${initUrl.toString()}`);
1273
- console.error("\nPlease verify the --api-base-url parameter.");
1274
- } else {
1275
- console.error(data);
1276
- }
2649
+ body: initData,
2650
+ });
2651
+ } catch (err) {
2652
+ const message = err instanceof Error ? err.message : String(err);
2653
+ console.error(`Failed to connect to API: ${message}`);
2654
+ callbackServer.server.stop();
2655
+ process.exit(1);
2656
+ return;
2657
+ }
1277
2658
 
1278
- callbackServer.server.close();
1279
- process.exit(1);
1280
- }
2659
+ if (!initResponse.ok) {
2660
+ const data = await initResponse.text();
2661
+ console.error(`Failed to initialize auth session: ${initResponse.status}`);
2662
+
2663
+ // Check if response is HTML (common for 404 pages)
2664
+ if (data.trim().startsWith("<!") || data.trim().startsWith("<html")) {
2665
+ console.error("Error: Received HTML response instead of JSON. This usually means:");
2666
+ console.error(" 1. The API endpoint URL is incorrect");
2667
+ console.error(" 2. The endpoint does not exist (404)");
2668
+ console.error(`\nAPI URL attempted: ${initUrl.toString()}`);
2669
+ console.error("\nPlease verify the --api-base-url parameter.");
2670
+ } else {
2671
+ console.error(data);
2672
+ }
2673
+
2674
+ callbackServer.server.stop();
2675
+ process.exit(1);
2676
+ return;
2677
+ }
1281
2678
 
1282
- // Step 3: Open browser
1283
- const authUrl = `${uiBaseUrl}/cli/auth?state=${encodeURIComponent(params.state)}&code_challenge=${encodeURIComponent(params.codeChallenge)}&code_challenge_method=S256&redirect_uri=${encodeURIComponent(redirectUri)}`;
2679
+ // Step 3: Open browser
2680
+ // Pass api_url so UI calls oauth_approve on the same backend where oauth_init created the session
2681
+ 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)}`;
1284
2682
 
1285
- if (opts.debug) {
1286
- console.log(`Debug: Auth URL: ${authUrl}`);
1287
- }
2683
+ if (opts.debug) {
2684
+ console.log(`Debug: Auth URL: ${authUrl}`);
2685
+ }
1288
2686
 
1289
- console.log(`\nOpening browser for authentication...`);
1290
- console.log(`If browser does not open automatically, visit:\n${authUrl}\n`);
2687
+ console.log(`\nOpening browser for authentication...`);
2688
+ console.log(`If browser does not open automatically, visit:\n${authUrl}\n`);
1291
2689
 
1292
- // Open browser (cross-platform)
1293
- const openCommand = process.platform === "darwin" ? "open" :
1294
- process.platform === "win32" ? "start" :
1295
- "xdg-open";
1296
- spawn(openCommand, [authUrl], { detached: true, stdio: "ignore" }).unref();
2690
+ // Open browser (cross-platform)
2691
+ const openCommand = process.platform === "darwin" ? "open" :
2692
+ process.platform === "win32" ? "start" :
2693
+ "xdg-open";
2694
+ spawn(openCommand, [authUrl], { detached: true, stdio: "ignore" }).unref();
1297
2695
 
1298
- // Step 4: Wait for callback
1299
- console.log("Waiting for authorization...");
1300
- console.log("(Press Ctrl+C to cancel)\n");
2696
+ // Step 4: Wait for callback
2697
+ console.log("Waiting for authorization...");
2698
+ console.log("(Press Ctrl+C to cancel)\n");
1301
2699
 
1302
- // Handle Ctrl+C gracefully
1303
- const cancelHandler = () => {
1304
- console.log("\n\nAuthentication cancelled by user.");
1305
- callbackServer.server.close();
1306
- process.exit(130); // Standard exit code for SIGINT
1307
- };
1308
- process.on("SIGINT", cancelHandler);
2700
+ // Handle Ctrl+C gracefully
2701
+ const cancelHandler = () => {
2702
+ console.log("\n\nAuthentication cancelled by user.");
2703
+ callbackServer.server.stop();
2704
+ process.exit(130); // Standard exit code for SIGINT
2705
+ };
2706
+ process.on("SIGINT", cancelHandler);
1309
2707
 
1310
- try {
1311
- const { code } = await callbackServer.promise;
2708
+ try {
2709
+ const { code } = await callbackServer.promise;
1312
2710
 
1313
- // Remove the cancel handler after successful auth
1314
- process.off("SIGINT", cancelHandler);
2711
+ // Remove the cancel handler after successful auth
2712
+ process.off("SIGINT", cancelHandler);
1315
2713
 
1316
- // Step 5: Exchange code for token
1317
- console.log("\nExchanging authorization code for API token...");
1318
- const exchangeData = JSON.stringify({
1319
- authorization_code: code,
1320
- code_verifier: params.codeVerifier,
1321
- state: params.state,
1322
- });
1323
- const exchangeUrl = new URL(`${apiBaseUrl}/rpc/oauth_token_exchange`);
1324
- const exchangeReq = http.request(
1325
- exchangeUrl,
1326
- {
1327
- method: "POST",
1328
- headers: {
1329
- "Content-Type": "application/json",
1330
- "Content-Length": Buffer.byteLength(exchangeData),
1331
- },
1332
- },
1333
- (exchangeRes) => {
1334
- let exchangeBody = "";
1335
- exchangeRes.on("data", (chunk) => (exchangeBody += chunk));
1336
- exchangeRes.on("end", () => {
1337
- if (exchangeRes.statusCode !== 200) {
1338
- console.error(`Failed to exchange code for token: ${exchangeRes.statusCode}`);
1339
-
1340
- // Check if response is HTML (common for 404 pages)
1341
- if (exchangeBody.trim().startsWith("<!") || exchangeBody.trim().startsWith("<html")) {
1342
- console.error("Error: Received HTML response instead of JSON. This usually means:");
1343
- console.error(" 1. The API endpoint URL is incorrect");
1344
- console.error(" 2. The endpoint does not exist (404)");
1345
- console.error(`\nAPI URL attempted: ${exchangeUrl.toString()}`);
1346
- console.error("\nPlease verify the --api-base-url parameter.");
1347
- } else {
1348
- console.error(exchangeBody);
1349
- }
1350
-
1351
- process.exit(1);
1352
- return;
1353
- }
1354
-
1355
- try {
1356
- const result = JSON.parse(exchangeBody);
1357
- 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.
1358
- 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.
1359
-
1360
- // Step 6: Save token to config
1361
- config.writeConfig({
1362
- apiKey: apiToken,
1363
- baseUrl: apiBaseUrl,
1364
- orgId: orgId,
1365
- });
1366
-
1367
- console.log("\nAuthentication successful!");
1368
- console.log(`API key saved to: ${config.getConfigPath()}`);
1369
- console.log(`Organization ID: ${orgId}`);
1370
- console.log(`\nYou can now use the CLI without specifying an API key.`);
1371
- process.exit(0);
1372
- } catch (err) {
1373
- const message = err instanceof Error ? err.message : String(err);
1374
- console.error(`Failed to parse response: ${message}`);
1375
- process.exit(1);
1376
- }
1377
- });
1378
- }
1379
- );
2714
+ // Step 5: Exchange code for token using fetch
2715
+ console.log("\nExchanging authorization code for API token...");
2716
+ const exchangeData = JSON.stringify({
2717
+ authorization_code: code,
2718
+ code_verifier: params.codeVerifier,
2719
+ state: params.state,
2720
+ });
2721
+ const exchangeUrl = new URL(`${apiBaseUrl}/rpc/oauth_token_exchange`);
1380
2722
 
1381
- exchangeReq.on("error", (err: Error) => {
1382
- console.error(`Exchange request failed: ${err.message}`);
1383
- process.exit(1);
1384
- });
2723
+ let exchangeResponse: Response;
2724
+ try {
2725
+ exchangeResponse = await fetch(exchangeUrl.toString(), {
2726
+ method: "POST",
2727
+ headers: {
2728
+ "Content-Type": "application/json",
2729
+ },
2730
+ body: exchangeData,
2731
+ });
2732
+ } catch (err) {
2733
+ const message = err instanceof Error ? err.message : String(err);
2734
+ console.error(`Exchange request failed: ${message}`);
2735
+ process.exit(1);
2736
+ return;
2737
+ }
1385
2738
 
1386
- exchangeReq.write(exchangeData);
1387
- exchangeReq.end();
2739
+ const exchangeBody = await exchangeResponse.text();
1388
2740
 
1389
- } catch (err) {
1390
- // Remove the cancel handler in error case too
1391
- process.off("SIGINT", cancelHandler);
2741
+ if (!exchangeResponse.ok) {
2742
+ console.error(`Failed to exchange code for token: ${exchangeResponse.status}`);
1392
2743
 
1393
- const message = err instanceof Error ? err.message : String(err);
2744
+ // Check if response is HTML (common for 404 pages)
2745
+ if (exchangeBody.trim().startsWith("<!") || exchangeBody.trim().startsWith("<html")) {
2746
+ console.error("Error: Received HTML response instead of JSON. This usually means:");
2747
+ console.error(" 1. The API endpoint URL is incorrect");
2748
+ console.error(" 2. The endpoint does not exist (404)");
2749
+ console.error(`\nAPI URL attempted: ${exchangeUrl.toString()}`);
2750
+ console.error("\nPlease verify the --api-base-url parameter.");
2751
+ } else {
2752
+ console.error(exchangeBody);
2753
+ }
1394
2754
 
1395
- // Provide more helpful error messages
1396
- if (message.includes("timeout")) {
1397
- console.error(`\nAuthentication timed out.`);
1398
- console.error(`This usually means you closed the browser window without completing authentication.`);
1399
- console.error(`Please try again and complete the authentication flow.`);
1400
- } else {
1401
- console.error(`\nAuthentication failed: ${message}`);
1402
- }
2755
+ process.exit(1);
2756
+ return;
2757
+ }
1403
2758
 
1404
- process.exit(1);
1405
- }
2759
+ try {
2760
+ const result = JSON.parse(exchangeBody);
2761
+ 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.
2762
+ 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.
2763
+
2764
+ // Step 6: Save token to config
2765
+ // Check if org changed to decide whether to preserve defaultProject
2766
+ const existingConfig = config.readConfig();
2767
+ const existingOrgId = existingConfig.orgId;
2768
+ const existingProject = existingConfig.defaultProject;
2769
+ const orgChanged = existingOrgId && existingOrgId !== orgId;
2770
+
2771
+ config.writeConfig({
2772
+ apiKey: apiToken,
2773
+ baseUrl: apiBaseUrl,
2774
+ orgId: orgId,
1406
2775
  });
2776
+
2777
+ // Only clear defaultProject if org actually changed
2778
+ if (orgChanged && existingProject) {
2779
+ config.deleteConfigKeys(["defaultProject"]);
2780
+ console.log(`\nNote: Organization changed (${existingOrgId} → ${orgId}).`);
2781
+ console.log(` Default project "${existingProject}" has been cleared.`);
2782
+ }
2783
+
2784
+ console.log("\nAuthentication successful!");
2785
+ console.log(`API key saved to: ${config.getConfigPath()}`);
2786
+ console.log(`Organization ID: ${orgId}`);
2787
+ if (!orgChanged && existingProject) {
2788
+ console.log(`Default project: ${existingProject} (preserved)`);
2789
+ }
2790
+ console.log(`\nYou can now use the CLI without specifying an API key.`);
2791
+ process.exit(0);
2792
+ } catch (err) {
2793
+ const message = err instanceof Error ? err.message : String(err);
2794
+ console.error(`Failed to parse response: ${message}`);
2795
+ process.exit(1);
1407
2796
  }
1408
- );
1409
2797
 
1410
- initReq.on("error", (err: Error) => {
1411
- console.error(`Failed to connect to API: ${err.message}`);
1412
- callbackServer.server.close();
1413
- process.exit(1);
1414
- });
2798
+ } catch (err) {
2799
+ // Remove the cancel handler in error case too
2800
+ process.off("SIGINT", cancelHandler);
2801
+
2802
+ const message = err instanceof Error ? err.message : String(err);
1415
2803
 
1416
- initReq.write(initData);
1417
- initReq.end();
2804
+ // Provide more helpful error messages
2805
+ if (message.includes("timeout")) {
2806
+ console.error(`\nAuthentication timed out.`);
2807
+ console.error(`This usually means you closed the browser window without completing authentication.`);
2808
+ console.error(`Please try again and complete the authentication flow.`);
2809
+ } else {
2810
+ console.error(`\nAuthentication failed: ${message}`);
2811
+ }
2812
+
2813
+ process.exit(1);
2814
+ }
1418
2815
 
1419
2816
  } catch (err) {
1420
2817
  const message = err instanceof Error ? err.message : String(err);
@@ -1423,15 +2820,7 @@ program
1423
2820
  }
1424
2821
  });
1425
2822
 
1426
- program
1427
- .command("add-key <apiKey>")
1428
- .description("store API key")
1429
- .action(async (apiKey: string) => {
1430
- config.writeConfig({ apiKey });
1431
- console.log(`API key saved to ${config.getConfigPath()}`);
1432
- });
1433
-
1434
- program
2823
+ auth
1435
2824
  .command("show-key")
1436
2825
  .description("show API key (masked)")
1437
2826
  .action(async () => {
@@ -1441,7 +2830,6 @@ program
1441
2830
  console.log(`\nTo authenticate, run: pgai auth`);
1442
2831
  return;
1443
2832
  }
1444
- const { maskSecret } = require("../lib/util");
1445
2833
  console.log(`Current API key: ${maskSecret(cfg.apiKey)}`);
1446
2834
  if (cfg.orgId) {
1447
2835
  console.log(`Organization ID: ${cfg.orgId}`);
@@ -1449,14 +2837,20 @@ program
1449
2837
  console.log(`Config location: ${config.getConfigPath()}`);
1450
2838
  });
1451
2839
 
1452
- program
2840
+ auth
1453
2841
  .command("remove-key")
1454
2842
  .description("remove API key")
1455
2843
  .action(async () => {
1456
2844
  // Check both new config and legacy config
1457
2845
  const newConfigPath = config.getConfigPath();
1458
2846
  const hasNewConfig = fs.existsSync(newConfigPath);
1459
- const legacyPath = path.resolve(process.cwd(), ".pgwatch-config");
2847
+ let legacyPath: string;
2848
+ try {
2849
+ const { projectDir } = await resolveOrInitPaths();
2850
+ legacyPath = path.resolve(projectDir, ".pgwatch-config");
2851
+ } catch {
2852
+ legacyPath = path.resolve(process.cwd(), ".pgwatch-config");
2853
+ }
1460
2854
  const hasLegacyConfig = fs.existsSync(legacyPath) && fs.statSync(legacyPath).isFile();
1461
2855
 
1462
2856
  if (!hasNewConfig && !hasLegacyConfig) {
@@ -1492,7 +2886,8 @@ mon
1492
2886
  .command("generate-grafana-password")
1493
2887
  .description("generate Grafana password for monitoring services")
1494
2888
  .action(async () => {
1495
- const cfgPath = path.resolve(process.cwd(), ".pgwatch-config");
2889
+ const { projectDir } = await resolveOrInitPaths();
2890
+ const cfgPath = path.resolve(projectDir, ".pgwatch-config");
1496
2891
 
1497
2892
  try {
1498
2893
  // Generate secure password using openssl
@@ -1543,9 +2938,10 @@ mon
1543
2938
  .command("show-grafana-credentials")
1544
2939
  .description("show Grafana credentials for monitoring services")
1545
2940
  .action(async () => {
1546
- const cfgPath = path.resolve(process.cwd(), ".pgwatch-config");
2941
+ const { projectDir } = await resolveOrInitPaths();
2942
+ const cfgPath = path.resolve(projectDir, ".pgwatch-config");
1547
2943
  if (!fs.existsSync(cfgPath)) {
1548
- console.error("Configuration file not found. Run 'postgres-ai mon quickstart' first.");
2944
+ console.error("Configuration file not found. Run 'postgres-ai mon local-install' first.");
1549
2945
  process.exitCode = 1;
1550
2946
  return;
1551
2947
  }
@@ -1671,7 +3067,7 @@ issues
1671
3067
  });
1672
3068
 
1673
3069
  issues
1674
- .command("post_comment <issueId> <content>")
3070
+ .command("post-comment <issueId> <content>")
1675
3071
  .description("post a new comment to an issue")
1676
3072
  .option("--parent <uuid>", "parent comment id")
1677
3073
  .option("--debug", "enable debug output")
@@ -1716,6 +3112,194 @@ issues
1716
3112
  }
1717
3113
  });
1718
3114
 
3115
+ issues
3116
+ .command("create <title>")
3117
+ .description("create a new issue")
3118
+ .option("--org-id <id>", "organization id (defaults to config orgId)", (v) => parseInt(v, 10))
3119
+ .option("--project-id <id>", "project id", (v) => parseInt(v, 10))
3120
+ .option("--description <text>", "issue description (supports \\\\n)")
3121
+ .option(
3122
+ "--label <label>",
3123
+ "issue label (repeatable)",
3124
+ (value: string, previous: string[]) => {
3125
+ previous.push(value);
3126
+ return previous;
3127
+ },
3128
+ [] as string[]
3129
+ )
3130
+ .option("--debug", "enable debug output")
3131
+ .option("--json", "output raw JSON")
3132
+ .action(async (rawTitle: string, opts: { orgId?: number; projectId?: number; description?: string; label?: string[]; debug?: boolean; json?: boolean }) => {
3133
+ try {
3134
+ const rootOpts = program.opts<CliOptions>();
3135
+ const cfg = config.readConfig();
3136
+ const { apiKey } = getConfig(rootOpts);
3137
+ if (!apiKey) {
3138
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
3139
+ process.exitCode = 1;
3140
+ return;
3141
+ }
3142
+
3143
+ const title = interpretEscapes(String(rawTitle || "").trim());
3144
+ if (!title) {
3145
+ console.error("title is required");
3146
+ process.exitCode = 1;
3147
+ return;
3148
+ }
3149
+
3150
+ const orgId = typeof opts.orgId === "number" && !Number.isNaN(opts.orgId) ? opts.orgId : cfg.orgId;
3151
+ if (typeof orgId !== "number") {
3152
+ console.error("org_id is required. Either pass --org-id or run 'pgai auth' to store it in config.");
3153
+ process.exitCode = 1;
3154
+ return;
3155
+ }
3156
+
3157
+ const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
3158
+ const labels = Array.isArray(opts.label) && opts.label.length > 0 ? opts.label.map(String) : undefined;
3159
+ const projectId = typeof opts.projectId === "number" && !Number.isNaN(opts.projectId) ? opts.projectId : undefined;
3160
+
3161
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3162
+ const result = await createIssue({
3163
+ apiKey,
3164
+ apiBaseUrl,
3165
+ title,
3166
+ orgId,
3167
+ description,
3168
+ projectId,
3169
+ labels,
3170
+ debug: !!opts.debug,
3171
+ });
3172
+ printResult(result, opts.json);
3173
+ } catch (err) {
3174
+ const message = err instanceof Error ? err.message : String(err);
3175
+ console.error(message);
3176
+ process.exitCode = 1;
3177
+ }
3178
+ });
3179
+
3180
+ issues
3181
+ .command("update <issueId>")
3182
+ .description("update an existing issue (title/description/status/labels)")
3183
+ .option("--title <text>", "new title (supports \\\\n)")
3184
+ .option("--description <text>", "new description (supports \\\\n)")
3185
+ .option("--status <value>", "status: open|closed|0|1")
3186
+ .option(
3187
+ "--label <label>",
3188
+ "set labels (repeatable). If provided, replaces existing labels.",
3189
+ (value: string, previous: string[]) => {
3190
+ previous.push(value);
3191
+ return previous;
3192
+ },
3193
+ [] as string[]
3194
+ )
3195
+ .option("--clear-labels", "set labels to an empty list")
3196
+ .option("--debug", "enable debug output")
3197
+ .option("--json", "output raw JSON")
3198
+ .action(async (issueId: string, opts: { title?: string; description?: string; status?: string; label?: string[]; clearLabels?: boolean; debug?: boolean; json?: boolean }) => {
3199
+ try {
3200
+ const rootOpts = program.opts<CliOptions>();
3201
+ const cfg = config.readConfig();
3202
+ const { apiKey } = getConfig(rootOpts);
3203
+ if (!apiKey) {
3204
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
3205
+ process.exitCode = 1;
3206
+ return;
3207
+ }
3208
+
3209
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3210
+
3211
+ const title = opts.title !== undefined ? interpretEscapes(String(opts.title)) : undefined;
3212
+ const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
3213
+
3214
+ let status: number | undefined = undefined;
3215
+ if (opts.status !== undefined) {
3216
+ const raw = String(opts.status).trim().toLowerCase();
3217
+ if (raw === "open") status = 0;
3218
+ else if (raw === "closed") status = 1;
3219
+ else {
3220
+ const n = Number(raw);
3221
+ if (!Number.isFinite(n)) {
3222
+ console.error("status must be open|closed|0|1");
3223
+ process.exitCode = 1;
3224
+ return;
3225
+ }
3226
+ status = n;
3227
+ }
3228
+ if (status !== 0 && status !== 1) {
3229
+ console.error("status must be 0 (open) or 1 (closed)");
3230
+ process.exitCode = 1;
3231
+ return;
3232
+ }
3233
+ }
3234
+
3235
+ let labels: string[] | undefined = undefined;
3236
+ if (opts.clearLabels) {
3237
+ labels = [];
3238
+ } else if (Array.isArray(opts.label) && opts.label.length > 0) {
3239
+ labels = opts.label.map(String);
3240
+ }
3241
+
3242
+ const result = await updateIssue({
3243
+ apiKey,
3244
+ apiBaseUrl,
3245
+ issueId,
3246
+ title,
3247
+ description,
3248
+ status,
3249
+ labels,
3250
+ debug: !!opts.debug,
3251
+ });
3252
+ printResult(result, opts.json);
3253
+ } catch (err) {
3254
+ const message = err instanceof Error ? err.message : String(err);
3255
+ console.error(message);
3256
+ process.exitCode = 1;
3257
+ }
3258
+ });
3259
+
3260
+ issues
3261
+ .command("update-comment <commentId> <content>")
3262
+ .description("update an existing issue comment")
3263
+ .option("--debug", "enable debug output")
3264
+ .option("--json", "output raw JSON")
3265
+ .action(async (commentId: string, content: string, opts: { debug?: boolean; json?: boolean }) => {
3266
+ try {
3267
+ if (opts.debug) {
3268
+ // eslint-disable-next-line no-console
3269
+ console.log(`Debug: Original content: ${JSON.stringify(content)}`);
3270
+ }
3271
+ content = interpretEscapes(content);
3272
+ if (opts.debug) {
3273
+ // eslint-disable-next-line no-console
3274
+ console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
3275
+ }
3276
+
3277
+ const rootOpts = program.opts<CliOptions>();
3278
+ const cfg = config.readConfig();
3279
+ const { apiKey } = getConfig(rootOpts);
3280
+ if (!apiKey) {
3281
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
3282
+ process.exitCode = 1;
3283
+ return;
3284
+ }
3285
+
3286
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3287
+
3288
+ const result = await updateIssueComment({
3289
+ apiKey,
3290
+ apiBaseUrl,
3291
+ commentId,
3292
+ content,
3293
+ debug: !!opts.debug,
3294
+ });
3295
+ printResult(result, opts.json);
3296
+ } catch (err) {
3297
+ const message = err instanceof Error ? err.message : String(err);
3298
+ console.error(message);
3299
+ process.exitCode = 1;
3300
+ }
3301
+ });
3302
+
1719
3303
  // MCP server
1720
3304
  const mcp = program.command("mcp").description("MCP server integration");
1721
3305
 
@@ -1743,15 +3327,7 @@ mcp
1743
3327
  console.log(" 4. Codex");
1744
3328
  console.log("");
1745
3329
 
1746
- const rl = readline.createInterface({
1747
- input: process.stdin,
1748
- output: process.stdout
1749
- });
1750
-
1751
- const answer = await new Promise<string>((resolve) => {
1752
- rl.question("Select your AI coding tool (1-4): ", resolve);
1753
- });
1754
- rl.close();
3330
+ const answer = await question("Select your AI coding tool (1-4): ");
1755
3331
 
1756
3332
  const choices: Record<string, string> = {
1757
3333
  "1": "cursor",
@@ -1886,5 +3462,7 @@ mcp
1886
3462
  }
1887
3463
  });
1888
3464
 
1889
- program.parseAsync(process.argv);
3465
+ program.parseAsync(process.argv).finally(() => {
3466
+ closeReadline();
3467
+ });
1890
3468