kanna-code 0.13.3 → 0.13.5

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.
@@ -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-DH02pJTN.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-DTEcy0lx.css">
8
+ <script type="module" crossorigin src="/assets/index-CK9rdSSm.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-CA-kJR8F.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.13.3",
4
+ "version": "0.13.5",
5
5
  "description": "A beautiful web UI for Claude Code",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -1,7 +1,9 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test"
2
- import { compareVersions, parseArgs, runCli } from "./cli-runtime"
2
+ import { compareVersions, classifyInstallVersionFailure, parseArgs, runCli } from "./cli-runtime"
3
+ import { CLI_SUPPRESS_OPEN_ONCE_ENV_VAR } from "./restart"
3
4
 
4
5
  const originalRuntimeProfile = process.env.KANNA_RUNTIME_PROFILE
6
+ const originalSuppressOpen = process.env[CLI_SUPPRESS_OPEN_ONCE_ENV_VAR]
5
7
 
6
8
  afterEach(() => {
7
9
  if (originalRuntimeProfile === undefined) {
@@ -9,6 +11,11 @@ afterEach(() => {
9
11
  } else {
10
12
  process.env.KANNA_RUNTIME_PROFILE = originalRuntimeProfile
11
13
  }
14
+ if (originalSuppressOpen === undefined) {
15
+ delete process.env[CLI_SUPPRESS_OPEN_ONCE_ENV_VAR]
16
+ } else {
17
+ process.env[CLI_SUPPRESS_OPEN_ONCE_ENV_VAR] = originalSuppressOpen
18
+ }
12
19
  })
13
20
 
14
21
  function createDeps(overrides: Partial<Parameters<typeof runCli>[1]> = {}) {
@@ -46,7 +53,12 @@ function createDeps(overrides: Partial<Parameters<typeof runCli>[1]> = {}) {
46
53
  },
47
54
  installVersion: (packageName, version) => {
48
55
  calls.installVersion.push({ packageName, version })
49
- return true
56
+ return {
57
+ ok: true,
58
+ errorCode: null,
59
+ userTitle: null,
60
+ userMessage: null,
61
+ }
50
62
  },
51
63
  openUrl: (url) => {
52
64
  calls.openUrl.push(url)
@@ -100,6 +112,17 @@ describe("compareVersions", () => {
100
112
  })
101
113
  })
102
114
 
115
+ describe("classifyInstallVersionFailure", () => {
116
+ test("maps version propagation failures to a user-facing retry message", () => {
117
+ expect(classifyInstallVersionFailure('error: No version matching "0.13.3" found for specifier "kanna-code"')).toEqual({
118
+ ok: false,
119
+ errorCode: "version_not_live_yet",
120
+ userTitle: "Update not live yet",
121
+ userMessage: "This update is still propagating. Try again in a few minutes.",
122
+ })
123
+ })
124
+ })
125
+
103
126
  describe("runCli", () => {
104
127
  test("skips update checks for --version", async () => {
105
128
  const { calls, deps } = createDeps()
@@ -164,6 +187,15 @@ describe("runCli", () => {
164
187
  expect(calls.openUrl).toEqual(["http://localhost:4000"])
165
188
  })
166
189
 
190
+ test("suppresses browser open for a ui-triggered restarted child", async () => {
191
+ process.env[CLI_SUPPRESS_OPEN_ONCE_ENV_VAR] = "1"
192
+ const { calls, deps } = createDeps()
193
+
194
+ await runCli(["--port", "4000"], deps)
195
+
196
+ expect(calls.openUrl).toEqual([])
197
+ })
198
+
167
199
  test("returns restarting when a newer version is available", async () => {
168
200
  const { calls, deps } = createDeps({
169
201
  fetchLatestVersion: async (packageName) => {
@@ -174,7 +206,7 @@ describe("runCli", () => {
174
206
 
175
207
  const result = await runCli(["--port", "4000", "--no-open"], deps)
176
208
 
177
- expect(result).toEqual({ kind: "restarting" })
209
+ expect(result).toEqual({ kind: "restarting", reason: "startup_update" })
178
210
  expect(calls.installVersion).toEqual([{ packageName: "kanna-code", version: "0.4.0" }])
179
211
  expect(calls.startServer).toEqual([])
180
212
  })
@@ -187,7 +219,12 @@ describe("runCli", () => {
187
219
  },
188
220
  installVersion: (packageName, version) => {
189
221
  calls.installVersion.push({ packageName, version })
190
- return false
222
+ return {
223
+ ok: false,
224
+ errorCode: "install_failed",
225
+ userTitle: "Update failed",
226
+ userMessage: "Kanna could not install the update. Try again later.",
227
+ }
191
228
  },
192
229
  })
193
230
 
@@ -2,7 +2,9 @@ import process from "node:process"
2
2
  import { spawnSync } from "node:child_process"
3
3
  import { hasCommand, spawnDetached } from "./process-utils"
4
4
  import { APP_NAME, CLI_COMMAND, getDataDirDisplay, LOG_PREFIX, PACKAGE_NAME } from "../shared/branding"
5
+ import type { UpdateInstallErrorCode } from "../shared/types"
5
6
  import { PROD_SERVER_PORT } from "../shared/ports"
7
+ import { CLI_SUPPRESS_OPEN_ONCE_ENV_VAR } from "./restart"
6
8
 
7
9
  export interface CliOptions {
8
10
  port: number
@@ -13,7 +15,7 @@ export interface CliOptions {
13
15
  export interface CliUpdateOptions {
14
16
  version: string
15
17
  fetchLatestVersion: (packageName: string) => Promise<string>
16
- installVersion: (packageName: string, version: string) => boolean
18
+ installVersion: (packageName: string, version: string) => UpdateInstallAttemptResult
17
19
  argv: string[]
18
20
  command: string
19
21
  }
@@ -25,6 +27,7 @@ export interface StartedCli {
25
27
 
26
28
  export interface RestartingCli {
27
29
  kind: "restarting"
30
+ reason: "startup_update" | "ui_update"
28
31
  }
29
32
 
30
33
  export interface ExitedCli {
@@ -39,12 +42,19 @@ export interface CliRuntimeDeps {
39
42
  bunVersion: string
40
43
  startServer: (options: CliOptions & { update: CliUpdateOptions }) => Promise<{ port: number; stop: () => Promise<void> }>
41
44
  fetchLatestVersion: (packageName: string) => Promise<string>
42
- installVersion: (packageName: string, version: string) => boolean
45
+ installVersion: (packageName: string, version: string) => UpdateInstallAttemptResult
43
46
  openUrl: (url: string) => void
44
47
  log: (message: string) => void
45
48
  warn: (message: string) => void
46
49
  }
47
50
 
51
+ export interface UpdateInstallAttemptResult {
52
+ ok: boolean
53
+ errorCode: UpdateInstallErrorCode | null
54
+ userTitle: string | null
55
+ userMessage: string | null
56
+ }
57
+
48
58
  type ParsedArgs =
49
59
  | { kind: "run"; options: CliOptions }
50
60
  | { kind: "help" }
@@ -156,13 +166,17 @@ async function maybeSelfUpdate(_argv: string[], deps: CliRuntimeDeps) {
156
166
  }
157
167
 
158
168
  deps.log(`${LOG_PREFIX} installing ${PACKAGE_NAME}@${latestVersion}`)
159
- if (!deps.installVersion(PACKAGE_NAME, latestVersion)) {
169
+ const installResult = deps.installVersion(PACKAGE_NAME, latestVersion)
170
+ if (!installResult.ok) {
160
171
  deps.warn(`${LOG_PREFIX} update failed, continuing current version`)
172
+ if (installResult.userMessage) {
173
+ deps.warn(`${LOG_PREFIX} ${installResult.userMessage}`)
174
+ }
161
175
  return null
162
176
  }
163
177
 
164
178
  deps.log(`${LOG_PREFIX} restarting into updated version`)
165
- return "restart"
179
+ return "startup_update"
166
180
  }
167
181
 
168
182
  export async function runCli(argv: string[], deps: CliRuntimeDeps): Promise<CliRunResult> {
@@ -183,7 +197,7 @@ export async function runCli(argv: string[], deps: CliRuntimeDeps): Promise<CliR
183
197
 
184
198
  const shouldRestart = await maybeSelfUpdate(argv, deps)
185
199
  if (shouldRestart !== null) {
186
- return { kind: "restarting" }
200
+ return { kind: "restarting", reason: shouldRestart }
187
201
  }
188
202
 
189
203
  const { port, stop } = await deps.startServer({
@@ -202,7 +216,8 @@ export async function runCli(argv: string[], deps: CliRuntimeDeps): Promise<CliR
202
216
  deps.log(`${LOG_PREFIX} listening on ${url}`)
203
217
  deps.log(`${LOG_PREFIX} data dir: ${getDataDirDisplay()}`)
204
218
 
205
- if (parsedArgs.options.openBrowser) {
219
+ const suppressOpenBrowser = process.env[CLI_SUPPRESS_OPEN_ONCE_ENV_VAR] === "1"
220
+ if (parsedArgs.options.openBrowser && !suppressOpenBrowser) {
206
221
  deps.openUrl(launchUrl)
207
222
  }
208
223
 
@@ -238,8 +253,51 @@ export async function fetchLatestPackageVersion(packageName: string) {
238
253
  return payload.version
239
254
  }
240
255
 
256
+ export function classifyInstallVersionFailure(output: string): UpdateInstallAttemptResult {
257
+ const normalizedOutput = output.trim()
258
+ if (/No version matching .* found|failed to resolve/i.test(normalizedOutput)) {
259
+ return {
260
+ ok: false,
261
+ errorCode: "version_not_live_yet",
262
+ userTitle: "Update not live yet",
263
+ userMessage: "This update is still propagating. Try again in a few minutes.",
264
+ }
265
+ }
266
+
267
+ return {
268
+ ok: false,
269
+ errorCode: "install_failed",
270
+ userTitle: "Update failed",
271
+ userMessage: "Kanna could not install the update. Try again later.",
272
+ }
273
+ }
274
+
241
275
  export function installPackageVersion(packageName: string, version: string) {
242
- if (!hasCommand("bun")) return false
243
- const result = spawnSync("bun", ["install", "-g", `${packageName}@${version}`], { stdio: "inherit" })
244
- return result.status === 0
276
+ if (!hasCommand("bun")) {
277
+ return {
278
+ ok: false,
279
+ errorCode: "command_missing",
280
+ userTitle: "Bun not found",
281
+ userMessage: "Kanna could not find Bun to install the update.",
282
+ } satisfies UpdateInstallAttemptResult
283
+ }
284
+
285
+ const result = spawnSync("bun", ["install", "-g", `${packageName}@${version}`], {
286
+ stdio: ["ignore", "pipe", "pipe"],
287
+ encoding: "utf8",
288
+ })
289
+ const stdout = result.stdout ?? ""
290
+ const stderr = result.stderr ?? ""
291
+ if (stdout) process.stdout.write(stdout)
292
+ if (stderr) process.stderr.write(stderr)
293
+ if (result.status === 0) {
294
+ return {
295
+ ok: true,
296
+ errorCode: null,
297
+ userTitle: null,
298
+ userMessage: null,
299
+ } satisfies UpdateInstallAttemptResult
300
+ }
301
+
302
+ return classifyInstallVersionFailure(`${stdout}\n${stderr}`)
245
303
  }
@@ -6,6 +6,8 @@ import {
6
6
  CLI_CHILD_COMMAND_ENV_VAR,
7
7
  CLI_CHILD_MODE,
8
8
  CLI_CHILD_MODE_ENV_VAR,
9
+ CLI_SUPPRESS_OPEN_ONCE_ENV_VAR,
10
+ isUiUpdateRestart,
9
11
  parseChildArgsEnv,
10
12
  shouldRestartCliProcess,
11
13
  } from "./restart"
@@ -23,12 +25,15 @@ function getChildProcessSpec() {
23
25
 
24
26
  function spawnChild(argv: string[]) {
25
27
  const childProcess = getChildProcessSpec()
28
+ const suppressOpenThisChild = suppressOpenOnNextChild
29
+ suppressOpenOnNextChild = false
26
30
  return new Promise<ChildExit>((resolve, reject) => {
27
31
  const child = spawn(childProcess.command, [...childProcess.args, ...argv], {
28
32
  stdio: "inherit",
29
33
  env: {
30
34
  ...process.env,
31
35
  [CLI_CHILD_MODE_ENV_VAR]: CLI_CHILD_MODE,
36
+ ...(suppressOpenThisChild ? { [CLI_SUPPRESS_OPEN_ONCE_ENV_VAR]: "1" } : {}),
32
37
  },
33
38
  })
34
39
 
@@ -62,10 +67,12 @@ function spawnChild(argv: string[]) {
62
67
  }
63
68
 
64
69
  const argv = process.argv.slice(2)
70
+ let suppressOpenOnNextChild = false
65
71
 
66
72
  while (true) {
67
73
  const result = await spawnChild(argv)
68
74
  if (shouldRestartCliProcess(result.code, result.signal)) {
75
+ suppressOpenOnNextChild = isUiUpdateRestart(result.code, result.signal)
69
76
  console.log(`${LOG_PREFIX} supervisor restarting ${CLI_COMMAND} in the same terminal session`)
70
77
  continue
71
78
  }
package/src/server/cli.ts CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  openUrl,
7
7
  runCli,
8
8
  } from "./cli-runtime"
9
- import { CLI_RESTART_EXIT_CODE } from "./restart"
9
+ import { CLI_STARTUP_UPDATE_RESTART_EXIT_CODE, CLI_UI_UPDATE_RESTART_EXIT_CODE } from "./restart"
10
10
  import { startKannaServer } from "./server"
11
11
 
12
12
  // Read version from package.json at the package root
@@ -14,7 +14,7 @@ const pkg = await Bun.file(new URL("../../package.json", import.meta.url)).json(
14
14
  const VERSION: string = pkg.version ?? "0.0.0"
15
15
 
16
16
  const argv = process.argv.slice(2)
17
- let resolveExitAction: ((action: "restart" | "exit") => void) | null = null
17
+ let resolveExitAction: ((action: "ui_restart" | "exit") => void) | null = null
18
18
 
19
19
  const result = await runCli(argv, {
20
20
  version: VERSION,
@@ -25,7 +25,7 @@ const result = await runCli(argv, {
25
25
  started.updateManager.onChange((snapshot) => {
26
26
  if (snapshot.status !== "restart_pending") return
27
27
  console.log(`${LOG_PREFIX} update installed, shutting down current process for restart`)
28
- resolveExitAction?.("restart")
28
+ resolveExitAction?.("ui_restart")
29
29
  })
30
30
  }
31
31
 
@@ -43,10 +43,10 @@ if (result.kind === "exited") {
43
43
  }
44
44
 
45
45
  if (result.kind === "restarting") {
46
- process.exit(CLI_RESTART_EXIT_CODE)
46
+ process.exit(result.reason === "startup_update" ? CLI_STARTUP_UPDATE_RESTART_EXIT_CODE : CLI_UI_UPDATE_RESTART_EXIT_CODE)
47
47
  }
48
48
 
49
- const exitAction = await new Promise<"restart" | "exit">((resolve) => {
49
+ const exitAction = await new Promise<"ui_restart" | "exit">((resolve) => {
50
50
  resolveExitAction = resolve
51
51
 
52
52
  const shutdown = () => {
@@ -58,7 +58,7 @@ const exitAction = await new Promise<"restart" | "exit">((resolve) => {
58
58
  })
59
59
 
60
60
  await result.stop()
61
- if (exitAction === "restart") {
61
+ if (exitAction === "ui_restart") {
62
62
  console.log(`${LOG_PREFIX} current process stopped, handing restart back to supervisor`)
63
63
  }
64
- process.exit(exitAction === "restart" ? CLI_RESTART_EXIT_CODE : 0)
64
+ process.exit(exitAction === "ui_restart" ? CLI_UI_UPDATE_RESTART_EXIT_CODE : 0)
@@ -1,12 +1,22 @@
1
1
  import { describe, expect, test } from "bun:test"
2
- import { CLI_CHILD_ARGS_ENV_VAR, CLI_RESTART_EXIT_CODE, parseChildArgsEnv, shouldRestartCliProcess } from "./restart"
2
+ import {
3
+ CLI_CHILD_ARGS_ENV_VAR,
4
+ CLI_STARTUP_UPDATE_RESTART_EXIT_CODE,
5
+ CLI_UI_UPDATE_RESTART_EXIT_CODE,
6
+ isUiUpdateRestart,
7
+ parseChildArgsEnv,
8
+ shouldRestartCliProcess,
9
+ } from "./restart"
3
10
 
4
11
  describe("shouldRestartCliProcess", () => {
5
12
  test("restarts only for the sentinel exit code without a signal", () => {
6
- expect(shouldRestartCliProcess(CLI_RESTART_EXIT_CODE, null)).toBe(true)
13
+ expect(shouldRestartCliProcess(CLI_STARTUP_UPDATE_RESTART_EXIT_CODE, null)).toBe(true)
14
+ expect(shouldRestartCliProcess(CLI_UI_UPDATE_RESTART_EXIT_CODE, null)).toBe(true)
7
15
  expect(shouldRestartCliProcess(0, null)).toBe(false)
8
16
  expect(shouldRestartCliProcess(1, null)).toBe(false)
9
- expect(shouldRestartCliProcess(CLI_RESTART_EXIT_CODE, "SIGTERM")).toBe(false)
17
+ expect(shouldRestartCliProcess(CLI_STARTUP_UPDATE_RESTART_EXIT_CODE, "SIGTERM")).toBe(false)
18
+ expect(isUiUpdateRestart(CLI_UI_UPDATE_RESTART_EXIT_CODE, null)).toBe(true)
19
+ expect(isUiUpdateRestart(CLI_STARTUP_UPDATE_RESTART_EXIT_CODE, null)).toBe(false)
10
20
  })
11
21
 
12
22
  test("parses configured child args from the environment", () => {
@@ -1,11 +1,17 @@
1
1
  export const CLI_CHILD_MODE_ENV_VAR = "KANNA_CLI_MODE"
2
2
  export const CLI_CHILD_MODE = "child"
3
- export const CLI_RESTART_EXIT_CODE = 75
3
+ export const CLI_STARTUP_UPDATE_RESTART_EXIT_CODE = 75
4
+ export const CLI_UI_UPDATE_RESTART_EXIT_CODE = 76
4
5
  export const CLI_CHILD_COMMAND_ENV_VAR = "KANNA_CLI_CHILD_COMMAND"
5
6
  export const CLI_CHILD_ARGS_ENV_VAR = "KANNA_CLI_CHILD_ARGS"
7
+ export const CLI_SUPPRESS_OPEN_ONCE_ENV_VAR = "KANNA_SUPPRESS_OPEN_ONCE"
6
8
 
7
9
  export function shouldRestartCliProcess(code: number | null, signal: NodeJS.Signals | null) {
8
- return signal === null && code === CLI_RESTART_EXIT_CODE
10
+ return signal === null && (code === CLI_STARTUP_UPDATE_RESTART_EXIT_CODE || code === CLI_UI_UPDATE_RESTART_EXIT_CODE)
11
+ }
12
+
13
+ export function isUiUpdateRestart(code: number | null, signal: NodeJS.Signals | null) {
14
+ return signal === null && code === CLI_UI_UPDATE_RESTART_EXIT_CODE
9
15
  }
10
16
 
11
17
  export function parseChildArgsEnv(value: string | undefined) {
@@ -7,6 +7,7 @@ import { KeybindingsManager } from "./keybindings"
7
7
  import { getMachineDisplayName } from "./machine-name"
8
8
  import { TerminalManager } from "./terminal-manager"
9
9
  import { UpdateManager } from "./update-manager"
10
+ import type { UpdateInstallAttemptResult } from "./cli-runtime"
10
11
  import { createWsRouter, type ClientState } from "./ws-router"
11
12
 
12
13
  export interface StartKannaServerOptions {
@@ -15,7 +16,7 @@ export interface StartKannaServerOptions {
15
16
  update?: {
16
17
  version: string
17
18
  fetchLatestVersion: (packageName: string) => Promise<string>
18
- installVersion: (packageName: string, version: string) => boolean
19
+ installVersion: (packageName: string, version: string) => UpdateInstallAttemptResult
19
20
  }
20
21
  }
21
22
 
@@ -6,7 +6,12 @@ describe("UpdateManager", () => {
6
6
  const manager = new UpdateManager({
7
7
  currentVersion: "0.12.0",
8
8
  fetchLatestVersion: async () => "0.13.0",
9
- installVersion: () => true,
9
+ installVersion: () => ({
10
+ ok: true,
11
+ errorCode: null,
12
+ userTitle: null,
13
+ userMessage: null,
14
+ }),
10
15
  })
11
16
 
12
17
  const snapshot = await manager.checkForUpdates({ force: true })
@@ -25,7 +30,12 @@ describe("UpdateManager", () => {
25
30
  calls += 1
26
31
  return calls === 1 ? "0.12.1" : "0.13.0"
27
32
  },
28
- installVersion: () => true,
33
+ installVersion: () => ({
34
+ ok: true,
35
+ errorCode: null,
36
+ userTitle: null,
37
+ userMessage: null,
38
+ }),
29
39
  })
30
40
 
31
41
  await manager.checkForUpdates()
@@ -42,13 +52,24 @@ describe("UpdateManager", () => {
42
52
  fetchLatestVersion: async () => "0.13.0",
43
53
  installVersion: (_packageName, version) => {
44
54
  installedVersion = version
45
- return false
55
+ return {
56
+ ok: false,
57
+ errorCode: "version_not_live_yet",
58
+ userTitle: "Update not live yet",
59
+ userMessage: "This update is still propagating. Try again in a few minutes.",
60
+ }
46
61
  },
47
62
  })
48
63
 
49
64
  const result = await manager.installUpdate()
50
65
 
51
- expect(result).toEqual({ ok: false, action: "restart" })
66
+ expect(result).toEqual({
67
+ ok: false,
68
+ action: "restart",
69
+ errorCode: "version_not_live_yet",
70
+ userTitle: "Update not live yet",
71
+ userMessage: "This update is still propagating. Try again in a few minutes.",
72
+ })
52
73
  expect(installedVersion === "0.13.0").toBe(true)
53
74
  expect(manager.getSnapshot().status).toBe("error")
54
75
  expect(manager.getSnapshot().currentVersion).toBe("0.12.0")
@@ -58,7 +79,12 @@ describe("UpdateManager", () => {
58
79
  const manager = new UpdateManager({
59
80
  currentVersion: "0.12.0",
60
81
  fetchLatestVersion: async () => "9.9.9",
61
- installVersion: () => true,
82
+ installVersion: () => ({
83
+ ok: true,
84
+ errorCode: null,
85
+ userTitle: null,
86
+ userMessage: null,
87
+ }),
62
88
  devMode: true,
63
89
  })
64
90
 
@@ -69,7 +95,13 @@ describe("UpdateManager", () => {
69
95
  })
70
96
 
71
97
  const result = await manager.installUpdate()
72
- expect(result).toEqual({ ok: true, action: "restart" })
98
+ expect(result).toEqual({
99
+ ok: true,
100
+ action: "restart",
101
+ errorCode: null,
102
+ userTitle: null,
103
+ userMessage: null,
104
+ })
73
105
  expect(manager.getSnapshot().status).toBe("restart_pending")
74
106
  })
75
107
  })
@@ -1,21 +1,16 @@
1
- import type { UpdateSnapshot } from "../shared/types"
1
+ import type { UpdateInstallResult, UpdateSnapshot } from "../shared/types"
2
2
  import { PACKAGE_NAME } from "../shared/branding"
3
- import { compareVersions } from "./cli-runtime"
3
+ import { compareVersions, type UpdateInstallAttemptResult } from "./cli-runtime"
4
4
 
5
5
  const UPDATE_CACHE_TTL_MS = 5 * 60 * 1000
6
6
 
7
7
  export interface UpdateManagerDeps {
8
8
  currentVersion: string
9
9
  fetchLatestVersion: (packageName: string) => Promise<string>
10
- installVersion: (packageName: string, version: string) => boolean
10
+ installVersion: (packageName: string, version: string) => UpdateInstallAttemptResult
11
11
  devMode?: boolean
12
12
  }
13
13
 
14
- export interface UpdateInstallResult {
15
- ok: boolean
16
- action: "restart" | "reload"
17
- }
18
-
19
14
  export class UpdateManager {
20
15
  private readonly deps: UpdateManagerDeps
21
16
  private readonly listeners = new Set<(snapshot: UpdateSnapshot) => void>()
@@ -100,6 +95,9 @@ export class UpdateManager {
100
95
  return {
101
96
  ok: true,
102
97
  action: "restart",
98
+ errorCode: null,
99
+ userTitle: null,
100
+ userMessage: null,
103
101
  }
104
102
  }
105
103
 
@@ -107,6 +105,9 @@ export class UpdateManager {
107
105
  return {
108
106
  ok: this.snapshot.updateAvailable,
109
107
  action: "restart",
108
+ errorCode: null,
109
+ userTitle: null,
110
+ userMessage: null,
110
111
  }
111
112
  }
112
113
 
@@ -159,6 +160,9 @@ export class UpdateManager {
159
160
  return {
160
161
  ok: false,
161
162
  action: "restart",
163
+ errorCode: null,
164
+ userTitle: null,
165
+ userMessage: null,
162
166
  }
163
167
  }
164
168
  }
@@ -179,19 +183,25 @@ export class UpdateManager {
179
183
  return {
180
184
  ok: false,
181
185
  action: "restart",
186
+ errorCode: "install_failed",
187
+ userTitle: "Update failed",
188
+ userMessage: "Kanna could not determine which version to install.",
182
189
  }
183
190
  }
184
191
 
185
192
  const installed = this.deps.installVersion(PACKAGE_NAME, targetVersion)
186
- if (!installed) {
193
+ if (!installed.ok) {
187
194
  this.setSnapshot({
188
195
  ...this.snapshot,
189
196
  status: "error",
190
- error: "Unable to install the latest version.",
197
+ error: installed.userMessage ?? "Unable to install the latest version.",
191
198
  })
192
199
  return {
193
200
  ok: false,
194
201
  action: "restart",
202
+ errorCode: installed.errorCode,
203
+ userTitle: installed.userTitle,
204
+ userMessage: installed.userMessage,
195
205
  }
196
206
  }
197
207
 
@@ -205,6 +215,9 @@ export class UpdateManager {
205
215
  return {
206
216
  ok: true,
207
217
  action: "restart",
218
+ errorCode: null,
219
+ userTitle: null,
220
+ userMessage: null,
208
221
  }
209
222
  }
210
223
 
@@ -281,7 +281,13 @@ describe("ws-router", () => {
281
281
  return this.snapshot
282
282
  },
283
283
  async installUpdate() {
284
- return true
284
+ return {
285
+ ok: false,
286
+ action: "restart",
287
+ errorCode: "version_not_live_yet",
288
+ userTitle: "Update not live yet",
289
+ userMessage: "This update is still propagating. Try again in a few minutes.",
290
+ }
285
291
  },
286
292
  }
287
293
 
@@ -351,5 +357,31 @@ describe("ws-router", () => {
351
357
  installAction: "restart",
352
358
  },
353
359
  })
360
+
361
+ router.handleMessage(
362
+ ws as never,
363
+ JSON.stringify({
364
+ v: 1,
365
+ type: "command",
366
+ id: "update-install-1",
367
+ command: {
368
+ type: "update.install",
369
+ },
370
+ })
371
+ )
372
+
373
+ await Promise.resolve()
374
+ expect(ws.sent[2]).toEqual({
375
+ v: PROTOCOL_VERSION,
376
+ type: "ack",
377
+ id: "update-install-1",
378
+ result: {
379
+ ok: false,
380
+ action: "restart",
381
+ errorCode: "version_not_live_yet",
382
+ userTitle: "Update not live yet",
383
+ userMessage: "This update is still propagating. Try again in a few minutes.",
384
+ },
385
+ })
354
386
  })
355
387
  })
@@ -186,6 +186,19 @@ export interface UpdateSnapshot {
186
186
  installAction: "restart" | "reload"
187
187
  }
188
188
 
189
+ export type UpdateInstallErrorCode =
190
+ | "version_not_live_yet"
191
+ | "install_failed"
192
+ | "command_missing"
193
+
194
+ export interface UpdateInstallResult {
195
+ ok: boolean
196
+ action: "restart" | "reload"
197
+ errorCode: UpdateInstallErrorCode | null
198
+ userTitle: string | null
199
+ userMessage: string | null
200
+ }
201
+
189
202
  export type KeybindingAction =
190
203
  | "toggleEmbeddedTerminal"
191
204
  | "toggleRightSidebar"