kanna-code 0.16.0 → 0.18.0
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/README.md +32 -0
- package/dist/client/assets/index-BvhI1JYs.css +32 -0
- package/dist/client/assets/index-CLqK0AWC.js +606 -0
- package/dist/client/index.html +2 -2
- package/package.json +5 -1
- package/src/server/agent.test.ts +64 -2
- package/src/server/agent.ts +45 -3
- package/src/server/cli-runtime.test.ts +130 -0
- package/src/server/cli-runtime.ts +43 -3
- package/src/server/event-store.ts +7 -31
- package/src/server/paths.ts +4 -0
- package/src/server/provider-catalog.test.ts +1 -1
- package/src/server/server.ts +178 -1
- package/src/server/share.test.ts +108 -0
- package/src/server/share.ts +113 -0
- package/src/server/uploads.test.ts +282 -0
- package/src/server/uploads.ts +127 -0
- package/src/shared/dev-ports.test.ts +32 -0
- package/src/shared/dev-ports.ts +55 -0
- package/src/shared/protocol.ts +2 -0
- package/src/shared/tools.test.ts +65 -0
- package/src/shared/tools.ts +76 -0
- package/src/shared/types.ts +36 -4
- package/dist/client/assets/index-BET6YVYr.css +0 -32
- package/dist/client/assets/index-DMxCijCf.js +0 -533
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { deleteProjectUpload, inferAttachmentContentType, persistProjectUpload } from "./uploads"
|
|
6
|
+
import { getProjectUploadDir } from "./paths"
|
|
7
|
+
import { persistUploadedFiles, startKannaServer } from "./server"
|
|
8
|
+
|
|
9
|
+
const PNG_BASE64 =
|
|
10
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yF9sAAAAASUVORK5CYII="
|
|
11
|
+
|
|
12
|
+
const tempDirs: string[] = []
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe("uploads", () => {
|
|
19
|
+
test("stores uploads in .kanna/uploads and keeps duplicate filenames", async () => {
|
|
20
|
+
const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-upload-test-"))
|
|
21
|
+
tempDirs.push(projectDir)
|
|
22
|
+
|
|
23
|
+
const first = await persistProjectUpload({
|
|
24
|
+
projectId: "project-1",
|
|
25
|
+
localPath: projectDir,
|
|
26
|
+
fileName: "notes.txt",
|
|
27
|
+
bytes: new TextEncoder().encode("hello"),
|
|
28
|
+
fallbackMimeType: "text/plain",
|
|
29
|
+
})
|
|
30
|
+
const second = await persistProjectUpload({
|
|
31
|
+
projectId: "project-1",
|
|
32
|
+
localPath: projectDir,
|
|
33
|
+
fileName: "notes.txt",
|
|
34
|
+
bytes: new TextEncoder().encode("world"),
|
|
35
|
+
fallbackMimeType: "text/plain",
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
expect(first.absolutePath).toBe(path.join(projectDir, ".kanna/uploads/notes.txt"))
|
|
39
|
+
expect(first.relativePath).toBe("./.kanna/uploads/notes.txt")
|
|
40
|
+
expect(first.contentUrl).toBe("/api/projects/project-1/uploads/notes.txt/content")
|
|
41
|
+
expect(second.absolutePath).toBe(path.join(projectDir, ".kanna/uploads/notes-1.txt"))
|
|
42
|
+
expect(second.relativePath).toBe("./.kanna/uploads/notes-1.txt")
|
|
43
|
+
expect(second.contentUrl).toBe("/api/projects/project-1/uploads/notes-1.txt/content")
|
|
44
|
+
expect(await Bun.file(path.join(projectDir, ".kanna/uploads/notes.txt")).text()).toBe("hello")
|
|
45
|
+
expect(await Bun.file(path.join(projectDir, ".kanna/uploads/notes-1.txt")).text()).toBe("world")
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test("stores concurrent same-name uploads without overwriting existing content", async () => {
|
|
49
|
+
const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-upload-concurrent-"))
|
|
50
|
+
tempDirs.push(projectDir)
|
|
51
|
+
|
|
52
|
+
const attachments = await Promise.all([
|
|
53
|
+
persistProjectUpload({
|
|
54
|
+
projectId: "project-1",
|
|
55
|
+
localPath: projectDir,
|
|
56
|
+
fileName: "notes.txt",
|
|
57
|
+
bytes: new TextEncoder().encode("first"),
|
|
58
|
+
fallbackMimeType: "text/plain",
|
|
59
|
+
}),
|
|
60
|
+
persistProjectUpload({
|
|
61
|
+
projectId: "project-1",
|
|
62
|
+
localPath: projectDir,
|
|
63
|
+
fileName: "notes.txt",
|
|
64
|
+
bytes: new TextEncoder().encode("second"),
|
|
65
|
+
fallbackMimeType: "text/plain",
|
|
66
|
+
}),
|
|
67
|
+
persistProjectUpload({
|
|
68
|
+
projectId: "project-1",
|
|
69
|
+
localPath: projectDir,
|
|
70
|
+
fileName: "notes.txt",
|
|
71
|
+
bytes: new TextEncoder().encode("third"),
|
|
72
|
+
fallbackMimeType: "text/plain",
|
|
73
|
+
}),
|
|
74
|
+
])
|
|
75
|
+
|
|
76
|
+
const storedNames = attachments.map((attachment) => path.basename(attachment.absolutePath)).sort()
|
|
77
|
+
expect(storedNames).toEqual(["notes-1.txt", "notes-2.txt", "notes.txt"])
|
|
78
|
+
|
|
79
|
+
const contents = await Promise.all(attachments.map((attachment) => Bun.file(attachment.absolutePath).text()))
|
|
80
|
+
expect(new Set(contents)).toEqual(new Set(["first", "second", "third"]))
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("detects image uploads and returns absolute plus project-relative paths", async () => {
|
|
84
|
+
const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-upload-image-"))
|
|
85
|
+
tempDirs.push(projectDir)
|
|
86
|
+
|
|
87
|
+
const attachment = await persistProjectUpload({
|
|
88
|
+
projectId: "project-2",
|
|
89
|
+
localPath: projectDir,
|
|
90
|
+
fileName: "pixel.png",
|
|
91
|
+
bytes: Buffer.from(PNG_BASE64, "base64"),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
expect(attachment.kind).toBe("image")
|
|
95
|
+
expect(attachment.mimeType).toBe("image/png")
|
|
96
|
+
expect(getProjectUploadDir(projectDir)).toBe(path.join(projectDir, ".kanna", "uploads"))
|
|
97
|
+
expect(attachment.absolutePath).toBe(path.join(projectDir, ".kanna/uploads/pixel.png"))
|
|
98
|
+
expect(attachment.relativePath).toBe("./.kanna/uploads/pixel.png")
|
|
99
|
+
expect(attachment.contentUrl).toBe("/api/projects/project-2/uploads/pixel.png/content")
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test("serves uploaded attachment content through the project content URL", async () => {
|
|
103
|
+
const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-"))
|
|
104
|
+
tempDirs.push(projectDir)
|
|
105
|
+
|
|
106
|
+
const server = await startKannaServer({ port: 4310, strictPort: true })
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const project = await server.store.openProject(projectDir, "Project")
|
|
110
|
+
const attachment = await persistProjectUpload({
|
|
111
|
+
projectId: project.id,
|
|
112
|
+
localPath: projectDir,
|
|
113
|
+
fileName: "hello.txt",
|
|
114
|
+
bytes: new TextEncoder().encode("hello from upload"),
|
|
115
|
+
fallbackMimeType: "text/plain",
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const response = await fetch(`http://localhost:${server.port}${attachment.contentUrl}`)
|
|
119
|
+
expect(response.status).toBe(200)
|
|
120
|
+
expect(response.headers.get("content-type")).toBe("text/plain; charset=utf-8")
|
|
121
|
+
expect(await response.text()).toBe("hello from upload")
|
|
122
|
+
} finally {
|
|
123
|
+
await server.stop()
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test("serves TypeScript uploads as text content", async () => {
|
|
128
|
+
const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-typescript-"))
|
|
129
|
+
tempDirs.push(projectDir)
|
|
130
|
+
|
|
131
|
+
const server = await startKannaServer({ port: 4314, strictPort: true })
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const project = await server.store.openProject(projectDir, "Project")
|
|
135
|
+
const attachment = await persistProjectUpload({
|
|
136
|
+
projectId: project.id,
|
|
137
|
+
localPath: projectDir,
|
|
138
|
+
fileName: "main.ts",
|
|
139
|
+
bytes: new TextEncoder().encode("export const value = 1\n"),
|
|
140
|
+
fallbackMimeType: "video/mp2t",
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const response = await fetch(`http://localhost:${server.port}${attachment.contentUrl}`)
|
|
144
|
+
expect(response.status).toBe(200)
|
|
145
|
+
expect(response.headers.get("content-type")).toBe("text/plain; charset=utf-8")
|
|
146
|
+
expect(await response.text()).toContain("export const value = 1")
|
|
147
|
+
} finally {
|
|
148
|
+
await server.stop()
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test("rejects non-GET requests for attachment content", async () => {
|
|
153
|
+
const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-content-method-"))
|
|
154
|
+
tempDirs.push(projectDir)
|
|
155
|
+
|
|
156
|
+
const server = await startKannaServer({ port: 4312, strictPort: true })
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const project = await server.store.openProject(projectDir, "Project")
|
|
160
|
+
const attachment = await persistProjectUpload({
|
|
161
|
+
projectId: project.id,
|
|
162
|
+
localPath: projectDir,
|
|
163
|
+
fileName: "hello.txt",
|
|
164
|
+
bytes: new TextEncoder().encode("hello from upload"),
|
|
165
|
+
fallbackMimeType: "text/plain",
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const response = await fetch(`http://localhost:${server.port}${attachment.contentUrl}`, { method: "POST" })
|
|
169
|
+
expect(response.status).toBe(405)
|
|
170
|
+
expect(response.headers.get("allow")).toBe("GET")
|
|
171
|
+
} finally {
|
|
172
|
+
await server.stop()
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test("rejects oversized uploads before reading them into memory", async () => {
|
|
177
|
+
const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-oversize-"))
|
|
178
|
+
tempDirs.push(projectDir)
|
|
179
|
+
|
|
180
|
+
const server = await startKannaServer({ port: 4313, strictPort: true })
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const project = await server.store.openProject(projectDir, "Project")
|
|
184
|
+
const formData = new FormData()
|
|
185
|
+
formData.append("files", new File([new Uint8Array(25 * 1024 * 1024 + 1)], "big.bin", { type: "application/octet-stream" }))
|
|
186
|
+
|
|
187
|
+
const response = await fetch(`http://localhost:${server.port}/api/projects/${project.id}/uploads`, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
body: formData,
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
expect(response.status).toBe(413)
|
|
193
|
+
expect(await response.json()).toEqual({
|
|
194
|
+
error: "File \"big.bin\" exceeds the 25 MB limit.",
|
|
195
|
+
})
|
|
196
|
+
} finally {
|
|
197
|
+
await server.stop()
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test("cleans up already-persisted files when a later file in the batch fails", async () => {
|
|
202
|
+
const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-cleanup-"))
|
|
203
|
+
tempDirs.push(projectDir)
|
|
204
|
+
|
|
205
|
+
const files = [
|
|
206
|
+
new File(["first"], "first.txt", { type: "text/plain" }),
|
|
207
|
+
new File(["second"], "second.txt", { type: "text/plain" }),
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
await expect(
|
|
211
|
+
persistUploadedFiles({
|
|
212
|
+
projectId: "project-4",
|
|
213
|
+
localPath: projectDir,
|
|
214
|
+
files,
|
|
215
|
+
persistUpload: async (args) => {
|
|
216
|
+
if (args.fileName === "second.txt") {
|
|
217
|
+
throw new Error("disk full")
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return persistProjectUpload(args)
|
|
221
|
+
},
|
|
222
|
+
})
|
|
223
|
+
).rejects.toThrow("disk full")
|
|
224
|
+
|
|
225
|
+
expect(await Bun.file(path.join(projectDir, ".kanna/uploads/first.txt")).exists()).toBe(false)
|
|
226
|
+
expect(await Bun.file(path.join(projectDir, ".kanna/uploads/second.txt")).exists()).toBe(false)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test("deletes uploaded attachments from the project uploads directory", async () => {
|
|
230
|
+
const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-upload-delete-"))
|
|
231
|
+
tempDirs.push(projectDir)
|
|
232
|
+
|
|
233
|
+
const attachment = await persistProjectUpload({
|
|
234
|
+
projectId: "project-3",
|
|
235
|
+
localPath: projectDir,
|
|
236
|
+
fileName: "delete-me.txt",
|
|
237
|
+
bytes: new TextEncoder().encode("bye"),
|
|
238
|
+
fallbackMimeType: "text/plain",
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
const deleted = await deleteProjectUpload({
|
|
242
|
+
localPath: projectDir,
|
|
243
|
+
storedName: "delete-me.txt",
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
expect(deleted).toBe(true)
|
|
247
|
+
expect(await Bun.file(attachment.absolutePath).exists()).toBe(false)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test("deletes uploaded attachment content through the project delete URL", async () => {
|
|
251
|
+
const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-delete-"))
|
|
252
|
+
tempDirs.push(projectDir)
|
|
253
|
+
|
|
254
|
+
const server = await startKannaServer({ port: 4311, strictPort: true })
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const project = await server.store.openProject(projectDir, "Project")
|
|
258
|
+
const attachment = await persistProjectUpload({
|
|
259
|
+
projectId: project.id,
|
|
260
|
+
localPath: projectDir,
|
|
261
|
+
fileName: "bye.txt",
|
|
262
|
+
bytes: new TextEncoder().encode("delete over http"),
|
|
263
|
+
fallbackMimeType: "text/plain",
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
const deleteUrl = `http://localhost:${server.port}${attachment.contentUrl.replace(/\/content$/, "")}`
|
|
267
|
+
const response = await fetch(deleteUrl, { method: "DELETE" })
|
|
268
|
+
expect(response.status).toBe(200)
|
|
269
|
+
expect(await response.json()).toEqual({ ok: true })
|
|
270
|
+
expect(await Bun.file(attachment.absolutePath).exists()).toBe(false)
|
|
271
|
+
} finally {
|
|
272
|
+
await server.stop()
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test("infers text-friendly content types for previewable source files", () => {
|
|
277
|
+
expect(inferAttachmentContentType("notes.txt")).toBe("text/plain; charset=utf-8")
|
|
278
|
+
expect(inferAttachmentContentType("README.md")).toBe("text/markdown; charset=utf-8")
|
|
279
|
+
expect(inferAttachmentContentType("main.ts", "video/mp2t")).toBe("text/plain; charset=utf-8")
|
|
280
|
+
expect(inferAttachmentContentType("archive.zip", "application/zip")).toBe("application/zip")
|
|
281
|
+
})
|
|
282
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { mkdir, open, rm } from "node:fs/promises"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { fileTypeFromBuffer } from "file-type"
|
|
5
|
+
import type { ChatAttachment } from "../shared/types"
|
|
6
|
+
import { getProjectUploadDir } from "./paths"
|
|
7
|
+
|
|
8
|
+
const DEFAULT_BINARY_MIME_TYPE = "application/octet-stream"
|
|
9
|
+
const IMAGE_MIME_PREFIX = "image/"
|
|
10
|
+
const TEXT_PLAIN_CONTENT_TYPE = "text/plain; charset=utf-8"
|
|
11
|
+
|
|
12
|
+
const TEXT_CONTENT_TYPE_BY_EXTENSION = new Map<string, string>([
|
|
13
|
+
[".csv", "text/csv; charset=utf-8"],
|
|
14
|
+
[".json", "application/json; charset=utf-8"],
|
|
15
|
+
[".jsonc", TEXT_PLAIN_CONTENT_TYPE],
|
|
16
|
+
[".md", "text/markdown; charset=utf-8"],
|
|
17
|
+
[".tsv", "text/tab-separated-values; charset=utf-8"],
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
const TEXT_LIKE_EXTENSIONS = new Set([
|
|
21
|
+
".c", ".cc", ".cfg", ".conf", ".cpp", ".cs", ".css", ".env", ".go", ".graphql", ".h", ".hpp", ".html",
|
|
22
|
+
".ini", ".java", ".js", ".jsx", ".kt", ".lua", ".mjs", ".php", ".pl", ".properties", ".py", ".rb", ".rs",
|
|
23
|
+
".scss", ".sh", ".sql", ".swift", ".toml", ".ts", ".tsx", ".txt", ".vue", ".xml", ".yaml", ".yml", ".zsh",
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
function sanitizeFileName(fileName: string) {
|
|
27
|
+
const baseName = path.basename(fileName).trim()
|
|
28
|
+
const cleaned = baseName.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "")
|
|
29
|
+
return cleaned || "upload"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getUploadCandidateNames(originalName: string) {
|
|
33
|
+
const sanitizedName = sanitizeFileName(originalName)
|
|
34
|
+
const parsed = path.parse(sanitizedName)
|
|
35
|
+
const extension = parsed.ext
|
|
36
|
+
const name = parsed.name || "upload"
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
first: sanitizedName,
|
|
40
|
+
withCounter(counter: number) {
|
|
41
|
+
return `${name}-${counter}${extension}`
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function persistProjectUpload(args: {
|
|
47
|
+
projectId: string
|
|
48
|
+
localPath: string
|
|
49
|
+
fileName: string
|
|
50
|
+
bytes: Uint8Array
|
|
51
|
+
fallbackMimeType?: string
|
|
52
|
+
}): Promise<ChatAttachment> {
|
|
53
|
+
const uploadDir = getProjectUploadDir(args.localPath)
|
|
54
|
+
await mkdir(uploadDir, { recursive: true })
|
|
55
|
+
|
|
56
|
+
const detectedType = await fileTypeFromBuffer(args.bytes)
|
|
57
|
+
const mimeType = detectedType?.mime ?? args.fallbackMimeType ?? DEFAULT_BINARY_MIME_TYPE
|
|
58
|
+
const candidates = getUploadCandidateNames(args.fileName)
|
|
59
|
+
|
|
60
|
+
let storedName = candidates.first
|
|
61
|
+
let absolutePath = path.join(uploadDir, storedName)
|
|
62
|
+
let counter = 1
|
|
63
|
+
|
|
64
|
+
while (true) {
|
|
65
|
+
try {
|
|
66
|
+
const handle = await open(absolutePath, "wx")
|
|
67
|
+
try {
|
|
68
|
+
await handle.writeFile(args.bytes)
|
|
69
|
+
} finally {
|
|
70
|
+
await handle.close()
|
|
71
|
+
}
|
|
72
|
+
break
|
|
73
|
+
} catch (error) {
|
|
74
|
+
const code = error instanceof Error && "code" in error ? (error as NodeJS.ErrnoException).code : undefined
|
|
75
|
+
if (code !== "EEXIST") {
|
|
76
|
+
throw error
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
storedName = candidates.withCounter(counter)
|
|
80
|
+
absolutePath = path.join(uploadDir, storedName)
|
|
81
|
+
counter += 1
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
id: randomUUID(),
|
|
87
|
+
kind: mimeType.startsWith(IMAGE_MIME_PREFIX) ? "image" : "file",
|
|
88
|
+
displayName: args.fileName,
|
|
89
|
+
absolutePath,
|
|
90
|
+
relativePath: `./.kanna/uploads/${storedName}`,
|
|
91
|
+
contentUrl: `/api/projects/${args.projectId}/uploads/${encodeURIComponent(storedName)}/content`,
|
|
92
|
+
mimeType,
|
|
93
|
+
size: args.bytes.byteLength,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function inferAttachmentContentType(fileName: string, fallbackType?: string): string {
|
|
98
|
+
const extension = path.extname(fileName).toLowerCase()
|
|
99
|
+
const mappedType = TEXT_CONTENT_TYPE_BY_EXTENSION.get(extension)
|
|
100
|
+
if (mappedType) {
|
|
101
|
+
return mappedType
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (TEXT_LIKE_EXTENSIONS.has(extension)) {
|
|
105
|
+
return TEXT_PLAIN_CONTENT_TYPE
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return fallbackType || DEFAULT_BINARY_MIME_TYPE
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function deleteProjectUpload(args: {
|
|
112
|
+
localPath: string
|
|
113
|
+
storedName: string
|
|
114
|
+
}): Promise<boolean> {
|
|
115
|
+
const storedName = args.storedName
|
|
116
|
+
if (!storedName || storedName.includes("/") || storedName.includes("\\") || storedName === "." || storedName === "..") {
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const absolutePath = path.join(getProjectUploadDir(args.localPath), storedName)
|
|
121
|
+
try {
|
|
122
|
+
await rm(absolutePath, { force: true })
|
|
123
|
+
return true
|
|
124
|
+
} catch {
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|
|
2
2
|
import {
|
|
3
3
|
DEFAULT_DEV_CLIENT_PORT,
|
|
4
4
|
getDefaultDevServerPort,
|
|
5
|
+
parseDevArgs,
|
|
5
6
|
resolveDevPorts,
|
|
6
7
|
stripPortArg,
|
|
7
8
|
} from "./dev-ports"
|
|
@@ -50,3 +51,34 @@ describe("stripPortArg", () => {
|
|
|
50
51
|
])
|
|
51
52
|
})
|
|
52
53
|
})
|
|
54
|
+
|
|
55
|
+
describe("parseDevArgs", () => {
|
|
56
|
+
test("derives dev share options from the client port", () => {
|
|
57
|
+
expect(parseDevArgs(["--share", "--port", "3333"], "dev-machine")).toEqual({
|
|
58
|
+
clientPort: 3333,
|
|
59
|
+
serverPort: 3334,
|
|
60
|
+
share: true,
|
|
61
|
+
backendTargetHost: "127.0.0.1",
|
|
62
|
+
allowedHosts: true,
|
|
63
|
+
serverArgs: [],
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test("rejects combining --share with --host or --remote", () => {
|
|
68
|
+
expect(() => parseDevArgs(["--share", "--host", "dev-box"], "dev-machine")).toThrow("--share cannot be used with --host")
|
|
69
|
+
expect(() => parseDevArgs(["--host", "dev-box", "--share"], "dev-machine")).toThrow("--share cannot be used with --host")
|
|
70
|
+
expect(() => parseDevArgs(["--share", "--remote"], "dev-machine")).toThrow("--share cannot be used with --remote")
|
|
71
|
+
expect(() => parseDevArgs(["--remote", "--share"], "dev-machine")).toThrow("--share cannot be used with --remote")
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test("preserves existing remote host behavior when share is off", () => {
|
|
75
|
+
expect(parseDevArgs(["--remote", "--port", "3333"], "dev-machine")).toEqual({
|
|
76
|
+
clientPort: 3333,
|
|
77
|
+
serverPort: 3334,
|
|
78
|
+
share: false,
|
|
79
|
+
backendTargetHost: "127.0.0.1",
|
|
80
|
+
allowedHosts: true,
|
|
81
|
+
serverArgs: ["--remote"],
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
})
|
package/src/shared/dev-ports.ts
CHANGED
|
@@ -4,6 +4,15 @@ export function getDefaultDevServerPort(clientPort = DEFAULT_DEV_CLIENT_PORT) {
|
|
|
4
4
|
return clientPort + 1
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
export interface DevArgResolution {
|
|
8
|
+
clientPort: number
|
|
9
|
+
serverPort: number
|
|
10
|
+
share: boolean
|
|
11
|
+
backendTargetHost: string
|
|
12
|
+
allowedHosts: true | string[]
|
|
13
|
+
serverArgs: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
7
16
|
export function resolveDevPorts(args: string[]) {
|
|
8
17
|
let clientPort = DEFAULT_DEV_CLIENT_PORT
|
|
9
18
|
|
|
@@ -41,3 +50,49 @@ export function stripPortArg(args: string[]) {
|
|
|
41
50
|
|
|
42
51
|
return stripped
|
|
43
52
|
}
|
|
53
|
+
|
|
54
|
+
export function parseDevArgs(args: string[], localHostname: string): DevArgResolution {
|
|
55
|
+
const { clientPort, serverPort } = resolveDevPorts(args)
|
|
56
|
+
const serverArgs = stripPortArg(args).filter((arg) => arg !== "--share")
|
|
57
|
+
let share = false
|
|
58
|
+
let sawHost = false
|
|
59
|
+
let sawRemote = false
|
|
60
|
+
let backendTargetHost = "127.0.0.1"
|
|
61
|
+
let allowAllHosts = false
|
|
62
|
+
const hosts = new Set<string>(["localhost", "127.0.0.1", "0.0.0.0", localHostname])
|
|
63
|
+
|
|
64
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
65
|
+
const arg = args[index]
|
|
66
|
+
if (arg === "--share") {
|
|
67
|
+
if (sawHost) throw new Error("--share cannot be used with --host")
|
|
68
|
+
if (sawRemote) throw new Error("--share cannot be used with --remote")
|
|
69
|
+
share = true
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
if (arg === "--remote") {
|
|
73
|
+
if (share) throw new Error("--share cannot be used with --remote")
|
|
74
|
+
sawRemote = true
|
|
75
|
+
backendTargetHost = "127.0.0.1"
|
|
76
|
+
allowAllHosts = true
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
if (arg !== "--host") continue
|
|
80
|
+
|
|
81
|
+
const next = args[index + 1]
|
|
82
|
+
if (!next || next.startsWith("-")) continue
|
|
83
|
+
if (share) throw new Error("--share cannot be used with --host")
|
|
84
|
+
sawHost = true
|
|
85
|
+
hosts.add(next)
|
|
86
|
+
backendTargetHost = next === "0.0.0.0" ? "127.0.0.1" : next
|
|
87
|
+
index += 1
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
clientPort,
|
|
92
|
+
serverPort,
|
|
93
|
+
share,
|
|
94
|
+
backendTargetHost,
|
|
95
|
+
allowedHosts: share || allowAllHosts ? true : [...hosts],
|
|
96
|
+
serverArgs,
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/shared/protocol.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AgentProvider,
|
|
3
|
+
ChatAttachment,
|
|
3
4
|
ChatSnapshot,
|
|
4
5
|
KeybindingsSnapshot,
|
|
5
6
|
LocalProjectsSnapshot,
|
|
@@ -67,6 +68,7 @@ export type ClientCommand =
|
|
|
67
68
|
projectId?: string
|
|
68
69
|
provider?: AgentProvider
|
|
69
70
|
content: string
|
|
71
|
+
attachments?: ChatAttachment[]
|
|
70
72
|
model?: string
|
|
71
73
|
modelOptions?: ModelOptions
|
|
72
74
|
effort?: string
|
package/src/shared/tools.test.ts
CHANGED
|
@@ -96,4 +96,69 @@ describe("hydrateToolResult", () => {
|
|
|
96
96
|
|
|
97
97
|
expect(hydrateToolResult(tool, "line 1\nline 2")).toBe("line 1\nline 2")
|
|
98
98
|
})
|
|
99
|
+
|
|
100
|
+
test("hydrates read image results with canonical image blocks intact", () => {
|
|
101
|
+
const tool = normalizeToolCall({
|
|
102
|
+
toolName: "Read",
|
|
103
|
+
toolId: "tool-image",
|
|
104
|
+
input: { file_path: "/tmp/example.png" },
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
expect(hydrateToolResult(tool, {
|
|
108
|
+
content: [
|
|
109
|
+
{
|
|
110
|
+
type: "text",
|
|
111
|
+
text: "Read image file [image/png]\n[Image: original 10x10, displayed at 10x10.]",
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
type: "image",
|
|
115
|
+
data: "ZmFrZS1pbWFnZS1kYXRh",
|
|
116
|
+
mimeType: "image/png",
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
})).toEqual({
|
|
120
|
+
content: "Read image file [image/png]\n[Image: original 10x10, displayed at 10x10.]",
|
|
121
|
+
blocks: [
|
|
122
|
+
{
|
|
123
|
+
type: "text",
|
|
124
|
+
text: "Read image file [image/png]\n[Image: original 10x10, displayed at 10x10.]",
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
type: "image",
|
|
128
|
+
data: "ZmFrZS1pbWFnZS1kYXRh",
|
|
129
|
+
mimeType: "image/png",
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test("hydrates Claude read image results with source.base64 into canonical image blocks", () => {
|
|
136
|
+
const tool = normalizeToolCall({
|
|
137
|
+
toolName: "Read",
|
|
138
|
+
toolId: "tool-image-claude",
|
|
139
|
+
input: { file_path: "/tmp/example.png" },
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
expect(hydrateToolResult(tool, {
|
|
143
|
+
content: [
|
|
144
|
+
{
|
|
145
|
+
type: "image",
|
|
146
|
+
source: {
|
|
147
|
+
type: "base64",
|
|
148
|
+
data: "ZmFrZS1pbWFnZS1kYXRh",
|
|
149
|
+
media_type: "image/png",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
})).toEqual({
|
|
154
|
+
content: "",
|
|
155
|
+
blocks: [
|
|
156
|
+
{
|
|
157
|
+
type: "image",
|
|
158
|
+
data: "ZmFrZS1pbWFnZS1kYXRh",
|
|
159
|
+
mimeType: "image/png",
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
})
|
|
163
|
+
})
|
|
99
164
|
})
|
package/src/shared/tools.ts
CHANGED
|
@@ -203,6 +203,72 @@ function parseJsonValue(value: unknown): unknown {
|
|
|
203
203
|
}
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
type ReadStructuredTextBlock = {
|
|
207
|
+
type: "text"
|
|
208
|
+
text: string
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
type ReadStructuredImageBlock = {
|
|
212
|
+
type: "image"
|
|
213
|
+
data: string
|
|
214
|
+
mimeType?: string
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function normalizeReadBlocks(value: unknown): Array<ReadStructuredTextBlock | ReadStructuredImageBlock> {
|
|
218
|
+
const blocks = (
|
|
219
|
+
value
|
|
220
|
+
&& typeof value === "object"
|
|
221
|
+
&& "content" in value
|
|
222
|
+
&& Array.isArray((value as { content?: unknown }).content)
|
|
223
|
+
)
|
|
224
|
+
? (value as { content: unknown[] }).content
|
|
225
|
+
: Array.isArray(value)
|
|
226
|
+
? value
|
|
227
|
+
: []
|
|
228
|
+
|
|
229
|
+
const normalized: Array<ReadStructuredTextBlock | ReadStructuredImageBlock> = []
|
|
230
|
+
|
|
231
|
+
for (const block of blocks) {
|
|
232
|
+
if (!block || typeof block !== "object" || !("type" in block)) {
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
237
|
+
normalized.push({ type: "text", text: block.text })
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (block.type === "image") {
|
|
242
|
+
if ("data" in block && typeof block.data === "string") {
|
|
243
|
+
normalized.push({
|
|
244
|
+
type: "image",
|
|
245
|
+
data: block.data,
|
|
246
|
+
mimeType: typeof block.mimeType === "string" ? block.mimeType : undefined,
|
|
247
|
+
})
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (
|
|
252
|
+
"source" in block
|
|
253
|
+
&& block.source
|
|
254
|
+
&& typeof block.source === "object"
|
|
255
|
+
&& "type" in block.source
|
|
256
|
+
&& block.source.type === "base64"
|
|
257
|
+
&& "data" in block.source
|
|
258
|
+
&& typeof block.source.data === "string"
|
|
259
|
+
) {
|
|
260
|
+
normalized.push({
|
|
261
|
+
type: "image",
|
|
262
|
+
data: block.source.data,
|
|
263
|
+
mimeType: typeof block.source.media_type === "string" ? block.source.media_type : undefined,
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return normalized
|
|
270
|
+
}
|
|
271
|
+
|
|
206
272
|
export function hydrateToolResult(tool: NormalizedToolCall, raw: unknown): HydratedToolCall["result"] {
|
|
207
273
|
const parsed = parseJsonValue(raw)
|
|
208
274
|
|
|
@@ -241,6 +307,16 @@ export function hydrateToolResult(tool: NormalizedToolCall, raw: unknown): Hydra
|
|
|
241
307
|
if (typeof parsed === "string") {
|
|
242
308
|
return parsed
|
|
243
309
|
}
|
|
310
|
+
const blocks = normalizeReadBlocks(parsed)
|
|
311
|
+
if (blocks.length > 0) {
|
|
312
|
+
return {
|
|
313
|
+
content: blocks
|
|
314
|
+
.flatMap((block) => block.type === "text" ? [block.text] : [])
|
|
315
|
+
.join(""),
|
|
316
|
+
blocks,
|
|
317
|
+
} satisfies ReadFileToolResult
|
|
318
|
+
}
|
|
319
|
+
|
|
244
320
|
const record = asRecord(parsed)
|
|
245
321
|
return {
|
|
246
322
|
content: typeof record?.content === "string" ? record.content : JSON.stringify(parsed, null, 2),
|