kanna-code 0.33.8 → 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,305 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { KannaAnalyticsReporter, getLaunchAnalyticsProperties } from "./analytics"
3
+
4
+ const originalLogAnalytics = process.env.KANNA_LOG_ANALYTICS
5
+
6
+ function restoreAnalyticsLoggingEnv() {
7
+ if (originalLogAnalytics === undefined) {
8
+ delete process.env.KANNA_LOG_ANALYTICS
9
+ return
10
+ }
11
+ process.env.KANNA_LOG_ANALYTICS = originalLogAnalytics
12
+ }
13
+
14
+ describe("getLaunchAnalyticsProperties", () => {
15
+ test("expands launch flags into app_launch properties", () => {
16
+ expect(getLaunchAnalyticsProperties({
17
+ port: 4000,
18
+ host: "0.0.0.0",
19
+ openBrowser: false,
20
+ share: "quick",
21
+ password: "secret",
22
+ strictPort: true,
23
+ })).toEqual({
24
+ custom_port_enabled: true,
25
+ no_open_enabled: true,
26
+ password_enabled: true,
27
+ strict_port_enabled: true,
28
+ remote_enabled: true,
29
+ host_enabled: false,
30
+ share_quick_enabled: true,
31
+ share_token_enabled: false,
32
+ })
33
+ })
34
+ })
35
+
36
+ describe("KannaAnalyticsReporter", () => {
37
+ test("posts the userId, event name, and shared properties", async () => {
38
+ const originalLog = console.log
39
+ const calls: Array<{ url: string; init?: RequestInit }> = []
40
+ console.log = () => {}
41
+
42
+ try {
43
+ const reporter = new KannaAnalyticsReporter({
44
+ endpoint: "https://kanna.sh/api/t",
45
+ currentVersion: "0.33.9",
46
+ environment: "dev",
47
+ settings: {
48
+ getState: () => ({
49
+ analyticsEnabled: true,
50
+ analyticsUserId: "anon_123",
51
+ warning: null,
52
+ filePathDisplay: "~/.kanna/data/settings.json",
53
+ }),
54
+ },
55
+ fetchImpl: async (url, init) => {
56
+ calls.push({ url: String(url), init })
57
+ return new Response(JSON.stringify({ ok: true }), { status: 200 })
58
+ },
59
+ })
60
+
61
+ reporter.track("message_sent")
62
+ await (reporter as any).queue
63
+
64
+ expect(calls).toHaveLength(1)
65
+ expect(calls[0]?.url).toBe("https://kanna.sh/api/t")
66
+ expect(calls[0]?.init?.method).toBe("POST")
67
+ expect(calls[0]?.init?.headers).toEqual({
68
+ "content-type": "application/json",
69
+ })
70
+ expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({
71
+ userId: "anon_123",
72
+ environment: "dev",
73
+ event: {
74
+ name: "message_sent",
75
+ properties: {
76
+ current_version: "0.33.9",
77
+ environment: "dev",
78
+ },
79
+ },
80
+ })
81
+ } finally {
82
+ console.log = originalLog
83
+ }
84
+ })
85
+
86
+ test("posts app_launch with launch flags as properties", async () => {
87
+ const calls: Array<{ url: string; init?: RequestInit }> = []
88
+ const reporter = new KannaAnalyticsReporter({
89
+ endpoint: "https://kanna.sh/api/t",
90
+ currentVersion: "0.33.9",
91
+ environment: "prod",
92
+ settings: {
93
+ getState: () => ({
94
+ analyticsEnabled: true,
95
+ analyticsUserId: "anon_123",
96
+ warning: null,
97
+ filePathDisplay: "~/.kanna/data/settings.json",
98
+ }),
99
+ },
100
+ fetchImpl: async (url, init) => {
101
+ calls.push({ url: String(url), init })
102
+ return new Response(JSON.stringify({ ok: true }), { status: 200 })
103
+ },
104
+ })
105
+
106
+ reporter.trackLaunch({
107
+ port: 4000,
108
+ host: "localhost",
109
+ openBrowser: false,
110
+ share: false,
111
+ password: null,
112
+ strictPort: true,
113
+ })
114
+ await (reporter as any).queue
115
+
116
+ expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({
117
+ userId: "anon_123",
118
+ environment: "prod",
119
+ event: {
120
+ name: "app_launch",
121
+ properties: {
122
+ current_version: "0.33.9",
123
+ environment: "prod",
124
+ custom_port_enabled: true,
125
+ no_open_enabled: true,
126
+ password_enabled: false,
127
+ strict_port_enabled: true,
128
+ remote_enabled: false,
129
+ host_enabled: false,
130
+ share_quick_enabled: false,
131
+ share_token_enabled: false,
132
+ },
133
+ },
134
+ })
135
+ })
136
+
137
+ test("skips requests when analytics is disabled", async () => {
138
+ let called = false
139
+ const reporter = new KannaAnalyticsReporter({
140
+ currentVersion: "0.33.9",
141
+ environment: "prod",
142
+ settings: {
143
+ getState: () => ({
144
+ analyticsEnabled: false,
145
+ analyticsUserId: "anon_123",
146
+ warning: null,
147
+ filePathDisplay: "~/.kanna/data/settings.json",
148
+ }),
149
+ },
150
+ fetchImpl: async () => {
151
+ called = true
152
+ return new Response(null, { status: 200 })
153
+ },
154
+ })
155
+
156
+ reporter.track("message_sent")
157
+ await (reporter as any).queue
158
+
159
+ expect(called).toBe(false)
160
+ })
161
+
162
+ test("does not warn when analytics request logging is disabled and the request fails", async () => {
163
+ const originalWarn = console.warn
164
+ const warnings: unknown[][] = []
165
+ console.warn = (...args: unknown[]) => {
166
+ warnings.push(args)
167
+ }
168
+ delete process.env.KANNA_LOG_ANALYTICS
169
+
170
+ try {
171
+ const reporter = new KannaAnalyticsReporter({
172
+ endpoint: "https://kanna.sh/api/t",
173
+ currentVersion: "0.33.9",
174
+ environment: "dev",
175
+ settings: {
176
+ getState: () => ({
177
+ analyticsEnabled: true,
178
+ analyticsUserId: "anon_123",
179
+ warning: null,
180
+ filePathDisplay: "~/.kanna/data/settings.json",
181
+ }),
182
+ },
183
+ fetchImpl: async () => new Response(JSON.stringify({ error: "bad request" }), { status: 400 }),
184
+ })
185
+
186
+ reporter.track("message_sent")
187
+ await (reporter as any).queue
188
+
189
+ expect(warnings).toHaveLength(0)
190
+ } finally {
191
+ console.warn = originalWarn
192
+ restoreAnalyticsLoggingEnv()
193
+ }
194
+ })
195
+
196
+ test("warns when analytics request logging is enabled and the request fails", async () => {
197
+ const originalWarn = console.warn
198
+ const warnings: unknown[][] = []
199
+ console.warn = (...args: unknown[]) => {
200
+ warnings.push(args)
201
+ }
202
+ process.env.KANNA_LOG_ANALYTICS = "1"
203
+
204
+ try {
205
+ const reporter = new KannaAnalyticsReporter({
206
+ endpoint: "https://kanna.sh/api/t",
207
+ currentVersion: "0.33.9",
208
+ environment: "prod",
209
+ settings: {
210
+ getState: () => ({
211
+ analyticsEnabled: true,
212
+ analyticsUserId: "anon_123",
213
+ warning: null,
214
+ filePathDisplay: "~/.kanna/data/settings.json",
215
+ }),
216
+ },
217
+ fetchImpl: async () => new Response(JSON.stringify({ error: "bad request" }), { status: 400 }),
218
+ })
219
+
220
+ reporter.track("message_sent")
221
+ await (reporter as any).queue
222
+
223
+ expect(warnings).toHaveLength(1)
224
+ expect(warnings[0]?.[0]).toBe("[kanna/analytics] Failed to send analytics event:")
225
+ expect(warnings[0]?.[1]).toBe("message_sent")
226
+ expect(warnings[0]?.[2]).toBeInstanceOf(Error)
227
+ } finally {
228
+ console.warn = originalWarn
229
+ restoreAnalyticsLoggingEnv()
230
+ }
231
+ })
232
+
233
+ test("logs when analytics request logging is enabled and the request succeeds", async () => {
234
+ const originalLog = console.log
235
+ const logs: unknown[][] = []
236
+ console.log = (...args: unknown[]) => {
237
+ logs.push(args)
238
+ }
239
+ process.env.KANNA_LOG_ANALYTICS = "1"
240
+
241
+ try {
242
+ const reporter = new KannaAnalyticsReporter({
243
+ endpoint: "https://kanna.sh/api/t",
244
+ currentVersion: "0.33.9",
245
+ environment: "dev",
246
+ settings: {
247
+ getState: () => ({
248
+ analyticsEnabled: true,
249
+ analyticsUserId: "anon_123",
250
+ warning: null,
251
+ filePathDisplay: "~/.kanna/data/settings.json",
252
+ }),
253
+ },
254
+ fetchImpl: async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),
255
+ })
256
+
257
+ reporter.track("message_sent")
258
+ await (reporter as any).queue
259
+
260
+ expect(logs).toHaveLength(1)
261
+ expect(logs[0]).toEqual([
262
+ "[kanna/analytics] Sent analytics event:",
263
+ "message_sent",
264
+ 200,
265
+ ])
266
+ } finally {
267
+ console.log = originalLog
268
+ restoreAnalyticsLoggingEnv()
269
+ }
270
+ })
271
+
272
+ test("does not log when analytics request logging is disabled and the request succeeds", async () => {
273
+ const originalLog = console.log
274
+ const logs: unknown[][] = []
275
+ console.log = (...args: unknown[]) => {
276
+ logs.push(args)
277
+ }
278
+ delete process.env.KANNA_LOG_ANALYTICS
279
+
280
+ try {
281
+ const reporter = new KannaAnalyticsReporter({
282
+ endpoint: "https://kanna.sh/api/t",
283
+ currentVersion: "0.33.9",
284
+ environment: "prod",
285
+ settings: {
286
+ getState: () => ({
287
+ analyticsEnabled: true,
288
+ analyticsUserId: "anon_123",
289
+ warning: null,
290
+ filePathDisplay: "~/.kanna/data/settings.json",
291
+ }),
292
+ },
293
+ fetchImpl: async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),
294
+ })
295
+
296
+ reporter.track("message_sent")
297
+ await (reporter as any).queue
298
+
299
+ expect(logs).toHaveLength(0)
300
+ } finally {
301
+ console.log = originalLog
302
+ restoreAnalyticsLoggingEnv()
303
+ }
304
+ })
305
+ })
@@ -0,0 +1,126 @@
1
+ import { ANALYTICS_ENDPOINT } from "../shared/analytics"
2
+ import { PROD_SERVER_PORT } from "../shared/ports"
3
+ import type { ShareMode } from "../shared/share"
4
+ import { isTokenShareMode } from "../shared/share"
5
+ import type { AppSettingsManager } from "./app-settings"
6
+
7
+ interface AnalyticsRequestBody {
8
+ userId: string
9
+ environment: AnalyticsEnvironment
10
+ event: {
11
+ name: string
12
+ properties: Record<string, unknown>
13
+ }
14
+ }
15
+
16
+ export interface LaunchAnalyticsOptions {
17
+ port: number
18
+ host: string
19
+ openBrowser: boolean
20
+ share: ShareMode
21
+ password: string | null
22
+ strictPort: boolean
23
+ }
24
+
25
+ type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>
26
+ type AnalyticsEnvironment = "dev" | "prod"
27
+
28
+ function isAnalyticsLoggingEnabled() {
29
+ return process.env.KANNA_LOG_ANALYTICS === "1"
30
+ }
31
+
32
+ export interface AnalyticsReporter {
33
+ track: (eventName: string, properties?: Record<string, unknown>) => void
34
+ trackLaunch: (options: LaunchAnalyticsOptions) => void
35
+ }
36
+
37
+ export class KannaAnalyticsReporter implements AnalyticsReporter {
38
+ private readonly settings: Pick<AppSettingsManager, "getState">
39
+ private readonly endpoint: string
40
+ private readonly fetchImpl: FetchLike
41
+ private readonly currentVersion: string
42
+ private readonly environment: AnalyticsEnvironment
43
+ private queue = Promise.resolve()
44
+
45
+ constructor(args: {
46
+ settings: Pick<AppSettingsManager, "getState">
47
+ currentVersion: string
48
+ environment: AnalyticsEnvironment
49
+ endpoint?: string
50
+ fetchImpl?: FetchLike
51
+ }) {
52
+ this.settings = args.settings
53
+ this.currentVersion = args.currentVersion
54
+ this.environment = args.environment
55
+ this.endpoint = args.endpoint ?? ANALYTICS_ENDPOINT
56
+ this.fetchImpl = args.fetchImpl ?? fetch
57
+ }
58
+
59
+ track(eventName: string, properties?: Record<string, unknown>) {
60
+ const { analyticsEnabled, analyticsUserId } = this.settings.getState()
61
+ if (!analyticsEnabled || !analyticsUserId) {
62
+ return
63
+ }
64
+
65
+ const body: AnalyticsRequestBody = {
66
+ userId: analyticsUserId,
67
+ environment: this.environment,
68
+ event: {
69
+ name: eventName,
70
+ properties: this.buildEventProperties(properties),
71
+ },
72
+ }
73
+
74
+ this.queue = this.queue
75
+ .then(async () => {
76
+ const response = await this.fetchImpl(this.endpoint, {
77
+ method: "POST",
78
+ headers: {
79
+ "content-type": "application/json",
80
+ },
81
+ body: JSON.stringify(body),
82
+ })
83
+ if (!response.ok) {
84
+ throw new Error(`Analytics request failed with status ${response.status}`)
85
+ }
86
+ if (isAnalyticsLoggingEnabled()) {
87
+ console.log("[kanna/analytics] Sent analytics event:", eventName, response.status)
88
+ }
89
+ })
90
+ .catch((error) => {
91
+ if (isAnalyticsLoggingEnabled()) {
92
+ console.warn("[kanna/analytics] Failed to send analytics event:", eventName, error)
93
+ }
94
+ })
95
+ }
96
+
97
+ trackLaunch(options: LaunchAnalyticsOptions) {
98
+ this.track("app_launch", getLaunchAnalyticsProperties(options))
99
+ }
100
+
101
+ private buildEventProperties(properties?: Record<string, unknown>) {
102
+ return {
103
+ current_version: this.currentVersion,
104
+ environment: this.environment,
105
+ ...(properties ?? {}),
106
+ }
107
+ }
108
+ }
109
+
110
+ export function getLaunchAnalyticsProperties(options: LaunchAnalyticsOptions) {
111
+ return {
112
+ custom_port_enabled: options.port !== PROD_SERVER_PORT,
113
+ no_open_enabled: !options.openBrowser,
114
+ password_enabled: Boolean(options.password),
115
+ strict_port_enabled: options.strictPort,
116
+ remote_enabled: options.host === "0.0.0.0",
117
+ host_enabled: options.host !== "0.0.0.0" && options.host !== "127.0.0.1" && options.host !== "localhost",
118
+ share_quick_enabled: options.share === "quick",
119
+ share_token_enabled: isTokenShareMode(options.share),
120
+ }
121
+ }
122
+
123
+ export const NoopAnalyticsReporter: AnalyticsReporter = {
124
+ track: () => {},
125
+ trackLaunch: () => {},
126
+ }
@@ -0,0 +1,90 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import path from "node:path"
5
+ import { AppSettingsManager, readAppSettingsSnapshot } from "./app-settings"
6
+
7
+ let tempDirs: string[] = []
8
+
9
+ afterEach(async () => {
10
+ await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
11
+ tempDirs = []
12
+ })
13
+
14
+ async function createTempFilePath() {
15
+ const dir = await mkdtemp(path.join(tmpdir(), "kanna-settings-"))
16
+ tempDirs.push(dir)
17
+ return path.join(dir, "settings.json")
18
+ }
19
+
20
+ describe("readAppSettingsSnapshot", () => {
21
+ test("returns defaults when the file does not exist", async () => {
22
+ const filePath = await createTempFilePath()
23
+ const snapshot = await readAppSettingsSnapshot(filePath)
24
+
25
+ expect(snapshot).toEqual({
26
+ analyticsEnabled: true,
27
+ warning: null,
28
+ filePathDisplay: filePath,
29
+ })
30
+ })
31
+
32
+ test("returns a warning when the file contains invalid json", async () => {
33
+ const filePath = await createTempFilePath()
34
+ await writeFile(filePath, "{not-json", "utf8")
35
+
36
+ const snapshot = await readAppSettingsSnapshot(filePath)
37
+ expect(snapshot.analyticsEnabled).toBe(true)
38
+ expect(snapshot.warning).toContain("invalid JSON")
39
+ })
40
+ })
41
+
42
+ describe("AppSettingsManager", () => {
43
+ test("creates a settings file with analytics enabled and a stable anonymous id", async () => {
44
+ const filePath = await createTempFilePath()
45
+ const manager = new AppSettingsManager(filePath)
46
+
47
+ await manager.initialize()
48
+
49
+ const payload = JSON.parse(await readFile(filePath, "utf8")) as {
50
+ analyticsEnabled: boolean
51
+ analyticsUserId: string
52
+ }
53
+ expect(payload.analyticsEnabled).toBe(true)
54
+ expect(payload.analyticsUserId).toMatch(/^anon_/)
55
+ expect(manager.getSnapshot()).toEqual({
56
+ analyticsEnabled: true,
57
+ warning: null,
58
+ filePathDisplay: filePath,
59
+ })
60
+
61
+ manager.dispose()
62
+ })
63
+
64
+ test("writes analyticsEnabled without replacing the stored user id", async () => {
65
+ const filePath = await createTempFilePath()
66
+ const manager = new AppSettingsManager(filePath)
67
+
68
+ await manager.initialize()
69
+ const initialPayload = JSON.parse(await readFile(filePath, "utf8")) as {
70
+ analyticsEnabled: boolean
71
+ analyticsUserId: string
72
+ }
73
+
74
+ const snapshot = await manager.write({ analyticsEnabled: false })
75
+ const nextPayload = JSON.parse(await readFile(filePath, "utf8")) as {
76
+ analyticsEnabled: boolean
77
+ analyticsUserId: string
78
+ }
79
+
80
+ expect(snapshot).toEqual({
81
+ analyticsEnabled: false,
82
+ warning: null,
83
+ filePathDisplay: filePath,
84
+ })
85
+ expect(nextPayload.analyticsEnabled).toBe(false)
86
+ expect(nextPayload.analyticsUserId).toBe(initialPayload.analyticsUserId)
87
+
88
+ manager.dispose()
89
+ })
90
+ })