postgresai 0.15.0-dev.2 → 0.15.0-dev.4
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/bin/postgres-ai.ts +164 -49
- package/dist/bin/postgres-ai.js +736 -389
- package/lib/checkup.ts +16 -10
- package/lib/config.ts +3 -0
- package/lib/init.ts +1 -1
- package/lib/issues.ts +72 -72
- package/lib/reports.ts +12 -12
- package/lib/storage.ts +291 -0
- package/lib/util.ts +7 -1
- package/package.json +1 -1
- package/test/checkup.test.ts +28 -0
- package/test/init.test.ts +1 -1
- package/test/issues.cli.test.ts +230 -1
- package/test/mcp-server.test.ts +69 -0
- package/test/reports.cli.test.ts +84 -0
- package/test/reports.test.ts +3 -3
- package/test/storage.test.ts +761 -0
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 `` 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 ``;
|
|
289
|
+
}
|
|
290
|
+
return `[${safeName}](${fullUrl})`;
|
|
291
|
+
}
|
package/lib/util.ts
CHANGED
|
@@ -70,15 +70,18 @@ export function maskSecret(secret: string): string {
|
|
|
70
70
|
export interface RootOptsLike {
|
|
71
71
|
apiBaseUrl?: string;
|
|
72
72
|
uiBaseUrl?: string;
|
|
73
|
+
storageBaseUrl?: string;
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
export interface ConfigLike {
|
|
76
77
|
baseUrl?: string | null;
|
|
78
|
+
storageBaseUrl?: string | null;
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
export interface ResolvedBaseUrls {
|
|
80
82
|
apiBaseUrl: string;
|
|
81
83
|
uiBaseUrl: string;
|
|
84
|
+
storageBaseUrl: string;
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
/**
|
|
@@ -105,17 +108,20 @@ export function normalizeBaseUrl(value: string): string {
|
|
|
105
108
|
export function resolveBaseUrls(
|
|
106
109
|
opts?: RootOptsLike,
|
|
107
110
|
cfg?: ConfigLike,
|
|
108
|
-
defaults: { apiBaseUrl?: string; uiBaseUrl?: string } = {}
|
|
111
|
+
defaults: { apiBaseUrl?: string; uiBaseUrl?: string; storageBaseUrl?: string } = {}
|
|
109
112
|
): ResolvedBaseUrls {
|
|
110
113
|
const defApi = defaults.apiBaseUrl || "https://postgres.ai/api/general/";
|
|
111
114
|
const defUi = defaults.uiBaseUrl || "https://console.postgres.ai";
|
|
115
|
+
const defStorage = defaults.storageBaseUrl || "https://postgres.ai/storage";
|
|
112
116
|
|
|
113
117
|
const apiCandidate = (opts?.apiBaseUrl || process.env.PGAI_API_BASE_URL || cfg?.baseUrl || defApi) as string;
|
|
114
118
|
const uiCandidate = (opts?.uiBaseUrl || process.env.PGAI_UI_BASE_URL || defUi) as string;
|
|
119
|
+
const storageCandidate = (opts?.storageBaseUrl || process.env.PGAI_STORAGE_BASE_URL || cfg?.storageBaseUrl || defStorage) as string;
|
|
115
120
|
|
|
116
121
|
return {
|
|
117
122
|
apiBaseUrl: normalizeBaseUrl(apiCandidate),
|
|
118
123
|
uiBaseUrl: normalizeBaseUrl(uiCandidate),
|
|
124
|
+
storageBaseUrl: normalizeBaseUrl(storageCandidate),
|
|
119
125
|
};
|
|
120
126
|
}
|
|
121
127
|
|
package/package.json
CHANGED
package/test/checkup.test.ts
CHANGED
|
@@ -1098,6 +1098,34 @@ describe("CLI tests", () => {
|
|
|
1098
1098
|
expect(r.stderr).not.toMatch(/connection string required/i);
|
|
1099
1099
|
expect(r.stderr).not.toMatch(/unknown option/i);
|
|
1100
1100
|
});
|
|
1101
|
+
|
|
1102
|
+
// Tests for --output flag behavior (suppresses stdout when specified)
|
|
1103
|
+
test("checkup --output option is recognized", () => {
|
|
1104
|
+
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--no-upload", "--output", "/tmp/test-output"]);
|
|
1105
|
+
// Connection will fail, but option parsing should succeed
|
|
1106
|
+
expect(r.stderr).not.toMatch(/unknown option/i);
|
|
1107
|
+
expect(r.stderr).not.toMatch(/did you mean/i);
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
test("checkup --json --output should NOT output JSON to stdout (writes to files only)", () => {
|
|
1111
|
+
// This is a behavioral test - when --output is specified along with --json,
|
|
1112
|
+
// JSON should only be written to files, not to stdout.
|
|
1113
|
+
// We verify by checking the help text describes this behavior
|
|
1114
|
+
const r = runCli(["checkup", "--help"]);
|
|
1115
|
+
expect(r.status).toBe(0);
|
|
1116
|
+
expect(r.stdout).toMatch(/--output/);
|
|
1117
|
+
expect(r.stdout).toMatch(/--json/);
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
test("checkup --output creates directory if it doesn't exist", () => {
|
|
1121
|
+
const env = { XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config" };
|
|
1122
|
+
// Use a temp directory that might not exist
|
|
1123
|
+
const tempDir = `/tmp/postgresai-test-output-${Date.now()}`;
|
|
1124
|
+
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--no-upload", "--json", "--output", tempDir], env);
|
|
1125
|
+
// Connection will fail, but directory creation should be attempted before connection
|
|
1126
|
+
// The error should be about connection, not about directory
|
|
1127
|
+
expect(r.stderr).not.toMatch(/Failed to create output directory/i);
|
|
1128
|
+
});
|
|
1101
1129
|
});
|
|
1102
1130
|
|
|
1103
1131
|
// Tests for checkup-api module
|
package/test/init.test.ts
CHANGED
|
@@ -1067,7 +1067,7 @@ describe("CLI commands", () => {
|
|
|
1067
1067
|
|
|
1068
1068
|
// Should indicate auto-yes mode without API key
|
|
1069
1069
|
expect(r.stdout).toMatch(/Auto-yes mode: no API key provided/);
|
|
1070
|
-
expect(r.
|
|
1070
|
+
expect(r.stderr).toMatch(/Reports will be generated locally only/);
|
|
1071
1071
|
});
|
|
1072
1072
|
|
|
1073
1073
|
test("cli: mon local-install --demo with global --api-key shows error", () => {
|
package/test/issues.cli.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect } from "bun:test";
|
|
2
2
|
import { resolve } from "path";
|
|
3
|
-
import { mkdtempSync } from "fs";
|
|
3
|
+
import { mkdtempSync, writeFileSync, existsSync, readFileSync } from "fs";
|
|
4
4
|
import { tmpdir } from "os";
|
|
5
5
|
|
|
6
6
|
function runCli(args: string[], env: Record<string, string> = {}) {
|
|
@@ -536,3 +536,232 @@ describe("CLI action items commands", () => {
|
|
|
536
536
|
});
|
|
537
537
|
});
|
|
538
538
|
|
|
539
|
+
async function startFakeStorageServer() {
|
|
540
|
+
const requests: Array<{
|
|
541
|
+
method: string;
|
|
542
|
+
pathname: string;
|
|
543
|
+
headers: Record<string, string>;
|
|
544
|
+
}> = [];
|
|
545
|
+
|
|
546
|
+
const server = Bun.serve({
|
|
547
|
+
hostname: "127.0.0.1",
|
|
548
|
+
port: 0,
|
|
549
|
+
async fetch(req) {
|
|
550
|
+
const url = new URL(req.url);
|
|
551
|
+
const headers: Record<string, string> = {};
|
|
552
|
+
for (const [k, v] of req.headers.entries()) headers[k.toLowerCase()] = v;
|
|
553
|
+
|
|
554
|
+
// Consume body to avoid warnings
|
|
555
|
+
await req.arrayBuffer().catch(() => {});
|
|
556
|
+
|
|
557
|
+
requests.push({
|
|
558
|
+
method: req.method,
|
|
559
|
+
pathname: url.pathname,
|
|
560
|
+
headers,
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// Upload endpoint
|
|
564
|
+
if (req.method === "POST" && url.pathname === "/upload") {
|
|
565
|
+
if (!headers["access-token"]) {
|
|
566
|
+
return new Response(
|
|
567
|
+
JSON.stringify({ code: "INVALID_API_TOKEN", message: "Missing token" }),
|
|
568
|
+
{ status: 401, headers: { "Content-Type": "application/json" } }
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return new Response(
|
|
573
|
+
JSON.stringify({
|
|
574
|
+
success: true,
|
|
575
|
+
url: "/files/123/1707500000000_test-uuid.png",
|
|
576
|
+
metadata: {
|
|
577
|
+
originalName: "test-file.png",
|
|
578
|
+
size: 16,
|
|
579
|
+
mimeType: "image/png",
|
|
580
|
+
uploadedAt: "2025-02-09T12:00:00.000Z",
|
|
581
|
+
duration: 50,
|
|
582
|
+
},
|
|
583
|
+
requestId: "req-test-123",
|
|
584
|
+
}),
|
|
585
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Download endpoint
|
|
590
|
+
if (req.method === "GET" && url.pathname.startsWith("/files/")) {
|
|
591
|
+
if (!headers["access-token"]) {
|
|
592
|
+
return new Response(
|
|
593
|
+
JSON.stringify({ code: "INVALID_API_TOKEN", message: "Missing token" }),
|
|
594
|
+
{ status: 401, headers: { "Content-Type": "application/json" } }
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return new Response(Buffer.from("fake-file-content"), {
|
|
599
|
+
status: 200,
|
|
600
|
+
headers: { "Content-Type": "image/png" },
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return new Response("not found", { status: 404 });
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const storageBaseUrl = `http://${server.hostname}:${server.port}`;
|
|
609
|
+
|
|
610
|
+
return {
|
|
611
|
+
storageBaseUrl,
|
|
612
|
+
requests,
|
|
613
|
+
stop: () => server.stop(true),
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
describe("CLI issues files commands", () => {
|
|
618
|
+
test("issues files upload fails fast when API key is missing", () => {
|
|
619
|
+
const r = runCli(["issues", "files", "upload", "/tmp/test.png"], isolatedEnv());
|
|
620
|
+
expect(r.status).toBe(1);
|
|
621
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test("issues files upload fails when file does not exist", () => {
|
|
625
|
+
const r = runCli(
|
|
626
|
+
["issues", "files", "upload", "/tmp/nonexistent-file-99999.png"],
|
|
627
|
+
isolatedEnv({ PGAI_API_KEY: "test-key" })
|
|
628
|
+
);
|
|
629
|
+
expect(r.status).toBe(1);
|
|
630
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("File not found");
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("issues files upload succeeds and shows URL and markdown", async () => {
|
|
634
|
+
const storage = await startFakeStorageServer();
|
|
635
|
+
const tmpFile = resolve(mkdtempSync(resolve(tmpdir(), "upload-test-")), "screenshot.png");
|
|
636
|
+
writeFileSync(tmpFile, "fake-png-content");
|
|
637
|
+
|
|
638
|
+
try {
|
|
639
|
+
const r = await runCliAsync(
|
|
640
|
+
["issues", "files", "upload", tmpFile],
|
|
641
|
+
isolatedEnv({
|
|
642
|
+
PGAI_API_KEY: "test-key",
|
|
643
|
+
PGAI_STORAGE_BASE_URL: storage.storageBaseUrl,
|
|
644
|
+
})
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
expect(r.status).toBe(0);
|
|
648
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
649
|
+
expect(out).toContain("URL:");
|
|
650
|
+
expect(out).toContain("/files/123/");
|
|
651
|
+
expect(out).toContain("Markdown:");
|
|
652
|
+
expect(out).toContain("![");
|
|
653
|
+
|
|
654
|
+
const uploadReq = storage.requests.find((x) => x.pathname === "/upload");
|
|
655
|
+
expect(uploadReq).toBeTruthy();
|
|
656
|
+
expect(uploadReq!.headers["access-token"]).toBe("test-key");
|
|
657
|
+
expect(uploadReq!.method).toBe("POST");
|
|
658
|
+
} finally {
|
|
659
|
+
storage.stop();
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("issues files upload --json returns structured JSON", async () => {
|
|
664
|
+
const storage = await startFakeStorageServer();
|
|
665
|
+
const tmpFile = resolve(mkdtempSync(resolve(tmpdir(), "upload-json-test-")), "data.txt");
|
|
666
|
+
writeFileSync(tmpFile, "test-content");
|
|
667
|
+
|
|
668
|
+
try {
|
|
669
|
+
const r = await runCliAsync(
|
|
670
|
+
["issues", "files", "upload", tmpFile, "--json"],
|
|
671
|
+
isolatedEnv({
|
|
672
|
+
PGAI_API_KEY: "test-key",
|
|
673
|
+
PGAI_STORAGE_BASE_URL: storage.storageBaseUrl,
|
|
674
|
+
})
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
expect(r.status).toBe(0);
|
|
678
|
+
const out = JSON.parse(r.stdout.trim());
|
|
679
|
+
expect(out.success).toBe(true);
|
|
680
|
+
expect(out.url).toContain("/files/");
|
|
681
|
+
expect(out.metadata.originalName).toBe("test-file.png");
|
|
682
|
+
expect(out.metadata.mimeType).toBe("image/png");
|
|
683
|
+
} finally {
|
|
684
|
+
storage.stop();
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test("issues files download fails fast when API key is missing", () => {
|
|
689
|
+
const r = runCli(["issues", "files", "download", "/files/123/test.png"], isolatedEnv());
|
|
690
|
+
expect(r.status).toBe(1);
|
|
691
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
test("issues files download succeeds and saves file", async () => {
|
|
695
|
+
const storage = await startFakeStorageServer();
|
|
696
|
+
const tmpOutDir = mkdtempSync(resolve(tmpdir(), "download-test-"));
|
|
697
|
+
const outputPath = resolve(tmpOutDir, "downloaded.png");
|
|
698
|
+
|
|
699
|
+
try {
|
|
700
|
+
const r = await runCliAsync(
|
|
701
|
+
["issues", "files", "download", "/files/123/image.png", "-o", outputPath],
|
|
702
|
+
isolatedEnv({
|
|
703
|
+
PGAI_API_KEY: "test-key",
|
|
704
|
+
PGAI_STORAGE_BASE_URL: storage.storageBaseUrl,
|
|
705
|
+
})
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
expect(r.status).toBe(0);
|
|
709
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
710
|
+
expect(out).toContain("Saved:");
|
|
711
|
+
expect(out).not.toContain("Size:");
|
|
712
|
+
expect(out).not.toContain("Type:");
|
|
713
|
+
|
|
714
|
+
expect(existsSync(outputPath)).toBe(true);
|
|
715
|
+
expect(readFileSync(outputPath).toString()).toBe("fake-file-content");
|
|
716
|
+
|
|
717
|
+
const downloadReq = storage.requests.find((x) => x.pathname.startsWith("/files/"));
|
|
718
|
+
expect(downloadReq).toBeTruthy();
|
|
719
|
+
expect(downloadReq!.headers["access-token"]).toBe("test-key");
|
|
720
|
+
expect(downloadReq!.method).toBe("GET");
|
|
721
|
+
} finally {
|
|
722
|
+
storage.stop();
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test("issues help shows files subcommand", () => {
|
|
727
|
+
const r = runCli(["issues", "--help"], isolatedEnv());
|
|
728
|
+
expect(r.status).toBe(0);
|
|
729
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
730
|
+
expect(out).toContain("files");
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
test("issues files help shows upload and download", () => {
|
|
734
|
+
const r = runCli(["issues", "files", "--help"], isolatedEnv());
|
|
735
|
+
expect(r.status).toBe(0);
|
|
736
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
737
|
+
expect(out).toContain("upload");
|
|
738
|
+
expect(out).toContain("download");
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
describe("CLI set-storage-url command", () => {
|
|
743
|
+
test("saves valid URL to config", () => {
|
|
744
|
+
const env = isolatedEnv();
|
|
745
|
+
const r = runCli(["set-storage-url", "https://v2.postgres.ai/storage"], env);
|
|
746
|
+
expect(r.status).toBe(0);
|
|
747
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("Storage URL saved: https://v2.postgres.ai/storage");
|
|
748
|
+
|
|
749
|
+
// Verify persisted in config
|
|
750
|
+
const cfgPath = resolve(env.XDG_CONFIG_HOME, "postgresai", "config.json");
|
|
751
|
+
const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
|
|
752
|
+
expect(cfg.storageBaseUrl).toBe("https://v2.postgres.ai/storage");
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
test("normalizes trailing slash", () => {
|
|
756
|
+
const env = isolatedEnv();
|
|
757
|
+
const r = runCli(["set-storage-url", "https://example.com/storage/"], env);
|
|
758
|
+
expect(r.status).toBe(0);
|
|
759
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("Storage URL saved: https://example.com/storage");
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
test("rejects invalid URL", () => {
|
|
763
|
+
const r = runCli(["set-storage-url", "not-a-url"], isolatedEnv());
|
|
764
|
+
expect(r.status).toBe(1);
|
|
765
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("invalid URL");
|
|
766
|
+
});
|
|
767
|
+
});
|