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.
- package/LICENSE +22 -0
- package/README.md +246 -0
- package/bin/kaizen +15 -0
- package/dist/client/apple-touch-icon.png +0 -0
- package/dist/client/assets/index-D-ORCGrq.js +603 -0
- package/dist/client/assets/index-r28mcHqz.css +32 -0
- package/dist/client/favicon.png +0 -0
- package/dist/client/fonts/body-medium.woff2 +0 -0
- package/dist/client/fonts/body-regular-italic.woff2 +0 -0
- package/dist/client/fonts/body-regular.woff2 +0 -0
- package/dist/client/fonts/body-semibold.woff2 +0 -0
- package/dist/client/index.html +22 -0
- package/dist/client/manifest-dark.webmanifest +24 -0
- package/dist/client/manifest.webmanifest +24 -0
- package/dist/client/pwa-192.png +0 -0
- package/dist/client/pwa-512.png +0 -0
- package/dist/client/pwa-icon.svg +15 -0
- package/dist/client/pwa-splash.png +0 -0
- package/dist/client/pwa-splash.svg +15 -0
- package/package.json +103 -0
- package/src/server/acp-shared.ts +315 -0
- package/src/server/agent.ts +1120 -0
- package/src/server/attachments.ts +133 -0
- package/src/server/backgrounds.ts +74 -0
- package/src/server/cli-runtime.ts +333 -0
- package/src/server/cli-supervisor.ts +81 -0
- package/src/server/cli.ts +68 -0
- package/src/server/codex-app-server-protocol.ts +453 -0
- package/src/server/codex-app-server.ts +1350 -0
- package/src/server/cursor-acp.ts +819 -0
- package/src/server/discovery.ts +322 -0
- package/src/server/event-store.ts +1369 -0
- package/src/server/events.ts +244 -0
- package/src/server/external-open.ts +272 -0
- package/src/server/gemini-acp.ts +844 -0
- package/src/server/gemini-cli.ts +525 -0
- package/src/server/generate-title.ts +36 -0
- package/src/server/git-manager.ts +79 -0
- package/src/server/git-repository.ts +101 -0
- package/src/server/harness-types.ts +20 -0
- package/src/server/keybindings.ts +177 -0
- package/src/server/machine-name.ts +22 -0
- package/src/server/paths.ts +112 -0
- package/src/server/process-utils.ts +22 -0
- package/src/server/project-icon.ts +344 -0
- package/src/server/project-metadata.ts +10 -0
- package/src/server/provider-catalog.ts +85 -0
- package/src/server/provider-settings.ts +155 -0
- package/src/server/quick-response.ts +153 -0
- package/src/server/read-models.ts +275 -0
- package/src/server/recovery.ts +507 -0
- package/src/server/restart.ts +30 -0
- package/src/server/server.ts +244 -0
- package/src/server/terminal-manager.ts +350 -0
- package/src/server/theme-settings.ts +179 -0
- package/src/server/update-manager.ts +230 -0
- package/src/server/usage/base-provider-usage.ts +57 -0
- package/src/server/usage/claude-usage.ts +558 -0
- package/src/server/usage/codex-usage.ts +144 -0
- package/src/server/usage/cursor-browser.ts +120 -0
- package/src/server/usage/cursor-cookies.ts +390 -0
- package/src/server/usage/cursor-usage.ts +490 -0
- package/src/server/usage/gemini-usage.ts +24 -0
- package/src/server/usage/provider-usage.ts +61 -0
- package/src/server/usage/test-helpers.ts +9 -0
- package/src/server/usage/types.ts +54 -0
- package/src/server/usage/utils.ts +325 -0
- package/src/server/ws-router.ts +717 -0
- package/src/shared/branding.ts +83 -0
- package/src/shared/dev-ports.ts +43 -0
- package/src/shared/ports.ts +2 -0
- package/src/shared/protocol.ts +152 -0
- package/src/shared/tools.ts +251 -0
- 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)
|