postgresai 0.14.0 → 0.15.0-dev.2

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.
@@ -11,6 +11,7 @@ import * as crypto from "node:crypto";
11
11
  import { Client } from "pg";
12
12
  import { startMcpServer } from "../lib/mcp-server";
13
13
  import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment, fetchActionItem, fetchActionItems, createActionItem, updateActionItem, type ConfigChange } from "../lib/issues";
14
+ import { fetchReports, fetchAllReports, fetchReportFiles, fetchReportFileData, renderMarkdownForTerminal, parseFlexibleDate } from "../lib/reports";
14
15
  import { resolveBaseUrls } from "../lib/util";
15
16
  import { applyInitPlan, applyUninitPlan, buildInitPlan, buildUninitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, KNOWN_PROVIDERS, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, validateProvider, verifyInitSetup } from "../lib/init";
16
17
  import { SupabaseClient, resolveSupabaseConfig, extractProjectRefFromUrl, applyInitPlanViaSupabase, verifyInitSetupViaSupabase, fetchPoolerDatabaseUrl, type PgCompatibleError } from "../lib/supabase";
@@ -24,6 +25,18 @@ import { getCheckupEntry } from "../lib/checkup-dictionary";
24
25
  import { createCheckupReport, uploadCheckupReportJson, convertCheckupReportJsonToMarkdown, RpcError, formatRpcErrorForDisplay, withRetry } from "../lib/checkup-api";
25
26
  import { generateCheckSummary } from "../lib/checkup-summary";
26
27
 
28
+ // Node.js version check - require Node 18+
29
+ // Node 14 reached EOL in April 2023, Node 16 in September 2023.
30
+ // Node 18+ is required for native ESM, modern crypto APIs, and security updates.
31
+ const nodeVersion = parseInt(process.versions.node.split('.')[0], 10);
32
+ if (nodeVersion < 18) {
33
+ console.error(`\x1b[31mError: postgresai requires Node 18 or higher.\x1b[0m`);
34
+ console.error(`You are running Node.js ${process.versions.node}.`);
35
+ console.error(`Please upgrade to Node.js 20 LTS or Node.js 22 for security updates.`);
36
+ console.error(`\nDownload: https://nodejs.org/`);
37
+ process.exit(1);
38
+ }
39
+
27
40
  // Singleton readline interface for stdin prompts
28
41
  let rl: ReturnType<typeof createInterface> | null = null;
29
42
  function getReadline() {
@@ -2061,6 +2074,85 @@ function checkRunningContainers(): { running: boolean; containers: string[] } {
2061
2074
  }
2062
2075
  }
2063
2076
 
2077
+ /**
2078
+ * Register monitoring instance with the API (non-blocking).
2079
+ * Returns immediately, logs result in background.
2080
+ */
2081
+ function registerMonitoringInstance(
2082
+ apiKey: string,
2083
+ projectName: string,
2084
+ opts?: { apiBaseUrl?: string; debug?: boolean }
2085
+ ): void {
2086
+ const { apiBaseUrl } = resolveBaseUrls(opts);
2087
+ const url = `${apiBaseUrl}/rpc/monitoring_instance_register`;
2088
+ const debug = opts?.debug;
2089
+
2090
+ if (debug) {
2091
+ console.log(`\nDebug: Registering monitoring instance...`);
2092
+ console.log(`Debug: POST ${url}`);
2093
+ console.log(`Debug: project_name=${projectName}`);
2094
+ }
2095
+
2096
+ // Fire and forget - don't block the main flow
2097
+ fetch(url, {
2098
+ method: "POST",
2099
+ headers: {
2100
+ "Content-Type": "application/json",
2101
+ },
2102
+ body: JSON.stringify({
2103
+ api_token: apiKey,
2104
+ project_name: projectName,
2105
+ }),
2106
+ })
2107
+ .then(async (res) => {
2108
+ const body = await res.text().catch(() => "");
2109
+ if (!res.ok) {
2110
+ if (debug) {
2111
+ console.log(`Debug: Monitoring registration failed: HTTP ${res.status}`);
2112
+ console.log(`Debug: Response: ${body}`);
2113
+ }
2114
+ return;
2115
+ }
2116
+ if (debug) {
2117
+ console.log(`Debug: Monitoring registration response: ${body}`);
2118
+ }
2119
+ })
2120
+ .catch((err) => {
2121
+ if (debug) {
2122
+ console.log(`Debug: Monitoring registration error: ${err.message}`);
2123
+ }
2124
+ });
2125
+ }
2126
+
2127
+ /**
2128
+ * Update .pgwatch-config file with key=value pairs.
2129
+ * Preserves existing values not being updated.
2130
+ */
2131
+ function updatePgwatchConfig(configPath: string, updates: Record<string, string>): void {
2132
+ let lines: string[] = [];
2133
+
2134
+ // Read existing config if it exists
2135
+ if (fs.existsSync(configPath)) {
2136
+ const stats = fs.statSync(configPath);
2137
+ if (!stats.isDirectory()) {
2138
+ const content = fs.readFileSync(configPath, "utf8");
2139
+ lines = content.split(/\r?\n/).filter(l => l.trim() !== "");
2140
+ }
2141
+ }
2142
+
2143
+ // Update or add each key
2144
+ for (const [key, value] of Object.entries(updates)) {
2145
+ const existingIndex = lines.findIndex(l => l.startsWith(key + "="));
2146
+ if (existingIndex >= 0) {
2147
+ lines[existingIndex] = `${key}=${value}`;
2148
+ } else {
2149
+ lines.push(`${key}=${value}`);
2150
+ }
2151
+ }
2152
+
2153
+ fs.writeFileSync(configPath, lines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
2154
+ }
2155
+
2064
2156
  /**
2065
2157
  * Run docker compose command
2066
2158
  */
@@ -2145,12 +2237,13 @@ mon
2145
2237
  .option("--api-key <key>", "Postgres AI API key for automated report uploads")
2146
2238
  .option("--db-url <url>", "PostgreSQL connection URL to monitor")
2147
2239
  .option("--tag <tag>", "Docker image tag to use (e.g., 0.14.0, 0.14.0-dev.33)")
2240
+ .option("--project <name>", "Docker Compose project name (default: postgres_ai)")
2148
2241
  .option("-y, --yes", "accept all defaults and skip interactive prompts", false)
2149
- .action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; tag?: string; yes: boolean }) => {
2242
+ .action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; tag?: string; project?: string; yes: boolean }) => {
2150
2243
  // Get apiKey from global program options (--api-key is defined globally)
2151
2244
  // This is needed because Commander.js routes --api-key to the global option, not the subcommand's option
2152
2245
  const globalOpts = program.opts<CliOptions>();
2153
- const apiKey = opts.apiKey || globalOpts.apiKey;
2246
+ let apiKey = opts.apiKey || globalOpts.apiKey;
2154
2247
 
2155
2248
  console.log("\n=================================");
2156
2249
  console.log(" PostgresAI monitoring local install");
@@ -2161,6 +2254,13 @@ mon
2161
2254
  const { projectDir } = await resolveOrInitPaths();
2162
2255
  console.log(`Project directory: ${projectDir}\n`);
2163
2256
 
2257
+ // Save project name to .pgwatch-config if provided (used by reporter container)
2258
+ if (opts.project) {
2259
+ const cfgPath = path.resolve(projectDir, ".pgwatch-config");
2260
+ updatePgwatchConfig(cfgPath, { project_name: opts.project });
2261
+ console.log(`Using project name: ${opts.project}\n`);
2262
+ }
2263
+
2164
2264
  // Update .env with custom tag if provided
2165
2265
  const envFile = path.resolve(projectDir, ".env");
2166
2266
 
@@ -2230,10 +2330,7 @@ mon
2230
2330
  console.log("Using API key provided via --api-key parameter");
2231
2331
  config.writeConfig({ apiKey });
2232
2332
  // Keep reporter compatibility (docker-compose mounts .pgwatch-config)
2233
- fs.writeFileSync(path.resolve(projectDir, ".pgwatch-config"), `api_key=${apiKey}\n`, {
2234
- encoding: "utf8",
2235
- mode: 0o600
2236
- });
2333
+ updatePgwatchConfig(path.resolve(projectDir, ".pgwatch-config"), { api_key: apiKey });
2237
2334
  console.log("✓ API key saved\n");
2238
2335
  } else if (opts.yes) {
2239
2336
  // Auto-yes mode without API key - skip API key setup
@@ -2252,10 +2349,8 @@ mon
2252
2349
  if (trimmedKey) {
2253
2350
  config.writeConfig({ apiKey: trimmedKey });
2254
2351
  // Keep reporter compatibility (docker-compose mounts .pgwatch-config)
2255
- fs.writeFileSync(path.resolve(projectDir, ".pgwatch-config"), `api_key=${trimmedKey}\n`, {
2256
- encoding: "utf8",
2257
- mode: 0o600
2258
- });
2352
+ updatePgwatchConfig(path.resolve(projectDir, ".pgwatch-config"), { api_key: trimmedKey });
2353
+ apiKey = trimmedKey; // Update for later use in registerMonitoringInstance
2259
2354
  console.log("✓ API key saved\n");
2260
2355
  break;
2261
2356
  }
@@ -2442,6 +2537,15 @@ mon
2442
2537
  }
2443
2538
  console.log("✓ Services started\n");
2444
2539
 
2540
+ // Register monitoring instance with API (non-blocking, only if API key is configured)
2541
+ if (apiKey && !opts.demo) {
2542
+ const projectName = opts.project || "postgres-ai-monitoring";
2543
+ registerMonitoringInstance(apiKey, projectName, {
2544
+ apiBaseUrl: globalOpts.apiBaseUrl,
2545
+ debug: !!process.env.DEBUG,
2546
+ });
2547
+ }
2548
+
2445
2549
  // Final summary
2446
2550
  console.log("=================================");
2447
2551
  console.log(" Local install completed!");
@@ -4087,6 +4191,228 @@ issues
4087
4191
  }
4088
4192
  });
4089
4193
 
4194
+ // Reports management
4195
+ const reports = program.command("reports").description("checkup reports management");
4196
+
4197
+ reports
4198
+ .command("list")
4199
+ .description("list checkup reports")
4200
+ .option("--project-id <id>", "filter by project id", (v: string) => parseInt(v, 10))
4201
+ .option("--status <status>", "filter by status (e.g., completed)")
4202
+ .option("--limit <n>", "max number of reports to return (default: 20)", (v: string) => parseInt(v, 10))
4203
+ .option("--before <date>", "show reports created before this date (YYYY-MM-DD, DD.MM.YYYY, etc.)")
4204
+ .option("--all", "fetch all reports (paginated automatically)")
4205
+ .option("--debug", "enable debug output")
4206
+ .option("--json", "output raw JSON")
4207
+ .action(async (opts: { projectId?: number; status?: string; limit?: number; before?: string; all?: boolean; debug?: boolean; json?: boolean }) => {
4208
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching reports...");
4209
+ try {
4210
+ const rootOpts = program.opts<CliOptions>();
4211
+ const cfg = config.readConfig();
4212
+ const { apiKey } = getConfig(rootOpts);
4213
+ if (!apiKey) {
4214
+ spinner.stop();
4215
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
4216
+ process.exitCode = 1;
4217
+ return;
4218
+ }
4219
+ if (opts.all && opts.before) {
4220
+ spinner.stop();
4221
+ console.error("--all and --before cannot be used together");
4222
+ process.exitCode = 1;
4223
+ return;
4224
+ }
4225
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4226
+
4227
+ let result;
4228
+ if (opts.all) {
4229
+ result = await fetchAllReports({
4230
+ apiKey,
4231
+ apiBaseUrl,
4232
+ projectId: opts.projectId,
4233
+ status: opts.status,
4234
+ limit: opts.limit,
4235
+ debug: !!opts.debug,
4236
+ });
4237
+ } else {
4238
+ result = await fetchReports({
4239
+ apiKey,
4240
+ apiBaseUrl,
4241
+ projectId: opts.projectId,
4242
+ status: opts.status,
4243
+ limit: opts.limit,
4244
+ beforeDate: opts.before ? parseFlexibleDate(opts.before) : undefined,
4245
+ debug: !!opts.debug,
4246
+ });
4247
+ }
4248
+ spinner.stop();
4249
+ printResult(result, opts.json);
4250
+ } catch (err) {
4251
+ spinner.stop();
4252
+ const message = err instanceof Error ? err.message : String(err);
4253
+ console.error(message);
4254
+ process.exitCode = 1;
4255
+ }
4256
+ });
4257
+
4258
+ reports
4259
+ .command("files [reportId]")
4260
+ .description("list files of a checkup report (metadata only, no content)")
4261
+ .option("--type <type>", "filter by file type: json, md")
4262
+ .option("--check-id <id>", "filter by check ID (e.g., H002)")
4263
+ .option("--debug", "enable debug output")
4264
+ .option("--json", "output raw JSON")
4265
+ .action(async (reportId: string | undefined, opts: { type?: "json" | "md"; checkId?: string; debug?: boolean; json?: boolean }) => {
4266
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching report files...");
4267
+ try {
4268
+ const rootOpts = program.opts<CliOptions>();
4269
+ const cfg = config.readConfig();
4270
+ const { apiKey } = getConfig(rootOpts);
4271
+ if (!apiKey) {
4272
+ spinner.stop();
4273
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
4274
+ process.exitCode = 1;
4275
+ return;
4276
+ }
4277
+ let numericId: number | undefined;
4278
+ if (reportId !== undefined) {
4279
+ numericId = parseInt(reportId, 10);
4280
+ if (isNaN(numericId)) {
4281
+ spinner.stop();
4282
+ console.error("reportId must be a number");
4283
+ process.exitCode = 1;
4284
+ return;
4285
+ }
4286
+ }
4287
+ if (numericId === undefined && !opts.checkId) {
4288
+ spinner.stop();
4289
+ console.error("Either reportId or --check-id is required");
4290
+ process.exitCode = 1;
4291
+ return;
4292
+ }
4293
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4294
+
4295
+ const result = await fetchReportFiles({
4296
+ apiKey,
4297
+ apiBaseUrl,
4298
+ reportId: numericId,
4299
+ type: opts.type,
4300
+ checkId: opts.checkId,
4301
+ debug: !!opts.debug,
4302
+ });
4303
+ spinner.stop();
4304
+ printResult(result, opts.json);
4305
+ } catch (err) {
4306
+ spinner.stop();
4307
+ const message = err instanceof Error ? err.message : String(err);
4308
+ console.error(message);
4309
+ process.exitCode = 1;
4310
+ }
4311
+ });
4312
+
4313
+ reports
4314
+ .command("data [reportId]")
4315
+ .description("get checkup report file data (includes content)")
4316
+ .option("--type <type>", "filter by file type: json, md")
4317
+ .option("--check-id <id>", "filter by check ID (e.g., H002)")
4318
+ .option("--formatted", "render markdown with ANSI styling (experimental)")
4319
+ .option("-o, --output <dir>", "save files to directory (uses original filenames)")
4320
+ .option("--debug", "enable debug output")
4321
+ .option("--json", "output raw JSON")
4322
+ .action(async (reportId: string | undefined, opts: { type?: "json" | "md"; checkId?: string; formatted?: boolean; output?: string; debug?: boolean; json?: boolean }) => {
4323
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching report data...");
4324
+ try {
4325
+ const rootOpts = program.opts<CliOptions>();
4326
+ const cfg = config.readConfig();
4327
+ const { apiKey } = getConfig(rootOpts);
4328
+ if (!apiKey) {
4329
+ spinner.stop();
4330
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
4331
+ process.exitCode = 1;
4332
+ return;
4333
+ }
4334
+ let numericId: number | undefined;
4335
+ if (reportId !== undefined) {
4336
+ numericId = parseInt(reportId, 10);
4337
+ if (isNaN(numericId)) {
4338
+ spinner.stop();
4339
+ console.error("reportId must be a number");
4340
+ process.exitCode = 1;
4341
+ return;
4342
+ }
4343
+ }
4344
+ if (numericId === undefined && !opts.checkId) {
4345
+ spinner.stop();
4346
+ console.error("Either reportId or --check-id is required");
4347
+ process.exitCode = 1;
4348
+ return;
4349
+ }
4350
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4351
+
4352
+ // Default to "md" for terminal output (human-readable); --json and --output get all types
4353
+ const effectiveType = opts.type ?? (!opts.json && !opts.output ? "md" as const : undefined);
4354
+ const result = await fetchReportFileData({
4355
+ apiKey,
4356
+ apiBaseUrl,
4357
+ reportId: numericId,
4358
+ type: effectiveType,
4359
+ checkId: opts.checkId,
4360
+ debug: !!opts.debug,
4361
+ });
4362
+ spinner.stop();
4363
+
4364
+ if (opts.output) {
4365
+ const dir = path.resolve(opts.output);
4366
+ fs.mkdirSync(dir, { recursive: true });
4367
+ for (const f of result) {
4368
+ const safeName = path.basename(f.filename);
4369
+ const filePath = path.join(dir, safeName);
4370
+ const content = f.type === "json"
4371
+ ? JSON.stringify(tryParseJson(f.data), null, 2)
4372
+ : f.data;
4373
+ fs.writeFileSync(filePath, content, "utf-8");
4374
+ console.log(filePath);
4375
+ }
4376
+ } else if (opts.json) {
4377
+ const processed = result.map((f) => ({
4378
+ ...f,
4379
+ data: f.type === "json" ? tryParseJson(f.data) : f.data,
4380
+ }));
4381
+ printResult(processed, true);
4382
+ } else if (opts.formatted && process.stdout.isTTY) {
4383
+ for (const f of result) {
4384
+ if (result.length > 1) {
4385
+ console.log(`\x1b[1m--- ${f.filename} (${f.check_id}, ${f.type}) ---\x1b[0m`);
4386
+ }
4387
+ if (f.type === "md") {
4388
+ console.log(renderMarkdownForTerminal(f.data));
4389
+ } else if (f.type === "json") {
4390
+ const parsed = tryParseJson(f.data);
4391
+ console.log(typeof parsed === "string" ? parsed : JSON.stringify(parsed, null, 2));
4392
+ } else {
4393
+ console.log(f.data);
4394
+ }
4395
+ }
4396
+ } else {
4397
+ for (const f of result) {
4398
+ if (result.length > 1) {
4399
+ console.log(`--- ${f.filename} (${f.check_id}, ${f.type}) ---`);
4400
+ }
4401
+ console.log(f.data);
4402
+ }
4403
+ }
4404
+ } catch (err) {
4405
+ spinner.stop();
4406
+ const message = err instanceof Error ? err.message : String(err);
4407
+ console.error(message);
4408
+ process.exitCode = 1;
4409
+ }
4410
+ });
4411
+
4412
+ function tryParseJson(s: string): unknown {
4413
+ try { return JSON.parse(s); } catch { return s; }
4414
+ }
4415
+
4090
4416
  // MCP server
4091
4417
  const mcp = program.command("mcp").description("MCP server integration");
4092
4418