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/dist/client/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
|
7
7
|
<title>Kanna</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-CLqK0AWC.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BvhI1JYs.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kanna-code",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.18.0",
|
|
5
5
|
"description": "A beautiful web UI for Claude Code",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -52,7 +52,10 @@
|
|
|
52
52
|
"@xterm/addon-web-links": "^0.12.0",
|
|
53
53
|
"@xterm/headless": "^6.0.0",
|
|
54
54
|
"@xterm/xterm": "^6.0.0",
|
|
55
|
+
"cloudflared": "^0.7.1",
|
|
55
56
|
"default-shell": "^2.2.0",
|
|
57
|
+
"file-type": "^22.0.0",
|
|
58
|
+
"qrcode": "^1.5.4",
|
|
56
59
|
"react-resizable-panels": "^4.7.3"
|
|
57
60
|
},
|
|
58
61
|
"devDependencies": {
|
|
@@ -66,6 +69,7 @@
|
|
|
66
69
|
"@tailwindcss/typography": "^0.5.19",
|
|
67
70
|
"@types/bun": "latest",
|
|
68
71
|
"@types/node": "^24.10.1",
|
|
72
|
+
"@types/qrcode": "^1.5.6",
|
|
69
73
|
"@types/react": "19.2.7",
|
|
70
74
|
"@types/react-dom": "19.2.3",
|
|
71
75
|
"@vitejs/plugin-react": "5.1.1",
|
package/src/server/agent.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test"
|
|
2
|
-
import { AgentCoordinator, normalizeClaudeStreamMessage } from "./agent"
|
|
2
|
+
import { AgentCoordinator, buildAttachmentHintText, buildPromptText, normalizeClaudeStreamMessage } from "./agent"
|
|
3
3
|
import type { HarnessTurn } from "./harness-types"
|
|
4
|
-
import type { TranscriptEntry } from "../shared/types"
|
|
4
|
+
import type { ChatAttachment, TranscriptEntry } from "../shared/types"
|
|
5
5
|
|
|
6
6
|
function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(entry: T): TranscriptEntry {
|
|
7
7
|
return {
|
|
@@ -63,6 +63,68 @@ describe("normalizeClaudeStreamMessage", () => {
|
|
|
63
63
|
})
|
|
64
64
|
})
|
|
65
65
|
|
|
66
|
+
describe("attachment prompt helpers", () => {
|
|
67
|
+
test("appends a structured attachment hint block for all attachment kinds", () => {
|
|
68
|
+
const attachments: ChatAttachment[] = [
|
|
69
|
+
{
|
|
70
|
+
id: "image-1",
|
|
71
|
+
kind: "image",
|
|
72
|
+
displayName: "shot.png",
|
|
73
|
+
absolutePath: "/tmp/project/.kanna/uploads/shot.png",
|
|
74
|
+
relativePath: "./.kanna/uploads/shot.png",
|
|
75
|
+
contentUrl: "/api/projects/project-1/uploads/shot.png/content",
|
|
76
|
+
mimeType: "image/png",
|
|
77
|
+
size: 512,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "file-1",
|
|
81
|
+
kind: "file",
|
|
82
|
+
displayName: "spec.pdf",
|
|
83
|
+
absolutePath: "/tmp/project/.kanna/uploads/spec.pdf",
|
|
84
|
+
relativePath: "./.kanna/uploads/spec.pdf",
|
|
85
|
+
contentUrl: "/api/projects/project-1/uploads/spec.pdf/content",
|
|
86
|
+
mimeType: "application/pdf",
|
|
87
|
+
size: 1234,
|
|
88
|
+
},
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
const prompt = buildPromptText("Review these", attachments)
|
|
92
|
+
expect(prompt).toContain("<kanna-attachments>")
|
|
93
|
+
expect(prompt).toContain('path="/tmp/project/.kanna/uploads/shot.png"')
|
|
94
|
+
expect(prompt).toContain('project_path="./.kanna/uploads/spec.pdf"')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("supports attachment-only prompts", () => {
|
|
98
|
+
const attachments: ChatAttachment[] = [{
|
|
99
|
+
id: "file-1",
|
|
100
|
+
kind: "file",
|
|
101
|
+
displayName: "todo.txt",
|
|
102
|
+
absolutePath: "/tmp/project/.kanna/uploads/todo.txt",
|
|
103
|
+
relativePath: "./.kanna/uploads/todo.txt",
|
|
104
|
+
contentUrl: "/api/projects/project-1/uploads/todo.txt/content",
|
|
105
|
+
mimeType: "text/plain",
|
|
106
|
+
size: 32,
|
|
107
|
+
}]
|
|
108
|
+
|
|
109
|
+
expect(buildPromptText("", attachments)).toContain("Please inspect the attached files.")
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test("escapes xml attribute values for attachment hint markup", () => {
|
|
113
|
+
const hint = buildAttachmentHintText([{
|
|
114
|
+
id: "file-1",
|
|
115
|
+
kind: "file",
|
|
116
|
+
displayName: "\"report\" <draft>.txt",
|
|
117
|
+
absolutePath: "/tmp/project/.kanna/uploads/report.txt",
|
|
118
|
+
relativePath: "./.kanna/uploads/report.txt",
|
|
119
|
+
contentUrl: "/api/projects/project-1/uploads/report.txt/content",
|
|
120
|
+
mimeType: "text/plain",
|
|
121
|
+
size: 64,
|
|
122
|
+
}])
|
|
123
|
+
|
|
124
|
+
expect(hint).toContain(""report" <draft>.txt")
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
66
128
|
describe("AgentCoordinator codex integration", () => {
|
|
67
129
|
test("generates a chat title in the background on the first user message", async () => {
|
|
68
130
|
const fakeCodexManager = {
|
package/src/server/agent.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { query, type CanUseTool, type PermissionResult, type Query } from "@anthropic-ai/claude-agent-sdk"
|
|
2
2
|
import type {
|
|
3
3
|
AgentProvider,
|
|
4
|
+
ChatAttachment,
|
|
4
5
|
NormalizedToolCall,
|
|
5
6
|
PendingToolSnapshot,
|
|
6
7
|
KannaStatus,
|
|
@@ -89,6 +90,41 @@ function stringFromUnknown(value: unknown) {
|
|
|
89
90
|
}
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
function escapeXmlAttribute(value: string) {
|
|
94
|
+
return value
|
|
95
|
+
.replaceAll("&", "&")
|
|
96
|
+
.replaceAll("\"", """)
|
|
97
|
+
.replaceAll("<", "<")
|
|
98
|
+
.replaceAll(">", ">")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function buildAttachmentHintText(attachments: ChatAttachment[]) {
|
|
102
|
+
if (attachments.length === 0) return ""
|
|
103
|
+
|
|
104
|
+
const lines = attachments.map((attachment) => (
|
|
105
|
+
`<attachment kind="${escapeXmlAttribute(attachment.kind)}" mime_type="${escapeXmlAttribute(attachment.mimeType)}" path="${escapeXmlAttribute(attachment.absolutePath)}" project_path="${escapeXmlAttribute(attachment.relativePath)}" size_bytes="${attachment.size}" display_name="${escapeXmlAttribute(attachment.displayName)}" />`
|
|
106
|
+
))
|
|
107
|
+
|
|
108
|
+
return [
|
|
109
|
+
"<kanna-attachments>",
|
|
110
|
+
...lines,
|
|
111
|
+
"</kanna-attachments>",
|
|
112
|
+
].join("\n")
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function buildPromptText(content: string, attachments: ChatAttachment[]) {
|
|
116
|
+
const attachmentHint = buildAttachmentHintText(attachments)
|
|
117
|
+
if (!attachmentHint) {
|
|
118
|
+
return content.trim()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const trimmed = content.trim()
|
|
122
|
+
return [
|
|
123
|
+
trimmed || "Please inspect the attached files.",
|
|
124
|
+
attachmentHint,
|
|
125
|
+
].join("\n\n").trim()
|
|
126
|
+
}
|
|
127
|
+
|
|
92
128
|
function discardedToolResult(
|
|
93
129
|
tool: NormalizedToolCall & { toolKind: "ask_user_question" | "exit_plan_mode" }
|
|
94
130
|
) {
|
|
@@ -389,6 +425,7 @@ export class AgentCoordinator {
|
|
|
389
425
|
chatId: string
|
|
390
426
|
provider: AgentProvider
|
|
391
427
|
content: string
|
|
428
|
+
attachments: ChatAttachment[]
|
|
392
429
|
model: string
|
|
393
430
|
effort?: string
|
|
394
431
|
serviceTier?: "fast"
|
|
@@ -409,7 +446,10 @@ export class AgentCoordinator {
|
|
|
409
446
|
const shouldGenerateTitle = args.appendUserPrompt && chat.title === "New Chat" && existingMessages.length === 0
|
|
410
447
|
|
|
411
448
|
if (args.appendUserPrompt) {
|
|
412
|
-
await this.store.appendMessage(
|
|
449
|
+
await this.store.appendMessage(
|
|
450
|
+
args.chatId,
|
|
451
|
+
timestamped({ kind: "user_prompt", content: args.content, attachments: args.attachments }, Date.now())
|
|
452
|
+
)
|
|
413
453
|
}
|
|
414
454
|
await this.store.recordTurnStarted(args.chatId)
|
|
415
455
|
|
|
@@ -443,7 +483,7 @@ export class AgentCoordinator {
|
|
|
443
483
|
let turn: HarnessTurn
|
|
444
484
|
if (args.provider === "claude") {
|
|
445
485
|
turn = await startClaudeTurn({
|
|
446
|
-
content: args.content,
|
|
486
|
+
content: buildPromptText(args.content, args.attachments),
|
|
447
487
|
localPath: project.localPath,
|
|
448
488
|
model: args.model,
|
|
449
489
|
effort: args.effort,
|
|
@@ -461,7 +501,7 @@ export class AgentCoordinator {
|
|
|
461
501
|
})
|
|
462
502
|
turn = await this.codexManager.startTurn({
|
|
463
503
|
chatId: args.chatId,
|
|
464
|
-
content: args.content,
|
|
504
|
+
content: buildPromptText(args.content, args.attachments),
|
|
465
505
|
model: args.model,
|
|
466
506
|
effort: args.effort as any,
|
|
467
507
|
serviceTier: args.serviceTier,
|
|
@@ -519,6 +559,7 @@ export class AgentCoordinator {
|
|
|
519
559
|
chatId,
|
|
520
560
|
provider,
|
|
521
561
|
content: command.content,
|
|
562
|
+
attachments: command.attachments ?? [],
|
|
522
563
|
model: settings.model,
|
|
523
564
|
effort: settings.effort,
|
|
524
565
|
serviceTier: settings.serviceTier,
|
|
@@ -600,6 +641,7 @@ export class AgentCoordinator {
|
|
|
600
641
|
chatId: active.chatId,
|
|
601
642
|
provider: active.provider,
|
|
602
643
|
content: active.postToolFollowUp.content,
|
|
644
|
+
attachments: [],
|
|
603
645
|
model: active.model,
|
|
604
646
|
effort: active.effort,
|
|
605
647
|
serviceTier: active.serviceTier,
|
|
@@ -36,6 +36,9 @@ function createDeps(overrides: Partial<Parameters<typeof runCli>[1]> = {}) {
|
|
|
36
36
|
openUrl: [] as string[],
|
|
37
37
|
log: [] as string[],
|
|
38
38
|
warn: [] as string[],
|
|
39
|
+
shareTunnel: [] as string[],
|
|
40
|
+
renderShareQr: [] as string[],
|
|
41
|
+
shareTunnelStops: 0,
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
const deps: Parameters<typeof runCli>[1] = {
|
|
@@ -70,6 +73,19 @@ function createDeps(overrides: Partial<Parameters<typeof runCli>[1]> = {}) {
|
|
|
70
73
|
warn: (message) => {
|
|
71
74
|
calls.warn.push(message)
|
|
72
75
|
},
|
|
76
|
+
renderShareQr: async (url) => {
|
|
77
|
+
calls.renderShareQr.push(url)
|
|
78
|
+
return `[qr:${url}]`
|
|
79
|
+
},
|
|
80
|
+
startShareTunnel: async (localUrl) => {
|
|
81
|
+
calls.shareTunnel.push(localUrl)
|
|
82
|
+
return {
|
|
83
|
+
publicUrl: "https://kanna.trycloudflare.com",
|
|
84
|
+
stop: () => {
|
|
85
|
+
calls.shareTunnelStops += 1
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
},
|
|
73
89
|
...overrides,
|
|
74
90
|
}
|
|
75
91
|
|
|
@@ -84,6 +100,7 @@ describe("parseArgs", () => {
|
|
|
84
100
|
port: 4000,
|
|
85
101
|
host: "127.0.0.1",
|
|
86
102
|
openBrowser: false,
|
|
103
|
+
share: false,
|
|
87
104
|
strictPort: false,
|
|
88
105
|
},
|
|
89
106
|
})
|
|
@@ -96,6 +113,7 @@ describe("parseArgs", () => {
|
|
|
96
113
|
port: 3210,
|
|
97
114
|
host: "127.0.0.1",
|
|
98
115
|
openBrowser: true,
|
|
116
|
+
share: false,
|
|
99
117
|
strictPort: true,
|
|
100
118
|
},
|
|
101
119
|
})
|
|
@@ -108,6 +126,20 @@ describe("parseArgs", () => {
|
|
|
108
126
|
port: 3210,
|
|
109
127
|
host: "0.0.0.0",
|
|
110
128
|
openBrowser: true,
|
|
129
|
+
share: false,
|
|
130
|
+
strictPort: false,
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test("--share enables public sharing", () => {
|
|
136
|
+
expect(parseArgs(["--share"])).toEqual({
|
|
137
|
+
kind: "run",
|
|
138
|
+
options: {
|
|
139
|
+
port: 3210,
|
|
140
|
+
host: "127.0.0.1",
|
|
141
|
+
openBrowser: true,
|
|
142
|
+
share: true,
|
|
111
143
|
strictPort: false,
|
|
112
144
|
},
|
|
113
145
|
})
|
|
@@ -120,6 +152,7 @@ describe("parseArgs", () => {
|
|
|
120
152
|
port: 3210,
|
|
121
153
|
host: "100.64.0.1",
|
|
122
154
|
openBrowser: true,
|
|
155
|
+
share: false,
|
|
123
156
|
strictPort: false,
|
|
124
157
|
},
|
|
125
158
|
})
|
|
@@ -132,6 +165,7 @@ describe("parseArgs", () => {
|
|
|
132
165
|
port: 3210,
|
|
133
166
|
host: "dev-box",
|
|
134
167
|
openBrowser: true,
|
|
168
|
+
share: false,
|
|
135
169
|
strictPort: false,
|
|
136
170
|
},
|
|
137
171
|
})
|
|
@@ -142,6 +176,13 @@ describe("parseArgs", () => {
|
|
|
142
176
|
expect(() => parseArgs(["--host", "--no-open"])).toThrow("Missing value for --host")
|
|
143
177
|
})
|
|
144
178
|
|
|
179
|
+
test("--share is incompatible with --host and --remote", () => {
|
|
180
|
+
expect(() => parseArgs(["--share", "--host", "dev-box"])).toThrow("--share cannot be used with --host")
|
|
181
|
+
expect(() => parseArgs(["--host", "dev-box", "--share"])).toThrow("--share cannot be used with --host")
|
|
182
|
+
expect(() => parseArgs(["--share", "--remote"])).toThrow("--share cannot be used with --remote")
|
|
183
|
+
expect(() => parseArgs(["--remote", "--share"])).toThrow("--share cannot be used with --remote")
|
|
184
|
+
})
|
|
185
|
+
|
|
145
186
|
test("returns version and help actions without running startup", () => {
|
|
146
187
|
expect(parseArgs(["--version"])).toEqual({ kind: "version" })
|
|
147
188
|
expect(parseArgs(["--help"])).toEqual({ kind: "help" })
|
|
@@ -192,6 +233,7 @@ describe("runCli", () => {
|
|
|
192
233
|
port: 4000,
|
|
193
234
|
host: "127.0.0.1",
|
|
194
235
|
openBrowser: false,
|
|
236
|
+
share: false,
|
|
195
237
|
strictPort: false,
|
|
196
238
|
update: {
|
|
197
239
|
version: "0.3.0",
|
|
@@ -251,6 +293,94 @@ describe("runCli", () => {
|
|
|
251
293
|
expect(calls.openUrl).toEqual([])
|
|
252
294
|
})
|
|
253
295
|
|
|
296
|
+
test("starts a share tunnel and prints qr/public/local urls", async () => {
|
|
297
|
+
delete process.env[CLI_SUPPRESS_OPEN_ONCE_ENV_VAR]
|
|
298
|
+
const { calls, deps } = createDeps()
|
|
299
|
+
|
|
300
|
+
const result = await runCli(["--share", "--port", "4000"], deps)
|
|
301
|
+
|
|
302
|
+
expect(result.kind).toBe("started")
|
|
303
|
+
expect(calls.openUrl).toEqual([])
|
|
304
|
+
expect(calls.shareTunnel).toEqual(["http://localhost:4000"])
|
|
305
|
+
expect(calls.renderShareQr).toEqual(["https://kanna.trycloudflare.com"])
|
|
306
|
+
expect(calls.log).toContain("QR Code:")
|
|
307
|
+
expect(calls.log).toContain("[qr:https://kanna.trycloudflare.com]")
|
|
308
|
+
expect(calls.log).toContain("Public URL:")
|
|
309
|
+
expect(calls.log).toContain("https://kanna.trycloudflare.com")
|
|
310
|
+
expect(calls.log).toContain("Local URL:")
|
|
311
|
+
expect(calls.log).toContain("http://localhost:4000")
|
|
312
|
+
|
|
313
|
+
if (result.kind !== "started") {
|
|
314
|
+
throw new Error(`expected started result, got ${result.kind}`)
|
|
315
|
+
}
|
|
316
|
+
await result.stop()
|
|
317
|
+
expect(calls.shareTunnelStops).toBe(1)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
test("logs share setup progress from the default tunnel helper", async () => {
|
|
321
|
+
const { calls, deps } = createDeps({
|
|
322
|
+
startShareTunnel: undefined,
|
|
323
|
+
renderShareQr: async () => "[qr]",
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
let installLogged = false
|
|
327
|
+
deps.startShareTunnel = async (_localUrl) => {
|
|
328
|
+
deps.log("[kanna] installing cloudflared binary")
|
|
329
|
+
installLogged = true
|
|
330
|
+
return {
|
|
331
|
+
publicUrl: "https://kanna.trycloudflare.com",
|
|
332
|
+
stop: () => {},
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
await runCli(["--share"], deps)
|
|
337
|
+
|
|
338
|
+
expect(installLogged).toBe(true)
|
|
339
|
+
expect(calls.log).toContain("[kanna] installing cloudflared binary")
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
test("uses the actual bound port for --share", async () => {
|
|
343
|
+
const { calls, deps } = createDeps({
|
|
344
|
+
startServer: async (options) => {
|
|
345
|
+
calls.startServer.push(options)
|
|
346
|
+
return {
|
|
347
|
+
port: 4001,
|
|
348
|
+
stop: async () => {},
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
const result = await runCli(["--share", "--port", "4000"], deps)
|
|
354
|
+
|
|
355
|
+
expect(result.kind).toBe("started")
|
|
356
|
+
expect(calls.shareTunnel).toEqual(["http://localhost:4001"])
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
test("fails cleanly when share tunnel startup fails", async () => {
|
|
360
|
+
let serverStopped = false
|
|
361
|
+
const { calls, deps } = createDeps({
|
|
362
|
+
startServer: async (options) => {
|
|
363
|
+
calls.startServer.push(options)
|
|
364
|
+
return {
|
|
365
|
+
port: options.port,
|
|
366
|
+
stop: async () => {
|
|
367
|
+
serverStopped = true
|
|
368
|
+
},
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
startShareTunnel: async () => {
|
|
372
|
+
throw new Error("cloudflared unavailable")
|
|
373
|
+
},
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
const result = await runCli(["--share"], deps)
|
|
377
|
+
|
|
378
|
+
expect(result).toEqual({ kind: "exited", code: 1 })
|
|
379
|
+
expect(serverStopped).toBe(true)
|
|
380
|
+
expect(calls.warn).toContain("[kanna] failed to start Cloudflare share tunnel")
|
|
381
|
+
expect(calls.warn).toContain("[kanna] cloudflared unavailable")
|
|
382
|
+
})
|
|
383
|
+
|
|
254
384
|
test("returns restarting when a newer version is available", async () => {
|
|
255
385
|
const { calls, deps } = createDeps({
|
|
256
386
|
fetchLatestVersion: async (packageName) => {
|
|
@@ -5,11 +5,13 @@ import { APP_NAME, CLI_COMMAND, getDataDirDisplay, LOG_PREFIX, PACKAGE_NAME } fr
|
|
|
5
5
|
import type { UpdateInstallErrorCode } from "../shared/types"
|
|
6
6
|
import { PROD_SERVER_PORT } from "../shared/ports"
|
|
7
7
|
import { CLI_SUPPRESS_OPEN_ONCE_ENV_VAR } from "./restart"
|
|
8
|
+
import { logShareDetails, renderTerminalQr, startShareTunnel, type StartedShareTunnel } from "./share"
|
|
8
9
|
|
|
9
10
|
export interface CliOptions {
|
|
10
11
|
port: number
|
|
11
12
|
host: string
|
|
12
13
|
openBrowser: boolean
|
|
14
|
+
share: boolean
|
|
13
15
|
strictPort: boolean
|
|
14
16
|
}
|
|
15
17
|
|
|
@@ -50,6 +52,8 @@ export interface CliRuntimeDeps {
|
|
|
50
52
|
openUrl: (url: string) => void
|
|
51
53
|
log: (message: string) => void
|
|
52
54
|
warn: (message: string) => void
|
|
55
|
+
renderShareQr?: (url: string) => Promise<string>
|
|
56
|
+
startShareTunnel?: (localUrl: string) => Promise<StartedShareTunnel>
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
export interface UpdateInstallAttemptResult {
|
|
@@ -76,6 +80,7 @@ Options:
|
|
|
76
80
|
--port <number> Port to listen on (default: ${PROD_SERVER_PORT})
|
|
77
81
|
--host <host> Bind to a specific host or IP
|
|
78
82
|
--remote Shortcut for --host 0.0.0.0
|
|
83
|
+
--share Create a public Cloudflare share URL with terminal QR
|
|
79
84
|
--strict-port Fail instead of trying another port
|
|
80
85
|
--no-open Don't open browser automatically
|
|
81
86
|
--version Print version and exit
|
|
@@ -86,6 +91,9 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
86
91
|
let port = PROD_SERVER_PORT
|
|
87
92
|
let host = "127.0.0.1"
|
|
88
93
|
let openBrowser = true
|
|
94
|
+
let share = false
|
|
95
|
+
let sawHost = false
|
|
96
|
+
let sawRemote = false
|
|
89
97
|
let strictPort = false
|
|
90
98
|
|
|
91
99
|
for (let index = 0; index < argv.length; index += 1) {
|
|
@@ -106,12 +114,22 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
106
114
|
if (arg === "--host") {
|
|
107
115
|
const next = argv[index + 1]
|
|
108
116
|
if (!next || next.startsWith("-")) throw new Error("Missing value for --host")
|
|
117
|
+
if (share) throw new Error("--share cannot be used with --host")
|
|
109
118
|
host = next
|
|
119
|
+
sawHost = true
|
|
110
120
|
index += 1
|
|
111
121
|
continue
|
|
112
122
|
}
|
|
113
123
|
if (arg === "--remote") {
|
|
124
|
+
if (share) throw new Error("--share cannot be used with --remote")
|
|
114
125
|
host = "0.0.0.0"
|
|
126
|
+
sawRemote = true
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
if (arg === "--share") {
|
|
130
|
+
if (sawHost) throw new Error("--share cannot be used with --host")
|
|
131
|
+
if (sawRemote) throw new Error("--share cannot be used with --remote")
|
|
132
|
+
share = true
|
|
115
133
|
continue
|
|
116
134
|
}
|
|
117
135
|
if (arg === "--no-open") {
|
|
@@ -131,6 +149,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
131
149
|
port,
|
|
132
150
|
host,
|
|
133
151
|
openBrowser,
|
|
152
|
+
share,
|
|
134
153
|
strictPort,
|
|
135
154
|
},
|
|
136
155
|
}
|
|
@@ -231,20 +250,41 @@ export async function runCli(argv: string[], deps: CliRuntimeDeps): Promise<CliR
|
|
|
231
250
|
},
|
|
232
251
|
})
|
|
233
252
|
const bindHost = parsedArgs.options.host
|
|
234
|
-
const displayHost = bindHost === "127.0.0.1" || bindHost === "0.0.0.0" ? "localhost" : bindHost
|
|
253
|
+
const displayHost = parsedArgs.options.share || bindHost === "127.0.0.1" || bindHost === "0.0.0.0" ? "localhost" : bindHost
|
|
235
254
|
const launchUrl = `http://${displayHost}:${port}`
|
|
255
|
+
let shareTunnelStop: (() => void) | null = null
|
|
236
256
|
|
|
237
257
|
deps.log(`${LOG_PREFIX} listening on http://${bindHost}:${port}`)
|
|
238
258
|
deps.log(`${LOG_PREFIX} data dir: ${getDataDirDisplay()}`)
|
|
239
259
|
|
|
240
260
|
const suppressOpenBrowser = process.env[CLI_SUPPRESS_OPEN_ONCE_ENV_VAR] === "1"
|
|
241
|
-
if (parsedArgs.options.
|
|
261
|
+
if (parsedArgs.options.share) {
|
|
262
|
+
try {
|
|
263
|
+
const shareTunnel = await (deps.startShareTunnel ?? ((localUrl) => startShareTunnel(localUrl, {
|
|
264
|
+
log: (message) => deps.log(`${LOG_PREFIX} ${message}`),
|
|
265
|
+
})))(launchUrl)
|
|
266
|
+
shareTunnelStop = shareTunnel.stop
|
|
267
|
+
await logShareDetails(deps.log, shareTunnel.publicUrl, launchUrl, deps.renderShareQr ?? renderTerminalQr)
|
|
268
|
+
} catch (error) {
|
|
269
|
+
await stop()
|
|
270
|
+
deps.warn(`${LOG_PREFIX} failed to start Cloudflare share tunnel`)
|
|
271
|
+
if (error instanceof Error && error.message) {
|
|
272
|
+
deps.warn(`${LOG_PREFIX} ${error.message}`)
|
|
273
|
+
}
|
|
274
|
+
return { kind: "exited", code: 1 }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (parsedArgs.options.openBrowser && !parsedArgs.options.share && !suppressOpenBrowser) {
|
|
242
279
|
deps.openUrl(launchUrl)
|
|
243
280
|
}
|
|
244
281
|
|
|
245
282
|
return {
|
|
246
283
|
kind: "started",
|
|
247
|
-
stop
|
|
284
|
+
stop: async () => {
|
|
285
|
+
shareTunnelStop?.()
|
|
286
|
+
await stop()
|
|
287
|
+
},
|
|
248
288
|
}
|
|
249
289
|
}
|
|
250
290
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendFile, mkdir,
|
|
1
|
+
import { appendFile, mkdir, rename, writeFile } from "node:fs/promises"
|
|
2
2
|
import { existsSync, readFileSync as readFileSyncImmediate } from "node:fs"
|
|
3
3
|
import { homedir } from "node:os"
|
|
4
4
|
import path from "node:path"
|
|
@@ -588,8 +588,8 @@ export class EventStore {
|
|
|
588
588
|
return (await this.getLegacyTranscriptStats()).hasLegacyData
|
|
589
589
|
}
|
|
590
590
|
|
|
591
|
-
private createSnapshot(
|
|
592
|
-
|
|
591
|
+
private createSnapshot(): SnapshotFile {
|
|
592
|
+
return {
|
|
593
593
|
v: STORE_VERSION,
|
|
594
594
|
generatedAt: Date.now(),
|
|
595
595
|
projects: this.listProjects().map((project) => ({ ...project })),
|
|
@@ -597,30 +597,10 @@ export class EventStore {
|
|
|
597
597
|
.filter((chat) => !chat.deletedAt)
|
|
598
598
|
.map((chat) => ({ ...chat })),
|
|
599
599
|
}
|
|
600
|
-
|
|
601
|
-
if (includeLegacyMessages) {
|
|
602
|
-
snapshot.messages = [...this.legacyMessagesByChatId.entries()].map(([chatId, entries]) => ({
|
|
603
|
-
chatId,
|
|
604
|
-
entries: cloneTranscriptEntries(entries),
|
|
605
|
-
}))
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
return snapshot
|
|
609
600
|
}
|
|
610
601
|
|
|
611
602
|
async compact() {
|
|
612
|
-
const snapshot = this.createSnapshot(
|
|
613
|
-
await Bun.write(this.snapshotPath, JSON.stringify(snapshot, null, 2))
|
|
614
|
-
await Promise.all([
|
|
615
|
-
Bun.write(this.projectsLogPath, ""),
|
|
616
|
-
Bun.write(this.chatsLogPath, ""),
|
|
617
|
-
Bun.write(this.messagesLogPath, ""),
|
|
618
|
-
Bun.write(this.turnsLogPath, ""),
|
|
619
|
-
])
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
private async compactLegacyMessagesIntoSnapshot() {
|
|
623
|
-
const snapshot = this.createSnapshot(true)
|
|
603
|
+
const snapshot = this.createSnapshot()
|
|
624
604
|
await Bun.write(this.snapshotPath, JSON.stringify(snapshot, null, 2))
|
|
625
605
|
await Promise.all([
|
|
626
606
|
Bun.write(this.projectsLogPath, ""),
|
|
@@ -636,17 +616,14 @@ export class EventStore {
|
|
|
636
616
|
|
|
637
617
|
const sourceSummary = stats.sources.map((source) => source === "messages_log" ? "messages.jsonl" : "snapshot.json").join(", ")
|
|
638
618
|
onProgress?.(`${LOG_PREFIX} transcript migration detected: ${stats.chatCount} chats, ${stats.entryCount} entries from ${sourceSummary}`)
|
|
639
|
-
onProgress?.(`${LOG_PREFIX} transcript migration: compacting legacy transcript state into snapshot`)
|
|
640
|
-
await this.compactLegacyMessagesIntoSnapshot()
|
|
641
619
|
|
|
642
|
-
const
|
|
643
|
-
const messageSets = snapshot.messages ?? []
|
|
620
|
+
const messageSets = [...this.legacyMessagesByChatId.entries()]
|
|
644
621
|
onProgress?.(`${LOG_PREFIX} transcript migration: writing ${messageSets.length} per-chat transcript files`)
|
|
645
622
|
|
|
646
623
|
await mkdir(this.transcriptsDir, { recursive: true })
|
|
647
624
|
const logEveryChat = messageSets.length <= 10
|
|
648
625
|
for (let index = 0; index < messageSets.length; index += 1) {
|
|
649
|
-
const
|
|
626
|
+
const [chatId, entries] = messageSets[index]
|
|
650
627
|
const transcriptPath = this.transcriptPath(chatId)
|
|
651
628
|
const tempPath = `${transcriptPath}.tmp`
|
|
652
629
|
const payload = entries.map((entry) => JSON.stringify(entry)).join("\n")
|
|
@@ -657,9 +634,8 @@ export class EventStore {
|
|
|
657
634
|
}
|
|
658
635
|
}
|
|
659
636
|
|
|
660
|
-
delete snapshot.messages
|
|
661
|
-
await Bun.write(this.snapshotPath, JSON.stringify(snapshot, null, 2))
|
|
662
637
|
this.clearLegacyTranscriptState()
|
|
638
|
+
await this.compact()
|
|
663
639
|
this.cachedTranscript = null
|
|
664
640
|
onProgress?.(`${LOG_PREFIX} transcript migration complete`)
|
|
665
641
|
return true
|
package/src/server/paths.ts
CHANGED
|
@@ -25,3 +25,7 @@ export async function ensureProjectDirectory(localPath: string) {
|
|
|
25
25
|
throw new Error("Project path must be a directory")
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
|
|
29
|
+
export function getProjectUploadDir(localPath: string) {
|
|
30
|
+
return path.join(resolveLocalPath(localPath), ".kanna", "uploads")
|
|
31
|
+
}
|