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/fresh-update-background-install.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Start automatic background updates as soon as startup's fresh update check finds a newer version.
100 changes: 81 additions & 19 deletions apps/kimi-code/src/cli/update/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,32 @@ function refreshInBackground(): void {
void refreshUpdateCache().catch(() => {});
}

function refreshAndMaybeInstallInBackground(
currentVersion: string,
isInteractive: boolean,
installState: UpdateInstallState,
platform: NodeJS.Platform,
track: RunUpdatePreflightOptions['track'],
logger: UpdateLogger,
): void {
void (async () => {
const refreshed = await refreshUpdateCache();
if (!isInteractive) return;
const target = selectUpdateTarget(currentVersion, refreshed.latest);
if (target === null) return;
const source = await detectInstallSource().catch(() => 'unsupported' as const);
await tryStartAutomaticBackgroundInstall(
installState,
currentVersion,
target,
source,
platform,
track,
logger,
);
})().catch(() => {});
}

function nowIso(): string {
return new Date().toISOString();
}
Expand Down Expand Up @@ -433,6 +459,35 @@ async function startBackgroundInstall(
}
}

async function tryStartAutomaticBackgroundInstall(
installState: UpdateInstallState,
currentVersion: string,
target: UpdateTarget,
source: InstallSource,
platform: NodeJS.Platform,
track: RunUpdatePreflightOptions['track'],
logger: UpdateLogger,
): Promise<boolean> {
const sourceCanAutoInstall = canAutoInstall(source, platform);
const autoInstallUpdates = sourceCanAutoInstall ? await shouldAutoInstallUpdates() : false;
if (!autoInstallUpdates || !sourceCanAutoInstall) return false;
if (failureAttemptsFor(installState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD) {
return false;
}
if (!hasFreshActiveInstall(installState, target)) {
await startBackgroundInstall(
installState,
currentVersion,
target,
source,
platform,
track,
logger,
).catch(() => {});
}
return true;
}

export function decideUpdateAction(
target: UpdateTarget | null,
isInteractive: boolean,
Expand Down Expand Up @@ -469,33 +524,40 @@ export async function runUpdatePreflight(
const cache = await readUpdateCache().catch(() => null);
const latest = cache?.latest ?? null;
const target = selectUpdateTarget(currentVersion, latest);
if (target === null) {
refreshAndMaybeInstallInBackground(
currentVersion,
isInteractive,
installState,
platform,
options.track,
logger,
);
return 'continue';
}

refreshInBackground();
const source: InstallSource =
target === null || !isInteractive
!isInteractive
? 'unsupported'
: await detectInstallSource().catch(() => 'unsupported' as const);

const decision = decideUpdateAction(target, isInteractive, source, platform);
if (decision === 'none' || target === null) return 'continue';
if (decision === 'none') 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';
}
if (
await tryStartAutomaticBackgroundInstall(
installState,
currentVersion,
target,
source,
platform,
options.track,
logger,
)
) {
return 'continue';
}

trackUpdatePrompted(options.track, currentVersion, target, source, decision);
Expand Down
49 changes: 46 additions & 3 deletions apps/kimi-code/test/cli/update/preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,15 +185,42 @@ describe('runUpdatePreflight', () => {

afterEach(() => { vi.clearAllMocks(); });

it('continues on first launch with empty cache, still refreshes in background', async () => {
it('starts an automatic update from the first fresh check when the cache is empty', async () => {
mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache());
mocks.refreshUpdateCache.mockResolvedValue(emptyUpdateCache());
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');
await flushBackgroundInstall();

expect(readUpdateCache).toHaveBeenCalledTimes(1);
expect(refreshUpdateCache).toHaveBeenCalledTimes(1);
expect(detectInstallSource).not.toHaveBeenCalled();
expect(promptForInstallChoice).not.toHaveBeenCalled();
expect(detectInstallSource).toHaveBeenCalledTimes(1);
expect(mocks.spawn).toHaveBeenCalledWith(
expect.stringMatching(/^npm(\.cmd)?$/),
['install', '-g', '@moonshot-ai/kimi-code@0.5.0'],
{ detached: true, stdio: 'ignore' },
);
});

it('does not start a fresh-check background install when automatic updates are disabled', async () => {
disableAutoInstall();
mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache());
mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0'));
mocks.detectInstallSource.mockResolvedValue('npm-global');
const { options } = captureOutput();

await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue');
await flushBackgroundInstall();

expect(refreshUpdateCache).toHaveBeenCalledTimes(1);
expect(detectInstallSource).toHaveBeenCalledTimes(1);
expect(promptForInstallChoice).not.toHaveBeenCalled();
expect(mocks.spawn).not.toHaveBeenCalled();
});

it('skips when non-interactive', async () => {
Expand All @@ -206,6 +233,22 @@ describe('runUpdatePreflight', () => {
expect(detectInstallSource).not.toHaveBeenCalled();
});

it('does not start a fresh-check background install when non-interactive', async () => {
mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache());
mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0'));
const { options } = captureOutput();

await expect(
runUpdatePreflight('0.4.0', { ...options, isTTY: false }),
).resolves.toBe('continue');
await flushBackgroundInstall();

expect(refreshUpdateCache).toHaveBeenCalledTimes(1);
expect(detectInstallSource).not.toHaveBeenCalled();
expect(promptForInstallChoice).not.toHaveBeenCalled();
expect(mocks.spawn).not.toHaveBeenCalled();
});

it('npm-global: prompts and spawns npm install -g when automatic updates are disabled', async () => {
disableAutoInstall();
mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0'));
Expand Down
Loading