kanna-code 0.2.0 → 0.4.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,180 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { compareVersions, parseArgs, runCli } from "./cli-runtime"
3
+
4
+ function createDeps(overrides: Partial<Parameters<typeof runCli>[1]> = {}) {
5
+ const calls = {
6
+ startServer: [] as Array<{ port: number; openBrowser: boolean; strictPort: boolean }>,
7
+ fetchLatestVersion: [] as string[],
8
+ installLatest: [] as string[],
9
+ relaunch: [] as Array<{ command: string; args: string[] }>,
10
+ openUrl: [] as string[],
11
+ log: [] as string[],
12
+ warn: [] as string[],
13
+ }
14
+
15
+ const deps: Parameters<typeof runCli>[1] = {
16
+ version: "0.3.0",
17
+ startServer: async (options) => {
18
+ calls.startServer.push(options)
19
+ return {
20
+ port: options.port,
21
+ stop: async () => {},
22
+ }
23
+ },
24
+ fetchLatestVersion: async (packageName) => {
25
+ calls.fetchLatestVersion.push(packageName)
26
+ return "0.3.0"
27
+ },
28
+ installLatest: (packageName) => {
29
+ calls.installLatest.push(packageName)
30
+ return true
31
+ },
32
+ relaunch: (command, args) => {
33
+ calls.relaunch.push({ command, args })
34
+ return 0
35
+ },
36
+ openUrl: (url) => {
37
+ calls.openUrl.push(url)
38
+ },
39
+ log: (message) => {
40
+ calls.log.push(message)
41
+ },
42
+ warn: (message) => {
43
+ calls.warn.push(message)
44
+ },
45
+ ...overrides,
46
+ }
47
+
48
+ return { calls, deps }
49
+ }
50
+
51
+ describe("parseArgs", () => {
52
+ test("parses runtime options", () => {
53
+ expect(parseArgs(["--port", "4000", "--no-open"])).toEqual({
54
+ kind: "run",
55
+ options: {
56
+ port: 4000,
57
+ openBrowser: false,
58
+ strictPort: false,
59
+ },
60
+ })
61
+ })
62
+
63
+ test("parses strict port mode", () => {
64
+ expect(parseArgs(["--strict-port"])).toEqual({
65
+ kind: "run",
66
+ options: {
67
+ port: 3210,
68
+ openBrowser: true,
69
+ strictPort: true,
70
+ },
71
+ })
72
+ })
73
+
74
+ test("returns version and help actions without running startup", () => {
75
+ expect(parseArgs(["--version"])).toEqual({ kind: "version" })
76
+ expect(parseArgs(["--help"])).toEqual({ kind: "help" })
77
+ })
78
+ })
79
+
80
+ describe("compareVersions", () => {
81
+ test("orders semver-like versions", () => {
82
+ expect(compareVersions("0.3.0", "0.3.0")).toBe(0)
83
+ expect(compareVersions("0.3.0", "0.3.1")).toBe(-1)
84
+ expect(compareVersions("1.0.0", "0.9.9")).toBe(1)
85
+ })
86
+ })
87
+
88
+ describe("runCli", () => {
89
+ test("skips update checks for --version", async () => {
90
+ const { calls, deps } = createDeps()
91
+
92
+ const result = await runCli(["--version"], deps)
93
+
94
+ expect(result).toEqual({ kind: "exited", code: 0 })
95
+ expect(calls.fetchLatestVersion).toEqual([])
96
+ expect(calls.startServer).toEqual([])
97
+ expect(calls.log).toEqual(["0.3.0"])
98
+ })
99
+
100
+ test("starts normally when no newer version exists", async () => {
101
+ const { calls, deps } = createDeps()
102
+
103
+ const result = await runCli(["--port", "4000", "--no-open"], deps)
104
+
105
+ expect(result.kind).toBe("started")
106
+ expect(calls.fetchLatestVersion).toEqual(["kanna-code"])
107
+ expect(calls.installLatest).toEqual([])
108
+ expect(calls.relaunch).toEqual([])
109
+ expect(calls.startServer).toEqual([{ port: 4000, openBrowser: false, strictPort: false }])
110
+ expect(calls.openUrl).toEqual([])
111
+ })
112
+
113
+ test("installs and relaunches when a newer version is available", async () => {
114
+ const { calls, deps } = createDeps({
115
+ fetchLatestVersion: async (packageName) => {
116
+ calls.fetchLatestVersion.push(packageName)
117
+ return "0.4.0"
118
+ },
119
+ })
120
+
121
+ const result = await runCli(["--port", "4000", "--no-open"], deps)
122
+
123
+ expect(result).toEqual({ kind: "exited", code: 0 })
124
+ expect(calls.installLatest).toEqual(["kanna-code"])
125
+ expect(calls.relaunch).toEqual([{ command: "kanna", args: ["--port", "4000", "--no-open"] }])
126
+ expect(calls.startServer).toEqual([])
127
+ })
128
+
129
+ test("falls back to current version when install fails", async () => {
130
+ const { calls, deps } = createDeps({
131
+ fetchLatestVersion: async (packageName) => {
132
+ calls.fetchLatestVersion.push(packageName)
133
+ return "0.4.0"
134
+ },
135
+ installLatest: (packageName) => {
136
+ calls.installLatest.push(packageName)
137
+ return false
138
+ },
139
+ })
140
+
141
+ const result = await runCli(["--no-open"], deps)
142
+
143
+ expect(result.kind).toBe("started")
144
+ expect(calls.installLatest).toEqual(["kanna-code"])
145
+ expect(calls.relaunch).toEqual([])
146
+ expect(calls.warn).toContain("[kanna] update failed, continuing current version")
147
+ })
148
+
149
+ test("falls back to current version when the registry check fails", async () => {
150
+ const { calls, deps } = createDeps({
151
+ fetchLatestVersion: async (packageName) => {
152
+ calls.fetchLatestVersion.push(packageName)
153
+ throw new Error("network unavailable")
154
+ },
155
+ })
156
+
157
+ const result = await runCli(["--no-open"], deps)
158
+
159
+ expect(result.kind).toBe("started")
160
+ expect(calls.installLatest).toEqual([])
161
+ expect(calls.relaunch).toEqual([])
162
+ expect(calls.warn).toContain("[kanna] update check failed, continuing current version")
163
+ })
164
+
165
+ test("preserves original argv when relaunching", async () => {
166
+ const { calls, deps } = createDeps({
167
+ fetchLatestVersion: async (packageName) => {
168
+ calls.fetchLatestVersion.push(packageName)
169
+ return "0.4.0"
170
+ },
171
+ })
172
+
173
+ await runCli(["--port", "4567", "--no-open"], deps)
174
+
175
+ expect(calls.relaunch[0]).toEqual({
176
+ command: "kanna",
177
+ args: ["--port", "4567", "--no-open"],
178
+ })
179
+ })
180
+ })
@@ -0,0 +1,274 @@
1
+ import process from "node:process"
2
+ import { spawn, spawnSync } from "node:child_process"
3
+ import { APP_NAME, CLI_COMMAND, getDataDirDisplay, LOG_PREFIX, PACKAGE_NAME } from "../shared/branding"
4
+ import { PROD_SERVER_PORT } from "../shared/ports"
5
+
6
+ export interface CliOptions {
7
+ port: number
8
+ openBrowser: boolean
9
+ strictPort: boolean
10
+ }
11
+
12
+ export interface StartedCli {
13
+ kind: "started"
14
+ stop: () => Promise<void>
15
+ }
16
+
17
+ export interface ExitedCli {
18
+ kind: "exited"
19
+ code: number
20
+ }
21
+
22
+ export type CliRunResult = StartedCli | ExitedCli
23
+
24
+ export interface CliRuntimeDeps {
25
+ version: string
26
+ startServer: (options: CliOptions) => Promise<{ port: number; stop: () => Promise<void> }>
27
+ fetchLatestVersion: (packageName: string) => Promise<string>
28
+ installLatest: (packageName: string) => boolean
29
+ relaunch: (command: string, args: string[]) => number | null
30
+ openUrl: (url: string) => void
31
+ log: (message: string) => void
32
+ warn: (message: string) => void
33
+ }
34
+
35
+ type ParsedArgs =
36
+ | { kind: "run"; options: CliOptions }
37
+ | { kind: "help" }
38
+ | { kind: "version" }
39
+
40
+ function printHelp() {
41
+ console.log(`${APP_NAME} — local-only project chat UI
42
+
43
+ Usage:
44
+ ${CLI_COMMAND} [options]
45
+
46
+ Options:
47
+ --port <number> Port to listen on (default: ${PROD_SERVER_PORT})
48
+ --strict-port Fail instead of trying another port
49
+ --no-open Don't open browser automatically
50
+ --version Print version and exit
51
+ --help Show this help message`)
52
+ }
53
+
54
+ export function parseArgs(argv: string[]): ParsedArgs {
55
+ let port = PROD_SERVER_PORT
56
+ let openBrowser = true
57
+ let strictPort = false
58
+
59
+ for (let index = 0; index < argv.length; index += 1) {
60
+ const arg = argv[index]
61
+ if (arg === "--version" || arg === "-v") {
62
+ return { kind: "version" }
63
+ }
64
+ if (arg === "--help" || arg === "-h") {
65
+ return { kind: "help" }
66
+ }
67
+ if (arg === "--port") {
68
+ const next = argv[index + 1]
69
+ if (!next) throw new Error("Missing value for --port")
70
+ port = Number(next)
71
+ index += 1
72
+ continue
73
+ }
74
+ if (arg === "--no-open") {
75
+ openBrowser = false
76
+ continue
77
+ }
78
+ if (arg === "--strict-port") {
79
+ strictPort = true
80
+ continue
81
+ }
82
+ if (!arg.startsWith("-")) throw new Error(`Unexpected positional argument: ${arg}`)
83
+ }
84
+
85
+ return {
86
+ kind: "run",
87
+ options: {
88
+ port,
89
+ openBrowser,
90
+ strictPort,
91
+ },
92
+ }
93
+ }
94
+
95
+ export function compareVersions(currentVersion: string, latestVersion: string) {
96
+ const currentParts = normalizeVersion(currentVersion)
97
+ const latestParts = normalizeVersion(latestVersion)
98
+ const length = Math.max(currentParts.length, latestParts.length)
99
+
100
+ for (let index = 0; index < length; index += 1) {
101
+ const current = currentParts[index] ?? 0
102
+ const latest = latestParts[index] ?? 0
103
+ if (current === latest) continue
104
+ return current < latest ? -1 : 1
105
+ }
106
+
107
+ return 0
108
+ }
109
+
110
+ function normalizeVersion(version: string) {
111
+ return version
112
+ .trim()
113
+ .replace(/^v/i, "")
114
+ .split("-")[0]
115
+ .split(".")
116
+ .map((part) => Number.parseInt(part, 10))
117
+ .filter((part) => Number.isFinite(part))
118
+ }
119
+
120
+ async function maybeSelfUpdate(argv: string[], deps: CliRuntimeDeps) {
121
+ deps.log(`${LOG_PREFIX} checking for updates`)
122
+
123
+ let latestVersion: string
124
+ try {
125
+ latestVersion = await deps.fetchLatestVersion(PACKAGE_NAME)
126
+ }
127
+ catch (error) {
128
+ deps.warn(`${LOG_PREFIX} update check failed, continuing current version`)
129
+ if (error instanceof Error && error.message) {
130
+ deps.warn(`${LOG_PREFIX} ${error.message}`)
131
+ }
132
+ return null
133
+ }
134
+
135
+ if (!latestVersion || compareVersions(deps.version, latestVersion) >= 0) {
136
+ return null
137
+ }
138
+
139
+ deps.log(`${LOG_PREFIX} updating to ${latestVersion}`)
140
+ if (!deps.installLatest(PACKAGE_NAME)) {
141
+ deps.warn(`${LOG_PREFIX} update failed, continuing current version`)
142
+ return null
143
+ }
144
+
145
+ deps.log(`${LOG_PREFIX} restarting into updated version`)
146
+ const exitCode = deps.relaunch(CLI_COMMAND, argv)
147
+ if (exitCode === null) {
148
+ deps.warn(`${LOG_PREFIX} restart failed, continuing current version`)
149
+ return null
150
+ }
151
+
152
+ return exitCode
153
+ }
154
+
155
+ export async function runCli(argv: string[], deps: CliRuntimeDeps): Promise<CliRunResult> {
156
+ const parsedArgs = parseArgs(argv)
157
+ if (parsedArgs.kind === "version") {
158
+ deps.log(deps.version)
159
+ return { kind: "exited", code: 0 }
160
+ }
161
+ if (parsedArgs.kind === "help") {
162
+ printHelp()
163
+ return { kind: "exited", code: 0 }
164
+ }
165
+
166
+ const relaunchExitCode = await maybeSelfUpdate(argv, deps)
167
+ if (relaunchExitCode !== null) {
168
+ return { kind: "exited", code: relaunchExitCode }
169
+ }
170
+
171
+ const { port, stop } = await deps.startServer(parsedArgs.options)
172
+ const url = `http://localhost:${port}`
173
+ const launchUrl = `${url}/projects`
174
+
175
+ deps.log(`${LOG_PREFIX} listening on ${url}`)
176
+ deps.log(`${LOG_PREFIX} data dir: ${getDataDirDisplay()}`)
177
+
178
+ if (parsedArgs.options.openBrowser) {
179
+ deps.openUrl(launchUrl)
180
+ }
181
+
182
+ return {
183
+ kind: "started",
184
+ stop,
185
+ }
186
+ }
187
+
188
+ function spawnDetached(command: string, args: string[]) {
189
+ spawn(command, args, { stdio: "ignore", detached: true }).unref()
190
+ }
191
+
192
+ function hasCommand(command: string) {
193
+ const result = spawnSync("sh", ["-lc", `command -v ${command}`], { stdio: "ignore" })
194
+ return result.status === 0
195
+ }
196
+
197
+ function canOpenMacApp(appName: string) {
198
+ const result = spawnSync("open", ["-Ra", appName], { stdio: "ignore" })
199
+ return result.status === 0
200
+ }
201
+
202
+ export function openUrl(url: string) {
203
+ const platform = process.platform
204
+ if (platform === "darwin") {
205
+ const appCandidates = [
206
+ "Google Chrome",
207
+ "Chromium",
208
+ "Brave Browser",
209
+ "Microsoft Edge",
210
+ "Arc",
211
+ ]
212
+
213
+ for (const appName of appCandidates) {
214
+ if (!canOpenMacApp(appName)) continue
215
+ spawnDetached("open", ["-a", appName, "--args", `--app=${url}`])
216
+ console.log(`${LOG_PREFIX} opened in app window via ${appName}`)
217
+ return
218
+ }
219
+
220
+ spawnDetached("open", [url])
221
+ console.log(`${LOG_PREFIX} opened in default browser`)
222
+ return
223
+ }
224
+ if (platform === "win32") {
225
+ const browserCommands = ["chrome", "msedge", "brave", "chromium"]
226
+ for (const command of browserCommands) {
227
+ if (!hasCommand(command)) continue
228
+ spawnDetached(command, [`--app=${url}`])
229
+ console.log(`${LOG_PREFIX} opened in app window via ${command}`)
230
+ return
231
+ }
232
+
233
+ spawnDetached("cmd", ["/c", "start", "", url])
234
+ console.log(`${LOG_PREFIX} opened in default browser`)
235
+ return
236
+ }
237
+
238
+ const browserCommands = ["google-chrome", "chromium", "brave-browser", "microsoft-edge"]
239
+ for (const command of browserCommands) {
240
+ if (!hasCommand(command)) continue
241
+ spawnDetached(command, [`--app=${url}`])
242
+ console.log(`${LOG_PREFIX} opened in app window via ${command}`)
243
+ return
244
+ }
245
+
246
+ spawnDetached("xdg-open", [url])
247
+ console.log(`${LOG_PREFIX} opened in default browser`)
248
+ }
249
+
250
+ export async function fetchLatestPackageVersion(packageName: string) {
251
+ const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`)
252
+ if (!response.ok) {
253
+ throw new Error(`registry returned ${response.status}`)
254
+ }
255
+
256
+ const payload = await response.json() as { version?: unknown }
257
+ if (typeof payload.version !== "string" || !payload.version.trim()) {
258
+ throw new Error("registry response did not include a version")
259
+ }
260
+
261
+ return payload.version
262
+ }
263
+
264
+ export function installLatestPackage(packageName: string) {
265
+ if (!hasCommand("bun")) return false
266
+ const result = spawnSync("bun", ["install", "-g", `${packageName}@latest`], { stdio: "inherit" })
267
+ return result.status === 0
268
+ }
269
+
270
+ export function relaunchCli(command: string, args: string[]) {
271
+ const result = spawnSync(command, args, { stdio: "inherit" })
272
+ if (result.error) return null
273
+ return result.status ?? 0
274
+ }
package/src/server/cli.ts CHANGED
@@ -1,141 +1,34 @@
1
1
  import process from "node:process"
2
- import { spawn, spawnSync } from "node:child_process"
3
- import { APP_NAME, CLI_COMMAND, getDataDirDisplay, LOG_PREFIX } from "../shared/branding"
4
- import { PROD_SERVER_PORT } from "../shared/ports"
2
+ import {
3
+ fetchLatestPackageVersion,
4
+ installLatestPackage,
5
+ openUrl,
6
+ relaunchCli,
7
+ runCli,
8
+ } from "./cli-runtime"
5
9
  import { startKannaServer } from "./server"
6
10
 
7
11
  // Read version from package.json at the package root
8
12
  const pkg = await Bun.file(new URL("../../package.json", import.meta.url)).json()
9
13
  const VERSION: string = pkg.version ?? "0.0.0"
10
14
 
11
- interface CliOptions {
12
- port: number
13
- openBrowser: boolean
14
- }
15
-
16
- function printHelp() {
17
- console.log(`${APP_NAME} — local-only project chat UI
18
-
19
- Usage:
20
- ${CLI_COMMAND} [options]
21
-
22
- Options:
23
- --port <number> Port to listen on (default: ${PROD_SERVER_PORT})
24
- --no-open Don't open browser automatically
25
- --version Print version and exit
26
- --help Show this help message`)
27
- }
28
-
29
- function parseArgs(argv: string[]): CliOptions {
30
- let port = PROD_SERVER_PORT
31
- let openBrowser = true
32
-
33
- for (let index = 0; index < argv.length; index += 1) {
34
- const arg = argv[index]
35
- if (arg === "--version" || arg === "-v") {
36
- console.log(VERSION)
37
- process.exit(0)
38
- }
39
- if (arg === "--help" || arg === "-h") {
40
- printHelp()
41
- process.exit(0)
42
- }
43
- if (arg === "--port") {
44
- const next = argv[index + 1]
45
- if (!next) throw new Error("Missing value for --port")
46
- port = Number(next)
47
- index += 1
48
- continue
49
- }
50
- if (arg === "--no-open") {
51
- openBrowser = false
52
- continue
53
- }
54
- if (!arg.startsWith("-")) throw new Error(`Unexpected positional argument: ${arg}`)
55
- }
56
-
57
- return {
58
- port,
59
- openBrowser,
60
- }
61
- }
62
-
63
- function spawnDetached(command: string, args: string[]) {
64
- spawn(command, args, { stdio: "ignore", detached: true }).unref()
65
- }
66
-
67
- function hasCommand(command: string) {
68
- const result = spawnSync("sh", ["-lc", `command -v ${command}`], { stdio: "ignore" })
69
- return result.status === 0
70
- }
71
-
72
- function canOpenMacApp(appName: string) {
73
- const result = spawnSync("open", ["-Ra", appName], { stdio: "ignore" })
74
- return result.status === 0
75
- }
76
-
77
- function openUrl(url: string) {
78
- const platform = process.platform
79
- if (platform === "darwin") {
80
- const appCandidates = [
81
- "Google Chrome",
82
- "Chromium",
83
- "Brave Browser",
84
- "Microsoft Edge",
85
- "Arc",
86
- ]
87
-
88
- for (const appName of appCandidates) {
89
- if (!canOpenMacApp(appName)) continue
90
- spawnDetached("open", ["-a", appName, "--args", `--app=${url}`])
91
- console.log(`${LOG_PREFIX} opened in app window via ${appName}`)
92
- return
93
- }
94
-
95
- spawnDetached("open", [url])
96
- console.log(`${LOG_PREFIX} opened in default browser`)
97
- return
98
- }
99
- if (platform === "win32") {
100
- const browserCommands = ["chrome", "msedge", "brave", "chromium"]
101
- for (const command of browserCommands) {
102
- if (!hasCommand(command)) continue
103
- spawnDetached(command, [`--app=${url}`])
104
- console.log(`${LOG_PREFIX} opened in app window via ${command}`)
105
- return
106
- }
107
-
108
- spawnDetached("cmd", ["/c", "start", "", url])
109
- console.log(`${LOG_PREFIX} opened in default browser`)
110
- return
111
- }
112
-
113
- const browserCommands = ["google-chrome", "chromium", "brave-browser", "microsoft-edge"]
114
- for (const command of browserCommands) {
115
- if (!hasCommand(command)) continue
116
- spawnDetached(command, [`--app=${url}`])
117
- console.log(`${LOG_PREFIX} opened in app window via ${command}`)
118
- return
119
- }
120
-
121
- spawnDetached("xdg-open", [url])
122
- console.log(`${LOG_PREFIX} opened in default browser`)
123
- }
124
-
125
- const options = parseArgs(process.argv.slice(2))
126
- const { port, stop } = await startKannaServer(options)
127
- const url = `http://localhost:${port}`
128
- const launchUrl = `${url}/projects`
129
-
130
- console.log(`${LOG_PREFIX} listening on ${url}`)
131
- console.log(`${LOG_PREFIX} data dir: ${getDataDirDisplay()}`)
15
+ const result = await runCli(process.argv.slice(2), {
16
+ version: VERSION,
17
+ startServer: startKannaServer,
18
+ fetchLatestVersion: fetchLatestPackageVersion,
19
+ installLatest: installLatestPackage,
20
+ relaunch: relaunchCli,
21
+ openUrl,
22
+ log: console.log,
23
+ warn: console.warn,
24
+ })
132
25
 
133
- if (options.openBrowser) {
134
- openUrl(launchUrl)
26
+ if (result.kind === "exited") {
27
+ process.exit(result.code)
135
28
  }
136
29
 
137
30
  const shutdown = async () => {
138
- await stop()
31
+ await result.stop()
139
32
  process.exit(0)
140
33
  }
141
34