-
Notifications
You must be signed in to change notification settings - Fork 3k
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
Changes from all commits
4206590
8b1eaaa
d6bc34d
10b0912
81a0e07
10d3f36
b4f7f5f
6a62bee
af5d733
3af0409
b1b6cfa
25903a1
0188035
96e4e1b
6f4df40
edeac9a
2b22391
a52696e
ca219f4
1a5025d
3a8e419
7e4ef88
10bc0a1
b510231
8518e46
a6f1d59
78e0c03
cbb1ea2
d0fae3e
3efeacb
a6e4c31
29fcb2d
8025c64
8b3c992
7530c3d
c6816ec
30a6b70
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,123 @@ | ||||||||||
| /** | ||||||||||
|
liziwl marked this conversation as resolved.
|
||||||||||
| * @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 () => { | ||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The
Either register a lightweight listener here to pipe |
||||||||||
| 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.
|
||||||||||
| await initializeI18n( | ||||||||||
| resolveLanguageSetting(settings.merged.general?.language as string), | ||||||||||
| ); | ||||||||||
|
|
||||||||||
| const updateCheck = await checkForUpdatesDetailed(); | ||||||||||
|
|
||||||||||
|
liziwl marked this conversation as 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.
|
||||||||||
| 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...')); | ||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Duplicate "Downloading update..." output. The
Suggested change
(Remove line 85 — the message is already emitted by — qwen3.7-max via Qwen Code /review |
||||||||||
| 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) { | ||||||||||
|
liziwl marked this conversation as resolved.
|
||||||||||
| 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)); | ||||||||||
| } | ||||||||||
| }, | ||||||||||
| }; | ||||||||||
Uh oh!
There was an error while loading. Please reload this page.