From 0d01a9fa4a9df233812aa4b506793af3fe3286eb Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 2 Jun 2026 17:59:41 +0800 Subject: [PATCH 1/3] feat(cli): add automatic and manual upgrades --- .changeset/automatic-background-updates.md | 5 + .changeset/manual-upgrade-command.md | 5 + apps/kimi-code/src/cli/commands.ts | 8 + apps/kimi-code/src/cli/sub/upgrade.ts | 222 ++++++++++ apps/kimi-code/src/cli/update/install-lock.ts | 92 ++++ .../kimi-code/src/cli/update/install-state.ts | 63 +++ apps/kimi-code/src/cli/update/preflight.ts | 305 ++++++++++++- apps/kimi-code/src/cli/update/prompt.ts | 10 +- apps/kimi-code/src/cli/update/types.ts | 32 ++ apps/kimi-code/src/constant/app.ts | 2 + apps/kimi-code/src/main.ts | 54 ++- apps/kimi-code/src/tui/commands/config.ts | 63 +++ .../components/dialogs/settings-selector.ts | 14 +- .../dialogs/update-preference-selector.ts | 38 ++ apps/kimi-code/src/tui/config.ts | 30 +- apps/kimi-code/src/tui/kimi-tui.ts | 1 + apps/kimi-code/src/tui/types.ts | 3 +- apps/kimi-code/src/utils/paths.ts | 16 + apps/kimi-code/test/cli/main.test.ts | 138 +++++- apps/kimi-code/test/cli/options.test.ts | 28 +- apps/kimi-code/test/cli/update/cache.test.ts | 46 +- .../test/cli/update/install-lock.test.ts | 39 ++ .../test/cli/update/preflight.test.ts | 410 +++++++++++++++++- apps/kimi-code/test/cli/update/prompt.test.ts | 6 +- apps/kimi-code/test/cli/upgrade.test.ts | 192 ++++++++ apps/kimi-code/test/tui/activity-pane.test.ts | 1 + .../tui/commands/update-preferences.test.ts | 52 +++ .../test/tui/components/chrome/footer.test.ts | 1 + .../tui/components/chrome/welcome.test.ts | 1 + .../components/dialogs/choice-picker.test.ts | 12 + apps/kimi-code/test/tui/config.test.ts | 12 +- .../test/tui/create-tui-state.test.ts | 1 + .../test/tui/kimi-tui-message-flow.test.ts | 1 + .../test/tui/kimi-tui-startup.test.ts | 11 +- .../kimi-code/test/tui/message-replay.test.ts | 1 + .../test/tui/signal-handlers.test.ts | 1 + apps/kimi-code/test/utils/paths.test.ts | 21 +- docs/en/configuration/data-locations.md | 16 +- docs/en/guides/getting-started.md | 8 +- docs/en/reference/kimi-command.md | 10 + docs/zh/configuration/data-locations.md | 16 +- docs/zh/guides/getting-started.md | 8 +- docs/zh/reference/kimi-command.md | 10 + 43 files changed, 1925 insertions(+), 80 deletions(-) create mode 100644 .changeset/automatic-background-updates.md create mode 100644 .changeset/manual-upgrade-command.md create mode 100644 apps/kimi-code/src/cli/sub/upgrade.ts create mode 100644 apps/kimi-code/src/cli/update/install-lock.ts create mode 100644 apps/kimi-code/src/cli/update/install-state.ts create mode 100644 apps/kimi-code/src/tui/components/dialogs/update-preference-selector.ts create mode 100644 apps/kimi-code/test/cli/update/install-lock.test.ts create mode 100644 apps/kimi-code/test/cli/upgrade.test.ts create mode 100644 apps/kimi-code/test/tui/commands/update-preferences.test.ts diff --git a/.changeset/automatic-background-updates.md b/.changeset/automatic-background-updates.md new file mode 100644 index 00000000..99b38de7 --- /dev/null +++ b/.changeset/automatic-background-updates.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add background automatic upgrades, which can be disabled in tui.toml. diff --git a/.changeset/manual-upgrade-command.md b/.changeset/manual-upgrade-command.md new file mode 100644 index 00000000..02e5e399 --- /dev/null +++ b/.changeset/manual-upgrade-command.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add a `kimi upgrade` command for manually checking and upgrade Kimi Code CLI. diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 2cdd4bd0..131d3857 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -10,12 +10,14 @@ import { registerExportCommand } from './sub/export'; export type MainCommandHandler = (opts: CLIOptions) => void; export type MigrateCommandHandler = () => void; export type PluginNodeRunnerHandler = (entry: string, args: readonly string[]) => void; +export type UpgradeCommandHandler = () => void | Promise; export function createProgram( version: string, onMain: MainCommandHandler, onMigrate: MigrateCommandHandler, onPluginNodeRunner: PluginNodeRunnerHandler = () => {}, + onUpgrade: UpgradeCommandHandler = () => {}, ): Command { const program = new Command(CLI_COMMAND_NAME) .description('The Starting Point for Next-Gen Agents') @@ -75,6 +77,12 @@ export function createProgram( registerExportCommand(program); registerMigrateCommand(program, onMigrate); + program + .command('upgrade') + .description('Upgrade Kimi Code to the latest version.') + .action(async () => { + await onUpgrade(); + }); program .command('__plugin_run_node', { hidden: true }) diff --git a/apps/kimi-code/src/cli/sub/upgrade.ts b/apps/kimi-code/src/cli/sub/upgrade.ts new file mode 100644 index 00000000..8b5c839e --- /dev/null +++ b/apps/kimi-code/src/cli/sub/upgrade.ts @@ -0,0 +1,222 @@ +import { log, type Logger } from '@moonshot-ai/kimi-code-sdk'; +import { track as trackTelemetry, type TelemetryProperties } from '@moonshot-ai/kimi-telemetry'; + +import { refreshUpdateCache } from '#/cli/update/refresh'; +import { selectUpdateTarget } from '#/cli/update/select'; +import { detectInstallSource } from '#/cli/update/source'; +import { + canAutoInstall, + installCommandFor, + installUpdate as installUpdateForeground, + renderInstallSuccessMessage, + renderManualUpdateMessage, +} from '#/cli/update/preflight'; +import { + promptForInstallChoice, + type InstallPromptChoiceValue, + type InstallPromptOptions, +} from '#/cli/update/prompt'; +import { + NPM_PACKAGE_NAME, + type InstallSource, + type UpdateCache, +} from '#/cli/update/types'; + +interface WritableLike { + write(chunk: string): boolean; +} + +type UpgradeTrack = (event: string, properties?: TelemetryProperties) => void; +type UpgradeLogger = Pick; + +export interface UpgradeDeps { + readonly refreshUpdateCache: () => Promise; + readonly detectInstallSource: () => Promise; + readonly installUpdate: ( + source: InstallSource, + version: string, + platform: NodeJS.Platform, + ) => Promise; + readonly promptForInstallChoice: ( + options: InstallPromptOptions, + ) => Promise; + readonly platform: NodeJS.Platform; + readonly stdout: WritableLike; + readonly stderr: WritableLike; + readonly track: UpgradeTrack; + readonly logger: UpgradeLogger; +} + +export async function handleUpgrade( + currentVersion: string, + overrides: Partial = {}, +): Promise { + const deps = createDefaultUpgradeDeps(overrides); + + let cache: UpdateCache; + try { + cache = await deps.refreshUpdateCache(); + } catch (error) { + const reason = formatErrorMessage(error); + trackUpgradeEvent(deps.track, 'upgrade_command_failed', { + current_version: currentVersion, + stage: 'refresh', + reason, + }); + logUpgradeWarn(deps.logger, 'manual upgrade check failed', { + currentVersion, + error, + }); + deps.stderr.write(`error: failed to check for updates: ${reason}\n`); + return 1; + } + + const target = selectUpdateTarget(currentVersion, cache.latest); + if (target === null) { + trackUpgradeEvent(deps.track, 'upgrade_command_no_update', { + current_version: currentVersion, + }); + logUpgradeInfo(deps.logger, 'manual upgrade no update', { + currentVersion, + }); + deps.stdout.write(`Kimi Code is already up to date (${formatDisplayVersion(currentVersion)}).\n`); + return 0; + } + + const source = await deps.detectInstallSource().catch(() => 'unsupported' as const); + const installCommand = installCommandFor(source, target.version, deps.platform); + if (!canAutoInstall(source, deps.platform)) { + trackUpgradeEvent(deps.track, 'upgrade_command_manual_command', { + current_version: currentVersion, + target_version: target.version, + source, + }); + logUpgradeInfo(deps.logger, 'manual upgrade command shown', { + currentVersion, + targetVersion: target.version, + source, + }); + deps.stdout.write(renderManualUpdateMessage(currentVersion, target, source, installCommand)); + return 0; + } + + trackUpgradeEvent(deps.track, 'upgrade_command_prompted', { + current_version: currentVersion, + target_version: target.version, + source, + }); + logUpgradeInfo(deps.logger, 'manual upgrade prompted', { + currentVersion, + targetVersion: target.version, + source, + }); + const choice = await deps.promptForInstallChoice({ + currentVersion, + target, + installCommand, + installSource: source, + }); + if (choice === 'skip') { + trackUpgradeEvent(deps.track, 'upgrade_command_skipped', { + current_version: currentVersion, + target_version: target.version, + source, + }); + logUpgradeInfo(deps.logger, 'manual upgrade skipped', { + currentVersion, + targetVersion: target.version, + source, + }); + return 0; + } + + try { + trackUpgradeEvent(deps.track, 'upgrade_command_install_selected', { + current_version: currentVersion, + target_version: target.version, + source, + }); + await deps.installUpdate(source, target.version, deps.platform); + trackUpgradeEvent(deps.track, 'upgrade_command_succeeded', { + current_version: currentVersion, + target_version: target.version, + source, + }); + logUpgradeInfo(deps.logger, 'manual upgrade install succeeded', { + currentVersion, + targetVersion: target.version, + source, + }); + deps.stdout.write(renderInstallSuccessMessage(target)); + return 0; + } catch (error) { + trackUpgradeEvent(deps.track, 'upgrade_command_failed', { + current_version: currentVersion, + target_version: target.version, + source, + stage: 'install', + reason: formatErrorMessage(error), + }); + logUpgradeWarn(deps.logger, 'manual upgrade install failed', { + currentVersion, + targetVersion: target.version, + source, + error, + }); + deps.stderr.write( + `warning: failed to install ${NPM_PACKAGE_NAME}@${target.version}: ` + + `${formatErrorMessage(error)}\n`, + ); + return 1; + } +} + +function createDefaultUpgradeDeps(overrides: Partial): UpgradeDeps { + return { + refreshUpdateCache: overrides.refreshUpdateCache ?? (() => refreshUpdateCache()), + detectInstallSource: overrides.detectInstallSource ?? (() => detectInstallSource()), + installUpdate: overrides.installUpdate ?? installUpdateForeground, + promptForInstallChoice: overrides.promptForInstallChoice ?? promptForInstallChoice, + platform: overrides.platform ?? process.platform, + stdout: overrides.stdout ?? process.stdout, + stderr: overrides.stderr ?? process.stderr, + track: overrides.track ?? trackTelemetry, + logger: overrides.logger ?? log, + }; +} + +function formatDisplayVersion(version: string): string { + return version.startsWith('v') ? version : `v${version}`; +} + +function formatErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function trackUpgradeEvent( + track: UpgradeTrack, + event: string, + properties: TelemetryProperties, +): void { + try { + track(event, properties); + } catch { + // Telemetry must never affect upgrade flow. + } +} + +function logUpgradeInfo(logger: UpgradeLogger, message: string, payload: Record): void { + try { + logger.info(message, payload); + } catch { + // Diagnostic logging must never affect upgrade flow. + } +} + +function logUpgradeWarn(logger: UpgradeLogger, message: string, payload: Record): void { + try { + logger.warn(message, payload); + } catch { + // Diagnostic logging must never affect upgrade flow. + } +} diff --git a/apps/kimi-code/src/cli/update/install-lock.ts b/apps/kimi-code/src/cli/update/install-lock.ts new file mode 100644 index 00000000..2e561fe6 --- /dev/null +++ b/apps/kimi-code/src/cli/update/install-lock.ts @@ -0,0 +1,92 @@ +import { mkdir, open, readFile, unlink } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { getUpdateInstallLockFile } from '#/utils/paths'; + +const UPDATE_INSTALL_LOCK_STALE_MS = 30 * 60 * 1000; + +export interface UpdateInstallLockRequest { + readonly version: string; + readonly now?: Date; +} + +export interface UpdateInstallLockHandle { + readonly filePath: string; + release(): Promise; +} + +function isNotFound(error: unknown): boolean { + return ( + typeof error === 'object' && error !== null && (error as { code?: string }).code === 'ENOENT' + ); +} + +function isAlreadyExists(error: unknown): boolean { + return ( + typeof error === 'object' && error !== null && (error as { code?: string }).code === 'EEXIST' + ); +} + +async function isStaleLock(filePath: string, now: Date): Promise { + try { + const raw = await readFile(filePath, 'utf-8'); + const parsed = JSON.parse(raw) as { startedAt?: unknown }; + if (typeof parsed.startedAt !== 'string') return true; + const startedAt = Date.parse(parsed.startedAt); + if (!Number.isFinite(startedAt)) return true; + return now.getTime() - startedAt > UPDATE_INSTALL_LOCK_STALE_MS; + } catch (error) { + if (isNotFound(error)) return true; + return false; + } +} + +async function createLockFile( + filePath: string, + request: UpdateInstallLockRequest, +): Promise { + const now = request.now ?? new Date(); + const file = await open(filePath, 'wx', 0o600); + try { + await file.writeFile(`${JSON.stringify({ + version: request.version, + pid: process.pid, + startedAt: now.toISOString(), + }, null, 2)}\n`, 'utf-8'); + } finally { + await file.close(); + } + + return { + filePath, + release: async (): Promise => { + await unlink(filePath).catch((error: unknown) => { + if (!isNotFound(error)) throw error; + }); + }, + }; +} + +export async function tryAcquireUpdateInstallLock( + request: UpdateInstallLockRequest, + filePath: string = getUpdateInstallLockFile(), +): Promise { + await mkdir(dirname(filePath), { recursive: true }); + try { + return await createLockFile(filePath, request); + } catch (error) { + if (!isAlreadyExists(error)) throw error; + } + + if (!(await isStaleLock(filePath, request.now ?? new Date()))) return null; + await unlink(filePath).catch((error: unknown) => { + if (!isNotFound(error)) throw error; + }); + + try { + return await createLockFile(filePath, request); + } catch (error) { + if (isAlreadyExists(error)) return null; + throw error; + } +} diff --git a/apps/kimi-code/src/cli/update/install-state.ts b/apps/kimi-code/src/cli/update/install-state.ts new file mode 100644 index 00000000..f9e10225 --- /dev/null +++ b/apps/kimi-code/src/cli/update/install-state.ts @@ -0,0 +1,63 @@ +import { z } from 'zod'; + +import { getUpdateInstallStateFile } from '#/utils/paths'; +import { readJsonFile, writeJsonFile } from '#/utils/persistence'; + +import { emptyUpdateInstallState, type InstallSource, type UpdateInstallState } from './types'; + +const InstallSourceSchema: z.ZodType = z.enum([ + 'npm-global', + 'pnpm-global', + 'yarn-global', + 'bun-global', + 'native', + 'unsupported', +]); + +const UpdateInstallStateSchema: z.ZodType = z + .object({ + active: z + .object({ + version: z.string().min(1), + source: InstallSourceSchema, + startedAt: z.string().min(1), + }) + .strict() + .nullable(), + lastFailure: z + .object({ + version: z.string().min(1), + failedAt: z.string().min(1), + attempts: z.number().int().min(1), + }) + .strict() + .nullable(), + lastSuccess: z + .object({ + version: z.string().min(1), + installedAt: z.string().min(1), + notifiedAt: z.string().min(1).nullable(), + }) + .strict() + .nullable(), + }) + .strict(); + +export { emptyUpdateInstallState }; + +export async function readUpdateInstallState( + filePath: string = getUpdateInstallStateFile(), +): Promise { + try { + return await readJsonFile(filePath, UpdateInstallStateSchema, emptyUpdateInstallState()); + } catch { + return emptyUpdateInstallState(); + } +} + +export async function writeUpdateInstallState( + value: UpdateInstallState, + filePath: string = getUpdateInstallStateFile(), +): Promise { + await writeJsonFile(filePath, UpdateInstallStateSchema, value); +} diff --git a/apps/kimi-code/src/cli/update/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts index 692784d4..5a183136 100644 --- a/apps/kimi-code/src/cli/update/preflight.ts +++ b/apps/kimi-code/src/cli/update/preflight.ts @@ -1,14 +1,23 @@ import { spawn } from 'node:child_process'; +import { log, type Logger } from '@moonshot-ai/kimi-code-sdk'; import type { TelemetryProperties } from '@moonshot-ai/kimi-telemetry'; import { NATIVE_INSTALL_COMMAND_UNIX, NATIVE_INSTALL_COMMAND_WIN, } from '#/constant/app'; +import { loadTuiConfig } from '#/tui/config'; import { readUpdateCache } from './cache'; -import { promptForInstallConfirmation, type InstallPromptOptions } from './prompt'; +import { tryAcquireUpdateInstallLock } from './install-lock'; +import { emptyUpdateInstallState, readUpdateInstallState, writeUpdateInstallState } from './install-state'; +import { + CHANGELOG_URL, + promptForInstallChoice, + type InstallPromptChoiceValue, + type InstallPromptOptions, +} from './prompt'; import { refreshUpdateCache } from './refresh'; import { selectUpdateTarget } from './select'; import { detectInstallSource } from './source'; @@ -16,6 +25,7 @@ import { NPM_PACKAGE_NAME, type InstallSource, type UpdateDecision, + type UpdateInstallState, type UpdatePreflightResult, type UpdateTarget, } from './types'; @@ -27,8 +37,14 @@ export interface RunUpdatePreflightOptions { readonly stderr?: { write(chunk: string): boolean }; readonly isTTY?: boolean; readonly track?: (event: string, properties?: TelemetryProperties) => void; + readonly logger?: UpdateLogger; } +const AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD = 2; +const AUTO_INSTALL_ACTIVE_TTL_MS = 6 * 60 * 60 * 1000; + +type UpdateLogger = Pick; + function withCmdSuffix(base: string, platform: NodeJS.Platform): string { return platform === 'win32' ? `${base}.cmd` : base; } @@ -37,7 +53,7 @@ function bunCommand(platform: NodeJS.Platform): string { return platform === 'win32' ? 'bun.exe' : 'bun'; } -function installCommandFor( +export function installCommandFor( source: InstallSource, version: string, platform: NodeJS.Platform, @@ -58,7 +74,7 @@ function installCommandFor( } } -function canAutoInstall(source: InstallSource, platform: NodeJS.Platform): boolean { +export function canAutoInstall(source: InstallSource, platform: NodeJS.Platform): boolean { switch (source) { case 'npm-global': case 'pnpm-global': @@ -107,7 +123,7 @@ function formatErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function renderManualUpdateMessage( +export function renderManualUpdateMessage( currentVersion: string, target: UpdateTarget, source: InstallSource, @@ -125,51 +141,166 @@ function renderManualUpdateMessage( ); } -function renderInstallSuccessMessage(target: UpdateTarget): string { +export function renderInstallSuccessMessage(target: UpdateTarget): string { return `Updated ${NPM_PACKAGE_NAME} to ${target.version}. Restart the CLI to use the new version.\n`; } +function renderBackgroundInstallSuccessNotice(version: string): string { + const displayVersion = version.startsWith('v') ? version : `v${version}`; + return `Kimi Code updated to ${displayVersion}\nChangelog: ${CHANGELOG_URL}\n`; +} + function refreshInBackground(): void { void refreshUpdateCache().catch(() => {}); } +function nowIso(): string { + return new Date().toISOString(); +} + +function failureAttemptsFor(state: UpdateInstallState, target: UpdateTarget): number { + return state.lastFailure?.version === target.version ? state.lastFailure.attempts : 0; +} + +function hasFreshActiveInstall(state: UpdateInstallState, target: UpdateTarget): boolean { + const active = state.active; + if (active === null || active.version !== target.version) return false; + const startedAt = Date.parse(active.startedAt); + if (!Number.isFinite(startedAt)) return false; + return Date.now() - startedAt < AUTO_INSTALL_ACTIVE_TTL_MS; +} + +async function showPendingBackgroundInstallNotice( + state: UpdateInstallState, + currentVersion: string, + stdout: { write(chunk: string): boolean }, + track: RunUpdatePreflightOptions['track'], + logger: UpdateLogger, +): Promise { + const success = state.lastSuccess; + if (success !== null && success.notifiedAt === null && success.version === currentVersion) { + stdout.write(renderBackgroundInstallSuccessNotice(success.version)); + trackUpdateEvent(track, 'update_success_notice_shown', { + version: success.version, + inferred_from_active: false, + }); + logUpdateInfo(logger, 'background update success notice shown', { + version: success.version, + inferredFromActive: false, + }); + const nextState: UpdateInstallState = { + ...state, + active: null, + lastFailure: null, + lastSuccess: { + ...success, + notifiedAt: nowIso(), + }, + }; + await writeUpdateInstallState(nextState).catch(() => {}); + return nextState; + } + + const active = state.active; + if (active === null || active.version !== currentVersion) return state; + if (success !== null && success.version === currentVersion && success.notifiedAt !== null) { + return state; + } + + const notifiedAt = nowIso(); + stdout.write(renderBackgroundInstallSuccessNotice(active.version)); + trackUpdateEvent(track, 'update_success_notice_shown', { + version: active.version, + inferred_from_active: true, + }); + logUpdateInfo(logger, 'background update success notice shown', { + version: active.version, + inferredFromActive: true, + }); + const nextState: UpdateInstallState = { + ...state, + active: null, + lastFailure: null, + lastSuccess: { + version: active.version, + installedAt: notifiedAt, + notifiedAt, + }, + }; + await writeUpdateInstallState(nextState).catch(() => {}); + return nextState; +} + +async function shouldAutoInstallUpdates(): Promise { + try { + const config = await loadTuiConfig(); + return config.upgrade.autoInstall; + } catch { + return true; + } +} + function trackUpdatePrompted( track: RunUpdatePreflightOptions['track'], currentVersion: string, target: UpdateTarget, source: InstallSource, decision: UpdateDecision, +): void { + trackUpdateEvent(track, 'update_prompted', { + current: currentVersion, + latest: target.version, + current_version: currentVersion, + target_version: target.version, + source, + decision, + }); +} + +function trackUpdateEvent( + track: RunUpdatePreflightOptions['track'], + event: string, + properties: TelemetryProperties, ): void { try { - track?.('update_prompted', { - current: currentVersion, - latest: target.version, - current_version: currentVersion, - target_version: target.version, - source, - decision, - }); + track?.(event, properties); } catch { // Telemetry must never affect update prompting. } } +function logUpdateInfo(logger: UpdateLogger, message: string, payload: Record): void { + try { + logger.info(message, payload); + } catch { + // Diagnostic logging must never affect update prompting. + } +} + +function logUpdateWarn(logger: UpdateLogger, message: string, payload: Record): void { + try { + logger.warn(message, payload); + } catch { + // Diagnostic logging must never affect update prompting. + } +} + async function promptInstall( currentVersion: string, target: UpdateTarget, source: InstallSource, installCommand: string, -): Promise { +): Promise { const options: InstallPromptOptions = { currentVersion, target, installSource: source, installCommand, }; - return promptForInstallConfirmation(options); + return promptForInstallChoice(options); } -async function installUpdate( +export async function installUpdate( source: InstallSource, version: string, platform: NodeJS.Platform, @@ -189,6 +320,108 @@ async function installUpdate( }); } +async function startBackgroundInstall( + state: UpdateInstallState, + currentVersion: string, + target: UpdateTarget, + source: InstallSource, + platform: NodeJS.Platform, + track: RunUpdatePreflightOptions['track'], + logger: UpdateLogger, +): Promise { + const lock = await tryAcquireUpdateInstallLock({ version: target.version }); + if (lock === null) return; + + try { + const freshState = await readUpdateInstallState().catch(() => state); + if ( + hasFreshActiveInstall(freshState, target) || + failureAttemptsFor(freshState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD + ) { + return; + } + + const startedState: UpdateInstallState = { + ...freshState, + active: { + version: target.version, + source, + startedAt: nowIso(), + }, + }; + await writeUpdateInstallState(startedState); + trackUpdateEvent(track, 'update_background_install_started', { + current_version: currentVersion, + target_version: target.version, + source, + }); + logUpdateInfo(logger, 'background update install started', { + currentVersion, + targetVersion: target.version, + source, + }); + + const { cmd, args } = spawnForSource(source, target.version, platform); + let settled = false; + + const finish = (succeeded: boolean): void => { + if (settled) return; + settled = true; + const attempts = failureAttemptsFor(startedState, target) + 1; + + const nextState: UpdateInstallState = succeeded + ? { + ...startedState, + active: null, + lastFailure: null, + lastSuccess: { + version: target.version, + installedAt: nowIso(), + notifiedAt: null, + }, + } + : { + ...startedState, + active: null, + lastFailure: { + version: target.version, + failedAt: nowIso(), + attempts, + }, + }; + void writeUpdateInstallState(nextState).catch(() => {}); + if (succeeded) { + trackUpdateEvent(track, 'update_background_install_succeeded', { + target_version: target.version, + source, + }); + logUpdateInfo(logger, 'background update install succeeded', { + targetVersion: target.version, + source, + }); + return; + } + trackUpdateEvent(track, 'update_background_install_failed', { + target_version: target.version, + source, + attempts, + }); + logUpdateWarn(logger, 'background update install failed', { + targetVersion: target.version, + source, + attempts, + }); + }; + + const child = spawn(cmd, [...args], { detached: true, stdio: 'ignore' }); + child.once('error', () => { finish(false); }); + child.once('exit', (code) => { finish(code === 0); }); + child.unref(); + } finally { + await lock.release().catch(() => {}); + } +} + export function decideUpdateAction( target: UpdateTarget | null, isInteractive: boolean, @@ -205,16 +438,27 @@ export async function runUpdatePreflight( ): Promise { const stdout = options.stdout ?? process.stdout; const stderr = options.stderr ?? process.stderr; + const logger = options.logger ?? log; const platform = process.platform; try { + const isInteractive = + options.isTTY ?? (process.stdin.isTTY && process.stdout.isTTY); + let installState = await readUpdateInstallState().catch(() => emptyUpdateInstallState()); + if (isInteractive) { + installState = await showPendingBackgroundInstallNotice( + installState, + currentVersion, + stdout, + options.track, + logger, + ); + } + const cache = await readUpdateCache().catch(() => null); const latest = cache?.latest ?? null; const target = selectUpdateTarget(currentVersion, latest); refreshInBackground(); - - const isInteractive = - options.isTTY ?? (process.stdin.isTTY && process.stdout.isTTY); const source: InstallSource = target === null || !isInteractive ? 'unsupported' @@ -224,6 +468,25 @@ export async function runUpdatePreflight( if (decision === 'none' || target === null) return 'continue'; const installCommand = installCommandFor(source, target.version, platform); + const sourceCanAutoInstall = canAutoInstall(source, platform); + const autoInstallUpdates = sourceCanAutoInstall ? await shouldAutoInstallUpdates() : false; + if (autoInstallUpdates && sourceCanAutoInstall) { + if (failureAttemptsFor(installState, target) < AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD) { + if (!hasFreshActiveInstall(installState, target)) { + await startBackgroundInstall( + installState, + currentVersion, + target, + source, + platform, + options.track, + logger, + ).catch(() => {}); + } + return 'continue'; + } + } + trackUpdatePrompted(options.track, currentVersion, target, source, decision); if (decision === 'manual-command') { @@ -231,8 +494,8 @@ export async function runUpdatePreflight( return 'continue'; } - const confirmed = await promptInstall(currentVersion, target, source, installCommand); - if (!confirmed) return 'continue'; + const choice = await promptInstall(currentVersion, target, source, installCommand); + if (choice === 'skip') return 'continue'; try { await installUpdate(source, target.version, platform); diff --git a/apps/kimi-code/src/cli/update/prompt.ts b/apps/kimi-code/src/cli/update/prompt.ts index bd3fd933..3b48ad07 100644 --- a/apps/kimi-code/src/cli/update/prompt.ts +++ b/apps/kimi-code/src/cli/update/prompt.ts @@ -14,7 +14,7 @@ import { import { type InstallSource, type UpdateTarget } from './types'; -const CHANGELOG_URL = 'https://moonshotai.github.io/kimi-code/en/release-notes/changelog.html'; +export const CHANGELOG_URL = 'https://moonshotai.github.io/kimi-code/en/release-notes/changelog.html'; export type InstallPromptChoiceValue = 'install' | 'skip'; @@ -120,15 +120,15 @@ function writePromptFrame( return lines.length; } -export async function promptForInstallConfirmation( +export async function promptForInstallChoice( options: InstallPromptOptions, -): Promise { +): Promise { const input = options.input ?? process.stdin; const output = options.output ?? process.stdout; const choices = createInstallPromptChoices(options.target); let selectedIndex = getDefaultInstallPromptSelection(choices); - return new Promise((resolve) => { + return new Promise((resolve) => { let lineCount = 0; const hadRawMode = 'isRaw' in input ? input.isRaw : false; const canSetRawMode = typeof input.setRawMode === 'function'; @@ -144,7 +144,7 @@ export async function promptForInstallConfirmation( const finish = (choice: InstallPromptChoiceValue): void => { cleanup(); - resolve(choice === 'install'); + resolve(choice); }; const render = (): void => { diff --git a/apps/kimi-code/src/cli/update/types.ts b/apps/kimi-code/src/cli/update/types.ts index 652fc97d..abdcc8f6 100644 --- a/apps/kimi-code/src/cli/update/types.ts +++ b/apps/kimi-code/src/cli/update/types.ts @@ -21,6 +21,30 @@ export interface UpdateCache { readonly latest: string | null; } +export interface UpdateInstallActive { + readonly version: string; + readonly source: InstallSource; + readonly startedAt: string; +} + +export interface UpdateInstallFailure { + readonly version: string; + readonly failedAt: string; + readonly attempts: number; +} + +export interface UpdateInstallSuccess { + readonly version: string; + readonly installedAt: string; + readonly notifiedAt: string | null; +} + +export interface UpdateInstallState { + readonly active: UpdateInstallActive | null; + readonly lastFailure: UpdateInstallFailure | null; + readonly lastSuccess: UpdateInstallSuccess | null; +} + export type UpdateDecision = 'none' | 'prompt-install' | 'manual-command'; export type UpdatePreflightResult = 'continue' | 'exit'; @@ -31,3 +55,11 @@ export function emptyUpdateCache(): UpdateCache { latest: null, }; } + +export function emptyUpdateInstallState(): UpdateInstallState { + return { + active: null, + lastFailure: null, + lastSuccess: null, + }; +} diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index 43bb7d70..47d3c595 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -19,6 +19,8 @@ export const KIMI_CODE_DATA_DIR_NAME = '.kimi-code'; export const KIMI_CODE_LOG_DIR_NAME = 'logs'; export const KIMI_CODE_UPDATE_DIR_NAME = 'updates'; export const KIMI_CODE_UPDATE_STATE_FILE_NAME = 'latest.json'; +export const KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME = 'install.json'; +export const KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME = 'install.lock'; export const KIMI_CODE_INPUT_HISTORY_DIR_NAME = 'user-history'; // Managed Kimi auth provider key shared with OAuth/SDK config. diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index ffa13412..56d82944 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -6,12 +6,20 @@ */ import { + KimiHarness, flushDiagnosticLogs, log, resolveGlobalLogPath, resolveKimiHome, + type TelemetryClient, } from '@moonshot-ai/kimi-code-sdk'; -import { installCrashHandlers, track } from '@moonshot-ai/kimi-telemetry'; +import { + installCrashHandlers, + setTelemetryContext, + shutdownTelemetry, + track, + withTelemetryContext, +} from '@moonshot-ai/kimi-telemetry'; import { createProgram } from './cli/commands'; import type { CLIOptions } from './cli/options'; @@ -20,8 +28,11 @@ import { runPrompt } from './cli/run-prompt'; import { runShell } from './cli/run-shell'; import { formatStartupError } from './cli/startup-error'; import { runPluginNodeEntry } from './cli/sub/plugin-run-node'; +import { handleUpgrade } from './cli/sub/upgrade'; +import { createCliTelemetryBootstrap, initializeCliTelemetry } from './cli/telemetry'; import { runUpdatePreflight } from './cli/update/preflight'; -import { getVersion } from './cli/version'; +import { createKimiCodeHostIdentity, getVersion } from './cli/version'; +import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from './constant/app'; import { cleanupStaleNativeCacheForCurrent } from './native/native-assets'; import { installNativeModuleHook } from './native/module-hook'; import { runNativeAssetSmokeIfRequested } from './native/smoke'; @@ -60,6 +71,37 @@ async function handleMigrateCommand(version: string): Promise { await runShell(MIGRATE_CLI_OPTIONS, version, { migrateOnly: true }); } +export async function handleUpgradeCommand(version: string): Promise { + const telemetryBootstrap = createCliTelemetryBootstrap(); + const telemetryClient: TelemetryClient = { + track, + withContext: withTelemetryContext, + setContext: setTelemetryContext, + }; + const harness = new KimiHarness({ + homeDir: telemetryBootstrap.homeDir, + identity: createKimiCodeHostIdentity(version), + telemetry: telemetryClient, + }); + let exitCode = 1; + try { + await harness.ensureConfigFile(); + const config = await harness.getConfig(); + initializeCliTelemetry({ + harness, + bootstrap: telemetryBootstrap, + config, + version, + uiMode: CLI_UI_MODE, + }); + exitCode = await handleUpgrade(version, { track, logger: log }); + } finally { + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }).catch(() => {}); + await harness.close().catch(() => {}); + } + process.exit(exitCode); +} + /** A neutral CLIOptions value — `kimi migrate` never opens a chat session. */ const MIGRATE_CLI_OPTIONS: CLIOptions = { session: undefined, @@ -120,6 +162,14 @@ export function main(): void { process.exit(1); }); }, + () => { + void handleUpgradeCommand(version).catch(async (error: unknown) => { + await logStartupFailure('upgrade', error); + process.stderr.write(formatStartupError(error, { operation: 'upgrade' })); + process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`); + process.exit(1); + }); + }, ); program.parse(process.argv); diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index b602f800..5259f75d 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -5,6 +5,7 @@ import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model import { PermissionSelectorComponent } from '../components/dialogs/permission-selector'; import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector'; import { ThemeSelectorComponent } from '../components/dialogs/theme-selector'; +import { UpdatePreferenceSelectorComponent } from '../components/dialogs/update-preference-selector'; import { saveTuiConfig } from '../config'; import type { Theme } from '../theme'; import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; @@ -229,6 +230,7 @@ async function applyEditorChoice(host: SlashCommandHost, value: string): Promise theme: host.state.appState.theme, editorCommand, notifications: host.state.appState.notifications, + upgrade: host.state.appState.upgrade, }); } catch (error) { host.showStatus( @@ -369,6 +371,7 @@ async function applyThemeChoice(host: SlashCommandHost, theme: Theme): Promise { + host.restoreEditor(); + void applyUpdatePreferenceChoice(host, value); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +type UpdatePreferenceHost = { + readonly state: { + readonly appState: Pick< + SlashCommandHost['state']['appState'], + 'theme' | 'editorCommand' | 'notifications' | 'upgrade' + >; + readonly theme: Pick; + }; + setAppState(patch: Pick): void; + showStatus(msg: string, color?: string): void; + track: SlashCommandHost['track']; +}; + +export async function applyUpdatePreferenceChoice( + host: UpdatePreferenceHost, + autoInstall: boolean, +): Promise { + if (autoInstall === host.state.appState.upgrade.autoInstall) { + host.showStatus(`Automatic updates already ${autoInstall ? 'enabled' : 'disabled'}.`); + return; + } + + const upgrade = { autoInstall }; + try { + await saveTuiConfig({ + theme: host.state.appState.theme, + editorCommand: host.state.appState.editorCommand, + notifications: host.state.appState.notifications, + upgrade, + }); + } catch (error) { + host.showStatus( + `Failed to save automatic update setting: ${formatErrorMessage(error)}`, + host.state.theme.colors.error, + ); + return; + } + + host.setAppState({ upgrade }); + host.track('upgrade_preference_changed', { auto_install: autoInstall }); + host.showStatus(`Automatic updates ${autoInstall ? 'enabled' : 'disabled'}.`); +} + async function applyPermissionChoice(host: SlashCommandHost, mode: PermissionMode): Promise { if (mode === host.state.appState.permissionMode) { host.showStatus(`Permission mode unchanged: ${mode}.`); @@ -441,6 +503,7 @@ function handleSettingsSelection(host: SlashCommandHost, value: SettingsSelectio case 'permission': showPermissionPicker(host); return; case 'theme': showThemePicker(host); return; case 'editor': showEditorPicker(host); return; + case 'upgrade': showUpdatePreferencePicker(host); return; case 'usage': void showUsage(host); return; } } diff --git a/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts index fb224345..339cbc90 100644 --- a/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts @@ -2,7 +2,13 @@ import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; import type { ColorPalette } from '#/tui/theme/colors'; -export type SettingsSelection = 'model' | 'theme' | 'editor' | 'permission' | 'usage'; +export type SettingsSelection = + | 'model' + | 'theme' + | 'editor' + | 'permission' + | 'upgrade' + | 'usage'; const SETTINGS_OPTIONS: readonly ChoiceOption[] = [ { @@ -25,6 +31,11 @@ const SETTINGS_OPTIONS: readonly ChoiceOption[] = [ label: 'Editor', description: 'Set the external editor command.', }, + { + value: 'upgrade', + label: 'Automatic updates', + description: 'Turn automatic CLI updates on or off.', + }, { value: 'usage', label: 'Usage', @@ -38,6 +49,7 @@ function isSettingsSelection(value: string): value is SettingsSelection { value === 'theme' || value === 'editor' || value === 'permission' || + value === 'upgrade' || value === 'usage' ); } diff --git a/apps/kimi-code/src/tui/components/dialogs/update-preference-selector.ts b/apps/kimi-code/src/tui/components/dialogs/update-preference-selector.ts new file mode 100644 index 00000000..a57ca7b8 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/update-preference-selector.ts @@ -0,0 +1,38 @@ +import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; + +import type { ColorPalette } from '#/tui/theme/colors'; + +const UPDATE_PREFERENCE_OPTIONS: readonly ChoiceOption[] = [ + { + value: 'on', + label: 'On', + description: 'Install new versions in the background.', + }, + { + value: 'off', + label: 'Off', + description: 'Show the install prompt instead.', + }, +]; + +export interface UpdatePreferenceSelectorOptions { + readonly currentValue: boolean; + readonly colors: ColorPalette; + readonly onSelect: (value: boolean) => void; + readonly onCancel: () => void; +} + +export class UpdatePreferenceSelectorComponent extends ChoicePickerComponent { + constructor(opts: UpdatePreferenceSelectorOptions) { + super({ + title: 'Automatic updates', + options: [...UPDATE_PREFERENCE_OPTIONS], + currentValue: opts.currentValue ? 'on' : 'off', + colors: opts.colors, + onSelect: (value) => { + opts.onSelect(value === 'on'); + }, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index e09774a4..54d4a876 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -1,8 +1,8 @@ /** - * TUI-owned configuration. + * Client-owned preferences. * - * Agent/runtime settings live in core's `config.toml`; this file owns only - * terminal UI preferences for the kimi-code client. + * Agent/runtime settings live in core's `config.toml`; this file owns + * kimi-code client preferences such as terminal UI and update behavior. */ import { existsSync } from 'node:fs'; @@ -26,6 +26,10 @@ export const NotificationsConfigSchema = z.object({ condition: NotificationConditionSchema, }); +export const UpgradePreferencesSchema = z.object({ + autoInstall: z.boolean(), +}); + export const TuiConfigFileSchema = z.object({ theme: TuiThemeSchema.optional(), editor: z @@ -39,27 +43,39 @@ export const TuiConfigFileSchema = z.object({ notification_condition: NotificationConditionSchema.optional(), }) .optional(), + upgrade: z + .object({ + auto_install: z.boolean().optional(), + }) + .optional(), }); export const TuiConfigSchema = z.object({ theme: TuiThemeSchema, editorCommand: z.string().nullable(), notifications: NotificationsConfigSchema, + upgrade: UpgradePreferencesSchema, }); export type TuiConfigFileShape = z.infer; export type TuiConfig = z.infer; export type NotificationsConfig = z.infer; +export type UpgradePreferences = z.infer; export const DEFAULT_NOTIFICATIONS_CONFIG: NotificationsConfig = { enabled: true, condition: 'unfocused', }; +export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = { + autoInstall: true, +}; + export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ theme: 'auto', editorCommand: null, notifications: DEFAULT_NOTIFICATIONS_CONFIG, + upgrade: DEFAULT_UPGRADE_PREFERENCES, }); /** @@ -122,12 +138,15 @@ export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig { condition: config.notifications?.notification_condition ?? DEFAULT_NOTIFICATIONS_CONFIG.condition, }, + upgrade: { + autoInstall: config.upgrade?.auto_install ?? DEFAULT_UPGRADE_PREFERENCES.autoInstall, + }, }); } export function renderTuiConfig(config: TuiConfig): string { return `# ~/.kimi-code/tui.toml -# Terminal UI preferences for kimi-code. +# Client preferences for kimi-code. # Agent/runtime settings stay in ~/.kimi-code/config.toml. theme = "${config.theme}" # "auto" | "dark" | "light" @@ -138,6 +157,9 @@ command = "${escapeTomlBasicString(config.editorCommand ?? '')}" # Empty uses $V [notifications] enabled = ${String(config.notifications.enabled)} # true | false notification_condition = "${config.notifications.condition}" # "unfocused" | "always" + +[upgrade] +auto_install = ${String(config.upgrade.autoInstall)} # true | false `; } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index f2658753..e9998716 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -171,6 +171,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { version: input.version, editorCommand: input.tuiConfig.editorCommand, notifications: input.tuiConfig.notifications, + upgrade: input.tuiConfig.upgrade, availableModels: {}, availableProviders: {}, sessionTitle: null, diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 6ada5fed..61962d74 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -6,7 +6,7 @@ import type { ToolInputDisplay, } from '@moonshot-ai/kimi-code-sdk'; -import type { NotificationsConfig } from './config'; +import type { NotificationsConfig, UpgradePreferences } from './config'; import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; import type { Theme } from './theme'; import type { ResolvedTheme } from './theme/colors'; @@ -29,6 +29,7 @@ export interface AppState { version: string; editorCommand: string | null; notifications: NotificationsConfig; + upgrade: UpgradePreferences; availableModels: Record; availableProviders: Record; sessionTitle: string | null; diff --git a/apps/kimi-code/src/utils/paths.ts b/apps/kimi-code/src/utils/paths.ts index b0375d37..445f71fa 100644 --- a/apps/kimi-code/src/utils/paths.ts +++ b/apps/kimi-code/src/utils/paths.ts @@ -14,6 +14,8 @@ import { KIMI_CODE_HOME_ENV, KIMI_CODE_INPUT_HISTORY_DIR_NAME, KIMI_CODE_LOG_DIR_NAME, + KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME, + KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME, KIMI_CODE_UPDATE_DIR_NAME, KIMI_CODE_UPDATE_STATE_FILE_NAME, } from '#/constant/app'; @@ -45,6 +47,20 @@ export function getUpdateStateFile(): string { return join(getDataDir(), KIMI_CODE_UPDATE_DIR_NAME, KIMI_CODE_UPDATE_STATE_FILE_NAME); } +/** + * Return the update install state file: `/updates/install.json`. + */ +export function getUpdateInstallStateFile(): string { + return join(getDataDir(), KIMI_CODE_UPDATE_DIR_NAME, KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME); +} + +/** + * Return the update install lock file: `/updates/install.lock`. + */ +export function getUpdateInstallLockFile(): string { + return join(getDataDir(), KIMI_CODE_UPDATE_DIR_NAME, KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME); +} + /** * Return the user input history file for a given working directory. * Layout: `/user-history/.jsonl`. diff --git a/apps/kimi-code/test/cli/main.test.ts b/apps/kimi-code/test/cli/main.test.ts index ab02ed02..8d674d44 100644 --- a/apps/kimi-code/test/cli/main.test.ts +++ b/apps/kimi-code/test/cli/main.test.ts @@ -8,7 +8,7 @@ import { runPrompt } from '#/cli/run-prompt'; import { runShell } from '#/cli/run-shell'; import { formatStartupError } from '#/cli/startup-error'; import { runUpdatePreflight } from '#/cli/update/preflight'; -import { handleMainCommand, main } from '#/main'; +import { handleMainCommand, handleUpgradeCommand, main } from '#/main'; const mocks = vi.hoisted(() => { const parse = vi.fn(); @@ -21,21 +21,86 @@ const mocks = vi.hoisted(() => { runShell: vi.fn(), runPrompt: vi.fn(), installCrashHandlers: vi.fn(), + track: vi.fn(), + setTelemetryContext: vi.fn(), + withTelemetryContext: vi.fn(), + shutdownTelemetry: vi.fn(), + createCliTelemetryBootstrap: vi.fn(() => ({ + homeDir: '/tmp/kimi-home', + deviceId: 'device-id', + firstLaunch: false, + })), + initializeCliTelemetry: vi.fn(), + handleUpgrade: vi.fn(), + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + harness: { + homeDir: '/tmp/kimi-home', + ensureConfigFile: vi.fn(), + getConfig: vi.fn(), + close: vi.fn(), + track: vi.fn(), + }, + KimiHarness: vi.fn(), }; }); vi.mock('@moonshot-ai/kimi-telemetry', () => ({ installCrashHandlers: mocks.installCrashHandlers, - track: vi.fn(), + track: mocks.track, + setTelemetryContext: mocks.setTelemetryContext, + withTelemetryContext: mocks.withTelemetryContext, + shutdownTelemetry: mocks.shutdownTelemetry, +})); + +vi.mock('@moonshot-ai/kimi-code-sdk', async () => { + const actual = await vi.importActual( + '@moonshot-ai/kimi-code-sdk', + ); + class MockKimiHarness { + readonly homeDir = mocks.harness.homeDir; + readonly ensureConfigFile = mocks.harness.ensureConfigFile; + readonly getConfig = mocks.harness.getConfig; + readonly close = mocks.harness.close; + readonly track = mocks.harness.track; + + constructor(...args: unknown[]) { + mocks.KimiHarness(...args); + } + } + return { + ...actual, + KimiHarness: MockKimiHarness, + log: mocks.log, + }; +}); + +vi.mock('../../src/cli/telemetry', () => ({ + createCliTelemetryBootstrap: mocks.createCliTelemetryBootstrap, + initializeCliTelemetry: mocks.initializeCliTelemetry, +})); + +vi.mock('../../src/cli/sub/upgrade', () => ({ + handleUpgrade: mocks.handleUpgrade, })); vi.mock('../../src/cli/commands', () => ({ createProgram: mocks.createProgram, })); -vi.mock('../../src/cli/version', () => ({ - getVersion: mocks.getVersion, -})); +vi.mock('../../src/cli/version', async () => { + const actual = await vi.importActual( + '../../src/cli/version.js', + ); + return { + ...actual, + getVersion: mocks.getVersion, + }; +}); vi.mock('../../src/cli/options', async () => { const actual = await vi.importActual('../../src/cli/options.js'); @@ -94,6 +159,23 @@ async function runHandleMainCommand(opts: CLIOptions): Promise { } } +async function runHandleUpgradeCommand(): Promise { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { + throw new ExitCalled(Number(code ?? 0)); + }); + try { + await handleUpgradeCommand('0.0.1-alpha.2'); + throw new Error('expected process.exit'); + } catch (error) { + if (error instanceof ExitCalled) { + return error.code; + } + throw error; + } finally { + exitSpy.mockRestore(); + } +} + describe('main entry command handling', () => { afterEach(() => { vi.clearAllMocks(); @@ -101,6 +183,14 @@ describe('main entry command handling', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.harness.ensureConfigFile.mockResolvedValue(undefined); + mocks.harness.getConfig.mockResolvedValue({ + defaultModel: 'kimi-k2', + telemetry: true, + }); + mocks.harness.close.mockResolvedValue(undefined); + mocks.shutdownTelemetry.mockResolvedValue(undefined); + mocks.handleUpgrade.mockResolvedValue(0); }); it('runs update preflight before starting the shell', async () => { @@ -177,6 +267,44 @@ describe('main entry command handling', () => { expect(runShell).not.toHaveBeenCalled(); }); + it('initializes and flushes telemetry around the upgrade command', async () => { + const exitCode = await runHandleUpgradeCommand(); + + expect(exitCode).toBe(0); + expect(mocks.createCliTelemetryBootstrap).toHaveBeenCalledTimes(1); + expect(mocks.KimiHarness).toHaveBeenCalledWith(expect.objectContaining({ + homeDir: '/tmp/kimi-home', + telemetry: { + track: mocks.track, + withContext: mocks.withTelemetryContext, + setContext: mocks.setTelemetryContext, + }, + })); + expect(mocks.harness.ensureConfigFile).toHaveBeenCalledTimes(1); + expect(mocks.initializeCliTelemetry).toHaveBeenCalledWith(expect.objectContaining({ + harness: expect.objectContaining({ + homeDir: '/tmp/kimi-home', + }), + bootstrap: { + homeDir: '/tmp/kimi-home', + deviceId: 'device-id', + firstLaunch: false, + }, + config: { + defaultModel: 'kimi-k2', + telemetry: true, + }, + version: '0.0.1-alpha.2', + uiMode: 'shell', + })); + expect(mocks.handleUpgrade).toHaveBeenCalledWith('0.0.1-alpha.2', { + track: mocks.track, + logger: mocks.log, + }); + expect(mocks.shutdownTelemetry).toHaveBeenCalledWith({ timeoutMs: 3000 }); + expect(mocks.harness.close).toHaveBeenCalledTimes(1); + }); + it('formats Kimi startup errors with structured fields', () => { const error = new KimiError( ErrorCodes.SHELL_GIT_BASH_NOT_FOUND, diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 0a8fd774..9b931710 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -256,12 +256,36 @@ describe('CLI options parsing', () => { }); describe('sub-commands', () => { - it('registers the diagnostic sub-commands during alpha', () => { + it('routes upgrade without calling the main action', () => { + let upgradeCalls = 0; + const program = createProgram( + '0.0.0', + () => { + throw new Error('main action should not run'); + }, + () => {}, + () => {}, + () => { + upgradeCalls += 1; + }, + ); + program.exitOverride(); + program.configureOutput({ + writeOut: () => {}, + writeErr: () => {}, + }); + + program.parse(['node', 'kimi', 'upgrade']); + + expect(upgradeCalls).toBe(1); + }); + + it('registers the visible sub-commands', () => { const program = createProgram('0.0.0', () => {}, () => {}); const commandNames: string[] = program.commands .filter((command) => !command.name().startsWith('__')) .map((command) => command.name()); - expect(commandNames).toEqual(['export', 'migrate']); + expect(commandNames).toEqual(['export', 'migrate', 'upgrade']); }); }); diff --git a/apps/kimi-code/test/cli/update/cache.test.ts b/apps/kimi-code/test/cli/update/cache.test.ts index a1ba436b..b7760fb8 100644 --- a/apps/kimi-code/test/cli/update/cache.test.ts +++ b/apps/kimi-code/test/cli/update/cache.test.ts @@ -4,9 +4,14 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + emptyUpdateInstallState, + readUpdateInstallState, + writeUpdateInstallState, +} from '#/cli/update/install-state'; import { readUpdateCache, writeUpdateCache } from '#/cli/update/cache'; -import { emptyUpdateCache } from '#/cli/update/types'; -import { getUpdateStateFile } from '#/utils/paths'; +import { emptyUpdateCache, type UpdateInstallState } from '#/cli/update/types'; +import { getUpdateInstallStateFile, getUpdateStateFile } from '#/utils/paths'; const originalEnv = { ...process.env }; @@ -60,3 +65,40 @@ describe('update cache', () => { await expect(readUpdateCache()).resolves.toEqual(cache); }); }); + +describe('update install state', () => { + it('returns an empty install state when the file is missing', async () => { + await expect(readUpdateInstallState()).resolves.toEqual(emptyUpdateInstallState()); + }); + + it('falls back to an empty install state when the file is corrupt', async () => { + mkdirSync(join(dir, 'updates'), { recursive: true }); + writeFileSync(getUpdateInstallStateFile(), '{"broken"', 'utf-8'); + await expect(readUpdateInstallState()).resolves.toEqual(emptyUpdateInstallState()); + }); + + it('writes and reads back the install state from updates/install.json', async () => { + const state: UpdateInstallState = { + active: { + version: '0.5.0', + source: 'npm-global', + startedAt: '2026-04-23T08:00:00.000Z', + }, + lastFailure: { + version: '0.4.0', + failedAt: '2026-04-22T08:00:00.000Z', + attempts: 1, + }, + lastSuccess: { + version: '0.3.0', + installedAt: '2026-04-21T08:00:00.000Z', + notifiedAt: null, + }, + }; + + await writeUpdateInstallState(state); + + expect(getUpdateInstallStateFile()).toBe(join(dir, 'updates', 'install.json')); + await expect(readUpdateInstallState()).resolves.toEqual(state); + }); +}); diff --git a/apps/kimi-code/test/cli/update/install-lock.test.ts b/apps/kimi-code/test/cli/update/install-lock.test.ts new file mode 100644 index 00000000..f6b36a28 --- /dev/null +++ b/apps/kimi-code/test/cli/update/install-lock.test.ts @@ -0,0 +1,39 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { tryAcquireUpdateInstallLock } from '#/cli/update/install-lock'; +import { getUpdateInstallLockFile } from '#/utils/paths'; + +const originalEnv = { ...process.env }; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-update-install-lock-')); + process.env['KIMI_CODE_HOME'] = dir; +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + process.env = { ...originalEnv }; +}); + +describe('update install lock', () => { + it('allows only one holder until the lock is released', async () => { + const first = await tryAcquireUpdateInstallLock({ version: '0.5.0' }); + expect(first).not.toBeNull(); + expect(getUpdateInstallLockFile()).toBe(join(dir, 'updates', 'install.lock')); + + const second = await tryAcquireUpdateInstallLock({ version: '0.5.0' }); + expect(second).toBeNull(); + + await first?.release(); + + const third = await tryAcquireUpdateInstallLock({ version: '0.5.0' }); + expect(third).not.toBeNull(); + await third?.release(); + }); +}); diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index e497cba5..ad24577d 100644 --- a/apps/kimi-code/test/cli/update/preflight.test.ts +++ b/apps/kimi-code/test/cli/update/preflight.test.ts @@ -2,21 +2,31 @@ import type * as ChildProcess from 'node:child_process'; import { spawnSync } from 'node:child_process'; import { EventEmitter } from 'node:events'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { readUpdateCache } from '#/cli/update/cache'; +import { + emptyUpdateInstallState, + readUpdateInstallState, + writeUpdateInstallState, +} from '#/cli/update/install-state'; import { runUpdatePreflight, spawnForSource } from '#/cli/update/preflight'; -import { promptForInstallConfirmation } from '#/cli/update/prompt'; +import { promptForInstallChoice } from '#/cli/update/prompt'; import type * as PromptModule from '#/cli/update/prompt'; import { refreshUpdateCache } from '#/cli/update/refresh'; import type * as RefreshModule from '#/cli/update/refresh'; import { detectInstallSource } from '#/cli/update/source'; -import { emptyUpdateCache, type UpdateCache } from '#/cli/update/types'; +import { emptyUpdateCache, type UpdateCache, type UpdateInstallState } from '#/cli/update/types'; +import type { TuiConfig } from '#/tui/config'; const mocks = vi.hoisted(() => ({ readUpdateCache: vi.fn(), + readUpdateInstallState: vi.fn(), + writeUpdateInstallState: vi.fn(), + tryAcquireUpdateInstallLock: vi.fn(), + loadTuiConfig: vi.fn(), detectInstallSource: vi.fn(), - promptForInstallConfirmation: vi.fn(), + promptForInstallChoice: vi.fn(), refreshUpdateCache: vi.fn(), spawn: vi.fn(), })); @@ -25,6 +35,32 @@ vi.mock('../../../src/cli/update/cache', () => ({ readUpdateCache: mocks.readUpdateCache, })); +vi.mock('../../../src/cli/update/install-lock', () => ({ + tryAcquireUpdateInstallLock: mocks.tryAcquireUpdateInstallLock, +})); + +vi.mock('../../../src/cli/update/install-state', () => ({ + emptyUpdateInstallState: () => ({ + active: null, + lastFailure: null, + lastSuccess: null, + }), + readUpdateInstallState: mocks.readUpdateInstallState, + writeUpdateInstallState: mocks.writeUpdateInstallState, +})); + +vi.mock('../../../src/tui/config', () => ({ + loadTuiConfig: mocks.loadTuiConfig, + TuiConfigParseError: class TuiConfigParseError extends Error { + readonly fallback: TuiConfig; + + constructor(fallback: TuiConfig) { + super('Invalid client preferences in ~/.kimi-code/tui.toml; using defaults.'); + this.fallback = fallback; + } + }, +})); + vi.mock('../../../src/cli/update/source', () => ({ detectInstallSource: mocks.detectInstallSource, })); @@ -33,7 +69,7 @@ vi.mock('../../../src/cli/update/prompt', async () => { const actual = await vi.importActual('../../../src/cli/update/prompt.js'); return { ...actual, - promptForInstallConfirmation: mocks.promptForInstallConfirmation, + promptForInstallChoice: mocks.promptForInstallChoice, }; }); @@ -61,6 +97,29 @@ function cacheWith(version: string): UpdateCache { }; } +function installState(overrides: Partial = {}): UpdateInstallState { + return { + active: null, + lastFailure: null, + lastSuccess: null, + ...overrides, + }; +} + +function tuiConfig(overrides: Partial = {}): TuiConfig { + return { + theme: 'auto', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, + ...overrides, + }; +} + +function disableAutoInstall(): void { + mocks.loadTuiConfig.mockResolvedValue(tuiConfig({ upgrade: { autoInstall: false } })); +} + function captureOutput(): { stdout: string[]; stderr: string[]; @@ -83,15 +142,47 @@ function captureOutput(): { }; } +type TestLogFn = ReturnType void>>; + +function captureLogger(): { + info: TestLogFn; + warn: TestLogFn; + error: TestLogFn; + debug: TestLogFn; +} { + return { + info: vi.fn<(message: string, payload?: unknown) => void>(), + warn: vi.fn<(message: string, payload?: unknown) => void>(), + error: vi.fn<(message: string, payload?: unknown) => void>(), + debug: vi.fn<(message: string, payload?: unknown) => void>(), + }; +} + function mockSpawnExit(code: number, signal: NodeJS.Signals | null = null): void { mocks.spawn.mockImplementation(() => { - const child = new EventEmitter(); + const child = Object.assign(new EventEmitter(), { unref: vi.fn() }); queueMicrotask(() => { child.emit('exit', code, signal); }); return child; }); } +async function flushBackgroundInstall(): Promise { + await new Promise((resolve) => { + setImmediate(resolve); + }); +} + describe('runUpdatePreflight', () => { + beforeEach(() => { + mocks.readUpdateInstallState.mockResolvedValue(emptyUpdateInstallState()); + mocks.writeUpdateInstallState.mockResolvedValue(undefined); + mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); + mocks.tryAcquireUpdateInstallLock.mockResolvedValue({ + filePath: '/tmp/kimi-update-install.lock', + release: vi.fn().mockResolvedValue(undefined), + }); + }); + afterEach(() => { vi.clearAllMocks(); }); it('continues on first launch with empty cache, still refreshes in background', async () => { @@ -115,16 +206,17 @@ describe('runUpdatePreflight', () => { expect(detectInstallSource).not.toHaveBeenCalled(); }); - it('npm-global: prompts and spawns npm install -g', async () => { + it('npm-global: prompts and spawns npm install -g when automatic updates are disabled', async () => { + disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.promptForInstallConfirmation.mockResolvedValue(true); + mocks.promptForInstallChoice.mockResolvedValue('install'); mockSpawnExit(0); const { stdout, options } = captureOutput(); await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('exit'); - expect(mocks.promptForInstallConfirmation).toHaveBeenCalledWith( + expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( expect.objectContaining({ installCommand: 'npm install -g @moonshot-ai/kimi-code@0.5.0', installSource: 'npm-global', @@ -139,10 +231,11 @@ describe('runUpdatePreflight', () => { }); it('pnpm-global: spawns pnpm add -g', async () => { + disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('pnpm-global'); - mocks.promptForInstallConfirmation.mockResolvedValue(true); + mocks.promptForInstallChoice.mockResolvedValue('install'); mockSpawnExit(0); const { options } = captureOutput(); await runUpdatePreflight('0.4.0', options); @@ -154,10 +247,11 @@ describe('runUpdatePreflight', () => { }); it('yarn-global: spawns yarn global add', async () => { + disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('yarn-global'); - mocks.promptForInstallConfirmation.mockResolvedValue(true); + mocks.promptForInstallChoice.mockResolvedValue('install'); mockSpawnExit(0); const { options } = captureOutput(); await runUpdatePreflight('0.4.0', options); @@ -169,10 +263,11 @@ describe('runUpdatePreflight', () => { }); it('bun-global: spawns bun add -g', async () => { + disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('bun-global'); - mocks.promptForInstallConfirmation.mockResolvedValue(true); + mocks.promptForInstallChoice.mockResolvedValue('install'); mockSpawnExit(0); const { options } = captureOutput(); await runUpdatePreflight('0.4.0', options); @@ -184,10 +279,11 @@ describe('runUpdatePreflight', () => { }); it('native on darwin: spawns bash -c with pipefail-guarded curl|bash', async () => { + disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('native'); - mocks.promptForInstallConfirmation.mockResolvedValue(true); + mocks.promptForInstallChoice.mockResolvedValue('install'); mockSpawnExit(0); const originalPlatform = process.platform; Object.defineProperty(process, 'platform', { value: 'darwin' }); @@ -219,7 +315,7 @@ describe('runUpdatePreflight', () => { const { stdout, options } = captureOutput(); await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); expect(stdout.join('')).toContain('irm https://code.kimi.com/kimi-code/install.ps1 | iex'); - expect(promptForInstallConfirmation).not.toHaveBeenCalled(); + expect(promptForInstallChoice).not.toHaveBeenCalled(); expect(mocks.spawn).not.toHaveBeenCalled(); } finally { Object.defineProperty(process, 'platform', { value: originalPlatform }); @@ -237,20 +333,22 @@ describe('runUpdatePreflight', () => { }); it('declined install continues without spawn', async () => { + disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.promptForInstallConfirmation.mockResolvedValue(false); + mocks.promptForInstallChoice.mockResolvedValue('skip'); const { options } = captureOutput(); await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); expect(mocks.spawn).not.toHaveBeenCalled(); }); it('warns and continues when spawn exits non-zero, without claiming success', async () => { + disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.promptForInstallConfirmation.mockResolvedValue(true); + mocks.promptForInstallChoice.mockResolvedValue('install'); mockSpawnExit(1); const { stdout, stderr, options } = captureOutput(); await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); @@ -259,11 +357,289 @@ describe('runUpdatePreflight', () => { expect(stdout.join('')).not.toContain('Updated @moonshot-ai/kimi-code'); }); + it('starts an automatic update in the background by default', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/), + ['install', '-g', '@moonshot-ai/kimi-code@0.5.0'], + { detached: true, stdio: 'ignore' }, + ); + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: expect.objectContaining({ + version: '0.5.0', + source: 'npm-global', + startedAt: expect.any(String), + }), + lastFailure: null, + })); + + await flushBackgroundInstall(); + + expect(writeUpdateInstallState).toHaveBeenLastCalledWith(expect.objectContaining({ + active: null, + lastFailure: null, + lastSuccess: expect.objectContaining({ + version: '0.5.0', + installedAt: expect.any(String), + notifiedAt: null, + }), + })); + }); + + it('tracks and logs successful background update installs', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + const track = vi.fn(); + const logger = captureLogger(); + + await expect(runUpdatePreflight('0.4.0', { ...options, track, logger })).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(track).toHaveBeenCalledWith('update_background_install_started', expect.objectContaining({ + current_version: '0.4.0', + target_version: '0.5.0', + source: 'npm-global', + })); + expect(track).toHaveBeenCalledWith('update_background_install_succeeded', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + })); + expect(logger.info).toHaveBeenCalledWith('background update install started', expect.objectContaining({ + currentVersion: '0.4.0', + targetVersion: '0.5.0', + source: 'npm-global', + })); + expect(logger.info).toHaveBeenCalledWith('background update install succeeded', expect.objectContaining({ + targetVersion: '0.5.0', + source: 'npm-global', + })); + }); + + it('defaults to automatic background updates when client preferences cannot be loaded', async () => { + mocks.loadTuiConfig.mockRejectedValue(new Error('broken tui.toml')); + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/), + ['install', '-g', '@moonshot-ai/kimi-code@0.5.0'], + { detached: true, stdio: 'ignore' }, + ); + }); + + it('starts only one background update when two sessions preflight concurrently', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + let acquired = false; + mocks.tryAcquireUpdateInstallLock.mockImplementation(async () => { + if (acquired) return null; + acquired = true; + return { + filePath: '/tmp/kimi-update-install.lock', + release: vi.fn().mockResolvedValue(undefined), + }; + }); + mockSpawnExit(0); + const first = captureOutput(); + const second = captureOutput(); + + await expect(Promise.all([ + runUpdatePreflight('0.4.0', first.options), + runUpdatePreflight('0.4.0', second.options), + ])).resolves.toEqual(['continue', 'continue']); + + expect(mocks.spawn).toHaveBeenCalledTimes(1); + }); + + it('records the first background failure silently so the next launch can retry', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(1); + const { stderr, options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(stderr.join('')).toBe(''); + expect(writeUpdateInstallState).toHaveBeenLastCalledWith(expect.objectContaining({ + active: null, + lastFailure: expect.objectContaining({ + version: '0.5.0', + attempts: 1, + failedAt: expect.any(String), + }), + lastSuccess: null, + })); + }); + + it('tracks and logs background update install failures without writing stderr', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(1); + const { stderr, options } = captureOutput(); + const track = vi.fn(); + const logger = captureLogger(); + + await expect(runUpdatePreflight('0.4.0', { ...options, track, logger })).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(stderr.join('')).toBe(''); + expect(track).toHaveBeenCalledWith('update_background_install_failed', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + attempts: 1, + })); + expect(logger.warn).toHaveBeenCalledWith('background update install failed', expect.objectContaining({ + targetVersion: '0.5.0', + source: 'npm-global', + attempts: 1, + })); + }); + + it('retries automatic update once after the first background failure', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + lastFailure: { + version: '0.5.0', + failedAt: '2026-04-23T08:00:00.000Z', + attempts: 1, + }, + })); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(1); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + expect(writeUpdateInstallState).toHaveBeenLastCalledWith(expect.objectContaining({ + lastFailure: expect.objectContaining({ + version: '0.5.0', + attempts: 2, + }), + })); + }); + + it('prompts for manual foreground install after two background failures', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + lastFailure: { + version: '0.5.0', + failedAt: '2026-04-23T08:00:00.000Z', + attempts: 2, + }, + })); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('skip'); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + + expect(promptForInstallChoice).toHaveBeenCalledWith(expect.objectContaining({ + target: { version: '0.5.0' }, + installSource: 'npm-global', + })); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('shows a one-shot notice after a background update succeeds and the new version starts', async () => { + mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache()); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + lastSuccess: { + version: '0.5.0', + installedAt: '2026-04-23T08:00:00.000Z', + notifiedAt: null, + }, + })); + mocks.refreshUpdateCache.mockResolvedValue(emptyUpdateCache()); + const { stdout, options } = captureOutput(); + const track = vi.fn(); + const logger = captureLogger(); + + await expect(runUpdatePreflight('0.5.0', { ...options, track, logger })).resolves.toBe('continue'); + + const rendered = stdout.join(''); + expect(rendered).toContain('Kimi Code updated to v0.5.0'); + expect(rendered).toContain( + 'https://moonshotai.github.io/kimi-code/en/release-notes/changelog.html', + ); + expect(track).toHaveBeenCalledWith('update_success_notice_shown', expect.objectContaining({ + version: '0.5.0', + inferred_from_active: false, + })); + expect(logger.info).toHaveBeenCalledWith('background update success notice shown', expect.objectContaining({ + version: '0.5.0', + inferredFromActive: false, + })); + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + lastSuccess: expect.objectContaining({ + version: '0.5.0', + notifiedAt: expect.any(String), + }), + })); + expect(detectInstallSource).not.toHaveBeenCalled(); + }); + + it('infers a background update success notice when the active install version is now running', async () => { + mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache()); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + active: { + version: '0.5.0', + source: 'npm-global', + startedAt: '2026-04-23T08:00:00.000Z', + }, + })); + mocks.refreshUpdateCache.mockResolvedValue(emptyUpdateCache()); + const { stdout, options } = captureOutput(); + + await expect(runUpdatePreflight('0.5.0', options)).resolves.toBe('continue'); + + expect(stdout.join('')).toContain('Kimi Code updated to v0.5.0'); + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: null, + lastFailure: null, + lastSuccess: expect.objectContaining({ + version: '0.5.0', + notifiedAt: expect.any(String), + }), + })); + }); + it('tracks update_prompted telemetry', async () => { + disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.promptForInstallConfirmation.mockResolvedValue(false); + mocks.promptForInstallChoice.mockResolvedValue('skip'); const { options } = captureOutput(); const track = vi.fn(); await runUpdatePreflight('0.4.0', { ...options, track }); diff --git a/apps/kimi-code/test/cli/update/prompt.test.ts b/apps/kimi-code/test/cli/update/prompt.test.ts index 86449268..12644049 100644 --- a/apps/kimi-code/test/cli/update/prompt.test.ts +++ b/apps/kimi-code/test/cli/update/prompt.test.ts @@ -6,7 +6,7 @@ import { createInstallPromptChoices, getDefaultInstallPromptSelection, moveInstallPromptSelection, - promptForInstallConfirmation, + promptForInstallChoice, } from '#/cli/update/prompt'; describe('install prompt helpers', () => { @@ -32,7 +32,7 @@ describe('install prompt helpers', () => { }); }); -describe('promptForInstallConfirmation', () => { +describe('promptForInstallChoice', () => { it('renders changelog hyperlink in the prompt output', async () => { const CHANGELOG_URL = 'https://moonshotai.github.io/kimi-code/en/release-notes/changelog.html'; @@ -51,7 +51,7 @@ describe('promptForInstallConfirmation', () => { }, } as NodeJS.WriteStream; - const promptPromise = promptForInstallConfirmation({ + const promptPromise = promptForInstallChoice({ currentVersion: '0.4.0', target: { version: '0.5.0' }, installCommand: 'npm install -g @moonshot-ai/kimi-code@0.5.0', diff --git a/apps/kimi-code/test/cli/upgrade.test.ts b/apps/kimi-code/test/cli/upgrade.test.ts new file mode 100644 index 00000000..124c91f6 --- /dev/null +++ b/apps/kimi-code/test/cli/upgrade.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { handleUpgrade } from '#/cli/sub/upgrade'; +import type { InstallPromptChoiceValue } from '#/cli/update/prompt'; +import type { InstallSource, UpdateCache } from '#/cli/update/types'; + +function cacheWith(version: string | null): UpdateCache { + return { + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: version, + }; +} + +function captureOutput(): { + stdout: string[]; + stderr: string[]; + writable: { + stdout: { write(chunk: string): boolean }; + stderr: { write(chunk: string): boolean }; + }; +} { + const stdout: string[] = []; + const stderr: string[] = []; + return { + stdout, + stderr, + writable: { + stdout: { write: (chunk: string) => { stdout.push(chunk); return true; } }, + stderr: { write: (chunk: string) => { stderr.push(chunk); return true; } }, + }, + }; +} + +function createDeps(overrides: { + readonly latest?: string | null; + readonly source?: InstallSource; + readonly promptForInstallChoice?: () => Promise; + readonly installUpdate?: (source: InstallSource, version: string, platform: NodeJS.Platform) => Promise; +} = {}) { + const installUpdate = + overrides.installUpdate ?? + vi.fn<( + source: InstallSource, + version: string, + platform: NodeJS.Platform, + ) => Promise>().mockResolvedValue(undefined); + + return { + refreshUpdateCache: vi.fn().mockResolvedValue(cacheWith(overrides.latest ?? '0.5.0')), + detectInstallSource: vi.fn().mockResolvedValue(overrides.source ?? 'npm-global'), + promptForInstallChoice: + overrides.promptForInstallChoice ?? vi.fn().mockResolvedValue('install'), + installUpdate, + track: vi.fn(), + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + platform: 'darwin' as NodeJS.Platform, + }; +} + +describe('handleUpgrade', () => { + it('prompts before installing the latest version when the install source supports it', async () => { + const { stdout, stderr, writable } = captureOutput(); + const deps = createDeps({ latest: '0.5.0', source: 'npm-global' }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.refreshUpdateCache).toHaveBeenCalledTimes(1); + expect(deps.detectInstallSource).toHaveBeenCalledTimes(1); + expect(deps.promptForInstallChoice).toHaveBeenCalledWith({ + currentVersion: '0.4.0', + target: { version: '0.5.0' }, + installCommand: 'npm install -g @moonshot-ai/kimi-code@0.5.0', + installSource: 'npm-global', + }); + expect(deps.installUpdate).toHaveBeenCalledWith('npm-global', '0.5.0', 'darwin'); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_prompted', expect.objectContaining({ + current_version: '0.4.0', + target_version: '0.5.0', + source: 'npm-global', + })); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_install_selected', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + })); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_succeeded', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + })); + expect(deps.logger.info).toHaveBeenCalledWith('manual upgrade install succeeded', expect.objectContaining({ + targetVersion: '0.5.0', + source: 'npm-global', + })); + expect(stdout.join('')).toContain('Updated @moonshot-ai/kimi-code to 0.5.0'); + expect(stderr.join('')).toBe(''); + }); + + it('skips the foreground install when the update prompt is declined', async () => { + const { stdout, writable } = captureOutput(); + const deps = createDeps({ + latest: '0.5.0', + source: 'npm-global', + promptForInstallChoice: vi.fn().mockResolvedValue('skip'), + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.promptForInstallChoice).toHaveBeenCalledTimes(1); + expect(deps.installUpdate).not.toHaveBeenCalled(); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_skipped', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + })); + expect(stdout.join('')).toBe(''); + }); + + it('prints up-to-date status without detecting the install source when no newer version exists', async () => { + const { stdout, writable } = captureOutput(); + const deps = createDeps({ latest: '0.4.0' }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.detectInstallSource).not.toHaveBeenCalled(); + expect(deps.installUpdate).not.toHaveBeenCalled(); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_no_update', expect.objectContaining({ + current_version: '0.4.0', + })); + expect(stdout.join('')).toContain('Kimi Code is already up to date (v0.4.0).'); + }); + + it('prints the manual update command when the install source cannot be auto-installed', async () => { + const { stdout, writable } = captureOutput(); + const deps = createDeps({ latest: '0.5.0', source: 'unsupported' }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.installUpdate).not.toHaveBeenCalled(); + expect(deps.promptForInstallChoice).not.toHaveBeenCalled(); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_manual_command', expect.objectContaining({ + target_version: '0.5.0', + source: 'unsupported', + })); + expect(stdout.join('')).toContain('To update manually, run: npm install -g @moonshot-ai/kimi-code@0.5.0'); + }); + + it('returns a failing exit code when the foreground install fails', async () => { + const { stderr, writable } = captureOutput(); + const deps = createDeps({ + latest: '0.5.0', + source: 'npm-global', + installUpdate: vi.fn().mockRejectedValue(new Error('npm exited with code 1')), + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(1); + + expect(stderr.join('')).toContain( + 'warning: failed to install @moonshot-ai/kimi-code@0.5.0: npm exited with code 1', + ); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_failed', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + stage: 'install', + })); + expect(deps.logger.warn).toHaveBeenCalledWith('manual upgrade install failed', expect.objectContaining({ + targetVersion: '0.5.0', + source: 'npm-global', + })); + }); + + it('returns a failing exit code when checking the latest version fails', async () => { + const { stderr, writable } = captureOutput(); + const deps = { + ...createDeps(), + refreshUpdateCache: vi.fn().mockRejectedValue(new Error('cdn unavailable')), + }; + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(1); + + expect(deps.detectInstallSource).not.toHaveBeenCalled(); + expect(deps.installUpdate).not.toHaveBeenCalled(); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_failed', expect.objectContaining({ + current_version: '0.4.0', + stage: 'refresh', + })); + expect(stderr.join('')).toContain('error: failed to check for updates: cdn unavailable'); + }); +}); diff --git a/apps/kimi-code/test/tui/activity-pane.test.ts b/apps/kimi-code/test/tui/activity-pane.test.ts index ec954e77..7b657d7c 100644 --- a/apps/kimi-code/test/tui/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/activity-pane.test.ts @@ -24,6 +24,7 @@ function makeStartupInput(): KimiTUIStartupInput { theme: 'dark', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, }, version: '0.0.0-test', workDir: '/tmp/proj-a', diff --git a/apps/kimi-code/test/tui/commands/update-preferences.test.ts b/apps/kimi-code/test/tui/commands/update-preferences.test.ts new file mode 100644 index 00000000..910fc4a5 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/update-preferences.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { applyUpdatePreferenceChoice } from '#/tui/commands/config'; +import { darkColors } from '#/tui/theme/colors'; + +const mocks = vi.hoisted(() => ({ + saveTuiConfig: vi.fn(), +})); + +vi.mock('../../../src/tui/config', async () => { + const actual = await vi.importActual( + '../../../src/tui/config.js', + ); + return { + ...actual, + saveTuiConfig: mocks.saveTuiConfig, + }; +}); + +describe('update preference commands', () => { + it('saves automatic update preference changes to tui.toml', async () => { + const setAppState = vi.fn(); + const showStatus = vi.fn(); + const track = vi.fn(); + const host = { + state: { + appState: { + theme: 'auto' as const, + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' as const }, + upgrade: { autoInstall: true }, + }, + theme: { colors: darkColors }, + }, + setAppState, + showStatus, + track, + }; + + await applyUpdatePreferenceChoice(host, false); + + expect(mocks.saveTuiConfig).toHaveBeenCalledWith({ + theme: 'auto', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: false }, + }); + expect(setAppState).toHaveBeenCalledWith({ upgrade: { autoInstall: false } }); + expect(track).toHaveBeenCalledWith('upgrade_preference_changed', { auto_install: false }); + expect(showStatus).toHaveBeenCalledWith('Automatic updates disabled.'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index fcfc9a44..a9f6eadd 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -50,6 +50,7 @@ const appState: AppState = { theme: 'dark', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, availableModels: {}, availableProviders: {}, mcpServersSummary: null, diff --git a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts index 773e39a0..163b3b53 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -27,6 +27,7 @@ const appState: AppState = { theme: 'dark', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, availableModels: {}, availableProviders: {}, mcpServersSummary: null, diff --git a/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts index 7f47bbe1..28588909 100644 --- a/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts @@ -6,6 +6,7 @@ import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector' import { PermissionSelectorComponent } from '#/tui/components/dialogs/permission-selector'; import { SettingsSelectorComponent } from '#/tui/components/dialogs/settings-selector'; import { ThemeSelectorComponent } from '#/tui/components/dialogs/theme-selector'; +import { UpdatePreferenceSelectorComponent } from '#/tui/components/dialogs/update-preference-selector'; import { darkColors } from '#/tui/theme/colors'; const ANSI_SGR = /\u001B\[[0-9;]*m/g; @@ -100,6 +101,17 @@ describe('ChoicePickerComponent', () => { const settingsOutput = settings.render(120).map(strip); expect(settingsOutput).toContain(' ❯ Model'); expect(settingsOutput).toContain(' Switch the active model and thinking mode.'); + expect(settingsOutput).toContain(' Turn automatic CLI updates on or off.'); + + const upgradePreference = new UpdatePreferenceSelectorComponent({ + currentValue: true, + colors: darkColors, + onSelect, + onCancel, + }); + const upgradePreferenceOutput = upgradePreference.render(120).map(strip); + expect(upgradePreferenceOutput).toContain(' ❯ On ← current'); + expect(upgradePreferenceOutput).toContain(' Install new versions in the background.'); }); it('submits the selected model and inline thinking state', () => { diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index a7ddf450..82bef301 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -32,9 +32,11 @@ describe('TUI config', () => { expect(result).toEqual(DEFAULT_TUI_CONFIG); const text = readFileSync(filePath, 'utf-8'); - expect(text).toContain('Terminal UI preferences for kimi-code.'); + expect(text).toContain('Client preferences for kimi-code.'); expect(text).toContain('theme = "auto"'); expect(text).toContain('command = ""'); + expect(text).toContain('[upgrade]'); + expect(text).toContain('auto_install = true'); expect(text).toContain('[notifications]'); expect(text).toContain('enabled = true'); expect(text).toContain('notification_condition = "unfocused"'); @@ -50,12 +52,16 @@ command = "code --wait" [notifications] enabled = false notification_condition = "always" + +[upgrade] +auto_install = false `); expect(config).toEqual({ theme: 'light', editorCommand: 'code --wait', notifications: { enabled: false, condition: 'always' }, + upgrade: { autoInstall: false }, }); }); @@ -69,6 +75,7 @@ command = " " theme: 'auto', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, }); }); @@ -76,6 +83,7 @@ command = " " const config = parseTuiConfig(`theme = "dark"`); expect(config.notifications).toEqual({ enabled: true, condition: 'unfocused' }); + expect(config.upgrade).toEqual({ autoInstall: true }); }); it('throws TuiConfigParseError with fallback when parsing fails, leaving the file untouched', async () => { @@ -98,6 +106,7 @@ command = " " theme: 'light', editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, + upgrade: { autoInstall: false }, }, filePath, ); @@ -106,6 +115,7 @@ command = " " theme: 'light', editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, + upgrade: { autoInstall: false }, }); }); }); diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 3ca65a9a..25a55263 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -23,6 +23,7 @@ function fakeInitialAppState(): AppState { version: '0.0.0-test', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, availableModels: {}, availableProviders: {}, sessionTitle: null, diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 8cdac1c1..74abf124 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -97,6 +97,7 @@ function makeStartupInput(): KimiTUIStartupInput { theme: 'dark', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, }, version: '0.0.0-test', workDir: '/tmp/proj-a', diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index de33c6ab..93597bb6 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -12,11 +12,6 @@ import { promptPlatformSelection, promptLogoutProviderSelection, } from "#/tui/commands/prompts"; - -vi.mock("#/tui/commands/prompts", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, promptPlatformSelection: vi.fn(), promptLogoutProviderSelection: vi.fn() }; -}); import { DISABLE_TERMINAL_THEME_REPORTING, ENABLE_TERMINAL_THEME_REPORTING, @@ -25,6 +20,11 @@ import { TERMINAL_THEME_LIGHT, } from "#/tui/utils/terminal-theme"; +vi.mock("#/tui/commands/prompts", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, promptPlatformSelection: vi.fn(), promptLogoutProviderSelection: vi.fn() }; +}); + interface StartupDriver { state: TUIState; init(): Promise; @@ -78,6 +78,7 @@ function makeStartupInput( theme: "dark", editorCommand: null, notifications: { enabled: true, condition: "unfocused" }, + upgrade: { autoInstall: true }, ...tuiConfig, }, version: "0.0.0-test", diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 2f8a8659..0a73dd2f 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -43,6 +43,7 @@ function makeStartupInput(): KimiTUIStartupInput { theme: 'dark', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, }, version: '0.0.0-test', workDir: '/tmp/proj-a', diff --git a/apps/kimi-code/test/tui/signal-handlers.test.ts b/apps/kimi-code/test/tui/signal-handlers.test.ts index f240f70f..3baa78f3 100644 --- a/apps/kimi-code/test/tui/signal-handlers.test.ts +++ b/apps/kimi-code/test/tui/signal-handlers.test.ts @@ -26,6 +26,7 @@ function makeStartupInput(): KimiTUIStartupInput { theme: 'dark', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, }, version: '0.0.0-test', workDir: '/tmp/proj-signals', diff --git a/apps/kimi-code/test/utils/paths.test.ts b/apps/kimi-code/test/utils/paths.test.ts index 5c9d3dc0..b7d61e60 100644 --- a/apps/kimi-code/test/utils/paths.test.ts +++ b/apps/kimi-code/test/utils/paths.test.ts @@ -4,7 +4,13 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { getDataDir, getInputHistoryFile, getLogDir, getUpdateStateFile } from '#/utils/paths'; +import { + getDataDir, + getInputHistoryFile, + getLogDir, + getUpdateInstallStateFile, + getUpdateStateFile, +} from '#/utils/paths'; const originalEnv = { ...process.env }; @@ -54,6 +60,19 @@ describe('getUpdateStateFile', () => { }); }); +describe('getUpdateInstallStateFile', () => { + it('returns /updates/install.json', () => { + expect(getUpdateInstallStateFile()).toBe( + join(homedir(), '.kimi-code', 'updates', 'install.json'), + ); + }); + + it('respects KIMI_CODE_HOME', () => { + process.env['KIMI_CODE_HOME'] = '/updates-home'; + expect(getUpdateInstallStateFile()).toBe(join('/updates-home', 'updates', 'install.json')); + }); +}); + describe('getInputHistoryFile', () => { it('returns /user-history/.jsonl', () => { const workDir = '/home/user/project'; diff --git a/docs/en/configuration/data-locations.md b/docs/en/configuration/data-locations.md index 9dc69547..43196ccc 100644 --- a/docs/en/configuration/data-locations.md +++ b/docs/en/configuration/data-locations.md @@ -12,7 +12,7 @@ You can override it to any path with the `KIMI_CODE_HOME` environment variable: export KIMI_CODE_HOME="$HOME/.config/kimi-code" ``` -Once set, runtime data such as the config, sessions, logs, input history, update cache, and OAuth credentials lands under that path. For the full reference on `KIMI_CODE_HOME` and other environment variables, see [Environment variables](./env-vars.md). +Once set, runtime data such as the config, sessions, logs, input history, update state, and OAuth credentials lands under that path. For the full reference on `KIMI_CODE_HOME` and other environment variables, see [Environment variables](./env-vars.md). ::: tip Exceptions The **built-in tool cache** (such as the auto-downloaded ripgrep binary) does not follow `KIMI_CODE_HOME`. It uses `KIMI_CODE_CACHE_DIR`, falling back to a platform cache directory — `~/Library/Caches/kimi-code` on macOS, `$XDG_CACHE_HOME/kimi-code` (default `~/.cache/kimi-code`) on Linux, and `%LOCALAPPDATA%\kimi-code` on Windows. @@ -27,6 +27,7 @@ A typical layout under the data root looks like: ``` $KIMI_CODE_HOME (default ~/.kimi-code) ├── config.toml # User config +├── tui.toml # Client preferences, including automatic updates ├── mcp.json # User-level MCP server declarations (optional) ├── plugins/ │ ├── installed.json # Installed plugin records and capability state @@ -59,7 +60,9 @@ $KIMI_CODE_HOME (default ~/.kimi-code) ├── logs/ # Global diagnostic logs │ └── kimi-code.log ├── updates/ -│ └── latest.json # Update check status +│ ├── latest.json # Update check status +│ ├── install.json # Automatic update install state +│ └── install.lock # Automatic update launch lock (transient) └── user-history/ └── .jsonl ``` @@ -70,7 +73,9 @@ The tree above shows a typical layout under the default data root (`~/.kimi-code ## Config files -`config.toml` is Kimi Code CLI's main config file, holding user-level settings such as providers, models, and loop control. See [Config files](./config-files.md) for details. +`config.toml` is Kimi Code CLI's main runtime config file, holding user-level settings such as providers, models, and loop control. See [Config files](./config-files.md) for details. + +`tui.toml` stores client preferences for the `kimi` CLI, including terminal UI preferences and `[upgrade].auto_install`. Automatic updates are enabled by default; turn them off from `/settings` or by setting `auto_install = false`. `mcp.json` holds user-level MCP server declarations and is merged with the project-local `.kimi-code/mcp.json` at load time. The fields are the same as the project-level file; see [MCP](../customization/mcp.md) for details. @@ -111,7 +116,7 @@ The top-level `logs/kimi-code.log` is the global diagnostic log. It mainly recor When filing a bug report, prefer `kimi export` for the relevant session (see [The kimi command](../reference/kimi-command.md) for details). If a session log exists, it is included in the export by default. The global diagnostic log is also bundled by default; because it may contain events from other sessions or projects, use `--no-include-global-log` when you do not want to share it. -`updates/latest.json` records the version update status detected via npm and is maintained automatically by the CLI — there is usually no need to edit it by hand. +`updates/latest.json` records the latest version detected by the CLI. `updates/install.json` stores active background install state, retry state after failed background installs, and the one-shot success notice shown after a background update is applied. `updates/install.lock` is a transient atomic lock that prevents concurrent sessions from starting duplicate background installs. These files are maintained automatically by the CLI — there is usually no need to edit them by hand. ## Input history @@ -128,10 +133,13 @@ To clean up only part of the data: | Goal | Action | | --- | --- | | Reset config | Delete `~/.kimi-code/config.toml` | +| Reset client preferences | Delete `~/.kimi-code/tui.toml` | | Clear all sessions | Delete `~/.kimi-code/sessions/` and `~/.kimi-code/session_index.jsonl` | | Clear diagnostic logs | Delete the `~/.kimi-code/logs/` directory | | Clear input history | Delete the `~/.kimi-code/user-history/` directory | | Reset update check state | Delete `~/.kimi-code/updates/latest.json` | +| Reset automatic update install state | Delete `~/.kimi-code/updates/install.json` | +| Clear a stale automatic update launch lock | Delete `~/.kimi-code/updates/install.lock` | | Force a ripgrep redownload | Delete the `~/.kimi-code/bin/` directory | | Clear hosted Kimi / Open Platform OAuth login state | Run `/logout` (clears only the current provider's OAuth), or delete the corresponding `~/.kimi-code/credentials/.json` | | Clear MCP server OAuth login state | Delete the `~/.kimi-code/credentials/mcp/` directory; `/logout` **does not** clear MCP OAuth credentials | diff --git a/docs/en/guides/getting-started.md b/docs/en/guides/getting-started.md index f4cb8302..b0731a05 100644 --- a/docs/en/guides/getting-started.md +++ b/docs/en/guides/getting-started.md @@ -60,7 +60,13 @@ After installation, verify that the executable is ready: kimi --version ``` -**Upgrade**: if you installed via the script, re-run it. If you installed via npm: +**Upgrade**: run `kimi upgrade` to let the CLI check the latest version and show the update prompt: + +```sh +kimi upgrade +``` + +Choose `Install update now` to upgrade through the detected installation source, or continue with the current version. If the current installation cannot be upgraded automatically, the command prints the manual command to run. You can also upgrade directly with your package manager. For npm: ```sh npm install -g @moonshot-ai/kimi-code@latest diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index cca98e03..4ed00cc1 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -161,3 +161,13 @@ kimi migrate ``` If you previously used an older version of kimi-cli, run this command to migrate historical sessions, configuration, and other data to kimi-code to avoid data loss. For the full migration flow, what gets migrated, and things to watch out for, see [Migrating from kimi-cli](../guides/migration.md). + +### `kimi upgrade` + +Check the latest Kimi Code CLI version immediately and show the update prompt. This command has no flags and exits after the selected action. + +```sh +kimi upgrade +``` + +For global npm, pnpm, yarn, bun, and macOS / Linux native installs, `kimi upgrade` uses the same prompt as startup update checks. Choose `Install update now` to run the corresponding foreground install command, or continue with the current version. If no newer version is available, it prints the current up-to-date status. If the current installation cannot be upgraded automatically, such as Windows native installs or an unsupported layout, it prints the manual update command instead. diff --git a/docs/zh/configuration/data-locations.md b/docs/zh/configuration/data-locations.md index f755be9f..e124b7ae 100644 --- a/docs/zh/configuration/data-locations.md +++ b/docs/zh/configuration/data-locations.md @@ -12,7 +12,7 @@ Kimi Code CLI 将运行时数据集中存储在用户主目录下的 `~/.kimi-co export KIMI_CODE_HOME="$HOME/.config/kimi-code" ``` -设置后,配置、会话、日志、输入历史、更新缓存、OAuth 凭据等运行时数据都会落到该路径下。`KIMI_CODE_HOME` 与其他环境变量的完整说明见 [环境变量](./env-vars.md)。 +设置后,配置、会话、日志、输入历史、更新状态、OAuth 凭据等运行时数据都会落到该路径下。`KIMI_CODE_HOME` 与其他环境变量的完整说明见 [环境变量](./env-vars.md)。 ::: tip 例外 **内置工具缓存**(例如自动下载的 ripgrep 二进制)不走 `KIMI_CODE_HOME`,而是走 `KIMI_CODE_CACHE_DIR`;未设置时使用平台缓存目录——macOS 上是 `~/Library/Caches/kimi-code`,Linux 上是 `$XDG_CACHE_HOME/kimi-code`(缺省 `~/.cache/kimi-code`),Windows 上是 `%LOCALAPPDATA%\kimi-code`。 @@ -27,6 +27,7 @@ export KIMI_CODE_HOME="$HOME/.config/kimi-code" ``` $KIMI_CODE_HOME (默认 ~/.kimi-code) ├── config.toml # 用户配置 +├── tui.toml # 客户端偏好,包含自动更新设置 ├── mcp.json # 用户级 MCP server 声明(可选) ├── plugins/ │ ├── installed.json # 已安装 plugin 记录与能力状态 @@ -59,7 +60,9 @@ $KIMI_CODE_HOME (默认 ~/.kimi-code) ├── logs/ # 全局诊断日志 │ └── kimi-code.log ├── updates/ -│ └── latest.json # 更新检查状态 +│ ├── latest.json # 更新检查状态 +│ ├── install.json # 自动更新安装状态 +│ └── install.lock # 自动更新启动锁(临时) └── user-history/ └── .jsonl ``` @@ -70,7 +73,9 @@ $KIMI_CODE_HOME (默认 ~/.kimi-code) ## 配置文件 -`config.toml` 是 Kimi Code CLI 的主配置文件,存放供应商、模型、循环控制等用户级设置。详见 [配置文件](./config-files.md)。 +`config.toml` 是 Kimi Code CLI 的主运行时配置文件,存放供应商、模型、循环控制等用户级设置。详见 [配置文件](./config-files.md)。 + +`tui.toml` 保存 `kimi` CLI 的客户端偏好,包括终端界面偏好和 `[upgrade].auto_install`。自动更新默认开启;可以在 `/settings` 中关闭,或手动设置 `auto_install = false`。 `mcp.json` 是用户级 MCP server 声明,会与项目内的 `.kimi-code/mcp.json` 合并加载。字段与项目级文件相同,详见 [MCP](../customization/mcp.md)。 @@ -111,7 +116,7 @@ Kimi Code CLI 在首次需要 ripgrep 时会自动下载并缓存。下载过程 如需报告 bug,优先使用 `kimi export` 导出相关会话(详见 [kimi 命令](../reference/kimi-command.md));如果会话日志存在,它会默认包含在导出包里。全局诊断日志默认也会打包;因为它可能包含其它会话或其它项目的事件,不想分享时使用 `--no-include-global-log` 排除。 -`updates/latest.json` 记录通过 npm 检查到的版本更新状态,由 CLI 自动维护,通常无需手动编辑。 +`updates/latest.json` 记录 CLI 检测到的最新版本。`updates/install.json` 保存正在进行的后台安装、后台安装失败后的重试状态,以及后台更新生效后的一次性成功提示。`updates/install.lock` 是一个临时原子锁,用于避免多个并发 session 同时启动重复的后台安装。这些文件都由 CLI 自动维护,通常无需手动编辑。 ## 输入历史 @@ -128,10 +133,13 @@ Kimi Code CLI 在首次需要 ripgrep 时会自动下载并缓存。下载过程 | 需求 | 操作 | | --- | --- | | 重置配置 | 删除 `~/.kimi-code/config.toml` | +| 重置客户端偏好 | 删除 `~/.kimi-code/tui.toml` | | 清理所有会话 | 删除 `~/.kimi-code/sessions/` 与 `~/.kimi-code/session_index.jsonl` | | 清理诊断日志 | 删除 `~/.kimi-code/logs/` 目录 | | 清理输入历史 | 删除 `~/.kimi-code/user-history/` 目录 | | 重置更新检查状态 | 删除 `~/.kimi-code/updates/latest.json` | +| 重置自动更新安装状态 | 删除 `~/.kimi-code/updates/install.json` | +| 清理过期的自动更新启动锁 | 删除 `~/.kimi-code/updates/install.lock` | | 强制重新下载 ripgrep | 删除 `~/.kimi-code/bin/` 目录 | | 清除托管 Kimi / Open Platform OAuth 登录态 | 运行 `/logout`(仅清理当前供应商的 OAuth),或删除对应 `~/.kimi-code/credentials/.json` | | 清除 MCP server OAuth 登录态 | 删除 `~/.kimi-code/credentials/mcp/` 目录;`/logout` **不会**清理 MCP 的 OAuth 凭据 | diff --git a/docs/zh/guides/getting-started.md b/docs/zh/guides/getting-started.md index 142d942d..fb5c2d90 100644 --- a/docs/zh/guides/getting-started.md +++ b/docs/zh/guides/getting-started.md @@ -60,7 +60,13 @@ pnpm add -g @moonshot-ai/kimi-code kimi --version ``` -**升级**:脚本安装的用户重新运行脚本即可;npm 安装的用户执行: +**升级**:运行 `kimi upgrade`,CLI 会检查最新版本并展示更新提示: + +```sh +kimi upgrade +``` + +选择 `Install update now` 后,会根据当前检测到的安装来源执行升级;也可以继续使用当前版本。如果当前安装方式无法自动升级,该命令会打印需要手动执行的命令。你也可以直接使用包管理器升级。npm 安装的用户执行: ```sh npm install -g @moonshot-ai/kimi-code@latest diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index d50c9ae0..574adb9d 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -161,3 +161,13 @@ kimi migrate ``` 如果你之前使用过旧版 kimi-cli,可以运行此命令将历史会话、配置等数据迁移到 kimi-code 中,避免数据丢失。完整的迁移流程、迁移内容与注意事项见 [从 kimi-cli 迁移](../guides/migration.md)。 + +### `kimi upgrade` + +立即检查最新的 Kimi Code CLI 版本,并展示更新提示。该命令无任何 flag,所选操作结束后退出。 + +```sh +kimi upgrade +``` + +对于全局 npm、pnpm、yarn、bun,以及 macOS / Linux 的 native 安装,`kimi upgrade` 使用与启动更新检查相同的提示框。选择 `Install update now` 后会运行对应的前台安装命令,也可以继续使用当前版本。如果没有更新版本,它会提示当前已经是最新版本。如果当前安装方式无法自动升级,例如 Windows native 安装或不支持的安装布局,它会改为打印手动更新命令。 From dc3f52d6253119b0024325b9fd91ff95bb3869b1 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 2 Jun 2026 18:20:06 +0800 Subject: [PATCH 2/3] fix(cli): avoid upgrade prompt in non-interactive mode --- apps/kimi-code/src/cli/sub/upgrade.ts | 4 +++- apps/kimi-code/src/cli/update/preflight.ts | 19 +++++++++++++++---- apps/kimi-code/test/cli/upgrade.test.ts | 17 +++++++++++++++++ 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/upgrade.ts b/apps/kimi-code/src/cli/sub/upgrade.ts index 8b5c839e..c5471064 100644 --- a/apps/kimi-code/src/cli/sub/upgrade.ts +++ b/apps/kimi-code/src/cli/sub/upgrade.ts @@ -43,6 +43,7 @@ export interface UpgradeDeps { readonly platform: NodeJS.Platform; readonly stdout: WritableLike; readonly stderr: WritableLike; + readonly isInteractive: boolean; readonly track: UpgradeTrack; readonly logger: UpgradeLogger; } @@ -85,7 +86,7 @@ export async function handleUpgrade( const source = await deps.detectInstallSource().catch(() => 'unsupported' as const); const installCommand = installCommandFor(source, target.version, deps.platform); - if (!canAutoInstall(source, deps.platform)) { + if (!canAutoInstall(source, deps.platform) || !deps.isInteractive) { trackUpgradeEvent(deps.track, 'upgrade_command_manual_command', { current_version: currentVersion, target_version: target.version, @@ -180,6 +181,7 @@ function createDefaultUpgradeDeps(overrides: Partial): UpgradeDeps platform: overrides.platform ?? process.platform, stdout: overrides.stdout ?? process.stdout, stderr: overrides.stderr ?? process.stderr, + isInteractive: overrides.isInteractive ?? (process.stdin.isTTY && process.stdout.isTTY), track: overrides.track ?? trackTelemetry, logger: overrides.logger ?? log, }; diff --git a/apps/kimi-code/src/cli/update/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts index 5a183136..98148075 100644 --- a/apps/kimi-code/src/cli/update/preflight.ts +++ b/apps/kimi-code/src/cli/update/preflight.ts @@ -129,10 +129,21 @@ export function renderManualUpdateMessage( source: InstallSource, installCommand: string, ): string { - const sourceDesc = - source === 'native' - ? 'native (windows). Auto-update is not supported on this platform.' - : 'unsupported package manager or layout.'; + let sourceDesc: string; + switch (source) { + case 'npm-global': + case 'pnpm-global': + case 'yarn-global': + case 'bun-global': + sourceDesc = source; + break; + case 'native': + sourceDesc = 'native (windows). Auto-update is not supported on this platform.'; + break; + case 'unsupported': + sourceDesc = 'unsupported package manager or layout.'; + break; + } return ( `A newer version of ${NPM_PACKAGE_NAME} is available ` + `(${currentVersion} -> ${target.version}).\n` + diff --git a/apps/kimi-code/test/cli/upgrade.test.ts b/apps/kimi-code/test/cli/upgrade.test.ts index 124c91f6..8995f07c 100644 --- a/apps/kimi-code/test/cli/upgrade.test.ts +++ b/apps/kimi-code/test/cli/upgrade.test.ts @@ -35,6 +35,7 @@ function captureOutput(): { function createDeps(overrides: { readonly latest?: string | null; readonly source?: InstallSource; + readonly isInteractive?: boolean; readonly promptForInstallChoice?: () => Promise; readonly installUpdate?: (source: InstallSource, version: string, platform: NodeJS.Platform) => Promise; } = {}) { @@ -60,6 +61,7 @@ function createDeps(overrides: { debug: vi.fn(), }, platform: 'darwin' as NodeJS.Platform, + isInteractive: overrides.isInteractive ?? true, }; } @@ -148,6 +150,21 @@ describe('handleUpgrade', () => { expect(stdout.join('')).toContain('To update manually, run: npm install -g @moonshot-ai/kimi-code@0.5.0'); }); + it('prints the manual update command without prompting when not interactive', async () => { + const { stdout, writable } = captureOutput(); + const deps = createDeps({ latest: '0.5.0', source: 'npm-global', isInteractive: false }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.promptForInstallChoice).not.toHaveBeenCalled(); + expect(deps.installUpdate).not.toHaveBeenCalled(); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_manual_command', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + })); + expect(stdout.join('')).toContain('To update manually, run: npm install -g @moonshot-ai/kimi-code@0.5.0'); + }); + it('returns a failing exit code when the foreground install fails', async () => { const { stderr, writable } = captureOutput(); const deps = createDeps({ From d3ddbad3a1bc13294ddbb67de76c8f978908f7b2 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 2 Jun 2026 18:23:47 +0800 Subject: [PATCH 3/3] fix(cli): recover from corrupt update install locks --- apps/kimi-code/src/cli/update/install-lock.ts | 9 ++++++--- .../test/cli/update/install-lock.test.ts | 15 +++++++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/kimi-code/src/cli/update/install-lock.ts b/apps/kimi-code/src/cli/update/install-lock.ts index 2e561fe6..0b6f3834 100644 --- a/apps/kimi-code/src/cli/update/install-lock.ts +++ b/apps/kimi-code/src/cli/update/install-lock.ts @@ -30,13 +30,16 @@ function isAlreadyExists(error: unknown): boolean { async function isStaleLock(filePath: string, now: Date): Promise { try { const raw = await readFile(filePath, 'utf-8'); - const parsed = JSON.parse(raw) as { startedAt?: unknown }; - if (typeof parsed.startedAt !== 'string') return true; - const startedAt = Date.parse(parsed.startedAt); + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== 'object' || parsed === null) return true; + const lock = parsed as { readonly startedAt?: unknown }; + if (typeof lock.startedAt !== 'string') return true; + const startedAt = Date.parse(lock.startedAt); if (!Number.isFinite(startedAt)) return true; return now.getTime() - startedAt > UPDATE_INSTALL_LOCK_STALE_MS; } catch (error) { if (isNotFound(error)) return true; + if (error instanceof SyntaxError) return true; return false; } } diff --git a/apps/kimi-code/test/cli/update/install-lock.test.ts b/apps/kimi-code/test/cli/update/install-lock.test.ts index f6b36a28..fd7b568f 100644 --- a/apps/kimi-code/test/cli/update/install-lock.test.ts +++ b/apps/kimi-code/test/cli/update/install-lock.test.ts @@ -1,6 +1,6 @@ -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -36,4 +36,15 @@ describe('update install lock', () => { expect(third).not.toBeNull(); await third?.release(); }); + + it('recovers from a corrupt lock file', async () => { + const filePath = getUpdateInstallLockFile(); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, '{', 'utf-8'); + + const lock = await tryAcquireUpdateInstallLock({ version: '0.5.0' }); + + expect(lock).not.toBeNull(); + await lock?.release(); + }); });