kaizenai 0.1.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.
Files changed (74) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +246 -0
  3. package/bin/kaizen +15 -0
  4. package/dist/client/apple-touch-icon.png +0 -0
  5. package/dist/client/assets/index-D-ORCGrq.js +603 -0
  6. package/dist/client/assets/index-r28mcHqz.css +32 -0
  7. package/dist/client/favicon.png +0 -0
  8. package/dist/client/fonts/body-medium.woff2 +0 -0
  9. package/dist/client/fonts/body-regular-italic.woff2 +0 -0
  10. package/dist/client/fonts/body-regular.woff2 +0 -0
  11. package/dist/client/fonts/body-semibold.woff2 +0 -0
  12. package/dist/client/index.html +22 -0
  13. package/dist/client/manifest-dark.webmanifest +24 -0
  14. package/dist/client/manifest.webmanifest +24 -0
  15. package/dist/client/pwa-192.png +0 -0
  16. package/dist/client/pwa-512.png +0 -0
  17. package/dist/client/pwa-icon.svg +15 -0
  18. package/dist/client/pwa-splash.png +0 -0
  19. package/dist/client/pwa-splash.svg +15 -0
  20. package/package.json +103 -0
  21. package/src/server/acp-shared.ts +315 -0
  22. package/src/server/agent.ts +1120 -0
  23. package/src/server/attachments.ts +133 -0
  24. package/src/server/backgrounds.ts +74 -0
  25. package/src/server/cli-runtime.ts +333 -0
  26. package/src/server/cli-supervisor.ts +81 -0
  27. package/src/server/cli.ts +68 -0
  28. package/src/server/codex-app-server-protocol.ts +453 -0
  29. package/src/server/codex-app-server.ts +1350 -0
  30. package/src/server/cursor-acp.ts +819 -0
  31. package/src/server/discovery.ts +322 -0
  32. package/src/server/event-store.ts +1369 -0
  33. package/src/server/events.ts +244 -0
  34. package/src/server/external-open.ts +272 -0
  35. package/src/server/gemini-acp.ts +844 -0
  36. package/src/server/gemini-cli.ts +525 -0
  37. package/src/server/generate-title.ts +36 -0
  38. package/src/server/git-manager.ts +79 -0
  39. package/src/server/git-repository.ts +101 -0
  40. package/src/server/harness-types.ts +20 -0
  41. package/src/server/keybindings.ts +177 -0
  42. package/src/server/machine-name.ts +22 -0
  43. package/src/server/paths.ts +112 -0
  44. package/src/server/process-utils.ts +22 -0
  45. package/src/server/project-icon.ts +344 -0
  46. package/src/server/project-metadata.ts +10 -0
  47. package/src/server/provider-catalog.ts +85 -0
  48. package/src/server/provider-settings.ts +155 -0
  49. package/src/server/quick-response.ts +153 -0
  50. package/src/server/read-models.ts +275 -0
  51. package/src/server/recovery.ts +507 -0
  52. package/src/server/restart.ts +30 -0
  53. package/src/server/server.ts +244 -0
  54. package/src/server/terminal-manager.ts +350 -0
  55. package/src/server/theme-settings.ts +179 -0
  56. package/src/server/update-manager.ts +230 -0
  57. package/src/server/usage/base-provider-usage.ts +57 -0
  58. package/src/server/usage/claude-usage.ts +558 -0
  59. package/src/server/usage/codex-usage.ts +144 -0
  60. package/src/server/usage/cursor-browser.ts +120 -0
  61. package/src/server/usage/cursor-cookies.ts +390 -0
  62. package/src/server/usage/cursor-usage.ts +490 -0
  63. package/src/server/usage/gemini-usage.ts +24 -0
  64. package/src/server/usage/provider-usage.ts +61 -0
  65. package/src/server/usage/test-helpers.ts +9 -0
  66. package/src/server/usage/types.ts +54 -0
  67. package/src/server/usage/utils.ts +325 -0
  68. package/src/server/ws-router.ts +717 -0
  69. package/src/shared/branding.ts +83 -0
  70. package/src/shared/dev-ports.ts +43 -0
  71. package/src/shared/ports.ts +2 -0
  72. package/src/shared/protocol.ts +152 -0
  73. package/src/shared/tools.ts +251 -0
  74. package/src/shared/types.ts +1028 -0
@@ -0,0 +1,133 @@
1
+ import path from "node:path"
2
+ import { mkdir, writeFile } from "node:fs/promises"
3
+ import {
4
+ MAX_CHAT_ATTACHMENTS,
5
+ MAX_CHAT_IMAGE_BYTES,
6
+ SUPPORTED_CHAT_IMAGE_MIME_TYPES,
7
+ type ChatAttachment,
8
+ type ChatAttachmentUpload,
9
+ type ChatImageAttachment,
10
+ type UserPromptEntry,
11
+ } from "../shared/types"
12
+
13
+ export const ATTACHMENTS_ROUTE_PREFIX = "/attachments"
14
+ export const MAX_CHAT_IMAGE_DATA_URL_CHARS = 14_000_000
15
+ export const SUPPORTED_CHAT_IMAGE_MIME_TYPES_SET = new Set(SUPPORTED_CHAT_IMAGE_MIME_TYPES)
16
+
17
+ const EXTENSIONS_BY_MIME_TYPE: Record<string, string> = {
18
+ "image/gif": ".gif",
19
+ "image/jpeg": ".jpg",
20
+ "image/png": ".png",
21
+ "image/webp": ".webp",
22
+ }
23
+
24
+ export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null {
25
+ const normalized = path.normalize(rawRelativePath).replace(/^[/\\]+/, "")
26
+ if (!normalized || normalized.startsWith("..") || normalized.includes("\0")) {
27
+ return null
28
+ }
29
+ return normalized.replace(/\\/g, "/")
30
+ }
31
+
32
+ export function resolveAttachmentPath(attachmentsDir: string, relativePath: string): string | null {
33
+ const normalizedRelativePath = normalizeAttachmentRelativePath(relativePath)
34
+ if (!normalizedRelativePath) return null
35
+
36
+ const attachmentsRoot = path.resolve(attachmentsDir)
37
+ const filePath = path.resolve(path.join(attachmentsRoot, normalizedRelativePath))
38
+ if (!filePath.startsWith(`${attachmentsRoot}${path.sep}`)) {
39
+ return null
40
+ }
41
+
42
+ return filePath
43
+ }
44
+
45
+ export function buildAttachmentPreviewUrl(relativePath: string): string {
46
+ return `${ATTACHMENTS_ROUTE_PREFIX}/${relativePath.split("/").map(encodeURIComponent).join("/")}`
47
+ }
48
+
49
+ function parseBase64DataUrl(dataUrl: string): { mimeType: string; bytes: Buffer } | null {
50
+ const match = /^data:([^,;]+)(?:;charset=[^,;]+)?;base64,([a-z0-9+/=\r\n ]+)$/i.exec(dataUrl.trim())
51
+ if (!match) return null
52
+
53
+ const mimeType = match[1].toLowerCase()
54
+ const base64 = match[2].replace(/\s+/g, "")
55
+ try {
56
+ return {
57
+ mimeType,
58
+ bytes: Buffer.from(base64, "base64"),
59
+ }
60
+ } catch {
61
+ return null
62
+ }
63
+ }
64
+
65
+ function extensionForMimeType(mimeType: string): string | null {
66
+ return EXTENSIONS_BY_MIME_TYPE[mimeType] ?? null
67
+ }
68
+
69
+ export async function persistChatAttachments(input: {
70
+ attachmentsDir: string
71
+ chatId: string
72
+ messageEntry: UserPromptEntry
73
+ uploads: ChatAttachmentUpload[] | undefined
74
+ }): Promise<ChatAttachment[] | undefined> {
75
+ const uploads = input.uploads ?? []
76
+ if (uploads.length === 0) return undefined
77
+ if (uploads.length > MAX_CHAT_ATTACHMENTS) {
78
+ throw new Error(`Too many image attachments. Maximum is ${MAX_CHAT_ATTACHMENTS}.`)
79
+ }
80
+
81
+ const persisted: ChatImageAttachment[] = []
82
+
83
+ for (const [index, attachment] of uploads.entries()) {
84
+ if (attachment.type !== "image") {
85
+ throw new Error("Unsupported attachment type.")
86
+ }
87
+ if (!attachment.name.trim()) {
88
+ throw new Error("Attachment name is required.")
89
+ }
90
+ if (!attachment.mimeType.trim() || !SUPPORTED_CHAT_IMAGE_MIME_TYPES_SET.has(attachment.mimeType.toLowerCase() as typeof SUPPORTED_CHAT_IMAGE_MIME_TYPES[number])) {
91
+ throw new Error(`Unsupported image type: ${attachment.mimeType}`)
92
+ }
93
+ if (attachment.sizeBytes <= 0 || attachment.sizeBytes > MAX_CHAT_IMAGE_BYTES) {
94
+ throw new Error(`Image attachment '${attachment.name}' is empty or too large.`)
95
+ }
96
+ if (!attachment.dataUrl.trim() || attachment.dataUrl.length > MAX_CHAT_IMAGE_DATA_URL_CHARS) {
97
+ throw new Error(`Image attachment '${attachment.name}' payload is invalid or too large.`)
98
+ }
99
+
100
+ const parsed = parseBase64DataUrl(attachment.dataUrl)
101
+ if (!parsed || parsed.mimeType !== attachment.mimeType.toLowerCase()) {
102
+ throw new Error(`Invalid image attachment payload for '${attachment.name}'.`)
103
+ }
104
+ if (parsed.bytes.byteLength !== attachment.sizeBytes) {
105
+ throw new Error(`Image attachment '${attachment.name}' size did not match payload.`)
106
+ }
107
+
108
+ const extension = extensionForMimeType(parsed.mimeType)
109
+ if (!extension) {
110
+ throw new Error(`Unsupported image type: ${attachment.mimeType}`)
111
+ }
112
+
113
+ const relativePath = `${input.chatId}/${input.messageEntry._id}/${index}${extension}`
114
+ const filePath = resolveAttachmentPath(input.attachmentsDir, relativePath)
115
+ if (!filePath) {
116
+ throw new Error(`Failed to resolve persisted path for '${attachment.name}'.`)
117
+ }
118
+
119
+ await mkdir(path.dirname(filePath), { recursive: true })
120
+ await writeFile(filePath, parsed.bytes)
121
+
122
+ persisted.push({
123
+ type: "image",
124
+ id: `${input.messageEntry._id}:${index}`,
125
+ name: attachment.name.trim(),
126
+ mimeType: parsed.mimeType,
127
+ sizeBytes: parsed.bytes.byteLength,
128
+ relativePath,
129
+ })
130
+ }
131
+
132
+ return persisted
133
+ }
@@ -0,0 +1,74 @@
1
+ import fs from "node:fs/promises"
2
+ import path from "node:path"
3
+ import os from "node:os"
4
+
5
+ const BACKGROUND_DIRS = [
6
+ path.join(os.homedir(), ".local/share/backgrounds"),
7
+ "/usr/share/backgrounds",
8
+ "/Library/Desktop Pictures",
9
+ path.join(os.homedir(), "Pictures"),
10
+ process.platform === "win32" ? "C:\\Windows\\Web\\Wallpaper" : "",
11
+ ].filter(Boolean)
12
+
13
+ export interface SystemBackground {
14
+ id: string
15
+ name: string
16
+ url: string
17
+ }
18
+
19
+ export async function getSystemBackgrounds(): Promise<SystemBackground[]> {
20
+ const backgrounds: SystemBackground[] = []
21
+ const seen = new Set<string>()
22
+
23
+ async function scanDir(dir: string, depth = 0) {
24
+ if (depth > 3) return // Prevent excessive recursion depth
25
+
26
+ try {
27
+ const entries = await fs.readdir(dir, { withFileTypes: true })
28
+
29
+ for (const entry of entries) {
30
+ if (entry.isDirectory()) {
31
+ await scanDir(path.join(dir, entry.name), depth + 1)
32
+ } else if (entry.isFile()) {
33
+ const ext = path.extname(entry.name).toLowerCase()
34
+ if (ext === ".jpg" || ext === ".jpeg" || ext === ".png" || ext === ".webp") {
35
+ const fullPath = path.join(dir, entry.name)
36
+ if (seen.has(fullPath)) continue
37
+ seen.add(fullPath)
38
+
39
+ const id = Buffer.from(fullPath).toString('base64url')
40
+ backgrounds.push({
41
+ id,
42
+ name: entry.name,
43
+ url: `/api/backgrounds/${id}`
44
+ })
45
+ }
46
+ }
47
+ }
48
+ } catch (err) {
49
+ // Ignore if directory doesn't exist or is inaccessible
50
+ }
51
+ }
52
+
53
+ for (const dir of BACKGROUND_DIRS) {
54
+ if (!dir) continue
55
+ await scanDir(dir)
56
+ }
57
+
58
+ return backgrounds
59
+ }
60
+
61
+ export async function resolveBackgroundPath(id: string): Promise<string | null> {
62
+ try {
63
+ const fullPath = Buffer.from(id, 'base64url').toString('utf-8')
64
+ const isAllowed = BACKGROUND_DIRS.some(dir => dir && fullPath.startsWith(dir))
65
+ if (!isAllowed) return null
66
+
67
+ const stats = await fs.stat(fullPath)
68
+ if (!stats.isFile()) return null
69
+
70
+ return fullPath
71
+ } catch {
72
+ return null
73
+ }
74
+ }
@@ -0,0 +1,333 @@
1
+ import process from "node:process"
2
+ import { spawnSync } from "node:child_process"
3
+ import { hasCommand, spawnDetached } from "./process-utils"
4
+ import {
5
+ APP_NAME,
6
+ CLI_COMMAND,
7
+ DISABLE_SELF_UPDATE_ENV_VAR,
8
+ getDataDirDisplay,
9
+ LOG_PREFIX,
10
+ PACKAGE_NAME,
11
+ } from "../shared/branding"
12
+ import type { UpdateInstallErrorCode } from "../shared/types"
13
+ import { PROD_SERVER_PORT } from "../shared/ports"
14
+ import { CLI_SUPPRESS_OPEN_ONCE_ENV_VAR } from "./restart"
15
+
16
+ export interface CliOptions {
17
+ port: number
18
+ host: string
19
+ openBrowser: boolean
20
+ strictPort: boolean
21
+ }
22
+
23
+ export interface CliUpdateOptions {
24
+ version: string
25
+ fetchLatestVersion: (packageName: string) => Promise<string>
26
+ installVersion: (packageName: string, version: string) => UpdateInstallAttemptResult
27
+ argv: string[]
28
+ command: string
29
+ }
30
+
31
+ export interface StartedCli {
32
+ kind: "started"
33
+ stop: () => Promise<void>
34
+ }
35
+
36
+ export interface RestartingCli {
37
+ kind: "restarting"
38
+ reason: "startup_update" | "ui_update"
39
+ }
40
+
41
+ export interface ExitedCli {
42
+ kind: "exited"
43
+ code: number
44
+ }
45
+
46
+ export type CliRunResult = StartedCli | RestartingCli | ExitedCli
47
+
48
+ export interface CliRuntimeDeps {
49
+ version: string
50
+ bunVersion: string
51
+ allowSelfUpdate?: boolean
52
+ startServer: (options: CliOptions & {
53
+ update: CliUpdateOptions
54
+ onMigrationProgress?: (message: string) => void
55
+ }) => Promise<{ port: number; stop: () => Promise<void> }>
56
+ fetchLatestVersion: (packageName: string) => Promise<string>
57
+ installVersion: (packageName: string, version: string) => UpdateInstallAttemptResult
58
+ openUrl: (url: string) => void
59
+ log: (message: string) => void
60
+ warn: (message: string) => void
61
+ }
62
+
63
+ export interface UpdateInstallAttemptResult {
64
+ ok: boolean
65
+ errorCode: UpdateInstallErrorCode | null
66
+ userTitle: string | null
67
+ userMessage: string | null
68
+ }
69
+
70
+ type ParsedArgs =
71
+ | { kind: "run"; options: CliOptions }
72
+ | { kind: "help" }
73
+ | { kind: "version" }
74
+
75
+ const MINIMUM_BUN_VERSION = "1.3.5"
76
+
77
+ function printHelp() {
78
+ console.log(`${APP_NAME} — local-only project chat UI
79
+
80
+ Usage:
81
+ ${CLI_COMMAND} [options]
82
+
83
+ Options:
84
+ --port <number> Port to listen on (default: ${PROD_SERVER_PORT})
85
+ --host <host> Bind to a specific host or IP
86
+ --remote Shortcut for --host 0.0.0.0
87
+ --strict-port Fail instead of trying another port
88
+ --no-open Don't open browser automatically
89
+ --version Print version and exit
90
+ --help Show this help message`)
91
+ }
92
+
93
+ export function parseArgs(argv: string[]): ParsedArgs {
94
+ let port = PROD_SERVER_PORT
95
+ let host = "127.0.0.1"
96
+ let openBrowser = true
97
+ let strictPort = false
98
+
99
+ for (let index = 0; index < argv.length; index += 1) {
100
+ const arg = argv[index]
101
+ if (arg === "--version" || arg === "-v") {
102
+ return { kind: "version" }
103
+ }
104
+ if (arg === "--help" || arg === "-h") {
105
+ return { kind: "help" }
106
+ }
107
+ if (arg === "--port") {
108
+ const next = argv[index + 1]
109
+ if (!next) throw new Error("Missing value for --port")
110
+ port = Number(next)
111
+ index += 1
112
+ continue
113
+ }
114
+ if (arg === "--host") {
115
+ const next = argv[index + 1]
116
+ if (!next || next.startsWith("-")) throw new Error("Missing value for --host")
117
+ host = next
118
+ index += 1
119
+ continue
120
+ }
121
+ if (arg === "--remote") {
122
+ host = "0.0.0.0"
123
+ continue
124
+ }
125
+ if (arg === "--no-open") {
126
+ openBrowser = false
127
+ continue
128
+ }
129
+ if (arg === "--strict-port") {
130
+ strictPort = true
131
+ continue
132
+ }
133
+ if (!arg.startsWith("-")) throw new Error(`Unexpected positional argument: ${arg}`)
134
+ }
135
+
136
+ return {
137
+ kind: "run",
138
+ options: {
139
+ port,
140
+ host,
141
+ openBrowser,
142
+ strictPort,
143
+ },
144
+ }
145
+ }
146
+
147
+ export function compareVersions(currentVersion: string, latestVersion: string) {
148
+ const currentParts = normalizeVersion(currentVersion)
149
+ const latestParts = normalizeVersion(latestVersion)
150
+ const length = Math.max(currentParts.length, latestParts.length)
151
+
152
+ for (let index = 0; index < length; index += 1) {
153
+ const current = currentParts[index] ?? 0
154
+ const latest = latestParts[index] ?? 0
155
+ if (current === latest) continue
156
+ return current < latest ? -1 : 1
157
+ }
158
+
159
+ return 0
160
+ }
161
+
162
+ function normalizeVersion(version: string) {
163
+ return version
164
+ .trim()
165
+ .replace(/^v/i, "")
166
+ .split("-")[0]
167
+ .split(".")
168
+ .map((part) => Number.parseInt(part, 10))
169
+ .filter((part) => Number.isFinite(part))
170
+ }
171
+
172
+ async function maybeSelfUpdate(_argv: string[], deps: CliRuntimeDeps) {
173
+ if (process.env[DISABLE_SELF_UPDATE_ENV_VAR] === "1") {
174
+ return null
175
+ }
176
+
177
+ deps.log(`${LOG_PREFIX} checking for updates`)
178
+
179
+ let latestVersion: string
180
+ try {
181
+ latestVersion = await deps.fetchLatestVersion(PACKAGE_NAME)
182
+ }
183
+ catch (error) {
184
+ deps.warn(`${LOG_PREFIX} update check failed, continuing current version`)
185
+ if (error instanceof Error && error.message) {
186
+ deps.warn(`${LOG_PREFIX} ${error.message}`)
187
+ }
188
+ return null
189
+ }
190
+
191
+ if (!latestVersion || compareVersions(deps.version, latestVersion) >= 0) {
192
+ return null
193
+ }
194
+
195
+ deps.log(`${LOG_PREFIX} installing ${PACKAGE_NAME}@${latestVersion}`)
196
+ const installResult = deps.installVersion(PACKAGE_NAME, latestVersion)
197
+ if (!installResult.ok) {
198
+ deps.warn(`${LOG_PREFIX} update failed, continuing current version`)
199
+ if (installResult.userMessage) {
200
+ deps.warn(`${LOG_PREFIX} ${installResult.userMessage}`)
201
+ }
202
+ return null
203
+ }
204
+
205
+ deps.log(`${LOG_PREFIX} restarting into updated version`)
206
+ return "startup_update"
207
+ }
208
+
209
+ export async function runCli(argv: string[], deps: CliRuntimeDeps): Promise<CliRunResult> {
210
+ const parsedArgs = parseArgs(argv)
211
+ if (parsedArgs.kind === "version") {
212
+ deps.log(deps.version)
213
+ return { kind: "exited", code: 0 }
214
+ }
215
+ if (parsedArgs.kind === "help") {
216
+ printHelp()
217
+ return { kind: "exited", code: 0 }
218
+ }
219
+ if (compareVersions(deps.bunVersion, MINIMUM_BUN_VERSION) < 0) {
220
+ deps.warn(`${LOG_PREFIX} Bun ${MINIMUM_BUN_VERSION}+ is required for the embedded terminal. Current Bun: ${deps.bunVersion}`)
221
+ return { kind: "exited", code: 1 }
222
+ }
223
+
224
+ if (deps.allowSelfUpdate !== false) {
225
+ const shouldRestart = await maybeSelfUpdate(argv, deps)
226
+ if (shouldRestart !== null) {
227
+ return { kind: "restarting", reason: shouldRestart }
228
+ }
229
+ }
230
+
231
+ const { port, stop } = await deps.startServer({
232
+ ...parsedArgs.options,
233
+ onMigrationProgress: deps.log,
234
+ update: {
235
+ version: deps.version,
236
+ fetchLatestVersion: deps.fetchLatestVersion,
237
+ installVersion: deps.installVersion,
238
+ argv,
239
+ command: CLI_COMMAND,
240
+ },
241
+ })
242
+ const bindHost = parsedArgs.options.host
243
+ const displayHost = bindHost === "127.0.0.1" || bindHost === "0.0.0.0" ? "localhost" : bindHost
244
+ const launchUrl = `http://${displayHost}:${port}`
245
+
246
+ deps.log(`${LOG_PREFIX} listening on http://${bindHost}:${port}`)
247
+ deps.log(`${LOG_PREFIX} data dir: ${getDataDirDisplay()}`)
248
+
249
+ const suppressOpenBrowser = process.env[CLI_SUPPRESS_OPEN_ONCE_ENV_VAR] === "1"
250
+ if (parsedArgs.options.openBrowser && !suppressOpenBrowser) {
251
+ deps.openUrl(launchUrl)
252
+ }
253
+
254
+ return {
255
+ kind: "started",
256
+ stop,
257
+ }
258
+ }
259
+
260
+ export function openUrl(url: string) {
261
+ const platform = process.platform
262
+ if (platform === "darwin") {
263
+ spawnDetached("open", [url])
264
+ } else if (platform === "win32") {
265
+ spawnDetached("cmd", ["/c", "start", "", url])
266
+ } else {
267
+ spawnDetached("xdg-open", [url])
268
+ }
269
+ console.log(`${LOG_PREFIX} opened in default browser`)
270
+ }
271
+
272
+ export async function fetchLatestPackageVersion(packageName: string) {
273
+ const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`)
274
+ if (!response.ok) {
275
+ throw new Error(`registry returned ${response.status}`)
276
+ }
277
+
278
+ const payload = await response.json() as { version?: unknown }
279
+ if (typeof payload.version !== "string" || !payload.version.trim()) {
280
+ throw new Error("registry response did not include a version")
281
+ }
282
+
283
+ return payload.version
284
+ }
285
+
286
+ export function classifyInstallVersionFailure(output: string): UpdateInstallAttemptResult {
287
+ const normalizedOutput = output.trim()
288
+ if (/No version matching .* found|failed to resolve/i.test(normalizedOutput)) {
289
+ return {
290
+ ok: false,
291
+ errorCode: "version_not_live_yet",
292
+ userTitle: "Update not live yet",
293
+ userMessage: "This update is still propagating. Try again in a few minutes.",
294
+ }
295
+ }
296
+
297
+ return {
298
+ ok: false,
299
+ errorCode: "install_failed",
300
+ userTitle: "Update failed",
301
+ userMessage: "Kaizen could not install the update. Try again later.",
302
+ }
303
+ }
304
+
305
+ export function installPackageVersion(packageName: string, version: string) {
306
+ if (!hasCommand("bun")) {
307
+ return {
308
+ ok: false,
309
+ errorCode: "command_missing",
310
+ userTitle: "Bun not found",
311
+ userMessage: "Kaizen could not find Bun to install the update.",
312
+ } satisfies UpdateInstallAttemptResult
313
+ }
314
+
315
+ const result = spawnSync("bun", ["install", "-g", `${packageName}@${version}`], {
316
+ stdio: ["ignore", "pipe", "pipe"],
317
+ encoding: "utf8",
318
+ })
319
+ const stdout = result.stdout ?? ""
320
+ const stderr = result.stderr ?? ""
321
+ if (stdout) process.stdout.write(stdout)
322
+ if (stderr) process.stderr.write(stderr)
323
+ if (result.status === 0) {
324
+ return {
325
+ ok: true,
326
+ errorCode: null,
327
+ userTitle: null,
328
+ userMessage: null,
329
+ } satisfies UpdateInstallAttemptResult
330
+ }
331
+
332
+ return classifyInstallVersionFailure(`${stdout}\n${stderr}`)
333
+ }
@@ -0,0 +1,81 @@
1
+ import process from "node:process"
2
+ import { spawn } from "node:child_process"
3
+ import { CLI_COMMAND, LOG_PREFIX } from "../shared/branding"
4
+ import {
5
+ CLI_CHILD_ARGS_ENV_VAR,
6
+ CLI_CHILD_COMMAND_ENV_VAR,
7
+ CLI_CHILD_MODE,
8
+ CLI_CHILD_MODE_ENV_VAR,
9
+ CLI_SUPPRESS_OPEN_ONCE_ENV_VAR,
10
+ isUiUpdateRestart,
11
+ parseChildArgsEnv,
12
+ shouldRestartCliProcess,
13
+ } from "./restart"
14
+
15
+ interface ChildExit {
16
+ code: number | null
17
+ signal: NodeJS.Signals | null
18
+ }
19
+
20
+ function getChildProcessSpec() {
21
+ const command = process.env[CLI_CHILD_COMMAND_ENV_VAR] || CLI_COMMAND
22
+ const args = parseChildArgsEnv(process.env[CLI_CHILD_ARGS_ENV_VAR])
23
+ return { command, args }
24
+ }
25
+
26
+ function spawnChild(argv: string[]) {
27
+ const childProcess = getChildProcessSpec()
28
+ const suppressOpenThisChild = suppressOpenOnNextChild
29
+ suppressOpenOnNextChild = false
30
+ return new Promise<ChildExit>((resolve, reject) => {
31
+ const child = spawn(childProcess.command, [...childProcess.args, ...argv], {
32
+ stdio: "inherit",
33
+ env: {
34
+ ...process.env,
35
+ [CLI_CHILD_MODE_ENV_VAR]: CLI_CHILD_MODE,
36
+ ...(suppressOpenThisChild ? { [CLI_SUPPRESS_OPEN_ONCE_ENV_VAR]: "1" } : {}),
37
+ },
38
+ })
39
+
40
+ const forwardSignal = (signal: NodeJS.Signals) => {
41
+ if (child.exitCode !== null) return
42
+ child.kill(signal)
43
+ }
44
+
45
+ const onSigint = () => {
46
+ forwardSignal("SIGINT")
47
+ }
48
+ const onSigterm = () => {
49
+ forwardSignal("SIGTERM")
50
+ }
51
+
52
+ process.on("SIGINT", onSigint)
53
+ process.on("SIGTERM", onSigterm)
54
+
55
+ child.once("error", (error) => {
56
+ process.off("SIGINT", onSigint)
57
+ process.off("SIGTERM", onSigterm)
58
+ reject(error)
59
+ })
60
+
61
+ child.once("exit", (code, signal) => {
62
+ process.off("SIGINT", onSigint)
63
+ process.off("SIGTERM", onSigterm)
64
+ resolve({ code, signal })
65
+ })
66
+ })
67
+ }
68
+
69
+ const argv = process.argv.slice(2)
70
+ let suppressOpenOnNextChild = false
71
+
72
+ while (true) {
73
+ const result = await spawnChild(argv)
74
+ if (shouldRestartCliProcess(result.code, result.signal)) {
75
+ suppressOpenOnNextChild = isUiUpdateRestart(result.code, result.signal)
76
+ console.log(`${LOG_PREFIX} supervisor restarting ${CLI_COMMAND} in the same terminal session`)
77
+ continue
78
+ }
79
+
80
+ process.exit(result.code ?? (result.signal ? 1 : 0))
81
+ }
@@ -0,0 +1,68 @@
1
+ import { existsSync } from "node:fs"
2
+ import process from "node:process"
3
+ import { LOG_PREFIX } from "../shared/branding"
4
+ import {
5
+ fetchLatestPackageVersion,
6
+ installPackageVersion,
7
+ openUrl,
8
+ runCli,
9
+ } from "./cli-runtime"
10
+ import { CLI_STARTUP_UPDATE_RESTART_EXIT_CODE, CLI_UI_UPDATE_RESTART_EXIT_CODE } from "./restart"
11
+ import { startKaizenServer } from "./server"
12
+
13
+ // Read version from package.json at the package root
14
+ const packageRootUrl = new URL("../../", import.meta.url)
15
+ const pkg = await Bun.file(new URL("package.json", packageRootUrl)).json()
16
+ const VERSION: string = pkg.version ?? "0.0.0"
17
+ const ALLOW_SELF_UPDATE = !existsSync(new URL(".git", packageRootUrl))
18
+
19
+ const argv = process.argv.slice(2)
20
+ let resolveExitAction: ((action: "ui_restart" | "exit") => void) | null = null
21
+
22
+ const result = await runCli(argv, {
23
+ version: VERSION,
24
+ bunVersion: Bun.version,
25
+ allowSelfUpdate: ALLOW_SELF_UPDATE,
26
+ startServer: async (options) => {
27
+ const started = await startKaizenServer(options)
28
+ if (started.updateManager && options.update) {
29
+ started.updateManager.onChange((snapshot) => {
30
+ if (snapshot.status !== "restart_pending") return
31
+ console.log(`${LOG_PREFIX} update installed, shutting down current process for restart`)
32
+ resolveExitAction?.("ui_restart")
33
+ })
34
+ }
35
+
36
+ return started
37
+ },
38
+ fetchLatestVersion: fetchLatestPackageVersion,
39
+ installVersion: installPackageVersion,
40
+ openUrl,
41
+ log: console.log,
42
+ warn: console.warn,
43
+ })
44
+
45
+ if (result.kind === "exited") {
46
+ process.exit(result.code)
47
+ }
48
+
49
+ if (result.kind === "restarting") {
50
+ process.exit(result.reason === "startup_update" ? CLI_STARTUP_UPDATE_RESTART_EXIT_CODE : CLI_UI_UPDATE_RESTART_EXIT_CODE)
51
+ }
52
+
53
+ const exitAction = await new Promise<"ui_restart" | "exit">((resolve) => {
54
+ resolveExitAction = resolve
55
+
56
+ const shutdown = () => {
57
+ resolve("exit")
58
+ }
59
+
60
+ process.once("SIGINT", shutdown)
61
+ process.once("SIGTERM", shutdown)
62
+ })
63
+
64
+ await result.stop()
65
+ if (exitAction === "ui_restart") {
66
+ console.log(`${LOG_PREFIX} current process stopped, handing restart back to supervisor`)
67
+ }
68
+ process.exit(exitAction === "ui_restart" ? CLI_UI_UPDATE_RESTART_EXIT_CODE : 0)