Repository navigation
feat: add qwen update and /update commands with auto-update support
#5780
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 35 commits
Commits
Show all changes
37 commits
Select commit
Hold shift + click to select a range
4206590
fix: align standalone-update RC markers with install script, add vers…
liziwl 8b1eaaa
i18n: add update command translations for all 8 non-English locales
liziwl d6bc34d
Merge branch 'main' into main
yiliang114 10b0912
fix update command review feedback
liziwl 81a0e07
Merge branch 'main' into main
liziwl 10d3f36
address update command review followups
liziwl b4f7f5f
fix update command test args type
liziwl 6a62bee
address update review hardlink and fallback feedback
liziwl af5d733
fix update tar filter typing and sdk bundle guard
liziwl 3af0409
Merge branch 'main' into main
liziwl b1b6cfa
Merge branch 'main' into main
liziwl 25903a1
fix(cli): address update review feedback
liziwl 0188035
address update review followups
liziwl 96e4e1b
restore windows archive traversal scan
liziwl 6f4df40
address update review observations
liziwl edeac9a
address latest update review findings
liziwl 2b22391
Merge branch 'main' into main
liziwl a52696e
fix latest update review regressions
liziwl ca219f4
fix(cli): localize update command output
liziwl 1a5025d
fix(cli): localize update install guidance
liziwl 3a8e419
fix(cli): harden update review follow-ups
liziwl 7e4ef88
fix(cli): address latest update review comments
liziwl 10bc0a1
fix(cli): honor explicit update requests
liziwl b510231
fix(cli): address update command review feedback
liziwl 8518e46
fix(cli): address update slash command review feedback
liziwl a6f1d59
Merge branch 'main' into main
wenshao 78e0c03
Merge branch 'main' into main
liziwl cbb1ea2
fix(cli): address latest update review feedback
liziwl d0fae3e
fix(cli): address update review follow-ups
liziwl 3efeacb
add code
liziwl a6e4c31
fix(cli): add .deferred marker to prevent Windows deferred update race
liziwl 29fcb2d
fix(cli): harden update review edge cases
liziwl 8025c64
fix(cli): address PR #5780 review feedback on update engine
liziwl 8b3c992
merge: resolve conflicts between update feature and main branch changes
qwen-code-dev-bot 7530c3d
Merge branch 'main' into main
yiliang114 c6816ec
test(cli): fix update check test import
yiliang114 30a6b70
fix(cli): avoid duplicate startup update checks
yiliang114 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,258 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2025 Google LLC | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import type { ArgumentsCamelCase } from 'yargs'; | ||
|
|
||
| const loadSettings = vi.fn(); | ||
| const checkForUpdatesDetailed = vi.fn(); | ||
| const getInstallationInfo = vi.fn(); | ||
| const resolveUpdateCommand = vi.fn( | ||
| (updateCommand: string, latestVersion: string) => | ||
| updateCommand.replace('@latest', `@${latestVersion}`), | ||
| ); | ||
| const formatUpdateInstructions = vi.fn( | ||
| ( | ||
| installationInfo: { | ||
| updateMessage?: string; | ||
| updateCommand?: string; | ||
| isStandalone?: boolean; | ||
| }, | ||
| latestVersion: string, | ||
| ) => { | ||
| if (installationInfo.updateMessage && !installationInfo.updateCommand) { | ||
| return [installationInfo.updateMessage]; | ||
| } | ||
| if (installationInfo.updateCommand) { | ||
| return [ | ||
| 'Run the following to update:', | ||
| ` ${resolveUpdateCommand(installationInfo.updateCommand, latestVersion)}`, | ||
| ]; | ||
| } | ||
| return ['Manual update required. Please reinstall Qwen Code.']; | ||
| }, | ||
| ); | ||
| const performStandaloneUpdate = vi.fn(); | ||
| const getPackageJson = vi.fn(); | ||
| const writeStdoutLine = vi.fn(); | ||
| const writeStderrLine = vi.fn(); | ||
| const initializeI18n = vi.fn(); | ||
| const resolveLanguageSetting = vi.fn((language?: string) => language || 'auto'); | ||
|
|
||
| vi.mock('../config/settings.js', () => ({ loadSettings })); | ||
| vi.mock('../ui/utils/updateCheck.js', () => ({ checkForUpdatesDetailed })); | ||
| vi.mock('../utils/installationInfo.js', () => ({ | ||
| formatUpdateInstructions, | ||
| getInstallationInfo, | ||
| resolveUpdateCommand, | ||
| })); | ||
| vi.mock('../utils/standalone-update.js', () => ({ performStandaloneUpdate })); | ||
| vi.mock('../utils/package.js', () => ({ getPackageJson })); | ||
| vi.mock('../utils/stdioHelpers.js', () => ({ | ||
| writeStdoutLine, | ||
| writeStderrLine, | ||
| })); | ||
| vi.mock('../i18n/index.js', () => ({ | ||
| initializeI18n, | ||
| resolveLanguageSetting, | ||
| t: (key: string, params?: Record<string, string>) => | ||
| key.replace( | ||
| /\{\{(\w+)\}\}/g, | ||
| (_, param: string) => params?.[param] ?? `{{${param}}}`, | ||
| ), | ||
| })); | ||
|
|
||
| const { updateCommand } = await import('./update.js'); | ||
|
|
||
| const updateArgs: ArgumentsCamelCase<object> = { | ||
| _: [], | ||
| $0: 'qwen', | ||
| }; | ||
|
|
||
| function settings(enableAutoUpdate?: boolean) { | ||
| return { | ||
| merged: { | ||
| general: { enableAutoUpdate, language: 'zh' }, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| describe('update command', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| process.exitCode = undefined; | ||
| loadSettings.mockReturnValue(settings(undefined)); | ||
| checkForUpdatesDetailed.mockResolvedValue({ | ||
| status: 'update', | ||
| info: { | ||
| message: 'Update available: 1.2.3', | ||
| update: { latest: '1.2.3' }, | ||
| }, | ||
| }); | ||
| getInstallationInfo.mockReturnValue({ | ||
| isStandalone: false, | ||
| updateCommand: 'npm install -g @qwen-code/qwen-code@latest', | ||
| }); | ||
| }); | ||
|
|
||
| it('prints the package-manager update command even when auto-update is disabled', async () => { | ||
| loadSettings.mockReturnValue(settings(false)); | ||
|
|
||
| await updateCommand.handler(updateArgs); | ||
|
|
||
| expect(resolveLanguageSetting).toHaveBeenCalledWith('zh'); | ||
| expect(initializeI18n).toHaveBeenCalledWith('zh'); | ||
| expect(getInstallationInfo).toHaveBeenCalledWith(expect.any(String), true); | ||
| expect(writeStdoutLine).toHaveBeenCalledWith('Update available: 1.2.3'); | ||
| expect(writeStdoutLine).toHaveBeenCalledWith( | ||
| 'Run the following to update:', | ||
| ); | ||
| expect(writeStdoutLine).toHaveBeenCalledWith( | ||
| ' npm install -g @qwen-code/qwen-code@1.2.3', | ||
| ); | ||
| }); | ||
|
|
||
| it('sets a non-zero exit code when a standalone update fails', async () => { | ||
| getInstallationInfo.mockReturnValue({ | ||
| isStandalone: true, | ||
| standaloneDir: '/tmp/qwen-code', | ||
| }); | ||
| performStandaloneUpdate.mockRejectedValue(new Error('boom')); | ||
|
|
||
| await updateCommand.handler(updateArgs); | ||
|
|
||
| expect(performStandaloneUpdate).toHaveBeenCalledWith( | ||
| '/tmp/qwen-code', | ||
| '1.2.3', | ||
| ); | ||
| expect(writeStdoutLine).toHaveBeenCalledWith('Downloading update...'); | ||
| expect(writeStderrLine).toHaveBeenCalledWith('Update failed: boom'); | ||
| expect(process.exitCode).toBe(1); | ||
| }); | ||
|
|
||
| it('updates standalone installs even when auto-update is disabled', async () => { | ||
| loadSettings.mockReturnValue(settings(false)); | ||
| getInstallationInfo.mockReturnValue({ | ||
| isStandalone: true, | ||
| standaloneDir: '/tmp/qwen-code', | ||
| }); | ||
| performStandaloneUpdate.mockResolvedValue('done'); | ||
|
|
||
| await updateCommand.handler(updateArgs); | ||
|
|
||
| expect(getInstallationInfo).toHaveBeenCalledWith(expect.any(String), true); | ||
| expect(performStandaloneUpdate).toHaveBeenCalledWith( | ||
| '/tmp/qwen-code', | ||
| '1.2.3', | ||
| ); | ||
| expect(writeStdoutLine).toHaveBeenCalledWith( | ||
| 'Update successful! The new version will be used on your next run.', | ||
| ); | ||
| }); | ||
|
|
||
| it('does not print generic fallback when installation info has updateMessage', async () => { | ||
| getInstallationInfo.mockReturnValue({ | ||
| isStandalone: false, | ||
| updateMessage: 'Running via npx, update not applicable.', | ||
| }); | ||
|
|
||
| await updateCommand.handler(updateArgs); | ||
|
|
||
| expect(writeStdoutLine).toHaveBeenCalledWith( | ||
| 'Running via npx, update not applicable.', | ||
| ); | ||
| expect(writeStdoutLine).not.toHaveBeenCalledWith( | ||
| 'Manual update required. Please reinstall Qwen Code.', | ||
| ); | ||
| }); | ||
|
|
||
| it('prints the update message when no update command is available', async () => { | ||
| getInstallationInfo.mockReturnValue({ | ||
| updateMessage: | ||
| 'Running from a local git clone. Please update with "git pull".', | ||
| }); | ||
|
|
||
| await updateCommand.handler(updateArgs); | ||
|
|
||
| expect(writeStdoutLine).toHaveBeenCalledWith( | ||
| 'Running from a local git clone. Please update with "git pull".', | ||
| ); | ||
| }); | ||
|
|
||
| it('prints success message on standalone update', async () => { | ||
| getInstallationInfo.mockReturnValue({ | ||
| isStandalone: true, | ||
| standaloneDir: '/tmp/qwen-code', | ||
| }); | ||
| performStandaloneUpdate.mockResolvedValue('done'); | ||
|
|
||
| await updateCommand.handler(updateArgs); | ||
|
|
||
| expect(writeStdoutLine).toHaveBeenCalledWith('Downloading update...'); | ||
| expect(writeStdoutLine).toHaveBeenCalledWith( | ||
| 'Update successful! The new version will be used on your next run.', | ||
| ); | ||
| }); | ||
|
|
||
| it('prints deferred message on standalone update', async () => { | ||
| getInstallationInfo.mockReturnValue({ | ||
| isStandalone: true, | ||
| standaloneDir: '/tmp/qwen-code', | ||
| }); | ||
| performStandaloneUpdate.mockResolvedValue('deferred'); | ||
|
|
||
| await updateCommand.handler(updateArgs); | ||
|
|
||
| expect(writeStdoutLine).toHaveBeenCalledWith('Downloading update...'); | ||
| expect(writeStdoutLine).toHaveBeenCalledWith( | ||
| 'Update downloaded. It will be applied after you exit this session.', | ||
| ); | ||
| }); | ||
|
|
||
| it('prints the current version when no update is available', async () => { | ||
| checkForUpdatesDetailed.mockResolvedValue({ | ||
| status: 'up-to-date', | ||
| currentVersion: '1.0.0', | ||
| }); | ||
|
|
||
| await updateCommand.handler(updateArgs); | ||
|
|
||
| expect(writeStdoutLine).toHaveBeenCalledWith( | ||
| 'Qwen Code 1.0.0 is up to date!', | ||
| ); | ||
| expect(getInstallationInfo).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('sets a non-zero exit code when the update check fails', async () => { | ||
| checkForUpdatesDetailed.mockResolvedValue({ | ||
| status: 'error', | ||
| error: new Error('registry unavailable'), | ||
| }); | ||
|
|
||
| await updateCommand.handler(updateArgs); | ||
|
|
||
| expect(writeStderrLine).toHaveBeenCalledWith( | ||
| 'Failed to check for updates. Please check your network or registry configuration.', | ||
| ); | ||
| expect(process.exitCode).toBe(1); | ||
| expect(getInstallationInfo).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('sets a non-zero exit code when the update check is skipped', async () => { | ||
| checkForUpdatesDetailed.mockResolvedValue({ | ||
| status: 'skipped', | ||
| reason: 'development mode', | ||
| }); | ||
|
|
||
| await updateCommand.handler(updateArgs); | ||
|
|
||
| expect(writeStderrLine).toHaveBeenCalledWith( | ||
| 'Unable to check for updates: development mode', | ||
| ); | ||
| expect(process.exitCode).toBe(1); | ||
| expect(getInstallationInfo).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2025 Google LLC | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import type { CommandModule } from 'yargs'; | ||
| import { initializeI18n, resolveLanguageSetting, t } from '../i18n/index.js'; | ||
|
|
||
| export const updateCommand: CommandModule = { | ||
| command: 'update', | ||
| get describe() { | ||
| return t('Check for Qwen Code updates and install if available'); | ||
| }, | ||
| handler: async () => { | ||
| const [ | ||
| { loadSettings }, | ||
| { checkForUpdatesDetailed }, | ||
| installationInfoModule, | ||
| standaloneUpdate, | ||
| stdioHelpers, | ||
| { updateEventEmitter }, | ||
| ] = await Promise.all([ | ||
| import('../config/settings.js'), | ||
| import('../ui/utils/updateCheck.js'), | ||
| import('../utils/installationInfo.js'), | ||
| import('../utils/standalone-update.js'), | ||
| import('../utils/stdioHelpers.js'), | ||
| import('../utils/updateEventEmitter.js'), | ||
| ]); | ||
|
|
||
| const { formatUpdateInstructions, getInstallationInfo } = | ||
| installationInfoModule; | ||
| const { performStandaloneUpdate } = standaloneUpdate; | ||
| const { writeStdoutLine, writeStderrLine } = stdioHelpers; | ||
|
|
||
| const cwd = process.cwd(); | ||
| const settings = loadSettings(cwd, false); | ||
|
liziwl marked this conversation as resolved.
Resolved
|
||
| await initializeI18n( | ||
| resolveLanguageSetting(settings.merged.general?.language as string), | ||
| ); | ||
|
|
||
| const updateCheck = await checkForUpdatesDetailed(); | ||
|
|
||
|
liziwl marked this conversation as resolved.
Resolved
|
||
| if (updateCheck.status === 'up-to-date') { | ||
| writeStdoutLine( | ||
| t('Qwen Code {{version}} is up to date!', { | ||
| version: updateCheck.currentVersion, | ||
| }), | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| if (updateCheck.status === 'error') { | ||
| writeStderrLine( | ||
| t( | ||
| 'Failed to check for updates. Please check your network or registry configuration.', | ||
| ), | ||
| ); | ||
| process.exitCode = 1; | ||
| return; | ||
| } | ||
|
|
||
| if (updateCheck.status === 'skipped') { | ||
| writeStderrLine( | ||
| t('Unable to check for updates: {{reason}}', { | ||
|
liziwl marked this conversation as resolved.
Resolved
|
||
| reason: updateCheck.reason, | ||
| }), | ||
| ); | ||
| process.exitCode = 1; | ||
| return; | ||
| } | ||
|
|
||
| const info = updateCheck.info; | ||
| writeStdoutLine(info.message); | ||
|
|
||
| const installationInfo = getInstallationInfo(cwd, true); | ||
|
|
||
| if (installationInfo.isStandalone && installationInfo.standaloneDir) { | ||
| const handleUpdateInfo = (data: { message: string }) => { | ||
| writeStdoutLine(data.message); | ||
| }; | ||
| updateEventEmitter.on('update-info', handleUpdateInfo); | ||
| try { | ||
| writeStdoutLine(t('Downloading update...')); | ||
| const result = await performStandaloneUpdate( | ||
| installationInfo.standaloneDir, | ||
| info.update.latest, | ||
| ); | ||
| if (result === 'done') { | ||
| writeStdoutLine( | ||
| t( | ||
| 'Update successful! The new version will be used on your next run.', | ||
| ), | ||
| ); | ||
| } else { | ||
| writeStdoutLine( | ||
| t( | ||
| 'Update downloaded. It will be applied after you exit this session.', | ||
| ), | ||
| ); | ||
| } | ||
| } catch (err) { | ||
| writeStderrLine( | ||
| t('Update failed: {{error}}', { | ||
| error: err instanceof Error ? err.message : String(err), | ||
| }), | ||
| ); | ||
| process.exitCode = 1; | ||
| } finally { | ||
| updateEventEmitter.off('update-info', handleUpdateInfo); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| for (const line of formatUpdateInstructions( | ||
| installationInfo, | ||
| info.update.latest, | ||
| )) { | ||
| writeStdoutLine(t(line)); | ||
| } | ||
| }, | ||
| }; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.