kanna-code 0.12.0 → 0.13.1
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.
- package/bin/kanna +8 -1
- package/dist/client/assets/index-D-HpnCbj.js +518 -0
- package/dist/client/assets/index-DTEcy0lx.css +32 -0
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/server/cli-runtime.test.ts +23 -29
- package/src/server/cli-runtime.ts +29 -15
- package/src/server/cli-supervisor.ts +74 -0
- package/src/server/cli.ts +35 -12
- package/src/server/restart.test.ts +17 -0
- package/src/server/restart.ts +24 -0
- package/src/server/server.ts +17 -1
- package/src/server/update-manager.test.ts +70 -0
- package/src/server/update-manager.ts +204 -0
- package/src/server/ws-router.test.ts +115 -11
- package/src/server/ws-router.ts +61 -0
- package/src/shared/protocol.ts +5 -0
- package/src/shared/types.ts +19 -0
- package/dist/client/assets/index-CMwdm7RL.js +0 -513
- package/dist/client/assets/index-DDfgv9ex.css +0 -32
package/src/server/server.ts
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
|
-
import { APP_NAME } from "../shared/branding"
|
|
2
|
+
import { APP_NAME, getRuntimeProfile } from "../shared/branding"
|
|
3
3
|
import { EventStore } from "./event-store"
|
|
4
4
|
import { AgentCoordinator } from "./agent"
|
|
5
5
|
import { discoverProjects, type DiscoveredProject } from "./discovery"
|
|
6
6
|
import { KeybindingsManager } from "./keybindings"
|
|
7
7
|
import { getMachineDisplayName } from "./machine-name"
|
|
8
8
|
import { TerminalManager } from "./terminal-manager"
|
|
9
|
+
import { UpdateManager } from "./update-manager"
|
|
9
10
|
import { createWsRouter, type ClientState } from "./ws-router"
|
|
10
11
|
|
|
11
12
|
export interface StartKannaServerOptions {
|
|
12
13
|
port?: number
|
|
13
14
|
strictPort?: boolean
|
|
15
|
+
update?: {
|
|
16
|
+
version: string
|
|
17
|
+
fetchLatestVersion: (packageName: string) => Promise<string>
|
|
18
|
+
installLatest: (packageName: string) => boolean
|
|
19
|
+
}
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
@@ -33,6 +39,14 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
33
39
|
const terminals = new TerminalManager()
|
|
34
40
|
const keybindings = new KeybindingsManager()
|
|
35
41
|
await keybindings.initialize()
|
|
42
|
+
const updateManager = options.update
|
|
43
|
+
? new UpdateManager({
|
|
44
|
+
currentVersion: options.update.version,
|
|
45
|
+
fetchLatestVersion: options.update.fetchLatestVersion,
|
|
46
|
+
installLatest: options.update.installLatest,
|
|
47
|
+
devMode: getRuntimeProfile() === "dev",
|
|
48
|
+
})
|
|
49
|
+
: null
|
|
36
50
|
const agent = new AgentCoordinator({
|
|
37
51
|
store,
|
|
38
52
|
onStateChange: () => {
|
|
@@ -47,6 +61,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
47
61
|
refreshDiscovery,
|
|
48
62
|
getDiscoveredProjects: () => discoveredProjects,
|
|
49
63
|
machineDisplayName,
|
|
64
|
+
updateManager,
|
|
50
65
|
})
|
|
51
66
|
|
|
52
67
|
const distDir = path.join(import.meta.dir, "..", "..", "dist", "client")
|
|
@@ -114,6 +129,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
114
129
|
return {
|
|
115
130
|
port: actualPort,
|
|
116
131
|
store,
|
|
132
|
+
updateManager,
|
|
117
133
|
stop: shutdown,
|
|
118
134
|
}
|
|
119
135
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { UpdateManager } from "./update-manager"
|
|
3
|
+
|
|
4
|
+
describe("UpdateManager", () => {
|
|
5
|
+
test("detects available updates", async () => {
|
|
6
|
+
const manager = new UpdateManager({
|
|
7
|
+
currentVersion: "0.12.0",
|
|
8
|
+
fetchLatestVersion: async () => "0.13.0",
|
|
9
|
+
installLatest: () => true,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const snapshot = await manager.checkForUpdates({ force: true })
|
|
13
|
+
|
|
14
|
+
expect(snapshot.status).toBe("available")
|
|
15
|
+
expect(snapshot.updateAvailable).toBe(true)
|
|
16
|
+
expect(snapshot.latestVersion).toBe("0.13.0")
|
|
17
|
+
expect(snapshot.installAction).toBe("restart")
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test("bypasses cache when force is true", async () => {
|
|
21
|
+
let calls = 0
|
|
22
|
+
const manager = new UpdateManager({
|
|
23
|
+
currentVersion: "0.12.0",
|
|
24
|
+
fetchLatestVersion: async () => {
|
|
25
|
+
calls += 1
|
|
26
|
+
return calls === 1 ? "0.12.1" : "0.13.0"
|
|
27
|
+
},
|
|
28
|
+
installLatest: () => true,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
await manager.checkForUpdates()
|
|
32
|
+
await manager.checkForUpdates({ force: true })
|
|
33
|
+
|
|
34
|
+
expect(calls).toBe(2)
|
|
35
|
+
expect(manager.getSnapshot().latestVersion).toBe("0.13.0")
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test("surfaces install failures without clearing the running version", async () => {
|
|
39
|
+
const manager = new UpdateManager({
|
|
40
|
+
currentVersion: "0.12.0",
|
|
41
|
+
fetchLatestVersion: async () => "0.13.0",
|
|
42
|
+
installLatest: () => false,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const result = await manager.installUpdate()
|
|
46
|
+
|
|
47
|
+
expect(result).toEqual({ ok: false, action: "restart" })
|
|
48
|
+
expect(manager.getSnapshot().status).toBe("error")
|
|
49
|
+
expect(manager.getSnapshot().currentVersion).toBe("0.12.0")
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test("always exposes an available reload action in dev mode", async () => {
|
|
53
|
+
const manager = new UpdateManager({
|
|
54
|
+
currentVersion: "0.12.0",
|
|
55
|
+
fetchLatestVersion: async () => "9.9.9",
|
|
56
|
+
installLatest: () => true,
|
|
57
|
+
devMode: true,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
expect(manager.getSnapshot()).toMatchObject({
|
|
61
|
+
status: "available",
|
|
62
|
+
updateAvailable: true,
|
|
63
|
+
installAction: "restart",
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const result = await manager.installUpdate()
|
|
67
|
+
expect(result).toEqual({ ok: true, action: "restart" })
|
|
68
|
+
expect(manager.getSnapshot().status).toBe("restart_pending")
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import type { UpdateSnapshot } from "../shared/types"
|
|
2
|
+
import { PACKAGE_NAME } from "../shared/branding"
|
|
3
|
+
import { compareVersions } from "./cli-runtime"
|
|
4
|
+
|
|
5
|
+
const UPDATE_CACHE_TTL_MS = 5 * 60 * 1000
|
|
6
|
+
|
|
7
|
+
export interface UpdateManagerDeps {
|
|
8
|
+
currentVersion: string
|
|
9
|
+
fetchLatestVersion: (packageName: string) => Promise<string>
|
|
10
|
+
installLatest: (packageName: string) => boolean
|
|
11
|
+
devMode?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface UpdateInstallResult {
|
|
15
|
+
ok: boolean
|
|
16
|
+
action: "restart" | "reload"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class UpdateManager {
|
|
20
|
+
private readonly deps: UpdateManagerDeps
|
|
21
|
+
private readonly listeners = new Set<(snapshot: UpdateSnapshot) => void>()
|
|
22
|
+
private snapshot: UpdateSnapshot
|
|
23
|
+
private checkPromise: Promise<UpdateSnapshot> | null = null
|
|
24
|
+
private installPromise: Promise<UpdateInstallResult> | null = null
|
|
25
|
+
|
|
26
|
+
constructor(deps: UpdateManagerDeps) {
|
|
27
|
+
this.deps = deps
|
|
28
|
+
this.snapshot = {
|
|
29
|
+
currentVersion: deps.currentVersion,
|
|
30
|
+
latestVersion: deps.devMode ? `${deps.currentVersion}-dev` : null,
|
|
31
|
+
status: deps.devMode ? "available" : "idle",
|
|
32
|
+
updateAvailable: Boolean(deps.devMode),
|
|
33
|
+
lastCheckedAt: deps.devMode ? Date.now() : null,
|
|
34
|
+
error: null,
|
|
35
|
+
installAction: "restart",
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
getSnapshot() {
|
|
40
|
+
return this.snapshot
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
onChange(listener: (snapshot: UpdateSnapshot) => void) {
|
|
44
|
+
this.listeners.add(listener)
|
|
45
|
+
return () => {
|
|
46
|
+
this.listeners.delete(listener)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async checkForUpdates(options: { force?: boolean } = {}) {
|
|
51
|
+
if (this.deps.devMode) {
|
|
52
|
+
return this.snapshot
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (this.snapshot.status === "updating" || this.snapshot.status === "restart_pending") {
|
|
56
|
+
return this.snapshot
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (this.checkPromise) {
|
|
60
|
+
return this.checkPromise
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!options.force && this.snapshot.lastCheckedAt && Date.now() - this.snapshot.lastCheckedAt < UPDATE_CACHE_TTL_MS) {
|
|
64
|
+
return this.snapshot
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.setSnapshot({
|
|
68
|
+
...this.snapshot,
|
|
69
|
+
status: "checking",
|
|
70
|
+
error: null,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const checkPromise = this.runCheck()
|
|
74
|
+
this.checkPromise = checkPromise
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
return await checkPromise
|
|
78
|
+
} finally {
|
|
79
|
+
if (this.checkPromise === checkPromise) {
|
|
80
|
+
this.checkPromise = null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async installUpdate(): Promise<UpdateInstallResult> {
|
|
86
|
+
if (this.deps.devMode) {
|
|
87
|
+
this.setSnapshot({
|
|
88
|
+
...this.snapshot,
|
|
89
|
+
status: "updating",
|
|
90
|
+
error: null,
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
this.setSnapshot({
|
|
94
|
+
...this.snapshot,
|
|
95
|
+
status: "restart_pending",
|
|
96
|
+
updateAvailable: false,
|
|
97
|
+
error: null,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
action: "restart",
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (this.snapshot.status === "updating" || this.snapshot.status === "restart_pending") {
|
|
107
|
+
return {
|
|
108
|
+
ok: this.snapshot.updateAvailable,
|
|
109
|
+
action: "restart",
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (this.installPromise) {
|
|
114
|
+
return this.installPromise
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const installPromise = this.runInstall()
|
|
118
|
+
this.installPromise = installPromise
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
return await installPromise
|
|
122
|
+
} finally {
|
|
123
|
+
if (this.installPromise === installPromise) {
|
|
124
|
+
this.installPromise = null
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private async runCheck() {
|
|
130
|
+
try {
|
|
131
|
+
const latestVersion = await this.deps.fetchLatestVersion(PACKAGE_NAME)
|
|
132
|
+
const updateAvailable = compareVersions(this.snapshot.currentVersion, latestVersion) < 0
|
|
133
|
+
const nextSnapshot: UpdateSnapshot = {
|
|
134
|
+
...this.snapshot,
|
|
135
|
+
latestVersion,
|
|
136
|
+
updateAvailable,
|
|
137
|
+
status: updateAvailable ? "available" : "up_to_date",
|
|
138
|
+
lastCheckedAt: Date.now(),
|
|
139
|
+
error: null,
|
|
140
|
+
}
|
|
141
|
+
this.setSnapshot(nextSnapshot)
|
|
142
|
+
return nextSnapshot
|
|
143
|
+
} catch (error) {
|
|
144
|
+
const nextSnapshot: UpdateSnapshot = {
|
|
145
|
+
...this.snapshot,
|
|
146
|
+
status: "error",
|
|
147
|
+
lastCheckedAt: Date.now(),
|
|
148
|
+
error: error instanceof Error ? error.message : String(error),
|
|
149
|
+
}
|
|
150
|
+
this.setSnapshot(nextSnapshot)
|
|
151
|
+
return nextSnapshot
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async runInstall(): Promise<UpdateInstallResult> {
|
|
156
|
+
if (!this.snapshot.updateAvailable) {
|
|
157
|
+
const snapshot = await this.checkForUpdates({ force: true })
|
|
158
|
+
if (!snapshot.updateAvailable) {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
action: "restart",
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
this.setSnapshot({
|
|
167
|
+
...this.snapshot,
|
|
168
|
+
status: "updating",
|
|
169
|
+
error: null,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
const installed = this.deps.installLatest(PACKAGE_NAME)
|
|
173
|
+
if (!installed) {
|
|
174
|
+
this.setSnapshot({
|
|
175
|
+
...this.snapshot,
|
|
176
|
+
status: "error",
|
|
177
|
+
error: "Unable to install the latest version.",
|
|
178
|
+
})
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
action: "restart",
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
this.setSnapshot({
|
|
186
|
+
...this.snapshot,
|
|
187
|
+
currentVersion: this.snapshot.latestVersion ?? this.snapshot.currentVersion,
|
|
188
|
+
status: "restart_pending",
|
|
189
|
+
updateAvailable: false,
|
|
190
|
+
error: null,
|
|
191
|
+
})
|
|
192
|
+
return {
|
|
193
|
+
ok: true,
|
|
194
|
+
action: "restart",
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private setSnapshot(snapshot: UpdateSnapshot) {
|
|
199
|
+
this.snapshot = snapshot
|
|
200
|
+
for (const listener of this.listeners) {
|
|
201
|
+
listener(snapshot)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test"
|
|
2
|
-
import type { KeybindingsSnapshot } from "../shared/types"
|
|
2
|
+
import type { KeybindingsSnapshot, UpdateSnapshot } from "../shared/types"
|
|
3
3
|
import { PROTOCOL_VERSION } from "../shared/types"
|
|
4
4
|
import { createEmptyState } from "./events"
|
|
5
5
|
import { createWsRouter } from "./ws-router"
|
|
@@ -27,6 +27,16 @@ const DEFAULT_KEYBINDINGS_SNAPSHOT: KeybindingsSnapshot = {
|
|
|
27
27
|
filePathDisplay: "~/.kanna/keybindings.json",
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
const DEFAULT_UPDATE_SNAPSHOT: UpdateSnapshot = {
|
|
31
|
+
currentVersion: "0.12.0",
|
|
32
|
+
latestVersion: null,
|
|
33
|
+
status: "idle",
|
|
34
|
+
updateAvailable: false,
|
|
35
|
+
lastCheckedAt: null,
|
|
36
|
+
error: null,
|
|
37
|
+
installAction: "restart",
|
|
38
|
+
}
|
|
39
|
+
|
|
30
40
|
describe("ws-router", () => {
|
|
31
41
|
test("acks system.ping without broadcasting snapshots", () => {
|
|
32
42
|
const router = createWsRouter({
|
|
@@ -43,6 +53,7 @@ describe("ws-router", () => {
|
|
|
43
53
|
refreshDiscovery: async () => [],
|
|
44
54
|
getDiscoveredProjects: () => [],
|
|
45
55
|
machineDisplayName: "Local Machine",
|
|
56
|
+
updateManager: null,
|
|
46
57
|
})
|
|
47
58
|
const ws = new FakeWebSocket()
|
|
48
59
|
|
|
@@ -82,6 +93,7 @@ describe("ws-router", () => {
|
|
|
82
93
|
refreshDiscovery: async () => [],
|
|
83
94
|
getDiscoveredProjects: () => [],
|
|
84
95
|
machineDisplayName: "Local Machine",
|
|
96
|
+
updateManager: null,
|
|
85
97
|
})
|
|
86
98
|
const ws = new FakeWebSocket()
|
|
87
99
|
|
|
@@ -124,6 +136,7 @@ describe("ws-router", () => {
|
|
|
124
136
|
refreshDiscovery: async () => [],
|
|
125
137
|
getDiscoveredProjects: () => [],
|
|
126
138
|
machineDisplayName: "Local Machine",
|
|
139
|
+
updateManager: null,
|
|
127
140
|
})
|
|
128
141
|
const ws = new FakeWebSocket()
|
|
129
142
|
|
|
@@ -188,6 +201,7 @@ describe("ws-router", () => {
|
|
|
188
201
|
refreshDiscovery: async () => [],
|
|
189
202
|
getDiscoveredProjects: () => [],
|
|
190
203
|
machineDisplayName: "Local Machine",
|
|
204
|
+
updateManager: null,
|
|
191
205
|
})
|
|
192
206
|
const ws = new FakeWebSocket()
|
|
193
207
|
|
|
@@ -235,17 +249,107 @@ describe("ws-router", () => {
|
|
|
235
249
|
v: PROTOCOL_VERSION,
|
|
236
250
|
type: "ack",
|
|
237
251
|
id: "keybindings-write-1",
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
252
|
+
result: {
|
|
253
|
+
bindings: {
|
|
254
|
+
toggleEmbeddedTerminal: ["cmd+k"],
|
|
255
|
+
toggleRightSidebar: ["ctrl+shift+b"],
|
|
256
|
+
openInFinder: ["cmd+shift+g"],
|
|
257
|
+
openInEditor: ["cmd+shift+p"],
|
|
258
|
+
addSplitTerminal: ["cmd+alt+j"],
|
|
259
|
+
},
|
|
260
|
+
warning: null,
|
|
261
|
+
filePathDisplay: "~/.kanna/keybindings.json",
|
|
262
|
+
},
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test("subscribes to update snapshots and handles update.check commands", async () => {
|
|
267
|
+
const updateManager = {
|
|
268
|
+
snapshot: { ...DEFAULT_UPDATE_SNAPSHOT },
|
|
269
|
+
getSnapshot() {
|
|
270
|
+
return this.snapshot
|
|
271
|
+
},
|
|
272
|
+
onChange: () => () => {},
|
|
273
|
+
async checkForUpdates({ force }: { force?: boolean }) {
|
|
274
|
+
this.snapshot = {
|
|
275
|
+
...this.snapshot,
|
|
276
|
+
latestVersion: force ? "0.13.0" : "0.12.1",
|
|
277
|
+
status: "available",
|
|
278
|
+
updateAvailable: true,
|
|
279
|
+
lastCheckedAt: 123,
|
|
280
|
+
}
|
|
281
|
+
return this.snapshot
|
|
282
|
+
},
|
|
283
|
+
async installUpdate() {
|
|
284
|
+
return true
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const router = createWsRouter({
|
|
289
|
+
store: { state: createEmptyState() } as never,
|
|
290
|
+
agent: { getActiveStatuses: () => new Map() } as never,
|
|
291
|
+
terminals: {
|
|
292
|
+
getSnapshot: () => null,
|
|
293
|
+
onEvent: () => () => {},
|
|
294
|
+
} as never,
|
|
295
|
+
keybindings: {
|
|
296
|
+
getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
|
|
297
|
+
onChange: () => () => {},
|
|
298
|
+
} as never,
|
|
299
|
+
refreshDiscovery: async () => [],
|
|
300
|
+
getDiscoveredProjects: () => [],
|
|
301
|
+
machineDisplayName: "Local Machine",
|
|
302
|
+
updateManager: updateManager as never,
|
|
303
|
+
})
|
|
304
|
+
const ws = new FakeWebSocket()
|
|
305
|
+
|
|
306
|
+
router.handleMessage(
|
|
307
|
+
ws as never,
|
|
308
|
+
JSON.stringify({
|
|
309
|
+
v: 1,
|
|
310
|
+
type: "subscribe",
|
|
311
|
+
id: "update-sub-1",
|
|
312
|
+
topic: { type: "update" },
|
|
313
|
+
})
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
expect(ws.sent[0]).toEqual({
|
|
317
|
+
v: PROTOCOL_VERSION,
|
|
318
|
+
type: "snapshot",
|
|
319
|
+
id: "update-sub-1",
|
|
320
|
+
snapshot: {
|
|
321
|
+
type: "update",
|
|
322
|
+
data: DEFAULT_UPDATE_SNAPSHOT,
|
|
323
|
+
},
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
router.handleMessage(
|
|
327
|
+
ws as never,
|
|
328
|
+
JSON.stringify({
|
|
329
|
+
v: 1,
|
|
330
|
+
type: "command",
|
|
331
|
+
id: "update-check-1",
|
|
332
|
+
command: {
|
|
333
|
+
type: "update.check",
|
|
334
|
+
force: true,
|
|
248
335
|
},
|
|
249
336
|
})
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
await Promise.resolve()
|
|
340
|
+
expect(ws.sent[1]).toEqual({
|
|
341
|
+
v: PROTOCOL_VERSION,
|
|
342
|
+
type: "ack",
|
|
343
|
+
id: "update-check-1",
|
|
344
|
+
result: {
|
|
345
|
+
currentVersion: "0.12.0",
|
|
346
|
+
latestVersion: "0.13.0",
|
|
347
|
+
status: "available",
|
|
348
|
+
updateAvailable: true,
|
|
349
|
+
lastCheckedAt: 123,
|
|
350
|
+
error: null,
|
|
351
|
+
installAction: "restart",
|
|
352
|
+
},
|
|
353
|
+
})
|
|
250
354
|
})
|
|
251
355
|
})
|
package/src/server/ws-router.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { openExternal } from "./external-open"
|
|
|
9
9
|
import { KeybindingsManager } from "./keybindings"
|
|
10
10
|
import { ensureProjectDirectory } from "./paths"
|
|
11
11
|
import { TerminalManager } from "./terminal-manager"
|
|
12
|
+
import type { UpdateManager } from "./update-manager"
|
|
12
13
|
import { deriveChatSnapshot, deriveLocalProjectsSnapshot, deriveSidebarData } from "./read-models"
|
|
13
14
|
|
|
14
15
|
export interface ClientState {
|
|
@@ -23,6 +24,7 @@ interface CreateWsRouterArgs {
|
|
|
23
24
|
refreshDiscovery: () => Promise<DiscoveredProject[]>
|
|
24
25
|
getDiscoveredProjects: () => DiscoveredProject[]
|
|
25
26
|
machineDisplayName: string
|
|
27
|
+
updateManager: UpdateManager | null
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
function send(ws: ServerWebSocket<ClientState>, message: ServerEnvelope) {
|
|
@@ -37,6 +39,7 @@ export function createWsRouter({
|
|
|
37
39
|
refreshDiscovery,
|
|
38
40
|
getDiscoveredProjects,
|
|
39
41
|
machineDisplayName,
|
|
42
|
+
updateManager,
|
|
40
43
|
}: CreateWsRouterArgs) {
|
|
41
44
|
const sockets = new Set<ServerWebSocket<ClientState>>()
|
|
42
45
|
|
|
@@ -80,6 +83,26 @@ export function createWsRouter({
|
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
if (topic.type === "update") {
|
|
87
|
+
return {
|
|
88
|
+
v: PROTOCOL_VERSION,
|
|
89
|
+
type: "snapshot",
|
|
90
|
+
id,
|
|
91
|
+
snapshot: {
|
|
92
|
+
type: "update",
|
|
93
|
+
data: updateManager?.getSnapshot() ?? {
|
|
94
|
+
currentVersion: "unknown",
|
|
95
|
+
latestVersion: null,
|
|
96
|
+
status: "idle",
|
|
97
|
+
updateAvailable: false,
|
|
98
|
+
lastCheckedAt: null,
|
|
99
|
+
error: null,
|
|
100
|
+
installAction: "restart",
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
83
106
|
if (topic.type === "terminal") {
|
|
84
107
|
return {
|
|
85
108
|
v: PROTOCOL_VERSION,
|
|
@@ -151,6 +174,15 @@ export function createWsRouter({
|
|
|
151
174
|
}
|
|
152
175
|
})
|
|
153
176
|
|
|
177
|
+
const disposeUpdateEvents = updateManager?.onChange(() => {
|
|
178
|
+
for (const ws of sockets) {
|
|
179
|
+
for (const [id, topic] of ws.data.subscriptions.entries()) {
|
|
180
|
+
if (topic.type !== "update") continue
|
|
181
|
+
send(ws, createEnvelope(id, topic))
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}) ?? (() => {})
|
|
185
|
+
|
|
154
186
|
async function handleCommand(ws: ServerWebSocket<ClientState>, message: Extract<ClientEnvelope, { type: "command" }>) {
|
|
155
187
|
const { command, id } = message
|
|
156
188
|
try {
|
|
@@ -159,6 +191,34 @@ export function createWsRouter({
|
|
|
159
191
|
send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
|
|
160
192
|
return
|
|
161
193
|
}
|
|
194
|
+
case "update.check": {
|
|
195
|
+
const snapshot = updateManager
|
|
196
|
+
? await updateManager.checkForUpdates({ force: command.force })
|
|
197
|
+
: {
|
|
198
|
+
currentVersion: "unknown",
|
|
199
|
+
latestVersion: null,
|
|
200
|
+
status: "error",
|
|
201
|
+
updateAvailable: false,
|
|
202
|
+
lastCheckedAt: Date.now(),
|
|
203
|
+
error: "Update manager unavailable.",
|
|
204
|
+
installAction: "restart",
|
|
205
|
+
}
|
|
206
|
+
send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
case "update.install": {
|
|
210
|
+
if (!updateManager) {
|
|
211
|
+
throw new Error("Update manager unavailable.")
|
|
212
|
+
}
|
|
213
|
+
const result = await updateManager.installUpdate()
|
|
214
|
+
send(ws, {
|
|
215
|
+
v: PROTOCOL_VERSION,
|
|
216
|
+
type: "ack",
|
|
217
|
+
id,
|
|
218
|
+
result,
|
|
219
|
+
})
|
|
220
|
+
return
|
|
221
|
+
}
|
|
162
222
|
case "settings.readKeybindings": {
|
|
163
223
|
send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: keybindings.getSnapshot() })
|
|
164
224
|
return
|
|
@@ -316,6 +376,7 @@ export function createWsRouter({
|
|
|
316
376
|
dispose() {
|
|
317
377
|
disposeTerminalEvents()
|
|
318
378
|
disposeKeybindingEvents()
|
|
379
|
+
disposeUpdateEvents()
|
|
319
380
|
},
|
|
320
381
|
}
|
|
321
382
|
}
|
package/src/shared/protocol.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
LocalProjectsSnapshot,
|
|
6
6
|
ModelOptions,
|
|
7
7
|
SidebarData,
|
|
8
|
+
UpdateSnapshot,
|
|
8
9
|
} from "./types"
|
|
9
10
|
|
|
10
11
|
export type EditorPreset = "cursor" | "vscode" | "windsurf" | "custom"
|
|
@@ -17,6 +18,7 @@ export interface EditorOpenSettings {
|
|
|
17
18
|
export type SubscriptionTopic =
|
|
18
19
|
| { type: "sidebar" }
|
|
19
20
|
| { type: "local-projects" }
|
|
21
|
+
| { type: "update" }
|
|
20
22
|
| { type: "keybindings" }
|
|
21
23
|
| { type: "chat"; chatId: string }
|
|
22
24
|
| { type: "terminal"; terminalId: string }
|
|
@@ -44,6 +46,8 @@ export type ClientCommand =
|
|
|
44
46
|
| { type: "project.create"; localPath: string; title: string }
|
|
45
47
|
| { type: "project.remove"; projectId: string }
|
|
46
48
|
| { type: "system.ping" }
|
|
49
|
+
| { type: "update.check"; force?: boolean }
|
|
50
|
+
| { type: "update.install" }
|
|
47
51
|
| { type: "settings.readKeybindings" }
|
|
48
52
|
| { type: "settings.writeKeybindings"; bindings: KeybindingsSnapshot["bindings"] }
|
|
49
53
|
| {
|
|
@@ -83,6 +87,7 @@ export type ClientEnvelope =
|
|
|
83
87
|
export type ServerSnapshot =
|
|
84
88
|
| { type: "sidebar"; data: SidebarData }
|
|
85
89
|
| { type: "local-projects"; data: LocalProjectsSnapshot }
|
|
90
|
+
| { type: "update"; data: UpdateSnapshot }
|
|
86
91
|
| { type: "keybindings"; data: KeybindingsSnapshot }
|
|
87
92
|
| { type: "chat"; data: ChatSnapshot | null }
|
|
88
93
|
| { type: "terminal"; data: TerminalSnapshot | null }
|
package/src/shared/types.ts
CHANGED
|
@@ -167,6 +167,25 @@ export interface LocalProjectsSnapshot {
|
|
|
167
167
|
projects: LocalProjectSummary[]
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
export type UpdateStatus =
|
|
171
|
+
| "idle"
|
|
172
|
+
| "checking"
|
|
173
|
+
| "available"
|
|
174
|
+
| "up_to_date"
|
|
175
|
+
| "updating"
|
|
176
|
+
| "restart_pending"
|
|
177
|
+
| "error"
|
|
178
|
+
|
|
179
|
+
export interface UpdateSnapshot {
|
|
180
|
+
currentVersion: string
|
|
181
|
+
latestVersion: string | null
|
|
182
|
+
status: UpdateStatus
|
|
183
|
+
updateAvailable: boolean
|
|
184
|
+
lastCheckedAt: number | null
|
|
185
|
+
error: string | null
|
|
186
|
+
installAction: "restart" | "reload"
|
|
187
|
+
}
|
|
188
|
+
|
|
170
189
|
export type KeybindingAction =
|
|
171
190
|
| "toggleEmbeddedTerminal"
|
|
172
191
|
| "toggleRightSidebar"
|