kanna-code 0.33.9 → 0.34.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.
@@ -0,0 +1,263 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { watch, type FSWatcher } from "node:fs"
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises"
4
+ import { homedir } from "node:os"
5
+ import path from "node:path"
6
+ import { getSettingsFilePath, LOG_PREFIX } from "../shared/branding"
7
+ import type { AppSettingsSnapshot } from "../shared/types"
8
+
9
+ interface AppSettingsFile {
10
+ analyticsEnabled?: unknown
11
+ analyticsUserId?: unknown
12
+ }
13
+
14
+ interface AppSettingsState extends AppSettingsSnapshot {
15
+ analyticsUserId: string
16
+ }
17
+
18
+ interface NormalizedAppSettings {
19
+ payload: {
20
+ analyticsEnabled: boolean
21
+ analyticsUserId: string
22
+ }
23
+ warning: string | null
24
+ shouldWrite: boolean
25
+ }
26
+
27
+ function formatDisplayPath(filePath: string) {
28
+ const homePath = homedir()
29
+ if (filePath === homePath) return "~"
30
+ if (filePath.startsWith(`${homePath}${path.sep}`)) {
31
+ return `~${filePath.slice(homePath.length)}`
32
+ }
33
+ return filePath
34
+ }
35
+
36
+ function createAnalyticsUserId() {
37
+ return `anon_${randomUUID()}`
38
+ }
39
+
40
+ function normalizeAppSettings(
41
+ value: unknown,
42
+ filePath = getSettingsFilePath(homedir())
43
+ ): NormalizedAppSettings {
44
+ const source = value && typeof value === "object" && !Array.isArray(value)
45
+ ? value as AppSettingsFile
46
+ : null
47
+ const warnings: string[] = []
48
+
49
+ if (value !== undefined && value !== null && !source) {
50
+ warnings.push("Settings file must contain a JSON object")
51
+ }
52
+
53
+ const analyticsEnabled = typeof source?.analyticsEnabled === "boolean"
54
+ ? source.analyticsEnabled
55
+ : true
56
+ if (source?.analyticsEnabled !== undefined && typeof source.analyticsEnabled !== "boolean") {
57
+ warnings.push("analyticsEnabled must be a boolean")
58
+ }
59
+
60
+ const rawAnalyticsUserId = typeof source?.analyticsUserId === "string"
61
+ ? source.analyticsUserId.trim()
62
+ : ""
63
+ if (source?.analyticsUserId !== undefined && typeof source.analyticsUserId !== "string") {
64
+ warnings.push("analyticsUserId must be a string")
65
+ }
66
+
67
+ const analyticsUserId = rawAnalyticsUserId || createAnalyticsUserId()
68
+ if (!rawAnalyticsUserId && source?.analyticsUserId !== undefined) {
69
+ warnings.push("analyticsUserId must be a non-empty string")
70
+ }
71
+
72
+ const shouldWrite = !source
73
+ || source.analyticsEnabled !== analyticsEnabled
74
+ || rawAnalyticsUserId !== analyticsUserId
75
+
76
+ return {
77
+ payload: {
78
+ analyticsEnabled,
79
+ analyticsUserId,
80
+ },
81
+ warning: warnings.length > 0
82
+ ? `Some settings were reset to defaults: ${warnings.join("; ")}`
83
+ : null,
84
+ shouldWrite,
85
+ }
86
+ }
87
+
88
+ function toSnapshot(state: AppSettingsState): AppSettingsSnapshot {
89
+ return {
90
+ analyticsEnabled: state.analyticsEnabled,
91
+ warning: state.warning,
92
+ filePathDisplay: state.filePathDisplay,
93
+ }
94
+ }
95
+
96
+ export async function readAppSettingsSnapshot(filePath = getSettingsFilePath(homedir())) {
97
+ try {
98
+ const text = await readFile(filePath, "utf8")
99
+ if (!text.trim()) {
100
+ const normalized = normalizeAppSettings(undefined, filePath)
101
+ return {
102
+ analyticsEnabled: normalized.payload.analyticsEnabled,
103
+ warning: "Settings file was empty. Using defaults.",
104
+ filePathDisplay: formatDisplayPath(filePath),
105
+ } satisfies AppSettingsSnapshot
106
+ }
107
+
108
+ const normalized = normalizeAppSettings(JSON.parse(text), filePath)
109
+ return {
110
+ analyticsEnabled: normalized.payload.analyticsEnabled,
111
+ warning: normalized.warning,
112
+ filePathDisplay: formatDisplayPath(filePath),
113
+ } satisfies AppSettingsSnapshot
114
+ } catch (error) {
115
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
116
+ return {
117
+ analyticsEnabled: true,
118
+ warning: null,
119
+ filePathDisplay: formatDisplayPath(filePath),
120
+ } satisfies AppSettingsSnapshot
121
+ }
122
+ if (error instanceof SyntaxError) {
123
+ return {
124
+ analyticsEnabled: true,
125
+ warning: "Settings file is invalid JSON. Using defaults.",
126
+ filePathDisplay: formatDisplayPath(filePath),
127
+ } satisfies AppSettingsSnapshot
128
+ }
129
+ throw error
130
+ }
131
+ }
132
+
133
+ export class AppSettingsManager {
134
+ readonly filePath: string
135
+ private watcher: FSWatcher | null = null
136
+ private state: AppSettingsState
137
+ private readonly listeners = new Set<(snapshot: AppSettingsSnapshot) => void>()
138
+
139
+ constructor(filePath = getSettingsFilePath(homedir())) {
140
+ this.filePath = filePath
141
+ const displayPath = formatDisplayPath(this.filePath)
142
+ this.state = {
143
+ analyticsEnabled: true,
144
+ analyticsUserId: createAnalyticsUserId(),
145
+ warning: null,
146
+ filePathDisplay: displayPath,
147
+ }
148
+ }
149
+
150
+ async initialize() {
151
+ await mkdir(path.dirname(this.filePath), { recursive: true })
152
+ await this.reload({ persistNormalized: true })
153
+ this.startWatching()
154
+ }
155
+
156
+ dispose() {
157
+ this.watcher?.close()
158
+ this.watcher = null
159
+ this.listeners.clear()
160
+ }
161
+
162
+ getSnapshot() {
163
+ return toSnapshot(this.state)
164
+ }
165
+
166
+ getState() {
167
+ return this.state
168
+ }
169
+
170
+ onChange(listener: (snapshot: AppSettingsSnapshot) => void) {
171
+ this.listeners.add(listener)
172
+ return () => {
173
+ this.listeners.delete(listener)
174
+ }
175
+ }
176
+
177
+ async reload(options?: { persistNormalized?: boolean }) {
178
+ const nextState = await this.readState(options)
179
+ this.setState(nextState)
180
+ }
181
+
182
+ async write(value: { analyticsEnabled: boolean }) {
183
+ const payload = {
184
+ analyticsEnabled: value.analyticsEnabled,
185
+ analyticsUserId: this.state.analyticsUserId || createAnalyticsUserId(),
186
+ }
187
+ await mkdir(path.dirname(this.filePath), { recursive: true })
188
+ await writeFile(this.filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8")
189
+ const nextState: AppSettingsState = {
190
+ analyticsEnabled: payload.analyticsEnabled,
191
+ analyticsUserId: payload.analyticsUserId,
192
+ warning: null,
193
+ filePathDisplay: formatDisplayPath(this.filePath),
194
+ }
195
+ this.setState(nextState)
196
+ return toSnapshot(nextState)
197
+ }
198
+
199
+ private async readState(options?: { persistNormalized?: boolean }) {
200
+ const file = Bun.file(this.filePath)
201
+ const displayPath = formatDisplayPath(this.filePath)
202
+
203
+ try {
204
+ const text = await file.text()
205
+ const hasText = text.trim().length > 0
206
+ const normalized = normalizeAppSettings(hasText ? JSON.parse(text) : undefined, this.filePath)
207
+ if (options?.persistNormalized && (!hasText || normalized.shouldWrite)) {
208
+ await writeFile(this.filePath, `${JSON.stringify(normalized.payload, null, 2)}\n`, "utf8")
209
+ }
210
+ return {
211
+ analyticsEnabled: normalized.payload.analyticsEnabled,
212
+ analyticsUserId: normalized.payload.analyticsUserId,
213
+ warning: !hasText
214
+ ? "Settings file was empty. Using defaults."
215
+ : normalized.warning,
216
+ filePathDisplay: displayPath,
217
+ } satisfies AppSettingsState
218
+ } catch (error) {
219
+ if ((error as NodeJS.ErrnoException)?.code !== "ENOENT" && !(error instanceof SyntaxError)) {
220
+ throw error
221
+ }
222
+
223
+ const warning = error instanceof SyntaxError
224
+ ? "Settings file is invalid JSON. Using defaults."
225
+ : null
226
+ const normalized = normalizeAppSettings(undefined, this.filePath)
227
+ if (options?.persistNormalized) {
228
+ await writeFile(this.filePath, `${JSON.stringify(normalized.payload, null, 2)}\n`, "utf8")
229
+ }
230
+ return {
231
+ analyticsEnabled: normalized.payload.analyticsEnabled,
232
+ analyticsUserId: normalized.payload.analyticsUserId,
233
+ warning,
234
+ filePathDisplay: displayPath,
235
+ } satisfies AppSettingsState
236
+ }
237
+ }
238
+
239
+ private setState(state: AppSettingsState) {
240
+ this.state = state
241
+ const snapshot = toSnapshot(state)
242
+ for (const listener of this.listeners) {
243
+ listener(snapshot)
244
+ }
245
+ }
246
+
247
+ private startWatching() {
248
+ this.watcher?.close()
249
+ try {
250
+ this.watcher = watch(path.dirname(this.filePath), { persistent: false }, (_eventType, filename) => {
251
+ if (filename && filename !== path.basename(this.filePath)) {
252
+ return
253
+ }
254
+ void this.reload().catch((error: unknown) => {
255
+ console.warn(`${LOG_PREFIX} Failed to reload settings:`, error)
256
+ })
257
+ })
258
+ } catch (error) {
259
+ console.warn(`${LOG_PREFIX} Failed to watch settings file:`, error)
260
+ this.watcher = null
261
+ }
262
+ }
263
+ }
@@ -2,9 +2,12 @@ import path from "node:path"
2
2
  import { stat } from "node:fs/promises"
3
3
  import { APP_NAME, getRuntimeProfile } from "../shared/branding"
4
4
  import type { ChatAttachment } from "../shared/types"
5
+ import type { ShareMode } from "../shared/share"
5
6
  import { createAuthManager } from "./auth"
6
7
  import { EventStore } from "./event-store"
7
8
  import { AgentCoordinator } from "./agent"
9
+ import { KannaAnalyticsReporter } from "./analytics"
10
+ import { AppSettingsManager } from "./app-settings"
8
11
  import { DiffStore } from "./diff-store"
9
12
  import { discoverProjects, type DiscoveredProject } from "./discovery"
10
13
  import { KeybindingsManager } from "./keybindings"
@@ -58,6 +61,8 @@ export async function persistUploadedFiles(args: {
58
61
  export interface StartKannaServerOptions {
59
62
  port?: number
60
63
  host?: string
64
+ openBrowser?: boolean
65
+ share?: ShareMode
61
66
  dataDir?: string
62
67
  password?: string | null
63
68
  strictPort?: boolean
@@ -80,6 +85,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
80
85
  const port = options.port ?? 3210
81
86
  const hostname = options.host ?? "127.0.0.1"
82
87
  const strictPort = options.strictPort ?? false
88
+ const runtimeProfile = getRuntimeProfile()
83
89
  const auth = options.password ? createAuthManager(options.password, { trustProxy: options.trustProxy ?? false }) : null
84
90
  const store = new EventStore(options.dataDir)
85
91
  const diffStore = new DiffStore(store.dataDir)
@@ -100,17 +106,26 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
100
106
  let router: ReturnType<typeof createWsRouter>
101
107
  const terminals = new TerminalManager()
102
108
  const keybindings = new KeybindingsManager()
109
+ const appSettings = new AppSettingsManager(path.join(store.dataDir, "settings.json"))
110
+ await appSettings.initialize()
103
111
  await keybindings.initialize()
112
+ const analytics = new KannaAnalyticsReporter({
113
+ settings: appSettings,
114
+ currentVersion: options.update?.version ?? "unknown",
115
+ environment: runtimeProfile === "dev" ? "dev" : "prod",
116
+ })
104
117
  const updateManager = options.update
105
118
  ? new UpdateManager({
106
119
  currentVersion: options.update.version,
107
120
  fetchLatestVersion: options.update.fetchLatestVersion,
108
121
  installVersion: options.update.installVersion,
109
- devMode: getRuntimeProfile() === "dev",
122
+ devMode: runtimeProfile === "dev",
123
+ trackEvent: analytics.track.bind(analytics),
110
124
  })
111
125
  : null
112
126
  const agent = new AgentCoordinator({
113
127
  store,
128
+ analytics,
114
129
  onStateChange: (chatId?: string, options?: { immediate?: boolean }) => {
115
130
  if (chatId) {
116
131
  if (options?.immediate) {
@@ -129,6 +144,8 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
129
144
  agent,
130
145
  terminals,
131
146
  keybindings,
147
+ appSettings,
148
+ analytics,
132
149
  llmProvider: {
133
150
  read: readLlmProviderSnapshot,
134
151
  write: writeLlmProviderSnapshot,
@@ -256,12 +273,22 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
256
273
  }
257
274
  }
258
275
 
276
+ analytics.trackLaunch({
277
+ port: actualPort,
278
+ host: hostname,
279
+ openBrowser: options.openBrowser ?? true,
280
+ share: options.share ?? false,
281
+ password: options.password ?? null,
282
+ strictPort,
283
+ })
284
+
259
285
  const shutdown = async () => {
260
286
  clearInterval(staleEmptyChatPruneInterval)
261
287
  for (const chatId of [...agent.activeTurns.keys()]) {
262
288
  await agent.cancel(chatId)
263
289
  }
264
290
  router.dispose()
291
+ appSettings.dispose()
265
292
  keybindings.dispose()
266
293
  terminals.closeAll()
267
294
  await store.compact()
@@ -2,6 +2,41 @@ import { describe, expect, test } from "bun:test"
2
2
  import { UpdateManager } from "./update-manager"
3
3
 
4
4
  describe("UpdateManager", () => {
5
+ test("tracks update lifecycle events", async () => {
6
+ const events: Array<{ name: string; properties?: Record<string, unknown> }> = []
7
+ const manager = new UpdateManager({
8
+ currentVersion: "0.12.0",
9
+ fetchLatestVersion: async () => "0.13.0",
10
+ installVersion: () => ({
11
+ ok: true,
12
+ errorCode: null,
13
+ userTitle: null,
14
+ userMessage: null,
15
+ }),
16
+ trackEvent: (eventName, properties) => {
17
+ events.push({ name: eventName, properties })
18
+ },
19
+ })
20
+
21
+ await manager.checkForUpdates({ force: true })
22
+ await manager.installUpdate()
23
+
24
+ expect(events).toEqual([
25
+ {
26
+ name: "update_checked",
27
+ properties: {
28
+ latest_version: "0.13.0",
29
+ },
30
+ },
31
+ {
32
+ name: "update_installed",
33
+ properties: {
34
+ latest_version: "0.13.0",
35
+ },
36
+ },
37
+ ])
38
+ })
39
+
5
40
  test("detects available updates", async () => {
6
41
  const manager = new UpdateManager({
7
42
  currentVersion: "0.12.0",
@@ -9,6 +9,7 @@ export interface UpdateManagerDeps {
9
9
  fetchLatestVersion: (packageName: string) => Promise<string>
10
10
  installVersion: (packageName: string, version: string) => UpdateInstallAttemptResult
11
11
  devMode?: boolean
12
+ trackEvent?: (eventName: string, properties?: Record<string, unknown>) => void
12
13
  }
13
14
 
14
15
  export class UpdateManager {
@@ -81,6 +82,9 @@ export class UpdateManager {
81
82
 
82
83
  async installUpdate(): Promise<UpdateInstallResult> {
83
84
  if (this.deps.devMode) {
85
+ this.deps.trackEvent?.("update_installed", {
86
+ latest_version: this.snapshot.latestVersion,
87
+ })
84
88
  this.setSnapshot({
85
89
  ...this.snapshot,
86
90
  status: "updating",
@@ -145,6 +149,9 @@ export class UpdateManager {
145
149
  reloadRequestedAt: null,
146
150
  }
147
151
  this.setSnapshot(nextSnapshot)
152
+ this.deps.trackEvent?.("update_checked", {
153
+ latest_version: latestVersion,
154
+ })
148
155
  return nextSnapshot
149
156
  } catch (error) {
150
157
  const nextSnapshot: UpdateSnapshot = {
@@ -155,6 +162,9 @@ export class UpdateManager {
155
162
  reloadRequestedAt: null,
156
163
  }
157
164
  this.setSnapshot(nextSnapshot)
165
+ this.deps.trackEvent?.("update_failed", {
166
+ latest_version: this.snapshot.latestVersion,
167
+ })
158
168
  return nextSnapshot
159
169
  }
160
170
  }
@@ -188,6 +198,9 @@ export class UpdateManager {
188
198
  error: "Unable to determine which version to install.",
189
199
  reloadRequestedAt: null,
190
200
  })
201
+ this.deps.trackEvent?.("update_failed", {
202
+ latest_version: null,
203
+ })
191
204
  return {
192
205
  ok: false,
193
206
  action: "restart",
@@ -205,6 +218,9 @@ export class UpdateManager {
205
218
  error: installed.userMessage ?? "Unable to install the latest version.",
206
219
  reloadRequestedAt: null,
207
220
  })
221
+ this.deps.trackEvent?.("update_failed", {
222
+ latest_version: targetVersion,
223
+ })
208
224
  return {
209
225
  ok: false,
210
226
  action: "restart",
@@ -222,6 +238,9 @@ export class UpdateManager {
222
238
  error: null,
223
239
  reloadRequestedAt: Date.now(),
224
240
  })
241
+ this.deps.trackEvent?.("update_installed", {
242
+ latest_version: targetVersion,
243
+ })
225
244
  return {
226
245
  ok: true,
227
246
  action: "restart",