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.
@@ -1,5 +1,7 @@
1
1
  import path from "node:path"
2
+ import { stat } from "node:fs/promises"
2
3
  import { APP_NAME, getRuntimeProfile } from "../shared/branding"
4
+ import type { ChatAttachment } from "../shared/types"
3
5
  import { EventStore } from "./event-store"
4
6
  import { AgentCoordinator } from "./agent"
5
7
  import { discoverProjects, type DiscoveredProject } from "./discovery"
@@ -9,6 +11,45 @@ import { TerminalManager } from "./terminal-manager"
9
11
  import { UpdateManager } from "./update-manager"
10
12
  import type { UpdateInstallAttemptResult } from "./cli-runtime"
11
13
  import { createWsRouter, type ClientState } from "./ws-router"
14
+ import { deleteProjectUpload, inferAttachmentContentType, persistProjectUpload } from "./uploads"
15
+ import { getProjectUploadDir } from "./paths"
16
+
17
+ const MAX_UPLOAD_FILES = 10
18
+ const MAX_UPLOAD_SIZE_BYTES = 25 * 1024 * 1024
19
+
20
+ export async function persistUploadedFiles(args: {
21
+ projectId: string
22
+ localPath: string
23
+ files: File[]
24
+ persistUpload?: typeof persistProjectUpload
25
+ }): Promise<ChatAttachment[]> {
26
+ const persistUpload = args.persistUpload ?? persistProjectUpload
27
+ const attachments: ChatAttachment[] = []
28
+
29
+ try {
30
+ for (const file of args.files) {
31
+ const bytes = new Uint8Array(await file.arrayBuffer())
32
+ const attachment = await persistUpload({
33
+ projectId: args.projectId,
34
+ localPath: args.localPath,
35
+ fileName: file.name,
36
+ bytes,
37
+ fallbackMimeType: file.type || undefined,
38
+ })
39
+ attachments.push(attachment)
40
+ }
41
+ } catch (error) {
42
+ await Promise.allSettled(
43
+ attachments.map((attachment) => deleteProjectUpload({
44
+ localPath: args.localPath,
45
+ storedName: path.basename(attachment.absolutePath),
46
+ }))
47
+ )
48
+ throw error
49
+ }
50
+
51
+ return attachments
52
+ }
12
53
 
13
54
  export interface StartKannaServerOptions {
14
55
  port?: number
@@ -79,7 +120,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
79
120
  server = Bun.serve<ClientState>({
80
121
  port: actualPort,
81
122
  hostname,
82
- fetch(req, serverInstance) {
123
+ async fetch(req, serverInstance) {
83
124
  const url = new URL(req.url)
84
125
 
85
126
  if (url.pathname === "/ws") {
@@ -95,6 +136,21 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
95
136
  return Response.json({ ok: true, port: actualPort })
96
137
  }
97
138
 
139
+ const uploadResponse = await handleProjectUpload(req, url, store)
140
+ if (uploadResponse) {
141
+ return uploadResponse
142
+ }
143
+
144
+ const deleteUploadResponse = await handleProjectUploadDelete(req, url, store)
145
+ if (deleteUploadResponse) {
146
+ return deleteUploadResponse
147
+ }
148
+
149
+ const attachmentContentResponse = await handleAttachmentContent(req, url, store)
150
+ if (attachmentContentResponse) {
151
+ return attachmentContentResponse
152
+ }
153
+
98
154
  return serveStatic(distDir, url.pathname)
99
155
  },
100
156
  websocket: {
@@ -140,6 +196,127 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
140
196
  }
141
197
  }
142
198
 
199
+ async function handleProjectUpload(req: Request, url: URL, store: EventStore) {
200
+ if (req.method !== "POST") {
201
+ return null
202
+ }
203
+
204
+ const match = url.pathname.match(/^\/api\/projects\/([^/]+)\/uploads$/)
205
+ if (!match) {
206
+ return null
207
+ }
208
+
209
+ const project = store.getProject(match[1])
210
+ if (!project) {
211
+ return Response.json({ error: "Project not found" }, { status: 404 })
212
+ }
213
+
214
+ const formData = await req.formData()
215
+ const files = formData
216
+ .getAll("files")
217
+ .filter((value): value is File => value instanceof File)
218
+
219
+ if (files.length === 0) {
220
+ return Response.json({ error: "No files uploaded" }, { status: 400 })
221
+ }
222
+
223
+ if (files.length > MAX_UPLOAD_FILES) {
224
+ return Response.json({ error: `You can upload up to ${MAX_UPLOAD_FILES} files at a time.` }, { status: 400 })
225
+ }
226
+
227
+ for (const file of files) {
228
+ if (file.size > MAX_UPLOAD_SIZE_BYTES) {
229
+ return Response.json(
230
+ { error: `File "${file.name}" exceeds the ${Math.floor(MAX_UPLOAD_SIZE_BYTES / (1024 * 1024))} MB limit.` },
231
+ { status: 413 }
232
+ )
233
+ }
234
+ }
235
+
236
+ try {
237
+ const attachments = await persistUploadedFiles({
238
+ projectId: project.id,
239
+ localPath: project.localPath,
240
+ files,
241
+ })
242
+ return Response.json({ attachments })
243
+ } catch (error) {
244
+ console.error("[uploads] Upload failed:", error)
245
+ return Response.json({ error: "Upload failed" }, { status: 500 })
246
+ }
247
+ }
248
+
249
+ async function handleAttachmentContent(req: Request, url: URL, store: EventStore) {
250
+ const match = url.pathname.match(/^\/api\/projects\/([^/]+)\/uploads\/([^/]+)\/content$/)
251
+ if (!match) {
252
+ return null
253
+ }
254
+
255
+ if (req.method !== "GET") {
256
+ return new Response(null, {
257
+ status: 405,
258
+ headers: {
259
+ Allow: "GET",
260
+ },
261
+ })
262
+ }
263
+
264
+ const project = store.getProject(match[1])
265
+ if (!project) {
266
+ return Response.json({ error: "Project not found" }, { status: 404 })
267
+ }
268
+
269
+ const storedName = decodeURIComponent(match[2])
270
+ if (!storedName || storedName.includes("/") || storedName.includes("\\") || storedName === "." || storedName === "..") {
271
+ return Response.json({ error: "Invalid attachment path" }, { status: 400 })
272
+ }
273
+
274
+ const filePath = path.join(getProjectUploadDir(project.localPath), storedName)
275
+ const file = Bun.file(filePath)
276
+ try {
277
+ const info = await stat(filePath)
278
+ if (!info.isFile()) {
279
+ return Response.json({ error: "Attachment not found" }, { status: 404 })
280
+ }
281
+ } catch {
282
+ return Response.json({ error: "Attachment not found" }, { status: 404 })
283
+ }
284
+
285
+ return new Response(file, {
286
+ headers: {
287
+ "Content-Type": inferAttachmentContentType(storedName, file.type),
288
+ },
289
+ })
290
+ }
291
+
292
+ async function handleProjectUploadDelete(req: Request, url: URL, store: EventStore) {
293
+ if (req.method !== "DELETE") {
294
+ return null
295
+ }
296
+
297
+ const match = url.pathname.match(/^\/api\/projects\/([^/]+)\/uploads\/([^/]+)$/)
298
+ if (!match) {
299
+ return null
300
+ }
301
+
302
+ const project = store.getProject(match[1])
303
+ if (!project) {
304
+ return Response.json({ error: "Project not found" }, { status: 404 })
305
+ }
306
+
307
+ const storedName = decodeURIComponent(match[2])
308
+ if (!storedName || storedName.includes("/") || storedName.includes("\\") || storedName === "." || storedName === "..") {
309
+ return Response.json({ error: "Invalid attachment path" }, { status: 400 })
310
+ }
311
+
312
+ const deleted = await deleteProjectUpload({
313
+ localPath: project.localPath,
314
+ storedName,
315
+ })
316
+
317
+ return Response.json({ ok: deleted })
318
+ }
319
+
143
320
  async function serveStatic(distDir: string, pathname: string) {
144
321
  const requestedPath = pathname === "/" ? "/index.html" : pathname
145
322
  const filePath = path.join(distDir, requestedPath)
@@ -0,0 +1,108 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { ensureCloudflaredInstalled, logShareDetails, startShareTunnel } from "./share"
3
+
4
+ describe("ensureCloudflaredInstalled", () => {
5
+ test("returns immediately when the binary already exists", async () => {
6
+ const installCalls: string[] = []
7
+
8
+ const result = await ensureCloudflaredInstalled({
9
+ cloudflaredBin: "/tmp/cloudflared",
10
+ existsSync: () => true,
11
+ installCloudflared: async (to) => {
12
+ installCalls.push(to)
13
+ return to
14
+ },
15
+ })
16
+
17
+ expect(result).toBe("/tmp/cloudflared")
18
+ expect(installCalls).toEqual([])
19
+ })
20
+
21
+ test("installs the binary on demand when it is missing", async () => {
22
+ const installCalls: string[] = []
23
+ const logLines: string[] = []
24
+
25
+ const result = await ensureCloudflaredInstalled({
26
+ cloudflaredBin: "/tmp/cloudflared",
27
+ existsSync: () => false,
28
+ installCloudflared: async (to) => {
29
+ installCalls.push(to)
30
+ return to
31
+ },
32
+ log: (message) => {
33
+ logLines.push(message)
34
+ },
35
+ })
36
+
37
+ expect(result).toBe("/tmp/cloudflared")
38
+ expect(installCalls).toEqual(["/tmp/cloudflared"])
39
+ expect(logLines).toEqual(["installing cloudflared binary"])
40
+ })
41
+ })
42
+
43
+ describe("startShareTunnel", () => {
44
+ test("starts a quick tunnel after ensuring the binary exists", async () => {
45
+ const installCalls: string[] = []
46
+ const quickTunnelUrls: string[] = []
47
+ let stopCalls = 0
48
+
49
+ const shareTunnel = await startShareTunnel("http://localhost:3333", {
50
+ cloudflaredBin: "/tmp/cloudflared",
51
+ existsSync: () => false,
52
+ installCloudflared: async (to) => {
53
+ installCalls.push(to)
54
+ return to
55
+ },
56
+ createQuickTunnel: (localUrl) => {
57
+ quickTunnelUrls.push(localUrl)
58
+ return {
59
+ once(event, listener) {
60
+ if (event === "url") {
61
+ queueMicrotask(() => (listener as (url: string) => void)("https://kanna.trycloudflare.com"))
62
+ }
63
+ return this
64
+ },
65
+ off(_event, _listener) {
66
+ return this
67
+ },
68
+ stop() {
69
+ stopCalls += 1
70
+ return true
71
+ },
72
+ }
73
+ },
74
+ })
75
+
76
+ expect(installCalls).toEqual(["/tmp/cloudflared"])
77
+ expect(quickTunnelUrls).toEqual(["http://localhost:3333"])
78
+ expect(shareTunnel.publicUrl).toBe("https://kanna.trycloudflare.com")
79
+ shareTunnel.stop()
80
+ expect(stopCalls).toBe(1)
81
+ })
82
+ })
83
+
84
+ describe("logShareDetails", () => {
85
+ test("prints qr, public url, and local url in the expected order", async () => {
86
+ const logLines: string[] = []
87
+
88
+ await logShareDetails(
89
+ (message) => {
90
+ logLines.push(message)
91
+ },
92
+ "https://kanna.trycloudflare.com",
93
+ "http://localhost:3333",
94
+ async (url) => `[qr:${url}]\n`,
95
+ )
96
+
97
+ expect(logLines).toEqual([
98
+ "QR Code:",
99
+ "[qr:https://kanna.trycloudflare.com]",
100
+ "",
101
+ "Public URL:",
102
+ "https://kanna.trycloudflare.com",
103
+ "",
104
+ "Local URL:",
105
+ "http://localhost:3333",
106
+ ])
107
+ })
108
+ })
@@ -0,0 +1,113 @@
1
+ import { existsSync } from "node:fs"
2
+ import QRCode from "qrcode"
3
+ import { Tunnel, bin as cloudflaredBin, install as installCloudflared } from "cloudflared"
4
+
5
+ export interface StartedShareTunnel {
6
+ publicUrl: string
7
+ stop: () => void
8
+ }
9
+
10
+ export interface ShareTunnelProcess {
11
+ once(event: "url", listener: (url: string) => void): ShareTunnelProcess
12
+ once(event: "error", listener: (error: Error) => void): ShareTunnelProcess
13
+ once(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): ShareTunnelProcess
14
+ off(event: "url", listener: (url: string) => void): ShareTunnelProcess
15
+ off(event: "error", listener: (error: Error) => void): ShareTunnelProcess
16
+ off(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): ShareTunnelProcess
17
+ stop(): boolean
18
+ }
19
+
20
+ export interface ShareTunnelDeps {
21
+ cloudflaredBin?: string
22
+ existsSync?: (path: string) => boolean
23
+ installCloudflared?: (to: string) => Promise<string>
24
+ createQuickTunnel?: (localUrl: string) => ShareTunnelProcess
25
+ log?: (message: string) => void
26
+ }
27
+
28
+ export async function renderTerminalQr(url: string) {
29
+ return QRCode.toString(url, {
30
+ type: "terminal",
31
+ small: true,
32
+ errorCorrectionLevel: "M",
33
+ })
34
+ }
35
+
36
+ export async function ensureCloudflaredInstalled(
37
+ deps: ShareTunnelDeps = {},
38
+ ) {
39
+ const resolvedBin = deps.cloudflaredBin ?? cloudflaredBin
40
+ const fileExists = deps.existsSync ?? existsSync
41
+ const installBinary = deps.installCloudflared ?? installCloudflared
42
+
43
+ if (fileExists(resolvedBin)) {
44
+ return resolvedBin
45
+ }
46
+
47
+ deps.log?.("installing cloudflared binary")
48
+ await installBinary(resolvedBin)
49
+ return resolvedBin
50
+ }
51
+
52
+ export async function startShareTunnel(localUrl: string, deps: ShareTunnelDeps = {}): Promise<StartedShareTunnel> {
53
+ await ensureCloudflaredInstalled(deps)
54
+ const tunnel = (deps.createQuickTunnel ?? ((url) => Tunnel.quick(url)))(localUrl)
55
+
56
+ const publicUrl = await new Promise<string>((resolve, reject) => {
57
+ let settled = false
58
+
59
+ const cleanup = () => {
60
+ tunnel.off("url", handleUrl)
61
+ tunnel.off("error", handleError)
62
+ tunnel.off("exit", handleExit)
63
+ }
64
+
65
+ const settle = (callback: () => void) => {
66
+ if (settled) return
67
+ settled = true
68
+ cleanup()
69
+ callback()
70
+ }
71
+
72
+ const handleUrl = (url: string) => {
73
+ settle(() => resolve(url))
74
+ }
75
+
76
+ const handleError = (error: Error) => {
77
+ settle(() => reject(error))
78
+ }
79
+
80
+ const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
81
+ settle(() => reject(new Error(`Cloudflare tunnel exited before a public URL was ready (code: ${String(code)}, signal: ${String(signal)})`)))
82
+ }
83
+
84
+ tunnel.once("url", handleUrl)
85
+ tunnel.once("error", handleError)
86
+ tunnel.once("exit", handleExit)
87
+ })
88
+
89
+ return {
90
+ publicUrl,
91
+ stop: () => {
92
+ tunnel.stop()
93
+ },
94
+ }
95
+ }
96
+
97
+ export async function logShareDetails(
98
+ log: (message: string) => void,
99
+ publicUrl: string,
100
+ localUrl: string,
101
+ renderShareQrImpl: (url: string) => Promise<string> = renderTerminalQr,
102
+ ) {
103
+ const qrCode = await renderShareQrImpl(publicUrl)
104
+
105
+ log("QR Code:")
106
+ log(qrCode.trimEnd())
107
+ log("")
108
+ log("Public URL:")
109
+ log(publicUrl)
110
+ log("")
111
+ log("Local URL:")
112
+ log(localUrl)
113
+ }