postgresai 0.15.0-dev.10 → 0.15.0-dev.11

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 CHANGED
@@ -211,7 +211,10 @@ export async function downloadFile(params: DownloadFileParams): Promise<Download
211
211
  // and restricting it to cwd would break legitimate use (e.g. -o /tmp/file.png).
212
212
  if (!outputPath) {
213
213
  const normalizedSave = path.normalize(saveTo);
214
- if (!normalizedSave.startsWith(path.normalize(process.cwd()))) {
214
+ const cwd = path.normalize(process.cwd());
215
+ // Append path.sep so that cwd "/home/u/proj" doesn't allow a sibling
216
+ // "/home/u/proj-evil/x" via plain prefix match.
217
+ if (normalizedSave !== cwd && !normalizedSave.startsWith(cwd + path.sep)) {
215
218
  throw new Error("Derived output path escapes current directory; please specify --output");
216
219
  }
217
220
  }
@@ -289,3 +292,76 @@ export function buildMarkdownLink(fileUrl: string, storageBaseUrl: string, filen
289
292
  }
290
293
  return `[${safeName}](${fullUrl})`;
291
294
  }
295
+
296
+ export interface UploadedAttachment {
297
+ path: string;
298
+ url: string;
299
+ markdown: string;
300
+ metadata: UploadFileMetadata;
301
+ }
302
+
303
+ export interface UploadAttachmentsParams {
304
+ apiKey: string;
305
+ storageBaseUrl: string;
306
+ attachmentPaths: string[];
307
+ debug?: boolean;
308
+ }
309
+
310
+ /**
311
+ * Upload a list of local files to storage and return one markdown link per file.
312
+ *
313
+ * Shared by both the CLI `--attach` flag and the MCP `attachments` parameter so
314
+ * that the two surfaces produce identical output. Uploads are sequential (not
315
+ * parallel) so that on failure of file N, the error from `uploadFile` (which
316
+ * includes the resolved path, e.g. `File not found: /abs/path`) pinpoints
317
+ * which file failed. Note: any files already uploaded successfully before
318
+ * the failure remain on the storage server; a retry of the same call will
319
+ * re-upload them.
320
+ *
321
+ * Returns an empty array if `attachmentPaths` is empty (callers don't have to
322
+ * guard).
323
+ */
324
+ export async function uploadAttachments(params: UploadAttachmentsParams): Promise<UploadedAttachment[]> {
325
+ const { apiKey, storageBaseUrl, attachmentPaths, debug } = params;
326
+ if (!attachmentPaths || attachmentPaths.length === 0) {
327
+ return [];
328
+ }
329
+ const out: UploadedAttachment[] = [];
330
+ for (const attachmentPath of attachmentPaths) {
331
+ const result = await uploadFile({
332
+ apiKey,
333
+ storageBaseUrl,
334
+ filePath: attachmentPath,
335
+ debug,
336
+ });
337
+ const markdown = buildMarkdownLink(result.url, storageBaseUrl, result.metadata.originalName);
338
+ out.push({
339
+ path: attachmentPath,
340
+ url: result.url,
341
+ markdown,
342
+ metadata: result.metadata,
343
+ });
344
+ }
345
+ return out;
346
+ }
347
+
348
+ /**
349
+ * Append uploaded-attachment markdown links to a body of content.
350
+ *
351
+ * - If `attachments` is empty, returns `content` unchanged.
352
+ * - If `content` is empty/whitespace, returns just the links.
353
+ * - Otherwise: `${content}\n\n${links joined by \n}`.
354
+ *
355
+ * One link per line keeps the renderer happy whether the surface is GFM
356
+ * (which collapses adjacent lines) or strict CommonMark.
357
+ */
358
+ export function appendAttachmentsToContent(content: string, attachments: UploadedAttachment[]): string {
359
+ if (!attachments || attachments.length === 0) {
360
+ return content;
361
+ }
362
+ const links = attachments.map((a) => a.markdown).join("\n");
363
+ if (!content || !content.trim()) {
364
+ return links;
365
+ }
366
+ return `${content}\n\n${links}`;
367
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.15.0-dev.10",
3
+ "version": "0.15.0-dev.11",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -49,8 +49,12 @@ async function startFakeApi() {
49
49
  headers: Record<string, string>;
50
50
  bodyText: string;
51
51
  bodyJson: any | null;
52
+ contentType?: string;
52
53
  }> = [];
53
54
 
55
+ // For storage tests: tracks file uploads served at /storage/upload.
56
+ const uploads: Array<{ filename: string; size: number; mimeType: string; url: string }> = [];
57
+
54
58
  const server = Bun.serve({
55
59
  hostname: "127.0.0.1",
56
60
  port: 0,
@@ -59,6 +63,50 @@ async function startFakeApi() {
59
63
  const headers: Record<string, string> = {};
60
64
  for (const [k, v] of req.headers.entries()) headers[k.toLowerCase()] = v;
61
65
 
66
+ // /storage/upload is multipart/form-data, not JSON. Branch on content type.
67
+ const contentType = headers["content-type"] || "";
68
+ const isMultipart = contentType.startsWith("multipart/form-data");
69
+
70
+ // Storage upload endpoint — return a deterministic /files/N/<idx>_<name> URL.
71
+ if (req.method === "POST" && url.pathname === "/storage/upload" && isMultipart) {
72
+ const form = await req.formData();
73
+ const file = form.get("file") as File | null;
74
+ if (!file) return new Response("missing file", { status: 400 });
75
+ const buf = new Uint8Array(await file.arrayBuffer());
76
+ const idx = uploads.length;
77
+ const fileUrl = `/files/test/${idx}_${file.name}`;
78
+ uploads.push({ filename: file.name, size: buf.length, mimeType: file.type, url: fileUrl });
79
+ requests.push({ method: req.method, pathname: url.pathname, headers, bodyText: "", bodyJson: null, contentType });
80
+ return new Response(
81
+ JSON.stringify({
82
+ success: true,
83
+ url: fileUrl,
84
+ metadata: {
85
+ originalName: file.name,
86
+ size: buf.length,
87
+ mimeType: file.type,
88
+ uploadedAt: "2025-01-01T00:00:00.000Z",
89
+ duration: 1,
90
+ },
91
+ requestId: `req-upload-${idx}`,
92
+ }),
93
+ { status: 200, headers: { "Content-Type": "application/json" } }
94
+ );
95
+ }
96
+
97
+ // Storage download endpoint — return whatever bytes were uploaded.
98
+ if (req.method === "GET" && url.pathname.startsWith("/storage/files/")) {
99
+ // Strip "/storage" so the lookup matches what we stored.
100
+ const fileUrl = url.pathname.replace(/^\/storage/, "");
101
+ const found = uploads.find((u) => u.url === fileUrl);
102
+ if (!found) return new Response("not found", { status: 404 });
103
+ // Re-upload-ish path: we don't keep bytes, but for test purposes return a known body.
104
+ return new Response("FAKE_DOWNLOADED_BYTES", {
105
+ status: 200,
106
+ headers: { "Content-Type": found.mimeType || "application/octet-stream" },
107
+ });
108
+ }
109
+
62
110
  const bodyText = await req.text();
63
111
  let bodyJson: any | null = null;
64
112
  try {
@@ -73,6 +121,7 @@ async function startFakeApi() {
73
121
  headers,
74
122
  bodyText,
75
123
  bodyJson,
124
+ contentType,
76
125
  });
77
126
 
78
127
  // Minimal fake PostgREST RPC endpoints used by our CLI.
@@ -133,6 +182,26 @@ async function startFakeApi() {
133
182
  );
134
183
  }
135
184
 
185
+ // GET /issues — used by `issues update --attach` to fetch existing description.
186
+ if (req.method === "GET" && url.pathname.endsWith("/issues")) {
187
+ const idParam = url.searchParams.get("id") || "";
188
+ const issueId = idParam.replace("eq.", "");
189
+ return new Response(
190
+ JSON.stringify([
191
+ {
192
+ id: issueId,
193
+ title: "Existing title",
194
+ description: "Existing description body",
195
+ status: 0,
196
+ created_at: "2025-01-01T00:00:00Z",
197
+ author_display_name: "tester",
198
+ action_items: [],
199
+ },
200
+ ]),
201
+ { status: 200, headers: { "Content-Type": "application/json" } }
202
+ );
203
+ }
204
+
136
205
  // Action Items endpoints
137
206
  if (req.method === "GET" && url.pathname.endsWith("/issue_action_items")) {
138
207
  const issueIdParam = url.searchParams.get("issue_id");
@@ -175,10 +244,13 @@ async function startFakeApi() {
175
244
  });
176
245
 
177
246
  const baseUrl = `http://${server.hostname}:${server.port}/api/general`;
247
+ const storageBaseUrl = `http://${server.hostname}:${server.port}/storage`;
178
248
 
179
249
  return {
180
250
  baseUrl,
251
+ storageBaseUrl,
181
252
  requests,
253
+ uploads,
182
254
  stop: () => server.stop(true),
183
255
  };
184
256
  }
@@ -765,3 +837,326 @@ describe("CLI set-storage-url command", () => {
765
837
  expect(`${r.stdout}\n${r.stderr}`).toContain("invalid URL");
766
838
  });
767
839
  });
840
+
841
+ describe("CLI issues --attach flag", () => {
842
+ // Helper: write a small image-named tmp file (not a real PNG, but the .png
843
+ // extension is what the CLI / storage helper read for MIME + markdown form).
844
+ function writeTmpFile(name: string, body = "X"): string {
845
+ const dir = mkdtempSync(resolve(tmpdir(), "pgai-attach-"));
846
+ const p = resolve(dir, name);
847
+ writeFileSync(p, body);
848
+ return p;
849
+ }
850
+
851
+ test("post-comment --attach uploads then appends image markdown to comment", async () => {
852
+ const api = await startFakeApi();
853
+ const png = writeTmpFile("pic.png", "PNGBYTES");
854
+ try {
855
+ const r = await runCliAsync(
856
+ [
857
+ "issues",
858
+ "post-comment",
859
+ "11111111-1111-1111-1111-111111111111",
860
+ "see attached",
861
+ "--attach",
862
+ png,
863
+ "--json",
864
+ ],
865
+ isolatedEnv({
866
+ PGAI_API_KEY: "test-key",
867
+ PGAI_API_BASE_URL: api.baseUrl,
868
+ PGAI_STORAGE_BASE_URL: api.storageBaseUrl,
869
+ })
870
+ );
871
+
872
+ expect(r.status).toBe(0);
873
+ // Upload happened first.
874
+ expect(api.uploads).toHaveLength(1);
875
+ expect(api.uploads[0].filename).toBe("pic.png");
876
+
877
+ // Comment-create request was sent with augmented content.
878
+ const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_comment_create"));
879
+ expect(req).toBeTruthy();
880
+ expect(req!.bodyJson.content).toBe(
881
+ `see attached\n\n![pic.png](${api.storageBaseUrl}${api.uploads[0].url})`
882
+ );
883
+ // Request order: upload before comment.
884
+ const uploadIdx = api.requests.findIndex((x) => x.pathname === "/storage/upload");
885
+ const commentIdx = api.requests.findIndex((x) => x.pathname.endsWith("/rpc/issue_comment_create"));
886
+ expect(uploadIdx).toBeGreaterThanOrEqual(0);
887
+ expect(commentIdx).toBeGreaterThan(uploadIdx);
888
+ } finally {
889
+ api.stop();
890
+ }
891
+ });
892
+
893
+ test("post-comment --attach with multiple files appends one link per line preserving order", async () => {
894
+ const api = await startFakeApi();
895
+ const png = writeTmpFile("a.png", "P");
896
+ const log = writeTmpFile("b.log", "L");
897
+ try {
898
+ const r = await runCliAsync(
899
+ [
900
+ "issues",
901
+ "post-comment",
902
+ "11111111-1111-1111-1111-111111111111",
903
+ "ctx",
904
+ "--attach",
905
+ png,
906
+ "--attach",
907
+ log,
908
+ "--json",
909
+ ],
910
+ isolatedEnv({
911
+ PGAI_API_KEY: "test-key",
912
+ PGAI_API_BASE_URL: api.baseUrl,
913
+ PGAI_STORAGE_BASE_URL: api.storageBaseUrl,
914
+ })
915
+ );
916
+ expect(r.status).toBe(0);
917
+
918
+ expect(api.uploads.map((u) => u.filename)).toEqual(["a.png", "b.log"]);
919
+
920
+ const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_comment_create"));
921
+ expect(req).toBeTruthy();
922
+ const expected =
923
+ `ctx\n\n![a.png](${api.storageBaseUrl}/files/test/0_a.png)\n` +
924
+ `[b.log](${api.storageBaseUrl}/files/test/1_b.log)`;
925
+ expect(req!.bodyJson.content).toBe(expected);
926
+ } finally {
927
+ api.stop();
928
+ }
929
+ });
930
+
931
+ test("create --attach appends markdown link to description", async () => {
932
+ const api = await startFakeApi();
933
+ const png = writeTmpFile("diagram.png", "PNG");
934
+ try {
935
+ const r = await runCliAsync(
936
+ [
937
+ "issues",
938
+ "create",
939
+ "Reproduces under load",
940
+ "--org-id",
941
+ "1",
942
+ "--description",
943
+ "see chart",
944
+ "--attach",
945
+ png,
946
+ "--json",
947
+ ],
948
+ isolatedEnv({
949
+ PGAI_API_KEY: "test-key",
950
+ PGAI_API_BASE_URL: api.baseUrl,
951
+ PGAI_STORAGE_BASE_URL: api.storageBaseUrl,
952
+ })
953
+ );
954
+ expect(r.status).toBe(0);
955
+
956
+ const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_create"));
957
+ expect(req).toBeTruthy();
958
+ expect(req!.bodyJson.description).toBe(
959
+ `see chart\n\n![diagram.png](${api.storageBaseUrl}/files/test/0_diagram.png)`
960
+ );
961
+ } finally {
962
+ api.stop();
963
+ }
964
+ });
965
+
966
+ test("create --attach without --description sets description to just the link", async () => {
967
+ const api = await startFakeApi();
968
+ const png = writeTmpFile("only.png", "P");
969
+ try {
970
+ const r = await runCliAsync(
971
+ [
972
+ "issues",
973
+ "create",
974
+ "tinier example",
975
+ "--org-id",
976
+ "1",
977
+ "--attach",
978
+ png,
979
+ "--json",
980
+ ],
981
+ isolatedEnv({
982
+ PGAI_API_KEY: "test-key",
983
+ PGAI_API_BASE_URL: api.baseUrl,
984
+ PGAI_STORAGE_BASE_URL: api.storageBaseUrl,
985
+ })
986
+ );
987
+ expect(r.status).toBe(0);
988
+
989
+ const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_create"));
990
+ expect(req).toBeTruthy();
991
+ expect(req!.bodyJson.description).toBe(
992
+ `![only.png](${api.storageBaseUrl}/files/test/0_only.png)`
993
+ );
994
+ } finally {
995
+ api.stop();
996
+ }
997
+ });
998
+
999
+ test("update --attach without --description fetches existing description and appends", async () => {
1000
+ const api = await startFakeApi();
1001
+ const png = writeTmpFile("evidence.png", "P");
1002
+ try {
1003
+ const r = await runCliAsync(
1004
+ [
1005
+ "issues",
1006
+ "update",
1007
+ "issue-1",
1008
+ "--attach",
1009
+ png,
1010
+ "--json",
1011
+ ],
1012
+ isolatedEnv({
1013
+ PGAI_API_KEY: "test-key",
1014
+ PGAI_API_BASE_URL: api.baseUrl,
1015
+ PGAI_STORAGE_BASE_URL: api.storageBaseUrl,
1016
+ })
1017
+ );
1018
+ expect(r.status).toBe(0);
1019
+
1020
+ // GET /issues happens first to read the existing description, then upload, then update.
1021
+ const seq = api.requests.map((x) => `${x.method} ${x.pathname}`);
1022
+ const fetchIdx = seq.indexOf("GET /api/general/issues");
1023
+ const uploadIdx = seq.indexOf("POST /storage/upload");
1024
+ const updateIdx = seq.indexOf("POST /api/general/rpc/issue_update");
1025
+ expect(fetchIdx).toBeGreaterThanOrEqual(0);
1026
+ expect(uploadIdx).toBeGreaterThan(fetchIdx);
1027
+ expect(updateIdx).toBeGreaterThan(uploadIdx);
1028
+
1029
+ const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_update"));
1030
+ expect(req).toBeTruthy();
1031
+ expect(req!.bodyJson.p_description).toBe(
1032
+ `Existing description body\n\n![evidence.png](${api.storageBaseUrl}/files/test/0_evidence.png)`
1033
+ );
1034
+ } finally {
1035
+ api.stop();
1036
+ }
1037
+ });
1038
+
1039
+ test("update --attach with --description appends to the new description (no fetch)", async () => {
1040
+ const api = await startFakeApi();
1041
+ const png = writeTmpFile("e2.png", "P");
1042
+ try {
1043
+ const r = await runCliAsync(
1044
+ [
1045
+ "issues",
1046
+ "update",
1047
+ "issue-1",
1048
+ "--description",
1049
+ "Rewritten body",
1050
+ "--attach",
1051
+ png,
1052
+ "--json",
1053
+ ],
1054
+ isolatedEnv({
1055
+ PGAI_API_KEY: "test-key",
1056
+ PGAI_API_BASE_URL: api.baseUrl,
1057
+ PGAI_STORAGE_BASE_URL: api.storageBaseUrl,
1058
+ })
1059
+ );
1060
+ expect(r.status).toBe(0);
1061
+
1062
+ // No GET /issues happened — we already have the new description.
1063
+ const fetched = api.requests.find((x) => x.method === "GET" && x.pathname.endsWith("/issues"));
1064
+ expect(fetched).toBeFalsy();
1065
+
1066
+ const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_update"));
1067
+ expect(req).toBeTruthy();
1068
+ expect(req!.bodyJson.p_description).toBe(
1069
+ `Rewritten body\n\n![e2.png](${api.storageBaseUrl}/files/test/0_e2.png)`
1070
+ );
1071
+ } finally {
1072
+ api.stop();
1073
+ }
1074
+ });
1075
+
1076
+ test("update-comment --attach appends markdown link to comment content", async () => {
1077
+ const api = await startFakeApi();
1078
+ const png = writeTmpFile("after.png", "P");
1079
+ try {
1080
+ const r = await runCliAsync(
1081
+ [
1082
+ "issues",
1083
+ "update-comment",
1084
+ "comment-1",
1085
+ "now with a screenshot",
1086
+ "--attach",
1087
+ png,
1088
+ "--json",
1089
+ ],
1090
+ isolatedEnv({
1091
+ PGAI_API_KEY: "test-key",
1092
+ PGAI_API_BASE_URL: api.baseUrl,
1093
+ PGAI_STORAGE_BASE_URL: api.storageBaseUrl,
1094
+ })
1095
+ );
1096
+ expect(r.status).toBe(0);
1097
+
1098
+ const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_comment_update"));
1099
+ expect(req).toBeTruthy();
1100
+ expect(req!.bodyJson.p_content).toBe(
1101
+ `now with a screenshot\n\n![after.png](${api.storageBaseUrl}/files/test/0_after.png)`
1102
+ );
1103
+ } finally {
1104
+ api.stop();
1105
+ }
1106
+ });
1107
+
1108
+ test("--attach with a missing file fails fast and never sends the comment-create request", async () => {
1109
+ const api = await startFakeApi();
1110
+ try {
1111
+ const r = await runCliAsync(
1112
+ [
1113
+ "issues",
1114
+ "post-comment",
1115
+ "11111111-1111-1111-1111-111111111111",
1116
+ "should not be posted",
1117
+ "--attach",
1118
+ "/tmp/this-path-does-not-exist-xyz123.png",
1119
+ ],
1120
+ isolatedEnv({
1121
+ PGAI_API_KEY: "test-key",
1122
+ PGAI_API_BASE_URL: api.baseUrl,
1123
+ PGAI_STORAGE_BASE_URL: api.storageBaseUrl,
1124
+ })
1125
+ );
1126
+ expect(r.status).toBe(1);
1127
+ expect(`${r.stdout}\n${r.stderr}`).toMatch(/File not found/);
1128
+ // No comment-create request reached the server — we bailed before posting.
1129
+ const commentReq = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_comment_create"));
1130
+ expect(commentReq).toBeFalsy();
1131
+ } finally {
1132
+ api.stop();
1133
+ }
1134
+ });
1135
+
1136
+ test("post-comment without --attach still works (regression check)", async () => {
1137
+ const api = await startFakeApi();
1138
+ try {
1139
+ const r = await runCliAsync(
1140
+ [
1141
+ "issues",
1142
+ "post-comment",
1143
+ "11111111-1111-1111-1111-111111111111",
1144
+ "plain comment",
1145
+ "--json",
1146
+ ],
1147
+ isolatedEnv({
1148
+ PGAI_API_KEY: "test-key",
1149
+ PGAI_API_BASE_URL: api.baseUrl,
1150
+ PGAI_STORAGE_BASE_URL: api.storageBaseUrl,
1151
+ })
1152
+ );
1153
+ expect(r.status).toBe(0);
1154
+ expect(api.uploads).toHaveLength(0);
1155
+ const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_comment_create"));
1156
+ expect(req).toBeTruthy();
1157
+ expect(req!.bodyJson.content).toBe("plain comment");
1158
+ } finally {
1159
+ api.stop();
1160
+ }
1161
+ });
1162
+ });