postgresai 0.15.0-dev.3 → 0.15.0-dev.5

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/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/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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.15.0-dev.3",
3
+ "version": "0.15.0-dev.5",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -0,0 +1,120 @@
1
+ import { describe, test, expect } from "bun:test";
2
+
3
+ /**
4
+ * Test getComposeCmd() selection logic.
5
+ * WARNING: This replicates the logic from postgres-ai.ts. If the production
6
+ * function changes, this replica must be updated to match.
7
+ * Since the function is internal to postgres-ai.ts, we replicate its logic
8
+ * with an injectable command checker (same pattern as monitoring.test.ts).
9
+ */
10
+ function getComposeCmd(
11
+ tryCmd: (cmd: string, args: string[]) => boolean,
12
+ ): string[] | null {
13
+ if (tryCmd("docker", ["compose", "version"])) return ["docker", "compose"];
14
+ if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
15
+ return null;
16
+ }
17
+
18
+ describe("getComposeCmd", () => {
19
+ test("prefers docker compose V2 when both are available", () => {
20
+ const result = getComposeCmd(() => true);
21
+ expect(result).toEqual(["docker", "compose"]);
22
+ });
23
+
24
+ test("falls back to docker-compose V1 when V2 is unavailable", () => {
25
+ const result = getComposeCmd((cmd, args) => {
26
+ // V2 plugin fails, V1 standalone succeeds
27
+ if (cmd === "docker" && args[0] === "compose") return false;
28
+ if (cmd === "docker-compose") return true;
29
+ return false;
30
+ });
31
+ expect(result).toEqual(["docker-compose"]);
32
+ });
33
+
34
+ test("returns null when neither is available", () => {
35
+ const result = getComposeCmd(() => false);
36
+ expect(result).toBeNull();
37
+ });
38
+
39
+ test("does not check V1 when V2 succeeds", () => {
40
+ const calls: Array<{ cmd: string; args: string[] }> = [];
41
+ getComposeCmd((cmd, args) => {
42
+ calls.push({ cmd, args });
43
+ return cmd === "docker" && args[0] === "compose";
44
+ });
45
+ expect(calls).toHaveLength(1);
46
+ expect(calls[0]).toEqual({ cmd: "docker", args: ["compose", "version"] });
47
+ });
48
+
49
+ test("checks V2 first, then V1", () => {
50
+ const calls: Array<{ cmd: string; args: string[] }> = [];
51
+ getComposeCmd((cmd, args) => {
52
+ calls.push({ cmd, args });
53
+ return false;
54
+ });
55
+ expect(calls).toHaveLength(2);
56
+ expect(calls[0]).toEqual({ cmd: "docker", args: ["compose", "version"] });
57
+ expect(calls[1]).toEqual({ cmd: "docker-compose", args: ["version"] });
58
+ });
59
+ });
60
+
61
+ /**
62
+ * Test the monitoring startup sequence's container cleanup logic.
63
+ * Before "up --force-recreate", stopped containers from "run --rm" dependencies
64
+ * (e.g. config-init) must be removed to avoid docker-compose v1's
65
+ * KeyError: 'ContainerConfig' bug.
66
+ *
67
+ * We replicate the relevant sequence from the monitoring start command
68
+ * with an injectable runCompose to verify ordering and error tolerance.
69
+ */
70
+ async function monitoringStartSequence(
71
+ runCompose: (args: string[]) => Promise<number>,
72
+ ): Promise<number> {
73
+ // Best-effort: remove stopped containers left by "run --rm" dependencies
74
+ await runCompose(["rm", "-f", "-s", "config-init"]);
75
+ // Start services
76
+ const code = await runCompose(["up", "-d", "--force-recreate"]);
77
+ return code;
78
+ }
79
+
80
+ describe("monitoring start: config-init cleanup", () => {
81
+ test("calls rm before up", async () => {
82
+ const calls: string[][] = [];
83
+ await monitoringStartSequence(async (args) => {
84
+ calls.push(args);
85
+ return 0;
86
+ });
87
+ expect(calls).toHaveLength(2);
88
+ expect(calls[0]).toEqual(["rm", "-f", "-s", "config-init"]);
89
+ expect(calls[1]).toEqual(["up", "-d", "--force-recreate"]);
90
+ });
91
+
92
+ test("continues to up even when rm fails", async () => {
93
+ const calls: string[][] = [];
94
+ await monitoringStartSequence(async (args) => {
95
+ calls.push(args);
96
+ // rm returns non-zero (container doesn't exist)
97
+ if (args[0] === "rm") return 1;
98
+ return 0;
99
+ });
100
+ expect(calls).toHaveLength(2);
101
+ expect(calls[0][0]).toBe("rm");
102
+ expect(calls[1][0]).toBe("up");
103
+ });
104
+
105
+ test("returns up exit code, not rm exit code", async () => {
106
+ // rm fails but up succeeds → overall success
107
+ const result1 = await monitoringStartSequence(async (args) => {
108
+ if (args[0] === "rm") return 1;
109
+ return 0;
110
+ });
111
+ expect(result1).toBe(0);
112
+
113
+ // rm succeeds but up fails → overall failure
114
+ const result2 = await monitoringStartSequence(async (args) => {
115
+ if (args[0] === "up") return 2;
116
+ return 0;
117
+ });
118
+ expect(result2).toBe(2);
119
+ });
120
+ });
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.stdout).toMatch(/Reports will be generated locally only/);
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", () => {