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
package/src/server/server.ts
CHANGED
|
@@ -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
|
+
}
|