kanna-code 0.30.0 → 0.32.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.
@@ -30,8 +30,8 @@
30
30
  })()
31
31
  </script>
32
32
  <title>Kanna</title>
33
- <script type="module" crossorigin src="/assets/index-DNENqajG.js"></script>
34
- <link rel="stylesheet" crossorigin href="/assets/index-Bnur1EEv.css">
33
+ <script type="module" crossorigin src="/assets/index-CoeDwJZ0.js"></script>
34
+ <link rel="stylesheet" crossorigin href="/assets/index-BxEBJJ_f.css">
35
35
  </head>
36
36
  <body>
37
37
  <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.30.0",
4
+ "version": "0.32.0",
5
5
  "description": "A beautiful web UI for Claude Code",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -57,6 +57,7 @@
57
57
  "cloudflared": "^0.7.1",
58
58
  "default-shell": "^2.2.0",
59
59
  "file-type": "^22.0.0",
60
+ "openai": "^6.34.0",
60
61
  "react-resizable-panels": "^4.7.3",
61
62
  "uqr": "^0.1.3"
62
63
  },
@@ -0,0 +1,207 @@
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 { persistProjectUpload } from "./uploads"
6
+ import { startKannaServer } from "./server"
7
+
8
+ const tempDirs: string[] = []
9
+
10
+ afterEach(async () => {
11
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
12
+ })
13
+
14
+ async function startPasswordServer() {
15
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-auth-test-"))
16
+ tempDirs.push(projectDir)
17
+ const server = await startKannaServer({ port: 4320, strictPort: true, password: "secret" })
18
+ const project = await server.store.openProject(projectDir, "Project")
19
+ return { server, projectDir, project }
20
+ }
21
+
22
+ function extractCookie(response: Response) {
23
+ const header = response.headers.get("set-cookie")
24
+ expect(header).toBeTruthy()
25
+ return header!.split(";", 1)[0]
26
+ }
27
+
28
+ describe("password auth", () => {
29
+ test("redirects unauthenticated html requests to the login page", async () => {
30
+ const { server } = await startPasswordServer()
31
+
32
+ try {
33
+ const response = await fetch(`http://localhost:${server.port}/`, { redirect: "manual", headers: { Accept: "text/html" } })
34
+ expect(response.status).toBe(302)
35
+ expect(response.headers.get("location")).toBe(`http://localhost:${server.port}/auth/login?next=%2F`)
36
+ } finally {
37
+ await server.stop()
38
+ }
39
+ })
40
+
41
+ test("blocks unauthenticated api requests", async () => {
42
+ const { server } = await startPasswordServer()
43
+
44
+ try {
45
+ const response = await fetch(`http://localhost:${server.port}/health`, { redirect: "manual" })
46
+ expect(response.status).toBe(401)
47
+ } finally {
48
+ await server.stop()
49
+ }
50
+ })
51
+
52
+ test("serves the login page without authentication", async () => {
53
+ const { server } = await startPasswordServer()
54
+
55
+ try {
56
+ const response = await fetch(`http://localhost:${server.port}/auth/login`)
57
+ expect(response.status).toBe(200)
58
+ expect(response.headers.get("content-type")).toContain("text/html")
59
+ expect(await response.text()).toContain("This server is password protected")
60
+ } finally {
61
+ await server.stop()
62
+ }
63
+ })
64
+
65
+ test("sets a session cookie after a successful login", async () => {
66
+ const { server } = await startPasswordServer()
67
+
68
+ try {
69
+ const formData = new FormData()
70
+ formData.append("password", "secret")
71
+ formData.append("next", "/")
72
+ const response = await fetch(`http://localhost:${server.port}/auth/login`, {
73
+ method: "POST",
74
+ body: formData,
75
+ redirect: "manual",
76
+ headers: {
77
+ Origin: `http://localhost:${server.port}`,
78
+ },
79
+ })
80
+
81
+ expect(response.status).toBe(302)
82
+ expect(response.headers.get("location")).toBe(`http://localhost:${server.port}/`)
83
+ expect(extractCookie(response)).toContain("kanna_session=")
84
+ } finally {
85
+ await server.stop()
86
+ }
87
+ })
88
+
89
+ test("rejects an invalid password", async () => {
90
+ const { server } = await startPasswordServer()
91
+
92
+ try {
93
+ const formData = new FormData()
94
+ formData.append("password", "wrong")
95
+ const response = await fetch(`http://localhost:${server.port}/auth/login`, {
96
+ method: "POST",
97
+ body: formData,
98
+ redirect: "manual",
99
+ headers: {
100
+ Origin: `http://localhost:${server.port}`,
101
+ },
102
+ })
103
+
104
+ expect(response.status).toBe(302)
105
+ expect(response.headers.get("location")).toContain("/auth/login?error=1")
106
+ expect(response.headers.get("set-cookie")).toBeNull()
107
+ } finally {
108
+ await server.stop()
109
+ }
110
+ })
111
+
112
+ test("rejects cross-origin login attempts", async () => {
113
+ const { server } = await startPasswordServer()
114
+
115
+ try {
116
+ const response = await fetch(`http://localhost:${server.port}/auth/login`, {
117
+ method: "POST",
118
+ body: JSON.stringify({ password: "secret" }),
119
+ headers: {
120
+ "Content-Type": "application/json",
121
+ Origin: "http://evil.test",
122
+ },
123
+ })
124
+
125
+ expect(response.status).toBe(403)
126
+ } finally {
127
+ await server.stop()
128
+ }
129
+ })
130
+
131
+ test("allows authenticated access to protected routes", async () => {
132
+ const { server, project, projectDir } = await startPasswordServer()
133
+
134
+ try {
135
+ const attachment = await persistProjectUpload({
136
+ projectId: project.id,
137
+ localPath: projectDir,
138
+ fileName: "hello.txt",
139
+ bytes: new TextEncoder().encode("hello from upload"),
140
+ fallbackMimeType: "text/plain",
141
+ })
142
+
143
+ const loginResponse = await fetch(`http://localhost:${server.port}/auth/login`, {
144
+ method: "POST",
145
+ body: JSON.stringify({ password: "secret", next: "/" }),
146
+ headers: {
147
+ "Content-Type": "application/json",
148
+ Origin: `http://localhost:${server.port}`,
149
+ },
150
+ })
151
+ const cookie = extractCookie(loginResponse)
152
+
153
+ const healthResponse = await fetch(`http://localhost:${server.port}/health`, {
154
+ headers: {
155
+ Cookie: cookie,
156
+ },
157
+ })
158
+ expect(healthResponse.status).toBe(200)
159
+
160
+ const contentResponse = await fetch(`http://localhost:${server.port}${attachment.contentUrl}`, {
161
+ headers: {
162
+ Cookie: cookie,
163
+ },
164
+ })
165
+ expect(contentResponse.status).toBe(200)
166
+ expect(await contentResponse.text()).toBe("hello from upload")
167
+ } finally {
168
+ await server.stop()
169
+ }
170
+ })
171
+
172
+ test("clears the session cookie on logout", async () => {
173
+ const { server } = await startPasswordServer()
174
+
175
+ try {
176
+ const loginResponse = await fetch(`http://localhost:${server.port}/auth/login`, {
177
+ method: "POST",
178
+ body: JSON.stringify({ password: "secret", next: "/" }),
179
+ headers: {
180
+ "Content-Type": "application/json",
181
+ Origin: `http://localhost:${server.port}`,
182
+ },
183
+ })
184
+ const cookie = extractCookie(loginResponse)
185
+
186
+ const logoutResponse = await fetch(`http://localhost:${server.port}/auth/logout`, {
187
+ method: "POST",
188
+ headers: {
189
+ Cookie: cookie,
190
+ Origin: `http://localhost:${server.port}`,
191
+ },
192
+ })
193
+
194
+ expect(logoutResponse.status).toBe(200)
195
+ expect(logoutResponse.headers.get("set-cookie")).toContain("Max-Age=0")
196
+
197
+ const healthResponse = await fetch(`http://localhost:${server.port}/health`, {
198
+ headers: {
199
+ Cookie: cookie,
200
+ },
201
+ })
202
+ expect(healthResponse.status).toBe(401)
203
+ } finally {
204
+ await server.stop()
205
+ }
206
+ })
207
+ })
@@ -0,0 +1,304 @@
1
+ import { randomBytes, timingSafeEqual } from "node:crypto"
2
+ import { APP_NAME } from "../shared/branding"
3
+
4
+ const SESSION_COOKIE_NAME = "kanna_session"
5
+
6
+ export interface AuthStatusPayload {
7
+ enabled: boolean
8
+ authenticated: boolean
9
+ }
10
+
11
+ export interface AuthManager {
12
+ readonly enabled: true
13
+ isAuthenticated(req: Request): boolean
14
+ validateOrigin(req: Request): boolean
15
+ createSessionCookie(req: Request): string
16
+ clearSessionCookie(req: Request): string
17
+ verifyPassword(candidate: string): boolean
18
+ handleLogin(req: Request, nextPath: string): Promise<Response>
19
+ handleLogout(req: Request): Response
20
+ handleStatus(req: Request): Response
21
+ renderLoginPage(req: Request): Response
22
+ unauthorizedResponse(req: Request): Response
23
+ }
24
+
25
+ function parseCookies(header: string | null) {
26
+ const cookies = new Map<string, string>()
27
+ if (!header) return cookies
28
+
29
+ for (const segment of header.split(";")) {
30
+ const trimmed = segment.trim()
31
+ if (!trimmed) continue
32
+ const separator = trimmed.indexOf("=")
33
+ if (separator <= 0) continue
34
+ const key = trimmed.slice(0, separator).trim()
35
+ const value = trimmed.slice(separator + 1).trim()
36
+ cookies.set(key, decodeURIComponent(value))
37
+ }
38
+
39
+ return cookies
40
+ }
41
+
42
+ function sanitizeNextPath(nextPath: string | null | undefined) {
43
+ if (!nextPath || typeof nextPath !== "string") return "/"
44
+ if (!nextPath.startsWith("/")) return "/"
45
+ if (nextPath.startsWith("//")) return "/"
46
+ if (nextPath.startsWith("/auth/login")) return "/"
47
+ return nextPath
48
+ }
49
+
50
+ function shouldUseSecureCookie(req: Request) {
51
+ return new URL(req.url).protocol === "https:"
52
+ }
53
+
54
+ function buildCookie(name: string, value: string, req: Request, extras: string[] = []) {
55
+ const parts = [
56
+ `${name}=${encodeURIComponent(value)}`,
57
+ "Path=/",
58
+ "HttpOnly",
59
+ "SameSite=Strict",
60
+ ]
61
+
62
+ if (shouldUseSecureCookie(req)) {
63
+ parts.push("Secure")
64
+ }
65
+
66
+ parts.push(...extras)
67
+ return parts.join("; ")
68
+ }
69
+
70
+ async function readLoginForm(req: Request) {
71
+ const contentType = req.headers.get("content-type") ?? ""
72
+
73
+ if (contentType.includes("application/json")) {
74
+ const payload = await req.json() as { password?: unknown; next?: unknown }
75
+ return {
76
+ password: typeof payload.password === "string" ? payload.password : "",
77
+ nextPath: sanitizeNextPath(typeof payload.next === "string" ? payload.next : "/"),
78
+ wantsJson: true,
79
+ }
80
+ }
81
+
82
+ const formData = await req.formData()
83
+ return {
84
+ password: String(formData.get("password") ?? ""),
85
+ nextPath: sanitizeNextPath(String(formData.get("next") ?? "/")),
86
+ wantsJson: false,
87
+ }
88
+ }
89
+
90
+ function requestWantsHtml(req: Request) {
91
+ const accept = req.headers.get("accept") ?? ""
92
+ return accept.includes("text/html")
93
+ }
94
+
95
+ function escapeHtml(value: string) {
96
+ return value
97
+ .replaceAll("&", "&amp;")
98
+ .replaceAll("<", "&lt;")
99
+ .replaceAll(">", "&gt;")
100
+ .replaceAll("\"", "&quot;")
101
+ .replaceAll("'", "&#39;")
102
+ }
103
+
104
+ export function createAuthManager(password: string): AuthManager {
105
+ const sessions = new Set<string>()
106
+ const expectedPassword = Buffer.from(password)
107
+
108
+ function getSessionToken(req: Request) {
109
+ return parseCookies(req.headers.get("cookie")).get(SESSION_COOKIE_NAME) ?? null
110
+ }
111
+
112
+ function isAuthenticated(req: Request) {
113
+ const sessionToken = getSessionToken(req)
114
+ return Boolean(sessionToken && sessions.has(sessionToken))
115
+ }
116
+
117
+ function validateOrigin(req: Request) {
118
+ const origin = req.headers.get("origin")
119
+ if (!origin) return true
120
+ return origin === new URL(req.url).origin
121
+ }
122
+
123
+ function createSessionCookie(req: Request) {
124
+ const sessionToken = randomBytes(32).toString("base64url")
125
+ sessions.add(sessionToken)
126
+ return buildCookie(SESSION_COOKIE_NAME, sessionToken, req)
127
+ }
128
+
129
+ function clearSessionCookie(req: Request) {
130
+ const sessionToken = getSessionToken(req)
131
+ if (sessionToken) {
132
+ sessions.delete(sessionToken)
133
+ }
134
+ return buildCookie(SESSION_COOKIE_NAME, "", req, ["Max-Age=0"])
135
+ }
136
+
137
+ function verifyPassword(candidate: string) {
138
+ const actual = Buffer.from(candidate)
139
+ if (actual.length !== expectedPassword.length) {
140
+ return false
141
+ }
142
+ return timingSafeEqual(actual, expectedPassword)
143
+ }
144
+
145
+ function handleStatus(req: Request) {
146
+ return Response.json({
147
+ enabled: true,
148
+ authenticated: isAuthenticated(req),
149
+ } satisfies AuthStatusPayload)
150
+ }
151
+
152
+ function unauthorizedResponse(req: Request) {
153
+ if (req.method === "GET" && requestWantsHtml(req)) {
154
+ const url = new URL(req.url)
155
+ const loginUrl = new URL("/auth/login", req.url)
156
+ loginUrl.searchParams.set("next", sanitizeNextPath(`${url.pathname}${url.search}`))
157
+ return Response.redirect(loginUrl, 302)
158
+ }
159
+
160
+ return Response.json({ error: "Unauthorized" }, { status: 401 })
161
+ }
162
+
163
+ function renderLoginPage(req: Request) {
164
+ if (isAuthenticated(req)) {
165
+ const currentUrl = new URL(req.url)
166
+ return Response.redirect(new URL(sanitizeNextPath(currentUrl.searchParams.get("next")), req.url), 302)
167
+ }
168
+
169
+ const currentUrl = new URL(req.url)
170
+ const nextPath = sanitizeNextPath(currentUrl.searchParams.get("next"))
171
+ const showError = currentUrl.searchParams.get("error") === "1"
172
+ const html = `<!doctype html>
173
+ <html lang="en">
174
+ <head>
175
+ <meta charset="utf-8" />
176
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
177
+ <title>${escapeHtml(APP_NAME)} Login</title>
178
+ <style>
179
+ :root { color-scheme: dark; }
180
+ * { box-sizing: border-box; }
181
+ body {
182
+ margin: 0;
183
+ min-height: 100vh;
184
+ font-family: ui-sans-serif, system-ui, sans-serif;
185
+ background:
186
+ radial-gradient(circle at top, rgba(255,255,255,0.10), transparent 28%),
187
+ linear-gradient(160deg, #16181d 0%, #0b0d10 55%, #050608 100%);
188
+ color: #f4f7fb;
189
+ display: grid;
190
+ place-items: center;
191
+ padding: 24px;
192
+ }
193
+ .card {
194
+ width: min(420px, 100%);
195
+ border: 1px solid rgba(255,255,255,0.12);
196
+ background: rgba(12, 15, 20, 0.88);
197
+ backdrop-filter: blur(10px);
198
+ border-radius: 24px;
199
+ padding: 28px;
200
+ box-shadow: 0 24px 70px rgba(0,0,0,0.35);
201
+ }
202
+ h1 { margin: 0 0 8px; font-size: 28px; }
203
+ p { margin: 0 0 20px; color: #a6b0bd; line-height: 1.5; }
204
+ label { display: block; font-size: 13px; margin-bottom: 8px; color: #d7dce4; }
205
+ input {
206
+ width: 100%;
207
+ border-radius: 14px;
208
+ border: 1px solid rgba(255,255,255,0.16);
209
+ background: rgba(255,255,255,0.04);
210
+ color: inherit;
211
+ padding: 14px 16px;
212
+ font-size: 15px;
213
+ margin-bottom: 16px;
214
+ }
215
+ button {
216
+ width: 100%;
217
+ border: 0;
218
+ border-radius: 14px;
219
+ padding: 14px 16px;
220
+ background: linear-gradient(135deg, #f4ede0 0%, #d9c4a1 100%);
221
+ color: #16181d;
222
+ font-size: 15px;
223
+ font-weight: 700;
224
+ cursor: pointer;
225
+ }
226
+ .error {
227
+ margin-bottom: 16px;
228
+ border-radius: 14px;
229
+ background: rgba(255, 106, 106, 0.12);
230
+ border: 1px solid rgba(255, 106, 106, 0.24);
231
+ color: #ffb8b8;
232
+ padding: 12px 14px;
233
+ font-size: 14px;
234
+ }
235
+ </style>
236
+ </head>
237
+ <body>
238
+ <form class="card" method="post" action="/auth/login">
239
+ <h1>${escapeHtml(APP_NAME)}</h1>
240
+ <p>This server is password protected. Enter the launch password to continue.</p>
241
+ ${showError ? '<div class="error">Incorrect password. Try again.</div>' : ""}
242
+ <input type="hidden" name="next" value="${escapeHtml(nextPath)}" />
243
+ <label for="password">Password</label>
244
+ <input id="password" name="password" type="password" autocomplete="current-password" autofocus required />
245
+ <button type="submit">Unlock</button>
246
+ </form>
247
+ </body>
248
+ </html>`
249
+ return new Response(html, {
250
+ headers: {
251
+ "Content-Type": "text/html; charset=utf-8",
252
+ },
253
+ })
254
+ }
255
+
256
+ async function handleLogin(req: Request, fallbackNextPath: string) {
257
+ if (!validateOrigin(req)) {
258
+ return Response.json({ error: "Forbidden" }, { status: 403 })
259
+ }
260
+
261
+ const { password: candidate, nextPath, wantsJson } = await readLoginForm(req)
262
+ if (!verifyPassword(candidate)) {
263
+ if (wantsJson) {
264
+ return Response.json({ error: "Invalid password" }, { status: 401 })
265
+ }
266
+
267
+ const redirectUrl = new URL("/auth/login", req.url)
268
+ redirectUrl.searchParams.set("error", "1")
269
+ redirectUrl.searchParams.set("next", sanitizeNextPath(nextPath || fallbackNextPath))
270
+ return Response.redirect(redirectUrl, 302)
271
+ }
272
+
273
+ const response = wantsJson
274
+ ? Response.json({ ok: true, nextPath: sanitizeNextPath(nextPath || fallbackNextPath) })
275
+ : Response.redirect(new URL(sanitizeNextPath(nextPath || fallbackNextPath), req.url), 302)
276
+
277
+ response.headers.set("Set-Cookie", createSessionCookie(req))
278
+ return response
279
+ }
280
+
281
+ function handleLogout(req: Request) {
282
+ if (!validateOrigin(req)) {
283
+ return Response.json({ error: "Forbidden" }, { status: 403 })
284
+ }
285
+
286
+ const response = Response.json({ ok: true })
287
+ response.headers.set("Set-Cookie", clearSessionCookie(req))
288
+ return response
289
+ }
290
+
291
+ return {
292
+ enabled: true,
293
+ isAuthenticated,
294
+ validateOrigin,
295
+ createSessionCookie,
296
+ clearSessionCookie,
297
+ verifyPassword,
298
+ handleLogin,
299
+ handleLogout,
300
+ handleStatus,
301
+ renderLoginPage,
302
+ unauthorizedResponse,
303
+ }
304
+ }
@@ -25,6 +25,7 @@ function createDeps(overrides: Partial<Parameters<typeof runCli>[1]> = {}) {
25
25
  host: string
26
26
  openBrowser: boolean
27
27
  share: false | "quick" | { kind: "token"; token: string }
28
+ password: string | null
28
29
  strictPort: boolean
29
30
  update: {
30
31
  version: string
@@ -102,6 +103,7 @@ describe("parseArgs", () => {
102
103
  host: "127.0.0.1",
103
104
  openBrowser: false,
104
105
  share: false,
106
+ password: null,
105
107
  strictPort: false,
106
108
  },
107
109
  })
@@ -115,6 +117,7 @@ describe("parseArgs", () => {
115
117
  host: "127.0.0.1",
116
118
  openBrowser: true,
117
119
  share: false,
120
+ password: null,
118
121
  strictPort: true,
119
122
  },
120
123
  })
@@ -128,6 +131,7 @@ describe("parseArgs", () => {
128
131
  host: "0.0.0.0",
129
132
  openBrowser: true,
130
133
  share: false,
134
+ password: null,
131
135
  strictPort: false,
132
136
  },
133
137
  })
@@ -141,6 +145,7 @@ describe("parseArgs", () => {
141
145
  host: "127.0.0.1",
142
146
  openBrowser: true,
143
147
  share: "quick",
148
+ password: null,
144
149
  strictPort: false,
145
150
  },
146
151
  })
@@ -154,11 +159,31 @@ describe("parseArgs", () => {
154
159
  host: "127.0.0.1",
155
160
  openBrowser: true,
156
161
  share: { kind: "token", token: "secret-token" },
162
+ password: null,
157
163
  strictPort: false,
158
164
  },
159
165
  })
160
166
  })
161
167
 
168
+ test("--password accepts a secret", () => {
169
+ expect(parseArgs(["--password", "secret"])).toEqual({
170
+ kind: "run",
171
+ options: {
172
+ port: 3210,
173
+ host: "127.0.0.1",
174
+ openBrowser: true,
175
+ share: false,
176
+ password: "secret",
177
+ strictPort: false,
178
+ },
179
+ })
180
+ })
181
+
182
+ test("--password without a value throws", () => {
183
+ expect(() => parseArgs(["--password"])).toThrow("Missing value for --password")
184
+ expect(() => parseArgs(["--password", "--no-open"])).toThrow("Missing value for --password")
185
+ })
186
+
162
187
  test("--cloudflared without a token throws", () => {
163
188
  expect(() => parseArgs(["--cloudflared"])).toThrow("Missing value for --cloudflared")
164
189
  expect(() => parseArgs(["--cloudflared", "--no-open"])).toThrow("Missing value for --cloudflared")
@@ -172,6 +197,7 @@ describe("parseArgs", () => {
172
197
  host: "100.64.0.1",
173
198
  openBrowser: true,
174
199
  share: false,
200
+ password: null,
175
201
  strictPort: false,
176
202
  },
177
203
  })
@@ -185,6 +211,7 @@ describe("parseArgs", () => {
185
211
  host: "dev-box",
186
212
  openBrowser: true,
187
213
  share: false,
214
+ password: null,
188
215
  strictPort: false,
189
216
  },
190
217
  })
@@ -261,6 +288,7 @@ describe("runCli", () => {
261
288
  host: "127.0.0.1",
262
289
  openBrowser: false,
263
290
  share: false,
291
+ password: null,
264
292
  strictPort: false,
265
293
  update: {
266
294
  version: "0.3.0",
@@ -14,6 +14,7 @@ export interface CliOptions {
14
14
  host: string
15
15
  openBrowser: boolean
16
16
  share: ShareMode
17
+ password: string | null
17
18
  strictPort: boolean
18
19
  }
19
20
 
@@ -85,6 +86,7 @@ Options:
85
86
  --share Create a public Cloudflare quick tunnel with terminal QR
86
87
  --cloudflared <token>
87
88
  Run a named Cloudflare tunnel from a token
89
+ --password <secret> Require a password before loading the app
88
90
  --strict-port Fail instead of trying another port
89
91
  --no-open Don't open browser automatically
90
92
  --version Print version and exit
@@ -96,6 +98,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
96
98
  let host = "127.0.0.1"
97
99
  let openBrowser = true
98
100
  let share: ShareMode = false
101
+ let password: string | null = null
99
102
  let sawHost = false
100
103
  let sawRemote = false
101
104
  let strictPort = false
@@ -153,6 +156,13 @@ export function parseArgs(argv: string[]): ParsedArgs {
153
156
  openBrowser = false
154
157
  continue
155
158
  }
159
+ if (arg === "--password") {
160
+ const next = argv[index + 1]
161
+ if (!next || next.startsWith("-")) throw new Error("Missing value for --password")
162
+ password = next
163
+ index += 1
164
+ continue
165
+ }
156
166
  if (arg === "--strict-port") {
157
167
  strictPort = true
158
168
  continue
@@ -167,6 +177,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
167
177
  host,
168
178
  openBrowser,
169
179
  share,
180
+ password,
170
181
  strictPort,
171
182
  },
172
183
  }
@@ -345,6 +345,24 @@ describe("EventStore", () => {
345
345
  expect(store.getChat("chat-1")?.unread).toBe(false)
346
346
  })
347
347
 
348
+ test("persists sidebar project order across restart and compaction", async () => {
349
+ const dataDir = await createTempDataDir()
350
+ const store = new EventStore(dataDir)
351
+ await store.initialize()
352
+
353
+ const first = await store.openProject("/tmp/project-a")
354
+ const second = await store.openProject("/tmp/project-b")
355
+
356
+ await store.setSidebarProjectOrder([second.id, first.id])
357
+ expect(store.state.sidebarProjectOrder).toEqual([second.id, first.id])
358
+
359
+ await store.compact()
360
+
361
+ const reloaded = new EventStore(dataDir)
362
+ await reloaded.initialize()
363
+ expect(reloaded.state.sidebarProjectOrder).toEqual([second.id, first.id])
364
+ })
365
+
348
366
  test("prunes stale empty chats after thirty minutes", async () => {
349
367
  const dataDir = await createTempDataDir()
350
368
  const store = new EventStore(dataDir)