Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/automatic-background-updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add background automatic upgrades, which can be disabled in tui.toml.
5 changes: 5 additions & 0 deletions .changeset/manual-upgrade-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add a `kimi upgrade` command for manually checking and upgrade Kimi Code CLI.
8 changes: 8 additions & 0 deletions apps/kimi-code/src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ import { registerProviderCommand } from './sub/provider';
export type MainCommandHandler = (opts: CLIOptions) => void;
export type MigrateCommandHandler = () => void;
export type PluginNodeRunnerHandler = (entry: string, args: readonly string[]) => void;
export type UpgradeCommandHandler = () => void | Promise<void>;

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')
Expand Down Expand Up @@ -77,6 +79,12 @@ export function createProgram(
registerExportCommand(program);
registerProviderCommand(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 })
Expand Down
224 changes: 224 additions & 0 deletions apps/kimi-code/src/cli/sub/upgrade.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
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<Logger, 'info' | 'warn'>;

export interface UpgradeDeps {
readonly refreshUpdateCache: () => Promise<UpdateCache>;
readonly detectInstallSource: () => Promise<InstallSource>;
readonly installUpdate: (
source: InstallSource,
version: string,
platform: NodeJS.Platform,
) => Promise<void>;
readonly promptForInstallChoice: (
options: InstallPromptOptions,
) => Promise<InstallPromptChoiceValue>;
readonly platform: NodeJS.Platform;
readonly stdout: WritableLike;
readonly stderr: WritableLike;
readonly isInteractive: boolean;
readonly track: UpgradeTrack;
readonly logger: UpgradeLogger;
}

export async function handleUpgrade(
currentVersion: string,
overrides: Partial<UpgradeDeps> = {},
): Promise<number> {
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) || !deps.isInteractive) {
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,
});
Comment thread
liruifengv marked this conversation as resolved.
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>): 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,
isInteractive: overrides.isInteractive ?? (process.stdin.isTTY && process.stdout.isTTY),
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<string, unknown>): void {
try {
logger.info(message, payload);
} catch {
// Diagnostic logging must never affect upgrade flow.
}
}

function logUpgradeWarn(logger: UpgradeLogger, message: string, payload: Record<string, unknown>): void {
try {
logger.warn(message, payload);
} catch {
// Diagnostic logging must never affect upgrade flow.
}
}
95 changes: 95 additions & 0 deletions apps/kimi-code/src/cli/update/install-lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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<void>;
}

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<boolean> {
try {
const raw = await readFile(filePath, 'utf-8');
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;
Comment thread
liruifengv marked this conversation as resolved.
}
}

async function createLockFile(
filePath: string,
request: UpdateInstallLockRequest,
): Promise<UpdateInstallLockHandle> {
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<void> => {
await unlink(filePath).catch((error: unknown) => {
if (!isNotFound(error)) throw error;
});
},
};
}

export async function tryAcquireUpdateInstallLock(
request: UpdateInstallLockRequest,
filePath: string = getUpdateInstallLockFile(),
): Promise<UpdateInstallLockHandle | null> {
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;
}
}
Loading
Loading