postgresai 0.15.0-dev.1 → 0.15.0-dev.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/reports.ts ADDED
@@ -0,0 +1,373 @@
1
+ import { formatHttpError, maskSecret, normalizeBaseUrl } from "./util";
2
+
3
+ // ============================================================================
4
+ // Types
5
+ // ============================================================================
6
+
7
+ export interface CheckupReport {
8
+ id: number;
9
+ org_id: number;
10
+ org_name: string;
11
+ project_id: number;
12
+ project_name: string;
13
+ created_at: string;
14
+ created_formatted: string;
15
+ epoch: number;
16
+ status: string;
17
+ }
18
+
19
+ export interface CheckupReportFile {
20
+ id: number;
21
+ checkup_report_id: number;
22
+ filename: string;
23
+ check_id: string;
24
+ type: "json" | "md";
25
+ created_at: string;
26
+ created_formatted: string;
27
+ project_id: number;
28
+ project_name: string;
29
+ }
30
+
31
+ export interface CheckupReportFileData extends CheckupReportFile {
32
+ data: string;
33
+ }
34
+
35
+ // ============================================================================
36
+ // Date parsing
37
+ // ============================================================================
38
+
39
+ /**
40
+ * Parse a date string in various formats into an ISO 8601 string.
41
+ * Supported formats:
42
+ * YYYY-MM-DD 2025-01-15
43
+ * YYYY-MM-DDTHH:mm:ss 2025-01-15T10:30:00
44
+ * YYYY-MM-DD HH:mm:ss 2025-01-15 10:30:00
45
+ * YYYY-MM-DD HH:mm 2025-01-15 10:30
46
+ * DD.MM.YYYY 15.01.2025
47
+ * DD.MM.YYYY HH:mm 15.01.2025 10:30
48
+ * DD.MM.YYYY HH:mm:ss 15.01.2025 10:30:00
49
+ */
50
+ export function parseFlexibleDate(input: string): string {
51
+ const s = input.trim();
52
+
53
+ // DD.MM.YYYY [HH:mm[:ss]]
54
+ const dotMatch = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/);
55
+ if (dotMatch) {
56
+ const [, dd, mm, yyyy, hh, min, ss] = dotMatch;
57
+ const iso = `${yyyy}-${mm.padStart(2, "0")}-${dd.padStart(2, "0")}T${(hh ?? "00").padStart(2, "0")}:${(min ?? "00").padStart(2, "0")}:${(ss ?? "00").padStart(2, "0")}Z`;
58
+ const d = new Date(iso);
59
+ if (isNaN(d.getTime())) throw new Error(`Invalid date: ${input}`);
60
+ return d.toISOString();
61
+ }
62
+
63
+ // YYYY-MM-DD[T ]HH:mm[:ss] or YYYY-MM-DD
64
+ const isoMatch = s.match(/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/);
65
+ if (isoMatch) {
66
+ const [, yyyy, mm, dd, hh, min, ss] = isoMatch;
67
+ const iso = `${yyyy}-${mm}-${dd}T${hh ?? "00"}:${min ?? "00"}:${ss ?? "00"}Z`;
68
+ const d = new Date(iso);
69
+ if (isNaN(d.getTime())) throw new Error(`Invalid date: ${input}`);
70
+ return d.toISOString();
71
+ }
72
+
73
+ throw new Error(`Unrecognized date format: ${input}. Use YYYY-MM-DD or DD.MM.YYYY`);
74
+ }
75
+
76
+ // ============================================================================
77
+ // Params
78
+ // ============================================================================
79
+
80
+ export interface FetchReportsParams {
81
+ apiKey: string;
82
+ apiBaseUrl: string;
83
+ projectId?: number;
84
+ status?: string;
85
+ limit?: number;
86
+ beforeDate?: string;
87
+ /** @internal Used by fetchAllReports for keyset pagination */
88
+ beforeId?: number;
89
+ debug?: boolean;
90
+ }
91
+
92
+ export interface FetchReportFilesParams {
93
+ apiKey: string;
94
+ apiBaseUrl: string;
95
+ reportId?: number;
96
+ type?: "json" | "md";
97
+ checkId?: string;
98
+ debug?: boolean;
99
+ }
100
+
101
+ export interface FetchReportFileDataParams {
102
+ apiKey: string;
103
+ apiBaseUrl: string;
104
+ reportId?: number;
105
+ type?: "json" | "md";
106
+ checkId?: string;
107
+ debug?: boolean;
108
+ }
109
+
110
+ // ============================================================================
111
+ // API functions
112
+ // ============================================================================
113
+
114
+ export async function fetchReports(params: FetchReportsParams): Promise<CheckupReport[]> {
115
+ const { apiKey, apiBaseUrl, projectId, status, limit = 20, beforeDate, beforeId, debug } = params;
116
+ if (!apiKey) {
117
+ throw new Error("API key is required");
118
+ }
119
+
120
+ const base = normalizeBaseUrl(apiBaseUrl);
121
+ const url = new URL(`${base}/checkup_reports`);
122
+ url.searchParams.set("order", "id.desc");
123
+ url.searchParams.set("limit", String(limit));
124
+ if (typeof projectId === "number") {
125
+ url.searchParams.set("project_id", `eq.${projectId}`);
126
+ }
127
+ if (status) {
128
+ url.searchParams.set("status", `eq.${status}`);
129
+ }
130
+ if (beforeDate) {
131
+ url.searchParams.set("created_at", `lt.${beforeDate}`);
132
+ }
133
+ if (typeof beforeId === "number") {
134
+ url.searchParams.set("id", `lt.${beforeId}`);
135
+ }
136
+
137
+ const headers: Record<string, string> = {
138
+ "access-token": apiKey,
139
+ "Prefer": "return=representation",
140
+ "Content-Type": "application/json",
141
+ "Connection": "close",
142
+ };
143
+
144
+ if (debug) {
145
+ const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
146
+ console.error(`Debug: Resolved API base URL: ${base}`);
147
+ console.error(`Debug: GET URL: ${url.toString()}`);
148
+ console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
149
+ }
150
+
151
+ const response = await fetch(url.toString(), { method: "GET", headers });
152
+
153
+ if (debug) {
154
+ console.error(`Debug: Response status: ${response.status}`);
155
+ }
156
+
157
+ const data = await response.text();
158
+
159
+ if (response.ok) {
160
+ try {
161
+ return JSON.parse(data) as CheckupReport[];
162
+ } catch {
163
+ throw new Error(`Failed to parse reports response: ${data}`);
164
+ }
165
+ } else {
166
+ throw new Error(formatHttpError("Failed to fetch reports", response.status, data));
167
+ }
168
+ }
169
+
170
+ const MAX_ALL_REPORTS = 10000;
171
+
172
+ export async function fetchAllReports(params: Omit<FetchReportsParams, "beforeId" | "beforeDate">): Promise<CheckupReport[]> {
173
+ const pageSize = params.limit ?? 100;
174
+ const all: CheckupReport[] = [];
175
+ let beforeId: number | undefined;
176
+
177
+ while (true) {
178
+ const page = await fetchReports({ ...params, limit: pageSize, beforeId });
179
+ if (page.length === 0) break;
180
+ all.push(...page);
181
+ if (all.length >= MAX_ALL_REPORTS) {
182
+ console.warn(`Warning: reached maximum of ${MAX_ALL_REPORTS} reports, stopping pagination`);
183
+ break;
184
+ }
185
+ beforeId = page[page.length - 1].id;
186
+ if (page.length < pageSize) break;
187
+ }
188
+
189
+ return all;
190
+ }
191
+
192
+ export async function fetchReportFiles(params: FetchReportFilesParams): Promise<CheckupReportFile[]> {
193
+ const { apiKey, apiBaseUrl, reportId, type, checkId, debug } = params;
194
+ if (!apiKey) {
195
+ throw new Error("API key is required");
196
+ }
197
+ if (reportId === undefined && !checkId) {
198
+ throw new Error("Either reportId or checkId is required");
199
+ }
200
+
201
+ const base = normalizeBaseUrl(apiBaseUrl);
202
+ const url = new URL(`${base}/checkup_report_files`);
203
+ if (typeof reportId === "number") {
204
+ url.searchParams.set("checkup_report_id", `eq.${reportId}`);
205
+ }
206
+ url.searchParams.set("order", "id.asc");
207
+ if (type) {
208
+ url.searchParams.set("type", `eq.${type}`);
209
+ }
210
+ if (checkId) {
211
+ url.searchParams.set("check_id", `eq.${checkId}`);
212
+ }
213
+
214
+ const headers: Record<string, string> = {
215
+ "access-token": apiKey,
216
+ "Prefer": "return=representation",
217
+ "Content-Type": "application/json",
218
+ "Connection": "close",
219
+ };
220
+
221
+ if (debug) {
222
+ const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
223
+ console.error(`Debug: Resolved API base URL: ${base}`);
224
+ console.error(`Debug: GET URL: ${url.toString()}`);
225
+ console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
226
+ }
227
+
228
+ const response = await fetch(url.toString(), { method: "GET", headers });
229
+
230
+ if (debug) {
231
+ console.error(`Debug: Response status: ${response.status}`);
232
+ }
233
+
234
+ const data = await response.text();
235
+
236
+ if (response.ok) {
237
+ try {
238
+ return JSON.parse(data) as CheckupReportFile[];
239
+ } catch {
240
+ throw new Error(`Failed to parse report files response: ${data}`);
241
+ }
242
+ } else {
243
+ throw new Error(formatHttpError("Failed to fetch report files", response.status, data));
244
+ }
245
+ }
246
+
247
+ export async function fetchReportFileData(params: FetchReportFileDataParams): Promise<CheckupReportFileData[]> {
248
+ const { apiKey, apiBaseUrl, reportId, type, checkId, debug } = params;
249
+ if (!apiKey) {
250
+ throw new Error("API key is required");
251
+ }
252
+ if (reportId === undefined && !checkId) {
253
+ throw new Error("Either reportId or checkId is required");
254
+ }
255
+
256
+ const base = normalizeBaseUrl(apiBaseUrl);
257
+ const url = new URL(`${base}/checkup_report_file_data`);
258
+ if (typeof reportId === "number") {
259
+ url.searchParams.set("checkup_report_id", `eq.${reportId}`);
260
+ }
261
+ url.searchParams.set("order", "id.asc");
262
+ if (type) {
263
+ url.searchParams.set("type", `eq.${type}`);
264
+ }
265
+ if (checkId) {
266
+ url.searchParams.set("check_id", `eq.${checkId}`);
267
+ }
268
+
269
+ const headers: Record<string, string> = {
270
+ "access-token": apiKey,
271
+ "Prefer": "return=representation",
272
+ "Content-Type": "application/json",
273
+ "Connection": "close",
274
+ };
275
+
276
+ if (debug) {
277
+ const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
278
+ console.error(`Debug: Resolved API base URL: ${base}`);
279
+ console.error(`Debug: GET URL: ${url.toString()}`);
280
+ console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
281
+ }
282
+
283
+ const response = await fetch(url.toString(), { method: "GET", headers });
284
+
285
+ if (debug) {
286
+ console.error(`Debug: Response status: ${response.status}`);
287
+ }
288
+
289
+ const data = await response.text();
290
+
291
+ if (response.ok) {
292
+ try {
293
+ return JSON.parse(data) as CheckupReportFileData[];
294
+ } catch {
295
+ throw new Error(`Failed to parse report file data response: ${data}`);
296
+ }
297
+ } else {
298
+ throw new Error(formatHttpError("Failed to fetch report file data", response.status, data));
299
+ }
300
+ }
301
+
302
+ // ============================================================================
303
+ // Lightweight markdown terminal renderer
304
+ // ============================================================================
305
+
306
+ export function renderMarkdownForTerminal(md: string): string {
307
+ if (!md) return "";
308
+
309
+ const RESET = "\x1b[0m";
310
+ const BOLD = "\x1b[1m";
311
+ const BOLD_UNDERLINE = "\x1b[1;4m";
312
+ const DIM = "\x1b[2m";
313
+ const ITALIC = "\x1b[3m";
314
+ const CYAN = "\x1b[36m";
315
+
316
+ const lines = md.split("\n");
317
+ const output: string[] = [];
318
+ let inCodeBlock = false;
319
+
320
+ for (const line of lines) {
321
+ // Code block toggle
322
+ if (line.trimStart().startsWith("```")) {
323
+ inCodeBlock = !inCodeBlock;
324
+ if (inCodeBlock) {
325
+ output.push(`${DIM}${"─".repeat(40)}${RESET}`);
326
+ } else {
327
+ output.push(`${DIM}${"─".repeat(40)}${RESET}`);
328
+ }
329
+ continue;
330
+ }
331
+
332
+ // Inside code block — dim output
333
+ if (inCodeBlock) {
334
+ output.push(`${DIM} ${line}${RESET}`);
335
+ continue;
336
+ }
337
+
338
+ // Horizontal rule
339
+ if (/^-{3,}$/.test(line.trim()) || /^\*{3,}$/.test(line.trim()) || /^_{3,}$/.test(line.trim())) {
340
+ output.push(`${DIM}${"─".repeat(60)}${RESET}`);
341
+ continue;
342
+ }
343
+
344
+ // Headings
345
+ const headingMatch = line.match(/^(#{1,6})\s+(.*)/);
346
+ if (headingMatch) {
347
+ const level = headingMatch[1].length;
348
+ const text = headingMatch[2];
349
+ if (level === 1) {
350
+ output.push(`${BOLD_UNDERLINE}${text}${RESET}`);
351
+ } else {
352
+ output.push(`${BOLD}${text}${RESET}`);
353
+ }
354
+ continue;
355
+ }
356
+
357
+ // Inline formatting
358
+ let formatted = line;
359
+ // Bold: **text** or __text__
360
+ formatted = formatted.replace(/\*\*(.+?)\*\*/g, `${BOLD}$1${RESET}`);
361
+ formatted = formatted.replace(/__(.+?)__/g, `${BOLD}$1${RESET}`);
362
+ // Italic: *text* (only single, not inside **)
363
+ formatted = formatted.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, `${ITALIC}$1${RESET}`);
364
+ // Italic: _text_ — only at word boundaries (not inside identifiers like foo_bar_baz)
365
+ formatted = formatted.replace(/(?<=^|[\s(])_([^\s_](?:.*?[^\s_])?)_(?=$|[\s),.:;!?])/g, `${ITALIC}$1${RESET}`);
366
+ // Inline code: `text`
367
+ formatted = formatted.replace(/`([^`]+)`/g, `${CYAN}$1${RESET}`);
368
+
369
+ output.push(formatted);
370
+ }
371
+
372
+ return output.join("\n");
373
+ }
package/lib/storage.ts ADDED
@@ -0,0 +1,291 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { formatHttpError, maskSecret, normalizeBaseUrl } from "./util";
4
+
5
+ const MAX_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB
6
+ const MAX_DOWNLOAD_SIZE = 500 * 1024 * 1024; // 500 MB
7
+
8
+ const MIME_TYPES: Record<string, string> = {
9
+ ".png": "image/png",
10
+ ".jpg": "image/jpeg",
11
+ ".jpeg": "image/jpeg",
12
+ ".gif": "image/gif",
13
+ ".webp": "image/webp",
14
+ ".svg": "image/svg+xml",
15
+ ".bmp": "image/bmp",
16
+ ".ico": "image/x-icon",
17
+ ".pdf": "application/pdf",
18
+ ".json": "application/json",
19
+ ".xml": "application/xml",
20
+ ".zip": "application/zip",
21
+ ".gz": "application/gzip",
22
+ ".tar": "application/x-tar",
23
+ ".csv": "text/csv",
24
+ ".txt": "text/plain",
25
+ ".log": "text/plain",
26
+ ".sql": "application/sql",
27
+ ".html": "text/html",
28
+ ".css": "text/css",
29
+ ".js": "application/javascript",
30
+ ".ts": "application/typescript",
31
+ ".md": "text/markdown",
32
+ ".yaml": "application/x-yaml",
33
+ ".yml": "application/x-yaml",
34
+ };
35
+
36
+ export interface UploadFileMetadata {
37
+ originalName: string;
38
+ size: number;
39
+ mimeType: string;
40
+ uploadedAt: string;
41
+ duration: number;
42
+ }
43
+
44
+ export interface UploadResult {
45
+ success: boolean;
46
+ url: string;
47
+ metadata: UploadFileMetadata;
48
+ requestId: string;
49
+ }
50
+
51
+ export interface UploadFileParams {
52
+ apiKey: string;
53
+ storageBaseUrl: string;
54
+ filePath: string;
55
+ debug?: boolean;
56
+ }
57
+
58
+ /**
59
+ * Upload a file to the storage service.
60
+ *
61
+ * @param params.apiKey - API token for authentication
62
+ * @param params.storageBaseUrl - Storage service base URL
63
+ * @param params.filePath - Local file path to upload
64
+ * @param params.debug - Enable debug logging
65
+ * @returns Upload result with URL and metadata
66
+ */
67
+ export async function uploadFile(params: UploadFileParams): Promise<UploadResult> {
68
+ const { apiKey, storageBaseUrl, filePath, debug } = params;
69
+ if (!apiKey) {
70
+ throw new Error("API key is required");
71
+ }
72
+ if (!storageBaseUrl) {
73
+ throw new Error("storageBaseUrl is required");
74
+ }
75
+ if (!filePath) {
76
+ throw new Error("filePath is required");
77
+ }
78
+
79
+ const resolvedPath = path.resolve(filePath);
80
+ if (!fs.existsSync(resolvedPath)) {
81
+ throw new Error(`File not found: ${resolvedPath}`);
82
+ }
83
+ const stat = fs.statSync(resolvedPath);
84
+ if (!stat.isFile()) {
85
+ throw new Error(`Not a file: ${resolvedPath}`);
86
+ }
87
+ if (stat.size > MAX_UPLOAD_SIZE) {
88
+ throw new Error(`File too large: ${stat.size} bytes (max ${MAX_UPLOAD_SIZE})`);
89
+ }
90
+
91
+ const base = normalizeBaseUrl(storageBaseUrl);
92
+ // Warn but don't reject HTTP — CLI needs to work with localhost during development.
93
+ // Rejecting would require an --allow-insecure flag that adds friction for local setups.
94
+ if (new URL(base).protocol === "http:") {
95
+ console.error("Warning: storage URL uses HTTP — API key will be sent unencrypted");
96
+ }
97
+ const url = `${base}/upload`;
98
+
99
+ // readFileSync is intentional: FormData/Blob API requires the full buffer anyway,
100
+ // and the 500MB size check above prevents excessive memory use. Streaming would
101
+ // need a custom multipart encoder (no native stream support in fetch FormData).
102
+ const fileBuffer = fs.readFileSync(resolvedPath);
103
+ const fileName = path.basename(resolvedPath);
104
+
105
+ const ext = path.extname(fileName).toLowerCase();
106
+ const mimeType = MIME_TYPES[ext] || "application/octet-stream";
107
+
108
+ const formData = new FormData();
109
+ formData.append("file", new Blob([fileBuffer], { type: mimeType }), fileName);
110
+
111
+ const headers: Record<string, string> = {
112
+ "access-token": apiKey,
113
+ };
114
+
115
+ if (debug) {
116
+ const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
117
+ console.error(`Debug: Storage base URL: ${base}`);
118
+ console.error(`Debug: POST URL: ${url}`);
119
+ console.error(`Debug: File: ${resolvedPath} (${stat.size} bytes, ${mimeType})`);
120
+ console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
121
+ }
122
+
123
+ const response = await fetch(url, {
124
+ method: "POST",
125
+ headers,
126
+ body: formData,
127
+ });
128
+
129
+ if (debug) {
130
+ console.error(`Debug: Response status: ${response.status}`);
131
+ console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
132
+ }
133
+
134
+ const data = await response.text();
135
+
136
+ if (response.ok) {
137
+ try {
138
+ return JSON.parse(data) as UploadResult;
139
+ } catch {
140
+ throw new Error(`Failed to parse upload response: ${data}`);
141
+ }
142
+ } else {
143
+ throw new Error(formatHttpError("Failed to upload file", response.status, data));
144
+ }
145
+ }
146
+
147
+ export interface DownloadFileParams {
148
+ apiKey: string;
149
+ storageBaseUrl: string;
150
+ fileUrl: string;
151
+ outputPath?: string;
152
+ debug?: boolean;
153
+ }
154
+
155
+ export interface DownloadResult {
156
+ savedTo: string;
157
+ size: number;
158
+ mimeType: string | null;
159
+ }
160
+
161
+ /**
162
+ * Download a file from the storage service.
163
+ *
164
+ * @param params.apiKey - API token for authentication
165
+ * @param params.storageBaseUrl - Storage service base URL
166
+ * @param params.fileUrl - File URL path (e.g. /files/123/xxx.png) or full URL
167
+ * @param params.outputPath - Local path to save the file (default: derive from URL)
168
+ * @param params.debug - Enable debug logging
169
+ * @returns Download result with saved path and size
170
+ */
171
+ export async function downloadFile(params: DownloadFileParams): Promise<DownloadResult> {
172
+ const { apiKey, storageBaseUrl, fileUrl, outputPath, debug } = params;
173
+ if (!apiKey) {
174
+ throw new Error("API key is required");
175
+ }
176
+ if (!storageBaseUrl) {
177
+ throw new Error("storageBaseUrl is required");
178
+ }
179
+ if (!fileUrl) {
180
+ throw new Error("fileUrl is required");
181
+ }
182
+
183
+ const base = normalizeBaseUrl(storageBaseUrl);
184
+ // Warn but don't reject HTTP — same rationale as uploadFile (localhost dev).
185
+ if (new URL(base).protocol === "http:") {
186
+ console.error("Warning: storage URL uses HTTP — API key will be sent unencrypted");
187
+ }
188
+
189
+ // Support both full URLs and relative paths
190
+ let fullUrl: string;
191
+ if (fileUrl.startsWith("http://") || fileUrl.startsWith("https://")) {
192
+ if (!fileUrl.startsWith(base + "/")) {
193
+ throw new Error(`URL must be under storage base URL: ${base}`);
194
+ }
195
+ fullUrl = fileUrl;
196
+ } else {
197
+ // Relative path like /files/123/xxx.png
198
+ const relativePath = fileUrl.startsWith("/") ? fileUrl : `/${fileUrl}`;
199
+ fullUrl = `${base}${relativePath}`;
200
+ }
201
+
202
+ // Derive output filename from URL if not specified
203
+ const urlFilename = path.basename(new URL(fullUrl).pathname);
204
+ if (!urlFilename) {
205
+ throw new Error("Cannot derive filename from URL; please specify --output");
206
+ }
207
+ const saveTo = outputPath ? path.resolve(outputPath) : path.resolve(urlFilename);
208
+
209
+ // Path traversal guard only for URL-derived filenames (untrusted input).
210
+ // Explicit --output (-o) is trusted: the user intentionally chose the path,
211
+ // and restricting it to cwd would break legitimate use (e.g. -o /tmp/file.png).
212
+ if (!outputPath) {
213
+ const normalizedSave = path.normalize(saveTo);
214
+ if (!normalizedSave.startsWith(path.normalize(process.cwd()))) {
215
+ throw new Error("Derived output path escapes current directory; please specify --output");
216
+ }
217
+ }
218
+
219
+ const headers: Record<string, string> = {
220
+ "access-token": apiKey,
221
+ };
222
+
223
+ if (debug) {
224
+ const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
225
+ console.error(`Debug: Storage base URL: ${base}`);
226
+ console.error(`Debug: GET URL: ${fullUrl}`);
227
+ console.error(`Debug: Output: ${saveTo}`);
228
+ console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
229
+ }
230
+
231
+ const response = await fetch(fullUrl, {
232
+ method: "GET",
233
+ headers,
234
+ });
235
+
236
+ if (debug) {
237
+ console.error(`Debug: Response status: ${response.status}`);
238
+ console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
239
+ }
240
+
241
+ if (!response.ok) {
242
+ const data = await response.text();
243
+ throw new Error(formatHttpError("Failed to download file", response.status, data));
244
+ }
245
+
246
+ const contentLength = response.headers.get("content-length");
247
+ if (contentLength && parseInt(contentLength, 10) > MAX_DOWNLOAD_SIZE) {
248
+ throw new Error(`File too large: ${contentLength} bytes (max ${MAX_DOWNLOAD_SIZE})`);
249
+ }
250
+
251
+ const arrayBuffer = await response.arrayBuffer();
252
+ const buffer = Buffer.from(arrayBuffer);
253
+
254
+ // Create parent dirs for the output path. recursive:true is intentional —
255
+ // the user may specify -o deeply/nested/path.png and expect it to work,
256
+ // same as curl --create-dirs or wget -P.
257
+ const parentDir = path.dirname(saveTo);
258
+ if (!fs.existsSync(parentDir)) {
259
+ fs.mkdirSync(parentDir, { recursive: true });
260
+ }
261
+
262
+ fs.writeFileSync(saveTo, buffer);
263
+
264
+ return {
265
+ savedTo: saveTo,
266
+ size: buffer.length,
267
+ mimeType: response.headers.get("content-type"),
268
+ };
269
+ }
270
+
271
+ const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico"]);
272
+
273
+ /**
274
+ * Build a markdown link for a file URL.
275
+ * Returns `![name](url)` for images, `[name](url)` for other files.
276
+ */
277
+ export function buildMarkdownLink(fileUrl: string, storageBaseUrl: string, filename?: string): string {
278
+ const base = normalizeBaseUrl(storageBaseUrl);
279
+ const normalizedFileUrl = fileUrl.startsWith("/") ? fileUrl : `/${fileUrl}`;
280
+ const fullUrl = (fileUrl.startsWith("http://") || fileUrl.startsWith("https://")) ? fileUrl : `${base}${normalizedFileUrl}`;
281
+ const name = filename || path.basename(new URL(fullUrl).pathname);
282
+ const safeName = name.replace(/[\[\]()]/g, "\\$&");
283
+ const ext = path.extname(name).toLowerCase();
284
+ // fullUrl is not escaped — storage URLs are server-generated (UUID-based paths
285
+ // like /files/641/1770646021425_019c42ba.png) and never contain parentheses.
286
+ // Escaping would break the URL for renderers that don't decode %29 in href.
287
+ if (IMAGE_EXTENSIONS.has(ext)) {
288
+ return `![${safeName}](${fullUrl})`;
289
+ }
290
+ return `[${safeName}](${fullUrl})`;
291
+ }
package/lib/supabase.ts CHANGED
@@ -350,6 +350,10 @@ export async function fetchPoolerDatabaseUrl(
350
350
  config: SupabaseConfig,
351
351
  username: string
352
352
  ): Promise<string | null> {
353
+ // Validate projectRef format to prevent SSRF via crafted project references
354
+ if (!isValidProjectRef(config.projectRef)) {
355
+ throw new Error(`Invalid Supabase project reference format: "${config.projectRef}". Expected 10-30 alphanumeric characters.`);
356
+ }
353
357
  const url = `${SUPABASE_API_BASE}/v1/projects/${encodeURIComponent(config.projectRef)}/config/database/pooler`;
354
358
 
355
359
  // For Supabase pooler connections, the username must include the project ref:
@@ -669,7 +673,10 @@ export async function verifyInitSetupViaSupabase(params: {
669
673
 
670
674
  // Check pg_statistic view
671
675
  const viewExistsRes = await params.client.query(
672
- "SELECT to_regclass('postgres_ai.pg_statistic') IS NOT NULL as ok",
676
+ `SELECT CASE
677
+ WHEN NOT has_schema_privilege(current_user, 'postgres_ai', 'USAGE') THEN NULL
678
+ ELSE to_regclass('postgres_ai.pg_statistic') IS NOT NULL
679
+ END as ok`,
673
680
  true
674
681
  );
675
682
  if (!viewExistsRes.rows?.[0]?.ok) {