From e250d6e3144c552bd0fc03cf576d1d8b705f1235 Mon Sep 17 00:00:00 2001 From: Ziqiang Li Date: Thu, 9 Jul 2026 23:11:45 +0800 Subject: [PATCH] feat: add `qwen update` and `/update` commands with auto-update support (#5780) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: align standalone-update RC markers with install script, add version to update output ## Changes ### Compatibility fixes (standalone-update.ts) - ensurePathInShellRc: use install script's begin/end block markers (# Qwen Code PATH block begin/end) instead of single-line marker, preventing duplicate PATH entries - ensurePathInShellRc: fish shell uses set -gx PATH (matching install script) - ensurePathInShellRc: use single-quoted paths with shell_quote-style escaping - ensureBinWrapper: use #!/usr/bin/env sh shebang (matching install script) ### Version output - qwen update: show current version in 'up to date' message (Qwen Code X.Y.Z is up to date!) - /update slash command: same version display ### Build fix - esbuild.config.js: add ink/dom and ink/components/CursorContext aliases for ink 7.x compatibility ### i18n - en.js: add 9 update-related translation keys Co-Authored-By: Claude * i18n: add update command translations for all 8 non-English locales - zh: Simplified Chinese - zh-TW: Traditional Chinese - ja: Japanese - ru: Russian - de: German - pt: Portuguese (Brazil) - fr: French - ca: Catalan Co-Authored-By: Claude * fix update command review feedback * address update command review followups * fix update command test args type * address update review hardlink and fallback feedback * fix update tar filter typing and sdk bundle guard * fix(cli): address update review feedback * address update review followups * restore windows archive traversal scan * address update review observations * address latest update review findings * fix latest update review regressions * fix(cli): localize update command output * fix(cli): localize update install guidance * fix(cli): harden update review follow-ups * fix(cli): address latest update review comments * fix(cli): honor explicit update requests * fix(cli): address update command review feedback * fix(cli): address update slash command review feedback * fix(cli): address latest update review feedback * fix(cli): address update review follow-ups * add code * fix(cli): add .deferred marker to prevent Windows deferred update race On Windows, when atomicReplace returns 'deferred', a bat script runs detached to complete the swap after the Node process exits. The lock file alone was insufficient because acquireLock falls through when the Node PID is dead (process.kill check), allowing a second `qwen update` to steal the lock and interfere with the in-flight bat script. Add a .deferred marker file containing the bat script's PID. acquireLock now checks this marker via isProcessAlive(batPid) before allowing lock theft, blocking concurrent updates while the swap is still in progress. Co-Authored-By: Claude * fix(cli): harden update review edge cases * fix(cli): address PR #5780 review feedback on update engine * test(cli): fix update check test import * fix(cli): avoid duplicate startup update checks --------- Co-authored-by: Claude Co-authored-by: 易良 <1204183885@qq.com> Co-authored-by: liziwl <23000702+liziwl@users.noreply.github.com> Co-authored-by: Shaojin Wen Co-authored-by: qwen-code-dev-bot Co-authored-by: yiliang114 --- packages/cli/src/cli.test.ts | 1 + packages/cli/src/cli.ts | 1 + packages/cli/src/commands/update.test.ts | 258 +++++++++ packages/cli/src/commands/update.ts | 123 +++++ packages/cli/src/config/config.test.ts | 44 ++ packages/cli/src/config/config.ts | 10 +- packages/cli/src/gemini.test.tsx | 58 +- packages/cli/src/i18n/locales/ca.js | 48 ++ packages/cli/src/i18n/locales/de.js | 47 ++ packages/cli/src/i18n/locales/en.js | 48 ++ packages/cli/src/i18n/locales/fr.js | 48 ++ packages/cli/src/i18n/locales/ja.js | 48 ++ packages/cli/src/i18n/locales/pt.js | 47 ++ packages/cli/src/i18n/locales/ru.js | 47 ++ packages/cli/src/i18n/locales/zh-TW.js | 44 ++ packages/cli/src/i18n/locales/zh.js | 44 ++ .../cli/src/services/BuiltinCommandLoader.ts | 2 + .../cli/src/startup/startup-prefetch.test.ts | 61 ++- packages/cli/src/startup/startup-prefetch.ts | 29 +- .../src/ui/commands/update-command.test.ts | 327 ++++++++++++ .../cli/src/ui/commands/update-command.ts | 131 +++++ packages/cli/src/ui/startInteractiveUI.tsx | 1 + packages/cli/src/ui/utils/updateCheck.test.ts | 96 +++- packages/cli/src/ui/utils/updateCheck.ts | 59 ++- .../cli/src/utils/handleAutoUpdate.test.ts | 8 +- packages/cli/src/utils/handleAutoUpdate.ts | 48 +- .../cli/src/utils/installationInfo.test.ts | 72 ++- packages/cli/src/utils/installationInfo.ts | 64 ++- .../cli/src/utils/standalone-update.test.ts | 459 +++++++++++++++- packages/cli/src/utils/standalone-update.ts | 498 +++++++++++++++--- 30 files changed, 2590 insertions(+), 181 deletions(-) create mode 100644 packages/cli/src/commands/update.test.ts create mode 100644 packages/cli/src/commands/update.ts create mode 100644 packages/cli/src/ui/commands/update-command.test.ts create mode 100644 packages/cli/src/ui/commands/update-command.ts diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 85eef6a000..e434d1a3b7 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -425,6 +425,7 @@ describe('bootstrap import boundaries', () => { ['reviewCommand', 'review'], ['serveCommand', 'serve'], ['sessionsCommand', 'sessions'], + ['updateCommand', 'update'], ]); const registeredIdentifiers = [ ...configSource.matchAll(/\.command\((\w+Command)\)/g), diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 901b53eebd..591b688f6b 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -32,6 +32,7 @@ export const TOP_LEVEL_COMMANDS = [ 'Run Qwen Code as a local HTTP daemon (Stage 1 experimental: --http-bridge)', ], ['sessions ', 'Manage Qwen Code sessions'], + ['update', 'Check for Qwen Code updates and install if available'], ] as const; export const MCP_COMMANDS = [ diff --git a/packages/cli/src/commands/update.test.ts b/packages/cli/src/commands/update.test.ts new file mode 100644 index 0000000000..edf4263380 --- /dev/null +++ b/packages/cli/src/commands/update.test.ts @@ -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) => + key.replace( + /\{\{(\w+)\}\}/g, + (_, param: string) => params?.[param] ?? `{{${param}}}`, + ), +})); + +const { updateCommand } = await import('./update.js'); + +const updateArgs: ArgumentsCamelCase = { + _: [], + $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(); + }); +}); diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts new file mode 100644 index 0000000000..daeeacd9b5 --- /dev/null +++ b/packages/cli/src/commands/update.ts @@ -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); + await initializeI18n( + resolveLanguageSetting(settings.merged.general?.language as string), + ); + + const updateCheck = await checkForUpdatesDetailed(); + + 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}}', { + 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)); + } + }, +}; diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index db7ac7caf1..2a96ec13c0 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -22,6 +22,7 @@ import { resetMcpApprovalsForTesting } from './mcpApprovals.js'; const mockWriteStderrLine = vi.hoisted(() => vi.fn()); const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); +const mockUpdateHandler = vi.hoisted(() => vi.fn()); const mockSessionServiceInstance = vi.hoisted(() => ({ loadLastSession: vi.fn(), loadSession: vi.fn(), @@ -39,6 +40,14 @@ vi.mock('../utils/stdioHelpers.js', () => ({ clearScreen: vi.fn(), })); +vi.mock('../commands/update.js', () => ({ + updateCommand: { + command: 'update', + describe: 'mock update command', + handler: mockUpdateHandler, + }, +})); + const createNativeLspServiceInstance = () => ({ discoverAndPrepare: vi.fn(), start: vi.fn(), @@ -270,6 +279,41 @@ describe('parseArguments', () => { expect(argv.promptInteractive).toBeUndefined(); }); + it('registers update as an exiting subcommand', async () => { + process.argv = ['node', 'script.js', 'update']; + mockUpdateHandler.mockResolvedValue(undefined); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + await expect(parseArguments()).rejects.toThrow('process.exit called'); + + expect(mockUpdateHandler).toHaveBeenCalled(); + expect(mockExit).toHaveBeenCalledWith(0); + mockExit.mockRestore(); + }); + + it('propagates non-zero exitCode from the update handler', async () => { + process.argv = ['node', 'script.js', 'update']; + mockUpdateHandler.mockImplementation(() => { + process.exitCode = 1; + }); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + try { + await expect(parseArguments()).rejects.toThrow('process.exit called'); + + expect(mockUpdateHandler).toHaveBeenCalled(); + expect(mockExit).toHaveBeenCalledWith(1); + } finally { + mockExit.mockRestore(); + mockUpdateHandler.mockReset(); + process.exitCode = undefined; + } + }); + it('should allow --prompt-interactive without --prompt', async () => { process.argv = [ 'node', diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 92cc0ed927..8c2947a995 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -65,6 +65,7 @@ import { authCommand } from '../commands/auth.js'; import { reviewCommand } from '../commands/review.js'; import { serveCommand } from '../commands/serve.js'; import { sessionsCommand } from '../commands/sessions.js'; +import { updateCommand } from '../commands/update.js'; // UUID v4 regex pattern for validation const SESSION_ID_REGEX = @@ -1080,7 +1081,9 @@ export async function parseArguments(): Promise { // Register `qwen serve` (Stage 1 daemon) .command(serveCommand) // Register sessions subcommands - .command(sessionsCommand); + .command(sessionsCommand) + // Register update command + .command(updateCommand); yargsInstance .version(await getCliVersion()) // This will enable the --version flag based on package.json @@ -1105,7 +1108,8 @@ export async function parseArguments(): Promise { result._[0] === 'hooks' || result._[0] === 'channel' || result._[0] === 'review' || - result._[0] === 'sessions') + result._[0] === 'sessions' || + result._[0] === 'update') ) { // Note: `serve` is intentionally NOT in this list. Its handler blocks // forever (after the listener is up); SIGINT/SIGTERM in runQwenServe @@ -1114,7 +1118,7 @@ export async function parseArguments(): Promise { // execution and exit. Returning here would let the main interactive // flow run, which would prompt for stdin input despite the user // having already invoked a subcommand. - process.exit(0); + process.exit(process.exitCode ?? 0); } // Normalize query args: handle both quoted "@path file" and unquoted @path file diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 88176b9059..99dcc3dfb3 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -1989,35 +1989,7 @@ describe('startInteractiveUI', () => { ); }); - it('can skip post-render IDE connection after prompt-interactive awaited it', async () => { - const promptInteractiveConfig = { - ...mockConfig, - isTelemetryInitializationDeferred: () => false, - } as unknown as Config; - const mockInitializationResult = { - authError: null, - themeError: null, - shouldOpenAuthDialog: false, - geminiMdFileCount: 0, - }; - - await startInteractiveUI( - promptInteractiveConfig, - mockSettings, - mockStartupWarnings, - mockWorkspaceRoot, - mockInitializationResult, - { postRenderConnectIde: false }, - ); - - expect(mockStartPostRenderPrefetches).toHaveBeenCalledWith( - promptInteractiveConfig, - mockSettings, - { connectIde: false, initializeTelemetry: false }, - ); - }); - - it('delegates auto-update gating to post-render prefetch', async () => { + it('delegates disabled auto-update settings to post-render prefetch', async () => { const settingsWithAutoUpdateDisabled = { merged: { general: { @@ -2050,4 +2022,32 @@ describe('startInteractiveUI', () => { { connectIde: false, initializeTelemetry: true }, ); }); + + it('can skip post-render IDE connection after prompt-interactive awaited it', async () => { + const promptInteractiveConfig = { + ...mockConfig, + isTelemetryInitializationDeferred: () => false, + } as unknown as Config; + const mockInitializationResult = { + authError: null, + themeError: null, + shouldOpenAuthDialog: false, + geminiMdFileCount: 0, + }; + + await startInteractiveUI( + promptInteractiveConfig, + mockSettings, + mockStartupWarnings, + mockWorkspaceRoot, + mockInitializationResult, + { postRenderConnectIde: false }, + ); + + expect(mockStartPostRenderPrefetches).toHaveBeenCalledWith( + promptInteractiveConfig, + mockSettings, + { connectIde: false, initializeTelemetry: false }, + ); + }); }); diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index cc1b6f5325..b5e2fede6a 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -2060,6 +2060,54 @@ export default { 'Ús: /history collapse-on-resume|expand-on-resume|expand-now', 'History collapsed: {{n}} messages hidden. Use /history expand-now to show.': 'Història reduïda: {{n}} missatges ocults. Utilitzeu /history expand-now per mostrar.', + // Update command + 'Check for Qwen Code updates and install if available': + 'Comprova les actualitzacions de Qwen Code i instal·la si estan disponibles', + 'Qwen Code update available! {{current}} → {{latest}}': + 'Actualització de Qwen Code disponible! {{current}} → {{latest}}', + 'A new version of Qwen Code is available! {{current}} → {{latest}}': + 'Hi ha una versió nova de Qwen Code disponible! {{current}} → {{latest}}', + 'Qwen Code {{version}} is up to date!': + 'Qwen Code {{version}} està actualitzat!', + 'Failed to check for updates. Please check your network or registry configuration.': + "No s'han pogut comprovar les actualitzacions. Comproveu la xarxa o la configuració del registre.", + 'Unable to check for updates: {{reason}}': + 'No es poden comprovar les actualitzacions: {{reason}}', + 'Update successful! The new version will be used on your next run.': + "Actualització correcta! La nova versió s'utilitzarà en la propera execució.", + 'Update downloaded. It will be applied after you exit this session.': + "Actualització descarregada. S'aplicarà després de sortir d'aquesta sessió.", + 'Update failed: {{error}}': 'Actualització fallida: {{error}}', + 'Downloading update...': "S'està baixant l'actualització...", + 'Update successful! Please restart Qwen Code to use the new version. Switching model providers before restarting may not work correctly.': + 'Actualització correcta! Reinicieu Qwen Code per utilitzar la nova versió. Canviar de proveïdor de models abans de reiniciar pot no funcionar correctament.', + 'Automatic update failed. Please try updating manually.': + "L'actualització automàtica ha fallat. Proveu d'actualitzar manualment.", + 'Automatic update failed: {{error}}. Re-run the installer to update manually.': + 'Ha fallat l’actualització automàtica: {{error}}. Torneu a executar l’instal·lador per actualitzar manualment.', + 'Running from a local git clone. Please update with "git pull".': + 'S’està executant des d’un clon Git local. Actualitzeu amb "git pull".', + 'Running via npx, update not applicable.': + 'S’està executant via npx, l’actualització no és aplicable.', + 'Running via pnpx, update not applicable.': + 'S’està executant via pnpx, l’actualització no és aplicable.', + 'Running via bunx, update not applicable.': + 'S’està executant via bunx, l’actualització no és aplicable.', + 'Installed via Homebrew. Please update with "brew upgrade".': + 'Instal·lat via Homebrew. Actualitzeu amb "brew upgrade".', + "Locally installed. Please update via your project's package.json.": + 'Instal·lat localment. Actualitzeu mitjançant el package.json del vostre projecte.', + 'Update requires sudo. Please run:': + 'L’actualització requereix sudo. Executeu:', + 'Standalone install detected. Attempting to automatically update now...': + 'S’ha detectat una instal·lació independent. S’intenta actualitzar automàticament...', + 'Standalone install detected. Please rerun the standalone installer to update:': + 'S’ha detectat una instal·lació independent. Torneu a executar l’instal·lador independent per actualitzar:', + 'Run the following to update:': 'Executeu el següent per actualitzar:', + 'Unable to auto-update this standalone installation. Please reinstall from:': + 'No es pot actualitzar automàticament aquesta instal·lació autònoma. Reinstal·leu des de:', + 'Manual update required. Please reinstall Qwen Code.': + 'Actualització manual requerida. Reinstal·leu Qwen Code.', // ============================================================================ // reload-plugins command diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 137c8ed96c..1c7b75a5eb 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -2105,6 +2105,53 @@ export default { in: 'ein', out: 'aus', 'In/Out': 'Ein/Aus', + // Update command + 'Check for Qwen Code updates and install if available': + 'Auf Qwen Code-Updates prüfen und installieren, falls verfügbar', + 'Qwen Code update available! {{current}} → {{latest}}': + 'Qwen Code-Update verfügbar! {{current}} → {{latest}}', + 'A new version of Qwen Code is available! {{current}} → {{latest}}': + 'Eine neue Version von Qwen Code ist verfügbar! {{current}} → {{latest}}', + 'Qwen Code {{version}} is up to date!': 'Qwen Code {{version}} ist aktuell!', + 'Failed to check for updates. Please check your network or registry configuration.': + 'Suche nach Updates fehlgeschlagen. Bitte Netzwerk- oder Registry-Konfiguration prüfen.', + 'Unable to check for updates: {{reason}}': + 'Updates können nicht geprüft werden: {{reason}}', + 'Update successful! The new version will be used on your next run.': + 'Update erfolgreich! Die neue Version wird beim nächsten Start verwendet.', + 'Update downloaded. It will be applied after you exit this session.': + 'Update heruntergeladen. Es wird nach dem Beenden dieser Sitzung angewendet.', + 'Update failed: {{error}}': 'Update fehlgeschlagen: {{error}}', + 'Downloading update...': 'Update wird heruntergeladen...', + 'Update successful! Please restart Qwen Code to use the new version. Switching model providers before restarting may not work correctly.': + 'Update erfolgreich! Bitte starten Sie Qwen Code neu, um die neue Version zu verwenden. Das Wechseln von Modellanbietern vor dem Neustart funktioniert möglicherweise nicht korrekt.', + 'Automatic update failed. Please try updating manually.': + 'Automatisches Update fehlgeschlagen. Bitte versuchen Sie, manuell zu aktualisieren.', + 'Automatic update failed: {{error}}. Re-run the installer to update manually.': + 'Automatisches Update fehlgeschlagen: {{error}}. Führen Sie das Installationsprogramm erneut aus, um manuell zu aktualisieren.', + 'Running from a local git clone. Please update with "git pull".': + 'Wird aus einem lokalen Git-Klon ausgeführt. Bitte mit "git pull" aktualisieren.', + 'Running via npx, update not applicable.': + 'Wird über npx ausgeführt, Update nicht anwendbar.', + 'Running via pnpx, update not applicable.': + 'Wird über pnpx ausgeführt, Update nicht anwendbar.', + 'Running via bunx, update not applicable.': + 'Wird über bunx ausgeführt, Update nicht anwendbar.', + 'Installed via Homebrew. Please update with "brew upgrade".': + 'Über Homebrew installiert. Bitte mit "brew upgrade" aktualisieren.', + "Locally installed. Please update via your project's package.json.": + 'Lokal installiert. Bitte über die package.json Ihres Projekts aktualisieren.', + 'Update requires sudo. Please run:': + 'Das Update erfordert sudo. Bitte ausführen:', + 'Standalone install detected. Attempting to automatically update now...': + 'Standalone-Installation erkannt. Automatisches Update wird jetzt versucht...', + 'Standalone install detected. Please rerun the standalone installer to update:': + 'Standalone-Installation erkannt. Bitte führen Sie den Standalone-Installer zum Aktualisieren erneut aus:', + 'Run the following to update:': 'Führen Sie Folgendes zum Aktualisieren aus:', + 'Unable to auto-update this standalone installation. Please reinstall from:': + 'Diese Standalone-Installation kann nicht automatisch aktualisiert werden. Bitte neu installieren von:', + 'Manual update required. Please reinstall Qwen Code.': + 'Manuelles Update erforderlich. Bitte installieren Sie Qwen Code neu.', // ============================================================================ // reload-plugins command diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 120c9870c8..3ca34a059a 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -2553,6 +2553,54 @@ export default { reqs: 'reqs', in: 'in', out: 'out', + + // Update command + 'Check for Qwen Code updates and install if available': + 'Check for Qwen Code updates and install if available', + 'Qwen Code update available! {{current}} → {{latest}}': + 'Qwen Code update available! {{current}} → {{latest}}', + 'A new version of Qwen Code is available! {{current}} → {{latest}}': + 'A new version of Qwen Code is available! {{current}} → {{latest}}', + 'Qwen Code {{version}} is up to date!': + 'Qwen Code {{version}} is up to date!', + 'Failed to check for updates. Please check your network or registry configuration.': + 'Failed to check for updates. Please check your network or registry configuration.', + 'Unable to check for updates: {{reason}}': + 'Unable to check for updates: {{reason}}', + 'Update successful! The new version will be used on your next run.': + 'Update successful! The new version will be used on your next run.', + 'Update downloaded. It will be applied after you exit this session.': + 'Update downloaded. It will be applied after you exit this session.', + 'Update failed: {{error}}': 'Update failed: {{error}}', + 'Downloading update...': 'Downloading update...', + 'Update successful! Please restart Qwen Code to use the new version. Switching model providers before restarting may not work correctly.': + 'Update successful! Please restart Qwen Code to use the new version. Switching model providers before restarting may not work correctly.', + 'Automatic update failed. Please try updating manually.': + 'Automatic update failed. Please try updating manually.', + 'Automatic update failed: {{error}}. Re-run the installer to update manually.': + 'Automatic update failed: {{error}}. Re-run the installer to update manually.', + 'Running from a local git clone. Please update with "git pull".': + 'Running from a local git clone. Please update with "git pull".', + 'Running via npx, update not applicable.': + 'Running via npx, update not applicable.', + 'Running via pnpx, update not applicable.': + 'Running via pnpx, update not applicable.', + 'Running via bunx, update not applicable.': + 'Running via bunx, update not applicable.', + 'Installed via Homebrew. Please update with "brew upgrade".': + 'Installed via Homebrew. Please update with "brew upgrade".', + "Locally installed. Please update via your project's package.json.": + "Locally installed. Please update via your project's package.json.", + 'Update requires sudo. Please run:': 'Update requires sudo. Please run:', + 'Standalone install detected. Attempting to automatically update now...': + 'Standalone install detected. Attempting to automatically update now...', + 'Standalone install detected. Please rerun the standalone installer to update:': + 'Standalone install detected. Please rerun the standalone installer to update:', + 'Run the following to update:': 'Run the following to update:', + 'Unable to auto-update this standalone installation. Please reinstall from:': + 'Unable to auto-update this standalone installation. Please reinstall from:', + 'Manual update required. Please reinstall Qwen Code.': + 'Manual update required. Please reinstall Qwen Code.', '⚠️ History gap: earlier conversation was lost before this point (storage interruption) and could not be recovered.': '⚠️ History gap: earlier conversation was lost before this point (storage interruption) and could not be recovered.', diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 564eea679b..1fe328bbb7 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -2107,6 +2107,54 @@ export default { in: 'ent.', out: 'sort.', 'In/Out': 'Ent/Sort', + // Update command + 'Check for Qwen Code updates and install if available': + 'Vérifier les mises à jour de Qwen Code et installer si disponible', + 'Qwen Code update available! {{current}} → {{latest}}': + 'Mise à jour de Qwen Code disponible ! {{current}} → {{latest}}', + 'A new version of Qwen Code is available! {{current}} → {{latest}}': + 'Une nouvelle version de Qwen Code est disponible ! {{current}} → {{latest}}', + 'Qwen Code {{version}} is up to date!': 'Qwen Code {{version}} est à jour !', + 'Failed to check for updates. Please check your network or registry configuration.': + 'Échec de la vérification des mises à jour. Vérifiez votre réseau ou la configuration du registre.', + 'Unable to check for updates: {{reason}}': + 'Impossible de vérifier les mises à jour : {{reason}}', + 'Update successful! The new version will be used on your next run.': + 'Mise à jour réussie ! La nouvelle version sera utilisée lors de la prochaine exécution.', + 'Update downloaded. It will be applied after you exit this session.': + 'Mise à jour téléchargée. Elle sera appliquée après avoir quitté cette session.', + 'Update failed: {{error}}': 'Échec de la mise à jour : {{error}}', + 'Downloading update...': 'Téléchargement de la mise à jour...', + 'Update successful! Please restart Qwen Code to use the new version. Switching model providers before restarting may not work correctly.': + 'Mise à jour réussie ! Redémarrez Qwen Code pour utiliser la nouvelle version. Changer de fournisseur de modèle avant le redémarrage peut ne pas fonctionner correctement.', + 'Automatic update failed. Please try updating manually.': + 'La mise à jour automatique a échoué. Essayez de mettre à jour manuellement.', + 'Automatic update failed: {{error}}. Re-run the installer to update manually.': + 'Échec de la mise à jour automatique : {{error}}. Relancez le programme d’installation pour mettre à jour manuellement.', + 'Running from a local git clone. Please update with "git pull".': + 'Exécution depuis un clone Git local. Veuillez mettre à jour avec "git pull".', + 'Running via npx, update not applicable.': + 'Exécution via npx, mise à jour non applicable.', + 'Running via pnpx, update not applicable.': + 'Exécution via pnpx, mise à jour non applicable.', + 'Running via bunx, update not applicable.': + 'Exécution via bunx, mise à jour non applicable.', + 'Installed via Homebrew. Please update with "brew upgrade".': + 'Installé via Homebrew. Veuillez mettre à jour avec "brew upgrade".', + "Locally installed. Please update via your project's package.json.": + 'Installé localement. Veuillez mettre à jour via le package.json de votre projet.', + 'Update requires sudo. Please run:': + 'La mise à jour nécessite sudo. Veuillez exécuter :', + 'Standalone install detected. Attempting to automatically update now...': + 'Installation autonome détectée. Tentative de mise à jour automatique...', + 'Standalone install detected. Please rerun the standalone installer to update:': + 'Installation autonome détectée. Veuillez relancer l’installateur autonome pour mettre à jour :', + 'Run the following to update:': + 'Exécutez la commande suivante pour mettre à jour :', + 'Unable to auto-update this standalone installation. Please reinstall from:': + 'Impossible de mettre à jour automatiquement cette installation autonome. Veuillez réinstaller depuis :', + 'Manual update required. Please reinstall Qwen Code.': + 'Mise à jour manuelle requise. Veuillez réinstaller Qwen Code.', // ============================================================================ // reload-plugins command diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index aa6b9c3820..791c9d189e 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -1869,6 +1869,54 @@ export default { in: '入力', out: '出力', 'In/Out': '入力/出力', + // Update command + 'Check for Qwen Code updates and install if available': + 'Qwen Codeのアップデートを確認し、利用可能な場合はインストールします', + 'Qwen Code update available! {{current}} → {{latest}}': + 'Qwen Code のアップデートがあります!{{current}} → {{latest}}', + 'A new version of Qwen Code is available! {{current}} → {{latest}}': + 'Qwen Code の新しいバージョンがあります!{{current}} → {{latest}}', + 'Qwen Code {{version}} is up to date!': 'Qwen Code {{version}} は最新です!', + 'Failed to check for updates. Please check your network or registry configuration.': + 'アップデートの確認に失敗しました。ネットワークまたはレジストリ設定を確認してください。', + 'Unable to check for updates: {{reason}}': + 'アップデートを確認できません: {{reason}}', + 'Update successful! The new version will be used on your next run.': + 'アップデート成功!新バージョンは次回起動時に使用されます。', + 'Update downloaded. It will be applied after you exit this session.': + 'アップデートをダウンロードしました。現在のセッション終了後に適用されます。', + 'Update failed: {{error}}': 'アップデート失敗:{{error}}', + 'Downloading update...': 'アップデートをダウンロードしています...', + 'Update successful! Please restart Qwen Code to use the new version. Switching model providers before restarting may not work correctly.': + 'アップデートに成功しました!新しいバージョンを使用するには Qwen Code を再起動してください。再起動前にモデルプロバイダーを切り替えると正しく動作しない場合があります。', + 'Automatic update failed. Please try updating manually.': + '自動アップデートに失敗しました。手動で更新してください。', + 'Automatic update failed: {{error}}. Re-run the installer to update manually.': + '自動更新に失敗しました: {{error}}。手動で更新するにはインストーラーを再実行してください。', + 'Running from a local git clone. Please update with "git pull".': + 'ローカル Git クローンから実行中です。"git pull" で更新してください。', + 'Running via npx, update not applicable.': + 'npx 経由で実行中のため、更新は適用されません。', + 'Running via pnpx, update not applicable.': + 'pnpx 経由で実行中のため、更新は適用されません。', + 'Running via bunx, update not applicable.': + 'bunx 経由で実行中のため、更新は適用されません。', + 'Installed via Homebrew. Please update with "brew upgrade".': + 'Homebrew 経由でインストールされています。"brew upgrade" で更新してください。', + "Locally installed. Please update via your project's package.json.": + 'ローカルにインストールされています。プロジェクトの package.json 経由で更新してください。', + 'Update requires sudo. Please run:': + '更新には sudo が必要です。次を実行してください:', + 'Standalone install detected. Attempting to automatically update now...': + 'スタンドアロンインストールを検出しました。自動更新を試行しています...', + 'Standalone install detected. Please rerun the standalone installer to update:': + 'スタンドアロンインストールを検出しました。更新するにはスタンドアロンインストーラーを再実行してください:', + 'Run the following to update:': + '以下のコマンドを実行してアップデートしてください:', + 'Unable to auto-update this standalone installation. Please reinstall from:': + 'このスタンドアロンインストールを自動更新できません。以下から再インストールしてください:', + 'Manual update required. Please reinstall Qwen Code.': + '手動更新が必要です。Qwen Codeを再インストールしてください。', // ============================================================================ // reload-plugins command diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index b1bd2d7593..c94c7e81df 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -2091,6 +2091,53 @@ export default { in: 'ent.', out: 'saída', 'In/Out': 'Ent/Saída', + // Update command + 'Check for Qwen Code updates and install if available': + 'Verificar atualizações do Qwen Code e instalar se disponível', + 'Qwen Code update available! {{current}} → {{latest}}': + 'Atualização do Qwen Code disponível! {{current}} → {{latest}}', + 'A new version of Qwen Code is available! {{current}} → {{latest}}': + 'Uma nova versão do Qwen Code está disponível! {{current}} → {{latest}}', + 'Qwen Code {{version}} is up to date!': + 'Qwen Code {{version}} está atualizado!', + 'Failed to check for updates. Please check your network or registry configuration.': + 'Falha ao verificar atualizações. Verifique sua rede ou configuração do registro.', + 'Unable to check for updates: {{reason}}': + 'Não foi possível verificar atualizações: {{reason}}', + 'Update successful! The new version will be used on your next run.': + 'Atualização bem-sucedida! A nova versão será usada na próxima execução.', + 'Update downloaded. It will be applied after you exit this session.': + 'Atualização baixada. Será aplicada após você sair desta sessão.', + 'Update failed: {{error}}': 'Falha na atualização: {{error}}', + 'Downloading update...': 'Baixando atualização...', + 'Update successful! Please restart Qwen Code to use the new version. Switching model providers before restarting may not work correctly.': + 'Atualização bem-sucedida! Reinicie o Qwen Code para usar a nova versão. Alternar provedores de modelo antes de reiniciar pode não funcionar corretamente.', + 'Automatic update failed. Please try updating manually.': + 'Falha na atualização automática. Tente atualizar manualmente.', + 'Automatic update failed: {{error}}. Re-run the installer to update manually.': + 'Falha na atualização automática: {{error}}. Execute novamente o instalador para atualizar manualmente.', + 'Running from a local git clone. Please update with "git pull".': + 'Executando a partir de um clone Git local. Atualize com "git pull".', + 'Running via npx, update not applicable.': + 'Executando via npx, atualização não aplicável.', + 'Running via pnpx, update not applicable.': + 'Executando via pnpx, atualização não aplicável.', + 'Running via bunx, update not applicable.': + 'Executando via bunx, atualização não aplicável.', + 'Installed via Homebrew. Please update with "brew upgrade".': + 'Instalado via Homebrew. Atualize com "brew upgrade".', + "Locally installed. Please update via your project's package.json.": + 'Instalado localmente. Atualize via package.json do seu projeto.', + 'Update requires sudo. Please run:': 'A atualização requer sudo. Execute:', + 'Standalone install detected. Attempting to automatically update now...': + 'Instalação standalone detectada. Tentando atualizar automaticamente agora...', + 'Standalone install detected. Please rerun the standalone installer to update:': + 'Instalação standalone detectada. Execute novamente o instalador standalone para atualizar:', + 'Run the following to update:': 'Execute o seguinte para atualizar:', + 'Unable to auto-update this standalone installation. Please reinstall from:': + 'Não foi possível atualizar automaticamente esta instalação independente. Reinstale de:', + 'Manual update required. Please reinstall Qwen Code.': + 'Atualização manual necessária. Reinstale o Qwen Code.', // ============================================================================ // reload-plugins command diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index acfe059665..854749ea06 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -2079,6 +2079,53 @@ export default { in: 'вх.', out: 'вых.', 'In/Out': 'Вх/Вых', + // Update command + 'Check for Qwen Code updates and install if available': + 'Проверить обновления Qwen Code и установить при наличии', + 'Qwen Code update available! {{current}} → {{latest}}': + 'Доступно обновление Qwen Code! {{current}} → {{latest}}', + 'A new version of Qwen Code is available! {{current}} → {{latest}}': + 'Доступна новая версия Qwen Code! {{current}} → {{latest}}', + 'Qwen Code {{version}} is up to date!': 'Qwen Code {{version}} актуален!', + 'Failed to check for updates. Please check your network or registry configuration.': + 'Не удалось проверить обновления. Проверьте сеть или настройки registry.', + 'Unable to check for updates: {{reason}}': + 'Невозможно проверить обновления: {{reason}}', + 'Update successful! The new version will be used on your next run.': + 'Обновление успешно! Новая версия будет использована при следующем запуске.', + 'Update downloaded. It will be applied after you exit this session.': + 'Обновление загружено. Оно будет применено после выхода из этого сеанса.', + 'Update failed: {{error}}': 'Ошибка обновления: {{error}}', + 'Downloading update...': 'Загрузка обновления...', + 'Update successful! Please restart Qwen Code to use the new version. Switching model providers before restarting may not work correctly.': + 'Обновление успешно! Перезапустите Qwen Code, чтобы использовать новую версию. Переключение поставщиков моделей до перезапуска может работать некорректно.', + 'Automatic update failed. Please try updating manually.': + 'Автоматическое обновление не удалось. Попробуйте обновить вручную.', + 'Automatic update failed: {{error}}. Re-run the installer to update manually.': + 'Автоматическое обновление не удалось: {{error}}. Повторно запустите установщик, чтобы обновить вручную.', + 'Running from a local git clone. Please update with "git pull".': + 'Запущено из локального клона Git. Обновите с помощью "git pull".', + 'Running via npx, update not applicable.': + 'Запущено через npx, обновление неприменимо.', + 'Running via pnpx, update not applicable.': + 'Запущено через pnpx, обновление неприменимо.', + 'Running via bunx, update not applicable.': + 'Запущено через bunx, обновление неприменимо.', + 'Installed via Homebrew. Please update with "brew upgrade".': + 'Установлено через Homebrew. Обновите с помощью "brew upgrade".', + "Locally installed. Please update via your project's package.json.": + 'Установлено локально. Обновите через package.json вашего проекта.', + 'Update requires sudo. Please run:': + 'Для обновления требуется sudo. Выполните:', + 'Standalone install detected. Attempting to automatically update now...': + 'Обнаружена автономная установка. Выполняется попытка автоматического обновления...', + 'Standalone install detected. Please rerun the standalone installer to update:': + 'Обнаружена автономная установка. Повторно запустите автономный установщик для обновления:', + 'Run the following to update:': 'Выполните следующую команду для обновления:', + 'Unable to auto-update this standalone installation. Please reinstall from:': + 'Невозможно автоматически обновить эту автономную установку. Переустановите с:', + 'Manual update required. Please reinstall Qwen Code.': + 'Требуется ручное обновление. Переустановите Qwen Code.', // ============================================================================ // reload-plugins command diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 52b499fad5..835a85f216 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -2146,6 +2146,50 @@ export default { ' (not in model registry)': '(不在模型註冊表中)', 'start server': '啟動伺服器', 'No compression needed.': '無需壓縮。', + // Update command + 'Check for Qwen Code updates and install if available': + '檢查 Qwen Code 更新並安裝(如果可用)', + 'Qwen Code update available! {{current}} → {{latest}}': + 'Qwen Code 有可用更新!{{current}} → {{latest}}', + 'A new version of Qwen Code is available! {{current}} → {{latest}}': + 'Qwen Code 有新版本可用!{{current}} → {{latest}}', + 'Qwen Code {{version}} is up to date!': 'Qwen Code {{version}} 已是最新!', + 'Failed to check for updates. Please check your network or registry configuration.': + '檢查更新失敗。請檢查網路或 registry 設定。', + 'Unable to check for updates: {{reason}}': '無法檢查更新:{{reason}}', + 'Update successful! The new version will be used on your next run.': + '更新成功!新版本將在下次執行時生效。', + 'Update downloaded. It will be applied after you exit this session.': + '更新已下載。將在結束目前工作階段後套用。', + 'Update failed: {{error}}': '更新失敗:{{error}}', + 'Downloading update...': '正在下載更新...', + 'Update successful! Please restart Qwen Code to use the new version. Switching model providers before restarting may not work correctly.': + '更新成功!請重新啟動 Qwen Code 以使用新版本。重新啟動前切換模型提供商可能無法正常運作。', + 'Automatic update failed. Please try updating manually.': + '自動更新失敗。請嘗試手動更新。', + 'Automatic update failed: {{error}}. Re-run the installer to update manually.': + '自動更新失敗:{{error}}。請重新執行安裝程式以手動更新。', + 'Running from a local git clone. Please update with "git pull".': + '正在從本機 Git 複製執行。請使用 "git pull" 更新。', + 'Running via npx, update not applicable.': '正在透過 npx 執行,更新不適用。', + 'Running via pnpx, update not applicable.': + '正在透過 pnpx 執行,更新不適用。', + 'Running via bunx, update not applicable.': + '正在透過 bunx 執行,更新不適用。', + 'Installed via Homebrew. Please update with "brew upgrade".': + '透過 Homebrew 安裝。請使用 "brew upgrade" 更新。', + "Locally installed. Please update via your project's package.json.": + '本機安裝。請透過專案的 package.json 更新。', + 'Update requires sudo. Please run:': '更新需要 sudo。請執行:', + 'Standalone install detected. Attempting to automatically update now...': + '偵測到獨立安裝。正在嘗試自動更新...', + 'Standalone install detected. Please rerun the standalone installer to update:': + '偵測到獨立安裝。請重新執行獨立安裝程式以更新:', + 'Run the following to update:': '執行以下命令進行更新:', + 'Unable to auto-update this standalone installation. Please reinstall from:': + '無法自動更新此獨立安裝。請從以下位址重新安裝:', + 'Manual update required. Please reinstall Qwen Code.': + '需要手動更新。請重新安裝 Qwen Code。', '⚠️ History gap: earlier conversation was lost before this point (storage interruption) and could not be recovered.': '⚠️ 歷史記錄缺口:此處之前的會話記錄已遺失(儲存中斷),且無法找回。', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 87289f7685..1d2a49fbb6 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -2348,6 +2348,50 @@ export default { '中国 (China) - 阿里云百炼': '中国 - 阿里云百炼', '阿里云百炼 (aliyun.com)': '阿里云百炼(aliyun.com)', 'No compression needed.': '无需压缩。', + // Update command + 'Check for Qwen Code updates and install if available': + '检查 Qwen Code 更新并安装(如果可用)', + 'Qwen Code update available! {{current}} → {{latest}}': + 'Qwen Code 有可用更新!{{current}} → {{latest}}', + 'A new version of Qwen Code is available! {{current}} → {{latest}}': + 'Qwen Code 有新版本可用!{{current}} → {{latest}}', + 'Qwen Code {{version}} is up to date!': 'Qwen Code {{version}} 已是最新!', + 'Failed to check for updates. Please check your network or registry configuration.': + '检查更新失败。请检查网络或 registry 配置。', + 'Unable to check for updates: {{reason}}': '无法检查更新:{{reason}}', + 'Update successful! The new version will be used on your next run.': + '更新成功!新版本将在下次运行时生效。', + 'Update downloaded. It will be applied after you exit this session.': + '更新已下载。将在退出当前会话后应用。', + 'Update failed: {{error}}': '更新失败:{{error}}', + 'Downloading update...': '正在下载更新...', + 'Update successful! Please restart Qwen Code to use the new version. Switching model providers before restarting may not work correctly.': + '更新成功!请重启 Qwen Code 以使用新版本。重启前切换模型提供商可能无法正常工作。', + 'Automatic update failed. Please try updating manually.': + '自动更新失败。请尝试手动更新。', + 'Automatic update failed: {{error}}. Re-run the installer to update manually.': + '自动更新失败:{{error}}。请重新运行安装程序以手动更新。', + 'Running from a local git clone. Please update with "git pull".': + '正在从本地 Git 克隆运行。请使用 "git pull" 更新。', + 'Running via npx, update not applicable.': '正在通过 npx 运行,更新不适用。', + 'Running via pnpx, update not applicable.': + '正在通过 pnpx 运行,更新不适用。', + 'Running via bunx, update not applicable.': + '正在通过 bunx 运行,更新不适用。', + 'Installed via Homebrew. Please update with "brew upgrade".': + '通过 Homebrew 安装。请使用 "brew upgrade" 更新。', + "Locally installed. Please update via your project's package.json.": + '本地安装。请通过项目的 package.json 更新。', + 'Update requires sudo. Please run:': '更新需要 sudo。请运行:', + 'Standalone install detected. Attempting to automatically update now...': + '检测到独立安装。正在尝试自动更新...', + 'Standalone install detected. Please rerun the standalone installer to update:': + '检测到独立安装。请重新运行独立安装程序以更新:', + 'Run the following to update:': '运行以下命令进行更新:', + 'Unable to auto-update this standalone installation. Please reinstall from:': + '无法自动更新此独立安装。请从以下地址重新安装:', + 'Manual update required. Please reinstall Qwen Code.': + '需要手动更新。请重新安装 Qwen Code。', '⚠️ History gap: earlier conversation was lost before this point (storage interruption) and could not be recovered.': '⚠️ 历史记录缺口:此处之前的会话记录已丢失(存储中断),且无法找回。', diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 181198de25..ac180855c0 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -72,6 +72,7 @@ import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; import { insightCommand } from '../ui/commands/insightCommand.js'; import { statuslineCommand } from '../ui/commands/statuslineCommand.js'; import { lspCommand } from '../ui/commands/lspCommand.js'; +import { updateCommand } from '../ui/commands/update-command.js'; const builtinDebugLogger = createDebugLogger('BUILTIN_COMMAND_LOADER'); @@ -168,6 +169,7 @@ export class BuiltinCommandLoader implements ICommandLoader { toolsCommand, settingsCommand, vimCommand, + updateCommand, voiceCommand, setupGithubCommand, terminalSetupCommand, diff --git a/packages/cli/src/startup/startup-prefetch.test.ts b/packages/cli/src/startup/startup-prefetch.test.ts index 247a0bb421..f3519a1ad9 100644 --- a/packages/cli/src/startup/startup-prefetch.test.ts +++ b/packages/cli/src/startup/startup-prefetch.test.ts @@ -21,8 +21,9 @@ const mockDebug = vi.hoisted(() => vi.fn()); const mockWarn = vi.hoisted(() => vi.fn()); const mockPreconnectApi = vi.hoisted(() => vi.fn()); const mockRecordStartupEvent = vi.hoisted(() => vi.fn()); -const mockCheckForUpdates = vi.hoisted(() => vi.fn()); +const mockCheckForUpdatesDetailed = vi.hoisted(() => vi.fn()); const mockHandleAutoUpdate = vi.hoisted(() => vi.fn()); +const mockUpdateEventEmit = vi.hoisted(() => vi.fn()); const mockConnectIdeForStartup = vi.hoisted(() => vi.fn()); const mockDisconnectIde = vi.hoisted(() => vi.fn()); const mockGetIdeClientInstance = vi.hoisted(() => @@ -49,13 +50,24 @@ vi.mock('../utils/startupProfiler.js', () => ({ })); vi.mock('../ui/utils/updateCheck.js', () => ({ - checkForUpdates: (...args: unknown[]) => mockCheckForUpdates(...args), + checkForUpdatesDetailed: (...args: unknown[]) => + mockCheckForUpdatesDetailed(...args), })); vi.mock('../utils/handleAutoUpdate.js', () => ({ handleAutoUpdate: (...args: unknown[]) => mockHandleAutoUpdate(...args), })); +vi.mock('../utils/updateEventEmitter.js', () => ({ + updateEventEmitter: { + emit: (...args: unknown[]) => mockUpdateEventEmit(...args), + }, +})); + +vi.mock('../i18n/index.js', () => ({ + t: (key: string) => key, +})); + vi.mock('../core/initializer.js', () => ({ connectIdeForStartup: (...args: unknown[]) => mockConnectIdeForStartup(...args), @@ -94,7 +106,10 @@ describe('startupPrefetch', () => { beforeEach(() => { vi.clearAllMocks(); vi.useRealTimers(); - mockCheckForUpdates.mockResolvedValue(null); + mockCheckForUpdatesDetailed.mockResolvedValue({ + status: 'up-to-date', + currentVersion: '1.0.0', + }); mockConnectIdeForStartup.mockResolvedValue(undefined); mockDisconnectIde.mockResolvedValue(undefined); mockGetIdeClientInstance.mockResolvedValue({ @@ -172,12 +187,8 @@ describe('startupPrefetch', () => { await vi.dynamicImportSettled(); - expect(mockCheckForUpdates).toHaveBeenCalledTimes(1); - expect(mockHandleAutoUpdate).toHaveBeenCalledWith( - null, - expect.any(Object), - '/repo', - ); + expect(mockCheckForUpdatesDetailed).toHaveBeenCalledTimes(1); + expect(mockHandleAutoUpdate).not.toHaveBeenCalled(); expect(mockRecordStartupEvent).toHaveBeenCalledWith( 'startup_prefetch_started', { name: 'update_check' }, @@ -191,13 +202,13 @@ describe('startupPrefetch', () => { it('starts post-render tasks without awaiting completion', async () => { const config = makeConfig(); const updatePromise = new Promise(() => {}); - mockCheckForUpdates.mockReturnValue(updatePromise); + mockCheckForUpdatesDetailed.mockReturnValue(updatePromise); startPostRenderPrefetches(config, makeSettings(), { connectIde: true }); await vi.dynamicImportSettled(); - expect(mockCheckForUpdates).toHaveBeenCalledTimes(1); + expect(mockCheckForUpdatesDetailed).toHaveBeenCalledTimes(1); expect(mockConnectIdeForStartup).toHaveBeenCalledWith(config); expect(mockStartBackgroundHousekeeping).toHaveBeenCalledWith( config, @@ -214,10 +225,28 @@ describe('startupPrefetch', () => { await vi.dynamicImportSettled(); - expect(mockCheckForUpdates).not.toHaveBeenCalled(); + expect(mockCheckForUpdatesDetailed).not.toHaveBeenCalled(); expect(mockConnectIdeForStartup).toHaveBeenCalledWith(config); }); + it('surfaces update check errors through the update event emitter', async () => { + const config = makeConfig(); + mockCheckForUpdatesDetailed.mockResolvedValue({ + status: 'error', + error: new Error('registry unavailable'), + }); + + startPostRenderPrefetches(config, makeSettings()); + + await vi.dynamicImportSettled(); + + expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-failed', { + message: + 'Failed to check for updates. Please check your network or registry configuration.', + }); + expect(mockHandleAutoUpdate).not.toHaveBeenCalled(); + }); + it('requires connectIde option before connecting IDE', async () => { const config = makeConfig(); const { statuses, stop } = captureIdeConnectionStatuses(); @@ -432,7 +461,7 @@ describe('startupPrefetch', () => { it('swallows deferred task failures', async () => { const config = makeConfig(); const error = new Error('network down'); - mockCheckForUpdates.mockRejectedValue(error); + mockCheckForUpdatesDetailed.mockRejectedValue(error); expect(() => startPostRenderPrefetches(config, makeSettings()), @@ -441,6 +470,10 @@ describe('startupPrefetch', () => { await vi.dynamicImportSettled(); expect(mockWarn).toHaveBeenCalledWith('update_check failed:', error); + expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-failed', { + message: + 'Failed to check for updates. Please check your network or registry configuration.', + }); }); it('does not start housekeeping for non-interactive configs', async () => { @@ -463,6 +496,6 @@ describe('startupPrefetch', () => { await vi.dynamicImportSettled(); - expect(mockCheckForUpdates).toHaveBeenCalledTimes(1); + expect(mockCheckForUpdatesDetailed).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/cli/src/startup/startup-prefetch.ts b/packages/cli/src/startup/startup-prefetch.ts index 45ed80d343..284bea7e83 100644 --- a/packages/cli/src/startup/startup-prefetch.ts +++ b/packages/cli/src/startup/startup-prefetch.ts @@ -148,12 +148,35 @@ export function startPostRenderPrefetches( if (settings.merged.general?.enableAutoUpdate !== false) { runDeferredTask('update_check', async () => { - const [{ checkForUpdates }, { handleAutoUpdate }] = await Promise.all([ + const [ + { checkForUpdatesDetailed }, + { handleAutoUpdate }, + { updateEventEmitter }, + { t }, + ] = await Promise.all([ import('../ui/utils/updateCheck.js'), import('../utils/handleAutoUpdate.js'), + import('../utils/updateEventEmitter.js'), + import('../i18n/index.js'), ]); - const info = await checkForUpdates(); - handleAutoUpdate(info, settings, config.getProjectRoot()); + const updateFailedMessage = t( + 'Failed to check for updates. Please check your network or registry configuration.', + ); + try { + const result = await checkForUpdatesDetailed(); + if (result.status === 'update') { + handleAutoUpdate(result.info, settings, config.getProjectRoot()); + } else if (result.status === 'error') { + updateEventEmitter.emit('update-failed', { + message: updateFailedMessage, + }); + } + } catch (error) { + updateEventEmitter.emit('update-failed', { + message: updateFailedMessage, + }); + throw error; + } }); } diff --git a/packages/cli/src/ui/commands/update-command.test.ts b/packages/cli/src/ui/commands/update-command.test.ts new file mode 100644 index 0000000000..e030e857c3 --- /dev/null +++ b/packages/cli/src/ui/commands/update-command.test.ts @@ -0,0 +1,327 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +const checkForUpdatesDetailed = vi.fn(); +const handleAutoUpdate = vi.fn(); +const performStandaloneUpdate = 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.']; + }, +); +vi.mock('../utils/updateCheck.js', () => ({ checkForUpdatesDetailed })); +vi.mock('../../utils/handleAutoUpdate.js', () => ({ handleAutoUpdate })); +vi.mock('../../utils/standalone-update.js', () => ({ + performStandaloneUpdate, +})); +vi.mock('../../utils/installationInfo.js', () => ({ + formatUpdateInstructions, + getInstallationInfo, + resolveUpdateCommand, +})); +const { updateCommand } = await import('./update-command.js'); + +function context( + executionMode: 'interactive' | 'non_interactive' | 'acp', + enableAutoUpdate?: boolean, +) { + return createMockCommandContext({ + executionMode, + services: { + settings: { + merged: { general: { enableAutoUpdate } }, + }, + config: { + getProjectRoot: () => '/repo', + }, + }, + }); +} + +describe('updateCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + 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('delegates to handleAutoUpdate in interactive mode', async () => { + const commandContext = context('interactive'); + handleAutoUpdate.mockImplementation((_info, settings) => { + expect(settings.merged.general?.enableAutoUpdate).toBeUndefined(); + }); + + const result = await updateCommand.action!(commandContext, ''); + + expect(result).toBeUndefined(); + expect(handleAutoUpdate).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Update available: 1.2.3' }), + commandContext.services.settings, + '/repo', + ); + expect( + commandContext.services.settings.merged.general?.enableAutoUpdate, + ).toBeUndefined(); + expect(getInstallationInfo).toHaveBeenCalledWith('/repo', true); + }); + + it('returns the manual update command in non-interactive mode', async () => { + const result = await updateCommand.action!(context('non_interactive'), ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Update available: 1.2.3\nRun the following to update:\n npm install -g @qwen-code/qwen-code@1.2.3', + }); + expect(handleAutoUpdate).not.toHaveBeenCalled(); + }); + + it('returns manual instructions in interactive mode when auto-update is disabled', async () => { + const commandContext = context('interactive', false); + + const result = await updateCommand.action!(commandContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Update available: 1.2.3\nRun the following to update:\n npm install -g @qwen-code/qwen-code@1.2.3', + }); + expect(handleAutoUpdate).not.toHaveBeenCalled(); + expect( + commandContext.services.settings.merged.general?.enableAutoUpdate, + ).toBe(false); + }); + + it('does not update standalone installs in interactive mode when auto-update is disabled', async () => { + getInstallationInfo.mockReturnValue({ + isStandalone: true, + standaloneDir: '/tmp/qwen-code', + }); + + const result = await updateCommand.action!( + context('interactive', false), + '', + ); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Update available: 1.2.3\nManual update required. Please reinstall Qwen Code.', + }); + expect(handleAutoUpdate).not.toHaveBeenCalled(); + expect(performStandaloneUpdate).not.toHaveBeenCalled(); + }); + + it('does not mutate enableAutoUpdate when handleAutoUpdate throws', async () => { + const commandContext = context('interactive'); + handleAutoUpdate.mockImplementation(() => { + throw new Error('spawn failed'); + }); + + await expect(updateCommand.action!(commandContext, '')).rejects.toThrow( + 'spawn failed', + ); + expect( + commandContext.services.settings.merged.general?.enableAutoUpdate, + ).toBeUndefined(); + }); + + it('falls back to manual guidance in interactive mode when auto-update cannot act', async () => { + getInstallationInfo.mockReturnValue({ + isStandalone: false, + }); + + const result = await updateCommand.action!(context('interactive'), ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Update available: 1.2.3\nManual update required. Please reinstall Qwen Code.', + }); + expect(handleAutoUpdate).not.toHaveBeenCalled(); + }); + + it('returns the manual update command in ACP mode', async () => { + const result = await updateCommand.action!(context('acp'), ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Update available: 1.2.3\nRun the following to update:\n npm install -g @qwen-code/qwen-code@1.2.3', + }); + }); + + it('does not append generic fallback when installation info has updateMessage', async () => { + getInstallationInfo.mockReturnValue({ + isStandalone: false, + updateMessage: 'Running via npx, update not applicable.', + }); + + const result = await updateCommand.action!(context('non_interactive'), ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Update available: 1.2.3\nRunning via npx, update not applicable.', + }); + }); + + it('updates standalone installs in non-interactive mode', async () => { + getInstallationInfo.mockReturnValue({ + isStandalone: true, + standaloneDir: '/tmp/qwen-code', + }); + performStandaloneUpdate.mockResolvedValue('done'); + + const result = await updateCommand.action!(context('non_interactive'), ''); + + expect(performStandaloneUpdate).toHaveBeenCalledWith( + '/tmp/qwen-code', + '1.2.3', + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Update available: 1.2.3\nDownloading update...\nUpdate successful! The new version will be used on your next run.', + }); + }); + + it('returns deferred message when standalone update is not yet active', async () => { + getInstallationInfo.mockReturnValue({ + isStandalone: true, + standaloneDir: '/tmp/qwen-code', + }); + performStandaloneUpdate.mockResolvedValue('deferred'); + + const result = await updateCommand.action!(context('non_interactive'), ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Update available: 1.2.3\nDownloading update...\nUpdate downloaded. It will be applied after you exit this session.', + }); + }); + + it('returns an error when standalone update fails in non-interactive mode', async () => { + getInstallationInfo.mockReturnValue({ + isStandalone: true, + standaloneDir: '/tmp/qwen-code', + }); + performStandaloneUpdate.mockRejectedValue(new Error('boom')); + + const result = await updateCommand.action!(context('non_interactive'), ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Update available: 1.2.3\nUpdate failed: boom', + }); + }); + + it('returns manual reinstall guidance when no update command is available', async () => { + getInstallationInfo.mockReturnValue({ + isStandalone: false, + }); + + const result = await updateCommand.action!(context('non_interactive'), ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Update available: 1.2.3\nManual update required. Please reinstall Qwen Code.', + }); + }); + + it('returns the current version when no update is available', async () => { + checkForUpdatesDetailed.mockResolvedValue({ + status: 'up-to-date', + currentVersion: '1.0.0', + }); + + const result = await updateCommand.action!(context('non_interactive'), ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Qwen Code 1.0.0 is up to date!', + }); + }); + + it('returns an error when the update check fails', async () => { + checkForUpdatesDetailed.mockResolvedValue({ + status: 'error', + error: new Error('registry unavailable'), + }); + + const result = await updateCommand.action!(context('non_interactive'), ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: + 'Failed to check for updates. Please check your network or registry configuration.', + }); + expect(getInstallationInfo).not.toHaveBeenCalled(); + }); + + it('returns an error when the update check is skipped', async () => { + checkForUpdatesDetailed.mockResolvedValue({ + status: 'skipped', + reason: 'development mode', + }); + + const result = await updateCommand.action!(context('non_interactive'), ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Unable to check for updates: development mode', + }); + expect(getInstallationInfo).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/commands/update-command.ts b/packages/cli/src/ui/commands/update-command.ts new file mode 100644 index 0000000000..46f39966ee --- /dev/null +++ b/packages/cli/src/ui/commands/update-command.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SlashCommand } from './types.js'; +import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; + +export const updateCommand: SlashCommand = { + name: 'update', + get description() { + return t('Check for Qwen Code updates and install if available'); + }, + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: async (context) => { + const [ + { checkForUpdatesDetailed }, + { handleAutoUpdate }, + { performStandaloneUpdate }, + installationInfo, + ] = await Promise.all([ + import('../utils/updateCheck.js'), + import('../../utils/handleAutoUpdate.js'), + import('../../utils/standalone-update.js'), + import('../../utils/installationInfo.js'), + ]); + const { formatUpdateInstructions, getInstallationInfo } = installationInfo; + + const settings = context.services.settings; + const projectRoot = context.services.config?.getProjectRoot(); + + const updateCheck = await checkForUpdatesDetailed(); + + if (updateCheck.status === 'up-to-date') { + const msg = t('Qwen Code {{version}} is up to date!', { + version: updateCheck.currentVersion, + }); + return { + type: 'message' as const, + messageType: 'info' as const, + content: msg, + }; + } + + if (updateCheck.status === 'error') { + return { + type: 'message' as const, + messageType: 'error' as const, + content: t( + 'Failed to check for updates. Please check your network or registry configuration.', + ), + }; + } + + if (updateCheck.status === 'skipped') { + return { + type: 'message' as const, + messageType: 'error' as const, + content: t('Unable to check for updates: {{reason}}', { + reason: updateCheck.reason, + }), + }; + } + + const info = updateCheck.info; + const installInfo = getInstallationInfo(projectRoot || process.cwd(), true); + const manualInstructions = () => { + const lines = [ + info.message, + ...formatUpdateInstructions(installInfo, info.update.latest).map( + (line) => t(line), + ), + ]; + return { + type: 'message' as const, + messageType: 'info' as const, + content: lines.join('\n'), + }; + }; + + if (context.executionMode === 'interactive' && projectRoot) { + const isAutoUpdateEnabled = + settings.merged.general?.enableAutoUpdate !== false; + const canAutoUpdate = + installInfo.updateCommand || + (installInfo.isStandalone && installInfo.standaloneDir); + if (isAutoUpdateEnabled && canAutoUpdate) { + handleAutoUpdate(info, settings, projectRoot); + return; + } + return manualInstructions(); + } + + if (installInfo.isStandalone && installInfo.standaloneDir) { + try { + const result = await performStandaloneUpdate( + installInfo.standaloneDir, + info.update.latest, + ); + const message = + result === 'done' + ? t( + 'Update successful! The new version will be used on your next run.', + ) + : t( + 'Update downloaded. It will be applied after you exit this session.', + ); + return { + type: 'message' as const, + messageType: 'info' as const, + content: `${info.message}\n${t('Downloading update...')}\n${message}`, + }; + } catch (err) { + const message = t('Update failed: {{error}}', { + error: err instanceof Error ? err.message : String(err), + }); + return { + type: 'message' as const, + messageType: 'error' as const, + content: `${info.message}\n${message}`, + }; + } + } + + // Non-interactive / ACP mode: report the available update and manual command. + return manualInstructions(); + }, +}; diff --git a/packages/cli/src/ui/startInteractiveUI.tsx b/packages/cli/src/ui/startInteractiveUI.tsx index 868de266d9..f20fab9d11 100644 --- a/packages/cli/src/ui/startInteractiveUI.tsx +++ b/packages/cli/src/ui/startInteractiveUI.tsx @@ -203,6 +203,7 @@ export async function startInteractiveUI( // after this — it carries the `config_initialize_*` and // `input_enabled` checkpoints that complete the first-screen picture. profileCheckpoint('first_paint'); + startPostRenderPrefetches(config, settings, { connectIde: options.postRenderConnectIde ?? false, initializeTelemetry: diff --git a/packages/cli/src/ui/utils/updateCheck.test.ts b/packages/cli/src/ui/utils/updateCheck.test.ts index c7214e8b61..4ae6070eea 100644 --- a/packages/cli/src/ui/utils/updateCheck.test.ts +++ b/packages/cli/src/ui/utils/updateCheck.test.ts @@ -5,7 +5,7 @@ */ import { vi, describe, it, expect, beforeEach } from 'vitest'; -import { checkForUpdates } from './updateCheck.js'; +import { checkForUpdates, checkForUpdatesDetailed } from './updateCheck.js'; const getPackageJson = vi.hoisted(() => vi.fn()); vi.mock('../../utils/package.js', () => ({ @@ -122,6 +122,100 @@ describe('checkForUpdates', () => { expect(result).toBeNull(); }); + it('should return a detailed skipped result in DEV mode', async () => { + process.env['DEV'] = 'true'; + + const result = await checkForUpdatesDetailed(); + + expect(result).toEqual({ status: 'skipped', reason: 'development mode' }); + expect(getPackageJson).not.toHaveBeenCalled(); + expect(updateNotifier).not.toHaveBeenCalled(); + }); + + it('should return a detailed skipped result if package metadata is missing', async () => { + getPackageJson.mockResolvedValue(null); + + const result = await checkForUpdatesDetailed(); + + expect(result).toEqual({ + status: 'skipped', + reason: 'package metadata unavailable', + }); + }); + + it('should return a detailed up-to-date result when there is no update', async () => { + getPackageJson.mockResolvedValue({ + name: 'test-package', + version: '1.0.0', + }); + updateNotifier.mockReturnValue({ + fetchInfo: vi.fn().mockResolvedValue(null), + }); + + const result = await checkForUpdatesDetailed(); + + expect(result).toEqual({ status: 'up-to-date', currentVersion: '1.0.0' }); + }); + + it('should return a detailed error result if fetchInfo rejects', async () => { + const error = new Error('Timeout'); + getPackageJson.mockResolvedValue({ + name: 'test-package', + version: '1.0.0', + }); + updateNotifier.mockReturnValue({ + fetchInfo: vi.fn().mockRejectedValue(error), + }); + + const result = await checkForUpdatesDetailed(); + + expect(result).toEqual({ + status: 'error', + error, + currentVersion: '1.0.0', + }); + }); + + it('should return a detailed update result when a newer version is available', async () => { + getPackageJson.mockResolvedValue({ + name: 'test-package', + version: '1.0.0', + }); + updateNotifier.mockReturnValue({ + fetchInfo: vi + .fn() + .mockResolvedValue({ current: '1.0.0', latest: '1.1.0' }), + }); + + const result = await checkForUpdatesDetailed(); + + expect(result).toEqual({ + status: 'update', + info: { + message: 'Qwen Code update available! 1.0.0 → 1.1.0', + update: { current: '1.0.0', latest: '1.1.0' }, + }, + }); + }); + + it('should pass a non-optional package version to update-notifier', async () => { + getPackageJson.mockResolvedValue({ + name: 'test-package', + version: '1.0.0', + }); + updateNotifier.mockReturnValue({ + fetchInfo: vi.fn().mockResolvedValue(null), + }); + + await checkForUpdatesDetailed(); + + expect(updateNotifier).toHaveBeenCalledWith( + expect.objectContaining({ + pkg: { name: 'test-package', version: '1.0.0' }, + }), + ); + }); + it('should handle errors gracefully', async () => { getPackageJson.mockRejectedValue(new Error('test error')); const result = await checkForUpdates(); diff --git a/packages/cli/src/ui/utils/updateCheck.ts b/packages/cli/src/ui/utils/updateCheck.ts index b13467251c..748e9fdb22 100644 --- a/packages/cli/src/ui/utils/updateCheck.ts +++ b/packages/cli/src/ui/utils/updateCheck.ts @@ -9,6 +9,7 @@ import updateNotifier from 'update-notifier'; import semver from 'semver'; import { getPackageJson } from '../../utils/package.js'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import { t } from '../../i18n/index.js'; const debugLogger = createDebugLogger('UPDATE_CHECK'); @@ -19,6 +20,12 @@ export interface UpdateObject { update: UpdateInfo; } +export type UpdateCheckResult = + | { status: 'update'; info: UpdateObject } + | { status: 'up-to-date'; currentVersion: string } + | { status: 'skipped'; reason: string; currentVersion?: string } + | { status: 'error'; error: Error; currentVersion?: string }; + /** * From a nightly and stable update, determines which is the "best" one to offer. * The rule is to always prefer nightly if the base versions are the same. @@ -42,24 +49,26 @@ function getBestAvailableUpdate( return semver.gt(stableVer, nightlyVer) ? stable : nightly; } -export async function checkForUpdates(): Promise { +export async function checkForUpdatesDetailed(): Promise { + let currentVersion: string | undefined; try { // Skip update check when running from source (development mode) if (process.env['DEV'] === 'true') { - return null; + return { status: 'skipped', reason: 'development mode' }; } const packageJson = await getPackageJson(); if (!packageJson || !packageJson.name || !packageJson.version) { - return null; + return { status: 'skipped', reason: 'package metadata unavailable' }; } - const { name, version: currentVersion } = packageJson; - const isNightly = currentVersion.includes('nightly'); + const { name, version } = packageJson; + currentVersion = version; + const isNightly = version.includes('nightly'); const createNotifier = (distTag: 'latest' | 'nightly') => updateNotifier({ pkg: { name, - version: currentVersion, + version, }, updateCheckInterval: 0, shouldNotifyInNpmScript: true, @@ -77,28 +86,44 @@ export async function checkForUpdates(): Promise { latestUpdateInfo, ); - if (bestUpdate && semver.gt(bestUpdate.latest, currentVersion)) { - const message = `A new version of Qwen Code is available! ${currentVersion} → ${bestUpdate.latest}`; + if (bestUpdate && semver.gt(bestUpdate.latest, version)) { return { - message, - update: { ...bestUpdate, current: currentVersion }, + status: 'update', + info: { + message: t( + 'A new version of Qwen Code is available! {{current}} → {{latest}}', + { current: version, latest: bestUpdate.latest }, + ), + update: { ...bestUpdate, current: version }, + }, }; } } else { const updateInfo = await createNotifier('latest').fetchInfo(); - if (updateInfo && semver.gt(updateInfo.latest, currentVersion)) { - const message = `Qwen Code update available! ${currentVersion} → ${updateInfo.latest}`; + if (updateInfo && semver.gt(updateInfo.latest, version)) { return { - message, - update: { ...updateInfo, current: currentVersion }, + status: 'update', + info: { + message: t('Qwen Code update available! {{current}} → {{latest}}', { + current: version, + latest: updateInfo.latest, + }), + update: { ...updateInfo, current: version }, + }, }; } } - return null; + return { status: 'up-to-date', currentVersion: version }; } catch (e) { - debugLogger.warn('Failed to check for updates: ' + e); - return null; + const error = e instanceof Error ? e : new Error(String(e)); + debugLogger.warn('Failed to check for updates: ' + error); + return { status: 'error', error, currentVersion }; } } + +export async function checkForUpdates(): Promise { + const result = await checkForUpdatesDetailed(); + return result.status === 'update' ? result.info : null; +} diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts index 04cb64f4b5..9fb09a7501 100644 --- a/packages/cli/src/utils/handleAutoUpdate.test.ts +++ b/packages/cli/src/utils/handleAutoUpdate.test.ts @@ -192,8 +192,7 @@ describe('handleAutoUpdate', () => { }); expect(emitSpy).toHaveBeenCalledWith('update-failed', { - message: - 'Automatic update failed. Please try updating manually. (command: npm i -g @qwen-code/qwen-code@2.0.0, stderr: An error occurred)', + message: 'Automatic update failed. Please try updating manually.', }); }); @@ -216,8 +215,7 @@ describe('handleAutoUpdate', () => { }); expect(emitSpy).toHaveBeenCalledWith('update-failed', { - message: - 'Automatic update failed. Please try updating manually. (error: Spawn error)', + message: 'Automatic update failed. Please try updating manually.', }); }); @@ -239,7 +237,7 @@ describe('handleAutoUpdate', () => { 'npm i -g @qwen-code/qwen-code@nightly', ]), { - stdio: 'pipe', + stdio: ['pipe', 'ignore', 'pipe'], }, ); }); diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/utils/handleAutoUpdate.ts index 8daa1c2ad2..b860da3aff 100644 --- a/packages/cli/src/utils/handleAutoUpdate.ts +++ b/packages/cli/src/utils/handleAutoUpdate.ts @@ -6,14 +6,21 @@ import type { UpdateObject } from '../ui/utils/updateCheck.js'; import type { LoadedSettings } from '../config/settings.js'; -import { getInstallationInfo } from './installationInfo.js'; +import { + getInstallationInfo, + resolveUpdateCommand, +} from './installationInfo.js'; import { updateEventEmitter } from './updateEventEmitter.js'; import type { HistoryItemWithoutId } from '../ui/types.js'; import { MessageType } from '../ui/types.js'; import { spawnWrapper } from './spawnWrapper.js'; import { performStandaloneUpdate } from './standalone-update.js'; +import { t } from '../i18n/index.js'; import type { spawn } from 'node:child_process'; import os from 'node:os'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; + +const debugLogger = createDebugLogger('AUTO_UPDATE'); const UPDATE_SUCCESS_MESSAGE = 'Update successful! Please restart Qwen Code to use the new version. ' + @@ -59,13 +66,20 @@ export function handleAutoUpdate( .then((result) => { const message = result === 'deferred' - ? 'Update downloaded. It will be applied after you exit this session.' - : 'Update successful! The new version will be used on your next run.'; + ? t( + 'Update downloaded. It will be applied after you exit this session.', + ) + : t( + 'Update successful! The new version will be used on your next run.', + ); updateEventEmitter.emit('update-success', { message }); }) .catch((err: Error) => { updateEventEmitter.emit('update-failed', { - message: `Automatic update failed: ${err.message}. Re-run the installer to update manually.`, + message: t( + 'Automatic update failed: {{error}}. Re-run the installer to update manually.', + { error: err.message }, + ), }); }); return; @@ -75,16 +89,16 @@ export function handleAutoUpdate( if (!installationInfo.updateCommand || !isAutoUpdateEnabled) { return; } - const isNightly = info.update.latest.includes('nightly'); - - const updateCommand = installationInfo.updateCommand.replace( - '@latest', - isNightly ? '@nightly' : `@${info.update.latest}`, + const updateCommand = resolveUpdateCommand( + installationInfo.updateCommand, + info.update.latest, ); const isWindows = os.platform() === 'win32'; const shell = isWindows ? 'cmd.exe' : 'bash'; const shellArgs = isWindows ? ['/c', updateCommand] : ['-c', updateCommand]; - const updateProcess = spawnFn(shell, shellArgs, { stdio: 'pipe' }); + const updateProcess = spawnFn(shell, shellArgs, { + stdio: ['pipe', 'ignore', 'pipe'], + }); let errorOutput = ''; updateProcess.stderr.on('data', (data) => { errorOutput += data.toString(); @@ -93,18 +107,22 @@ export function handleAutoUpdate( updateProcess.on('close', (code) => { if (code === 0) { updateEventEmitter.emit('update-success', { - message: UPDATE_SUCCESS_MESSAGE, + message: t(UPDATE_SUCCESS_MESSAGE), }); } else { + debugLogger.warn( + `Automatic update command failed: ${updateCommand}; stderr: ${errorOutput.trim()}`, + ); updateEventEmitter.emit('update-failed', { - message: `${UPDATE_FAILED_MESSAGE} (command: ${updateCommand}, stderr: ${errorOutput.trim()})`, + message: t(UPDATE_FAILED_MESSAGE), }); } }); updateProcess.on('error', (err) => { + debugLogger.warn('Automatic update command failed to start:', err); updateEventEmitter.emit('update-failed', { - message: `${UPDATE_FAILED_MESSAGE} (error: ${err.message})`, + message: t(UPDATE_FAILED_MESSAGE), }); }); return updateProcess; @@ -144,7 +162,7 @@ export function setUpdateHandler( setUpdateInfo(null); addItemOrDefer({ type: MessageType.ERROR, - text: data?.message ?? UPDATE_FAILED_MESSAGE, + text: data?.message ?? t(UPDATE_FAILED_MESSAGE), }); }; @@ -153,7 +171,7 @@ export function setUpdateHandler( setUpdateInfo(null); addItemOrDefer({ type: MessageType.INFO, - text: data?.message ?? UPDATE_SUCCESS_MESSAGE, + text: data?.message ?? t(UPDATE_SUCCESS_MESSAGE), }); }; diff --git a/packages/cli/src/utils/installationInfo.test.ts b/packages/cli/src/utils/installationInfo.test.ts index f978972bb9..7a8ee4cee6 100644 --- a/packages/cli/src/utils/installationInfo.test.ts +++ b/packages/cli/src/utils/installationInfo.test.ts @@ -5,7 +5,12 @@ */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { getInstallationInfo, PackageManager } from './installationInfo.js'; +import { + formatUpdateInstructions, + getInstallationInfo, + PackageManager, + resolveUpdateCommand, +} from './installationInfo.js'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as childProcess from 'node:child_process'; @@ -670,3 +675,68 @@ describe('getInstallationInfo', () => { expect(info.updateMessage).toContain('sudo'); }); }); + +describe('resolveUpdateCommand', () => { + it('replaces @latest with the pinned stable version', () => { + expect( + resolveUpdateCommand('npm i -g @qwen-code/qwen-code@latest', '1.2.3'), + ).toBe('npm i -g @qwen-code/qwen-code@1.2.3'); + }); + + it('replaces @latest with @nightly for nightly versions', () => { + expect( + resolveUpdateCommand( + 'npm i -g @qwen-code/qwen-code@latest', + '1.2.3-nightly.20250101', + ), + ).toBe('npm i -g @qwen-code/qwen-code@nightly'); + }); +}); + +describe('formatUpdateInstructions', () => { + it('formats package-manager update commands', () => { + expect( + formatUpdateInstructions( + { + packageManager: PackageManager.NPM, + isGlobal: true, + updateCommand: 'npm i -g @qwen-code/qwen-code@latest', + }, + '1.2.3', + ), + ).toEqual([ + 'Run the following to update:', + ' npm i -g @qwen-code/qwen-code@1.2.3', + ]); + }); + + it('resolves @latest in updateMessage-only guidance for nightly versions', () => { + expect( + formatUpdateInstructions( + { + packageManager: PackageManager.NPM, + isGlobal: true, + updateMessage: + 'Update requires sudo. Please run: sudo npm i -g @qwen-code/qwen-code@latest', + }, + '1.2.3-nightly.20250101', + ), + ).toEqual([ + 'Update requires sudo. Please run:', + ' sudo npm i -g @qwen-code/qwen-code@nightly', + ]); + }); + + it('keeps updateMessage-only guidance as-is when no formatter applies', () => { + expect( + formatUpdateInstructions( + { + packageManager: PackageManager.NPX, + isGlobal: true, + updateMessage: 'Running via npx, update not applicable.', + }, + '1.2.3', + ), + ).toEqual(['Running via npx, update not applicable.']); + }); +}); diff --git a/packages/cli/src/utils/installationInfo.ts b/packages/cli/src/utils/installationInfo.ts index 4b6c6fb764..c32398ba1f 100644 --- a/packages/cli/src/utils/installationInfo.ts +++ b/packages/cli/src/utils/installationInfo.ts @@ -28,6 +28,65 @@ const STANDALONE_UNIX_INSTALLER = const STANDALONE_WINDOWS_INSTALLER = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1'; +function getStandaloneInstallerUrl(): string { + return process.platform === 'win32' + ? STANDALONE_WINDOWS_INSTALLER + : STANDALONE_UNIX_INSTALLER; +} + +export function resolveUpdateCommand( + updateCommand: string, + latestVersion: string, +): string { + const isNightly = latestVersion.includes('nightly'); + return updateCommand.replace( + '@latest', + isNightly ? '@nightly' : `@${latestVersion}`, + ); +} + +export function formatUpdateInstructions( + installationInfo: InstallationInfo, + latestVersion: string, +): string[] { + const lines: string[] = []; + + if (installationInfo.updateMessage && !installationInfo.updateCommand) { + lines.push( + ...formatUpdateMessage(installationInfo.updateMessage, latestVersion), + ); + } + + if (installationInfo.updateCommand) { + const updateCmd = resolveUpdateCommand( + installationInfo.updateCommand, + latestVersion, + ); + lines.push('Run the following to update:', ` ${updateCmd}`); + } else if (!installationInfo.updateMessage) { + lines.push('Manual update required. Please reinstall Qwen Code.'); + } + + return lines; +} + +function formatUpdateMessage( + updateMessage: string, + latestVersion: string, +): string[] { + const message = resolveUpdateCommand(updateMessage, latestVersion); + + const sudoPrefix = 'Update requires sudo. Please run: '; + if (message.startsWith(sudoPrefix)) { + return [ + 'Update requires sudo. Please run:', + ` ${message.slice(sudoPrefix.length)}`, + ]; + } + + return [message]; +} + export interface InstallationInfo { packageManager: PackageManager; isGlobal: boolean; @@ -242,10 +301,11 @@ function getStandaloneInstallInfo( return null; } + const installerUrl = getStandaloneInstallerUrl(); const updateCommand = process.platform === 'win32' - ? `powershell -ExecutionPolicy Bypass -c "irm ${STANDALONE_WINDOWS_INSTALLER} | iex"` - : `curl -fsSL ${STANDALONE_UNIX_INSTALLER} | bash`; + ? `powershell -ExecutionPolicy Bypass -c "irm ${installerUrl} | iex"` + : `curl -fsSL ${installerUrl} | bash`; return { packageManager: PackageManager.STANDALONE, diff --git a/packages/cli/src/utils/standalone-update.test.ts b/packages/cli/src/utils/standalone-update.test.ts index fa71158e82..ae85068471 100644 --- a/packages/cli/src/utils/standalone-update.test.ts +++ b/packages/cli/src/utils/standalone-update.test.ts @@ -9,11 +9,15 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { + acquireLock, rollbackStandaloneUpdate, ensureBinWrapper, ensurePathInShellRc, + cleanupFirstTimeMigrationArtifacts, performStandaloneUpdate, isSafeTarEntryPath, + isSafeTarEntry, + isSafeTarLinkTarget, } from './standalone-update.js'; describe('standalone-update', () => { @@ -143,7 +147,7 @@ describe('standalone-update', () => { const wrapperPath = path.join(tempDir, '.local', 'bin', 'qwen'); expect(fs.existsSync(wrapperPath)).toBe(true); const content = fs.readFileSync(wrapperPath, 'utf-8'); - expect(content).toContain('#!/bin/sh'); + expect(content).toContain('#!/usr/bin/env sh'); expect(content).toContain(standaloneDir); const mode = fs.statSync(wrapperPath).mode; expect(mode & 0o111).toBeGreaterThan(0); @@ -163,6 +167,66 @@ describe('standalone-update', () => { expect(content).toContain('@echo off'); }); + it.skipIf(process.platform === 'win32')( + 'escapes single quotes in Unix wrapper paths', + () => { + const libDir = path.join(tempDir, "o'brien", '.local', 'lib'); + const standaloneDir = path.join(libDir, 'qwen-code'); + fs.mkdirSync(standaloneDir, { recursive: true }); + + const origHome = process.env['HOME']; + const origShell = process.env['SHELL']; + process.env['HOME'] = tempDir; + process.env['SHELL'] = '/bin/zsh'; + try { + ensureBinWrapper(standaloneDir, 'linux-x64'); + } finally { + process.env['HOME'] = origHome; + process.env['SHELL'] = origShell; + } + + const wrapperPath = path.join( + tempDir, + "o'brien", + '.local', + 'bin', + 'qwen', + ); + const content = fs.readFileSync(wrapperPath, 'utf-8'); + expect(content).toContain("o'\\''brien"); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'allows shell metacharacters in single-quoted Unix wrapper paths', + () => { + const libDir = path.join(tempDir, 'with$dollar', '.local', 'lib'); + const standaloneDir = path.join(libDir, 'qwen-code'); + fs.mkdirSync(standaloneDir, { recursive: true }); + + const origHome = process.env['HOME']; + const origShell = process.env['SHELL']; + process.env['HOME'] = tempDir; + process.env['SHELL'] = '/bin/zsh'; + try { + ensureBinWrapper(standaloneDir, 'linux-x64'); + } finally { + process.env['HOME'] = origHome; + process.env['SHELL'] = origShell; + } + + const wrapperPath = path.join( + tempDir, + 'with$dollar', + '.local', + 'bin', + 'qwen', + ); + const content = fs.readFileSync(wrapperPath, 'utf-8'); + expect(content).toContain('with$dollar'); + }, + ); + it.skipIf(process.platform === 'win32')( 'does not overwrite existing wrapper', () => { @@ -191,6 +255,19 @@ describe('standalone-update', () => { } }, ); + + it.skipIf(process.platform === 'win32')( + 'throws when wrapper creation fails safety validation', + () => { + const libDir = path.join(tempDir, 'bad\npath', '.local', 'lib'); + const standaloneDir = path.join(libDir, 'qwen-code'); + fs.mkdirSync(standaloneDir, { recursive: true }); + + expect(() => ensureBinWrapper(standaloneDir, 'linux-x64')).toThrow( + 'Failed to create bin wrapper', + ); + }, + ); }); describe('performStandaloneUpdate', () => { @@ -259,6 +336,30 @@ describe('standalone-update', () => { // Clean up lock fs.unlinkSync(lockPath); }); + + it('rejects a stale lock when a Windows deferred swap is pending', async () => { + const standaloneDir = path.join(tempDir, 'qwen-code'); + const parentDir = path.dirname(standaloneDir); + fs.mkdirSync(standaloneDir, { recursive: true }); + fs.mkdirSync(`${standaloneDir}.new`); + fs.writeFileSync( + path.join(standaloneDir, 'manifest.json'), + JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'win-x64', + }), + ); + + const lockPath = path.join(parentDir, '.qwen-update.lock'); + fs.writeFileSync(lockPath, '999999999'); + + await expect( + performStandaloneUpdate(standaloneDir, '1.0.0'), + ).rejects.toThrow('A previous update left a pending swap'); + + expect(fs.existsSync(lockPath)).toBe(true); + expect(fs.existsSync(`${standaloneDir}.new`)).toBe(true); + }); }); describe('isSafeTarEntryPath', () => { @@ -266,6 +367,7 @@ describe('standalone-update', () => { expect(isSafeTarEntryPath('qwen-code/release..notes.md')).toBe(true); expect(isSafeTarEntryPath('qwen-code/node/lib/foo..bar')).toBe(true); expect(isSafeTarEntryPath('qwen-code/.../file.txt')).toBe(true); + expect(isSafeTarEntryPath('./qwen-code/bin/qwen')).toBe(true); }); it('rejects parent-directory segments and absolute paths', () => { @@ -280,6 +382,141 @@ describe('standalone-update', () => { }); }); + describe('isSafeTarLinkTarget', () => { + it('allows relative link targets inside the extraction directory', () => { + const dest = path.join(tempDir, 'extract'); + expect( + isSafeTarLinkTarget('qwen-code/bin/qwen', '../lib/cli.js', dest), + ).toBe(true); + expect(isSafeTarLinkTarget('qwen-code/bin/qwen', './qwen', dest)).toBe( + true, + ); + }); + + it('allows symlink targets in child directories starting with two dots', () => { + const dest = path.join(tempDir, 'extract'); + expect( + isSafeTarLinkTarget('qwen-code/bin/qwen', '../..hidden/tool', dest), + ).toBe(true); + }); + + it('rejects symlink targets outside the extraction directory', () => { + const dest = path.join(tempDir, 'extract'); + expect( + isSafeTarLinkTarget('qwen-code/bin/qwen', '../../../etc/passwd', dest), + ).toBe(false); + expect( + isSafeTarLinkTarget('qwen-code/bin/qwen', '/etc/passwd', dest), + ).toBe(false); + expect( + isSafeTarLinkTarget( + 'qwen-code/bin/qwen', + 'C:\\Windows\\System32', + dest, + ), + ).toBe(false); + }); + + it('rejects symlink targets outside the archive root that will be installed', () => { + const dest = path.join(tempDir, 'extract'); + expect( + isSafeTarLinkTarget('qwen-code/bin/qwen', '../../shared/node', dest), + ).toBe(false); + expect( + isSafeTarLinkTarget('./qwen-code/bin/qwen', '../../shared/node', dest), + ).toBe(false); + }); + }); + + describe('isSafeTarEntry', () => { + it('rejects hardlinks outright', () => { + const dest = path.join(tempDir, 'extract'); + expect( + isSafeTarEntry( + 'qwen-code/bin/qwen', + { type: 'Link', linkpath: '../poc.txt' }, + dest, + ), + ).toBe(false); + }); + + it('allows safe regular entries and safe symlinks', () => { + const dest = path.join(tempDir, 'extract'); + expect(isSafeTarEntry('qwen-code/bin/qwen', { type: 'File' }, dest)).toBe( + true, + ); + expect(isSafeTarEntry('qwen-code/lib', { type: 'Directory' }, dest)).toBe( + true, + ); + expect( + isSafeTarEntry( + 'qwen-code/bin/qwen', + { type: 'SymbolicLink', linkpath: '../lib/cli.js' }, + dest, + ), + ).toBe(true); + }); + + it('accepts fs stats entries from tar filter typing', () => { + const dest = path.join(tempDir, 'extract'); + const filePath = path.join(tempDir, 'entry.txt'); + fs.writeFileSync(filePath, 'entry'); + + expect( + isSafeTarEntry('qwen-code/bin/qwen', fs.statSync(filePath), dest), + ).toBe(true); + }); + + it('rejects special archive entry types', () => { + const dest = path.join(tempDir, 'extract'); + for (const type of [ + 'BlockDevice', + 'CharacterDevice', + 'FIFO', + 'ContiguousFile', + ]) { + expect(isSafeTarEntry('qwen-code/bin/qwen', { type }, dest)).toBe( + false, + ); + } + }); + }); + + describe('cleanupFirstTimeMigrationArtifacts', () => { + it.skipIf(process.platform === 'win32')( + 'removes the wrapper and PATH block created during a failed migration', + () => { + const originalHome = process.env['HOME']; + const originalShell = process.env['SHELL']; + const home = path.join(tempDir, 'home'); + process.env['HOME'] = home; + process.env['SHELL'] = '/bin/bash'; + + try { + const standaloneDir = path.join(home, '.local', 'lib', 'qwen-code'); + const artifacts = ensureBinWrapper(standaloneDir, 'linux-x64'); + const wrapperPath = path.join(home, '.local', 'bin', 'qwen'); + const bashrc = path.join(home, '.bashrc'); + + expect(fs.existsSync(wrapperPath)).toBe(true); + expect(fs.readFileSync(bashrc, 'utf-8')).toContain( + '# Qwen Code PATH block begin', + ); + + cleanupFirstTimeMigrationArtifacts(artifacts); + + expect(fs.existsSync(wrapperPath)).toBe(false); + expect(fs.readFileSync(bashrc, 'utf-8')).not.toContain( + '# Qwen Code PATH block begin', + ); + } finally { + process.env['HOME'] = originalHome; + process.env['SHELL'] = originalShell; + } + }, + ); + }); + describe('rollbackStandaloneUpdate — concurrent lock protection', () => { it('returns error when an active update holds the lock', () => { const standaloneDir = path.join(tempDir, 'qwen-code'); @@ -318,6 +555,43 @@ describe('standalone-update', () => { }); }); + describe('acquireLock deferred marker handling', () => { + it('rejects lock takeover while a deferred bat process is alive', () => { + const standaloneDir = path.join(tempDir, 'qwen-code'); + const lockPath = path.join(tempDir, '.qwen-update.lock'); + fs.writeFileSync(lockPath, '999999999'); + fs.writeFileSync(`${standaloneDir}.deferred`, String(process.pid)); + + expect(() => acquireLock(lockPath, standaloneDir)).toThrow( + 'A previous update is still being applied', + ); + expect(fs.existsSync(lockPath)).toBe(true); + expect(fs.existsSync(`${standaloneDir}.deferred`)).toBe(true); + }); + + it('cleans a stale deferred marker before taking over a dead lock', () => { + const standaloneDir = path.join(tempDir, 'qwen-code'); + const lockPath = path.join(tempDir, '.qwen-update.lock'); + fs.writeFileSync(lockPath, '999999999'); + fs.writeFileSync(`${standaloneDir}.deferred`, '999999998'); + + expect(acquireLock(lockPath, standaloneDir)).toBe(true); + expect(fs.existsSync(`${standaloneDir}.deferred`)).toBe(false); + expect(fs.readFileSync(lockPath, 'utf-8')).toBe(String(process.pid)); + }); + + it('cleans an unparseable deferred marker before taking over a dead lock', () => { + const standaloneDir = path.join(tempDir, 'qwen-code'); + const lockPath = path.join(tempDir, '.qwen-update.lock'); + fs.writeFileSync(lockPath, '999999999'); + fs.writeFileSync(`${standaloneDir}.deferred`, 'not-a-pid'); + + expect(acquireLock(lockPath, standaloneDir)).toBe(true); + expect(fs.existsSync(`${standaloneDir}.deferred`)).toBe(false); + expect(fs.readFileSync(lockPath, 'utf-8')).toBe(String(process.pid)); + }); + }); + describe.skipIf(process.platform === 'win32')('ensurePathInShellRc', () => { it('appends PATH export to zshrc when SHELL is zsh', () => { const binDir = path.join(tempDir, 'bin'); @@ -332,15 +606,41 @@ describe('standalone-update', () => { try { ensurePathInShellRc(binDir); const content = fs.readFileSync(zshrc, 'utf-8'); - expect(content).toContain('# Added by Qwen Code standalone installer'); - expect(content).toContain(`export PATH="${binDir}:$PATH"`); + expect(content).toContain('# Qwen Code PATH block begin'); + expect(content).toContain('# Qwen Code PATH block end'); + // Uses single-quoted paths matching install-qwen-standalone.sh shell_quote + expect(content).toContain(`export PATH='${binDir}':$PATH`); } finally { process.env['SHELL'] = origShell; process.env['HOME'] = origHome; } }); - it('skips if marker already in rc file', () => { + it('skips if block markers already in rc file', () => { + const binDir = path.join(tempDir, 'bin'); + const zshrc = path.join(tempDir, '.zshrc'); + fs.writeFileSync( + zshrc, + `# Qwen Code PATH block begin\nexport PATH='${binDir}':$PATH\n# Qwen Code PATH block end\n`, + ); + + const origShell = process.env['SHELL']; + const origHome = process.env['HOME']; + process.env['SHELL'] = '/bin/zsh'; + process.env['HOME'] = tempDir; + + try { + ensurePathInShellRc(binDir); + const content = fs.readFileSync(zshrc, 'utf-8'); + const matches = content.match(/# Qwen Code PATH block begin/g); + expect(matches).toHaveLength(1); + } finally { + process.env['SHELL'] = origShell; + process.env['HOME'] = origHome; + } + }); + + it('skips if legacy marker already in rc file', () => { const binDir = path.join(tempDir, 'bin'); const zshrc = path.join(tempDir, '.zshrc'); fs.writeFileSync( @@ -356,17 +656,62 @@ describe('standalone-update', () => { try { ensurePathInShellRc(binDir); const content = fs.readFileSync(zshrc, 'utf-8'); - const matches = content.match( - /# Added by Qwen Code standalone installer/g, - ); + const matches = content.match(/export PATH/g); expect(matches).toHaveLength(1); + expect(content).not.toContain('# Qwen Code PATH block begin'); } finally { process.env['SHELL'] = origShell; process.env['HOME'] = origHome; } }); - it('appends fish_add_path for fish shell', () => { + it('uses .bashrc before .bash_profile for bash shells', () => { + const binDir = path.join(tempDir, 'bin'); + const bashrc = path.join(tempDir, '.bashrc'); + const profile = path.join(tempDir, '.bash_profile'); + fs.writeFileSync(bashrc, '# bashrc\n'); + fs.writeFileSync(profile, '# profile\n'); + + const origShell = process.env['SHELL']; + const origHome = process.env['HOME']; + process.env['SHELL'] = '/bin/bash'; + process.env['HOME'] = tempDir; + + try { + ensurePathInShellRc(binDir); + expect(fs.readFileSync(bashrc, 'utf-8')).toContain( + '# Qwen Code PATH block begin', + ); + expect(fs.readFileSync(profile, 'utf-8')).toBe('# profile\n'); + } finally { + process.env['SHELL'] = origShell; + process.env['HOME'] = origHome; + } + }); + + it('falls back to .bash_profile for bash when .bashrc is absent', () => { + const binDir = path.join(tempDir, 'bin'); + const profile = path.join(tempDir, '.bash_profile'); + fs.writeFileSync(profile, '# profile\n'); + + const origShell = process.env['SHELL']; + const origHome = process.env['HOME']; + process.env['SHELL'] = '/bin/bash'; + process.env['HOME'] = tempDir; + + try { + ensurePathInShellRc(binDir); + expect(fs.readFileSync(profile, 'utf-8')).toContain( + '# Qwen Code PATH block begin', + ); + expect(fs.existsSync(path.join(tempDir, '.bashrc'))).toBe(false); + } finally { + process.env['SHELL'] = origShell; + process.env['HOME'] = origHome; + } + }); + + it('appends set -gx PATH for fish shell (matching install script)', () => { const binDir = path.join(tempDir, 'bin'); const fishDir = path.join(tempDir, '.config', 'fish'); const fishConfig = path.join(fishDir, 'config.fish'); @@ -379,7 +724,10 @@ describe('standalone-update', () => { try { ensurePathInShellRc(binDir); const content = fs.readFileSync(fishConfig, 'utf-8'); - expect(content).toContain('fish_add_path'); + // Matches install-qwen-standalone.sh's maybe_update_shell_path fish branch + expect(content).toContain('set -gx PATH'); + expect(content).toContain('# Qwen Code PATH block begin'); + expect(content).toContain('# Qwen Code PATH block end'); expect(content).toContain(binDir); } finally { process.env['SHELL'] = origShell; @@ -387,8 +735,99 @@ describe('standalone-update', () => { } }); - it('rejects binDir with shell metacharacters', () => { + it('escapes single quotes in fish PATH entries', () => { + const binDir = path.join(tempDir, "o'brien", 'bin'); + const fishDir = path.join(tempDir, '.config', 'fish'); + const fishConfig = path.join(fishDir, 'config.fish'); + fs.mkdirSync(fishDir, { recursive: true }); + fs.writeFileSync(fishConfig, '# existing config\n'); + const origShell = process.env['SHELL']; + const origHome = process.env['HOME']; + process.env['SHELL'] = '/usr/bin/fish'; + process.env['HOME'] = tempDir; + try { + ensurePathInShellRc(binDir); + const content = fs.readFileSync(fishConfig, 'utf-8'); + expect(content).toContain("set -gx PATH '"); + expect(content).toContain("o'\\''brien"); + } finally { + process.env['SHELL'] = origShell; + process.env['HOME'] = origHome; + } + }); + + it('creates fish config parent directories before appending PATH', () => { + const binDir = path.join(tempDir, 'bin'); + const fishConfig = path.join(tempDir, '.config', 'fish', 'config.fish'); + const origShell = process.env['SHELL']; + const origHome = process.env['HOME']; + process.env['SHELL'] = '/usr/bin/fish'; + process.env['HOME'] = tempDir; + try { + expect(fs.existsSync(path.dirname(fishConfig))).toBe(false); + ensurePathInShellRc(binDir); + const content = fs.readFileSync(fishConfig, 'utf-8'); + expect(content).toContain('set -gx PATH'); + expect(content).toContain(binDir); + } finally { + process.env['SHELL'] = origShell; + process.env['HOME'] = origHome; + } + }); + + it('allows shell metacharacters in single-quoted PATH entries', () => { const binDir = path.join(tempDir, 'bin$(evil)'); + const zshrc = path.join(tempDir, '.zshrc'); + fs.writeFileSync(zshrc, '# existing config\n'); + const origShell = process.env['SHELL']; + const origHome = process.env['HOME']; + process.env['SHELL'] = '/bin/zsh'; + process.env['HOME'] = tempDir; + try { + ensurePathInShellRc(binDir); + expect(fs.readFileSync(zshrc, 'utf-8')).toContain( + `export PATH='${binDir}':$PATH`, + ); + } finally { + process.env['SHELL'] = origShell; + process.env['HOME'] = origHome; + } + }); + + it('rejects binDir with newlines', () => { + const binDir = path.join(tempDir, 'bin\nevil'); + const origShell = process.env['SHELL']; + const origHome = process.env['HOME']; + process.env['SHELL'] = '/bin/zsh'; + process.env['HOME'] = tempDir; + try { + expect(() => ensurePathInShellRc(binDir)).toThrow( + 'unsafe for shell embedding', + ); + } finally { + process.env['SHELL'] = origShell; + process.env['HOME'] = origHome; + } + }); + + it('rejects binDir with null bytes', () => { + const binDir = path.join(tempDir, 'bin\0evil'); + const origShell = process.env['SHELL']; + const origHome = process.env['HOME']; + process.env['SHELL'] = '/bin/zsh'; + process.env['HOME'] = tempDir; + try { + expect(() => ensurePathInShellRc(binDir)).toThrow( + 'unsafe for shell embedding', + ); + } finally { + process.env['SHELL'] = origShell; + process.env['HOME'] = origHome; + } + }); + + it('rejects binDir with carriage returns', () => { + const binDir = path.join(tempDir, 'bin\revil'); const origShell = process.env['SHELL']; const origHome = process.env['HOME']; process.env['SHELL'] = '/bin/zsh'; diff --git a/packages/cli/src/utils/standalone-update.ts b/packages/cli/src/utils/standalone-update.ts index 8df0418b1c..965ae967c7 100644 --- a/packages/cli/src/utils/standalone-update.ts +++ b/packages/cli/src/utils/standalone-update.ts @@ -11,10 +11,14 @@ import * as path from 'node:path'; import { Readable, Transform } from 'node:stream'; import { spawn, execFile } from 'node:child_process'; import { pipeline } from 'node:stream/promises'; +import type { Stats } from 'node:fs'; import { fetch } from 'undici'; import * as tar from 'tar'; +import type { ReadEntry } from 'tar'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; import { verifySignature } from './standalone-update-verify.js'; +import { updateEventEmitter } from './updateEventEmitter.js'; +import { t } from '../i18n/index.js'; const debugLogger = createDebugLogger('STANDALONE_UPDATE'); @@ -35,6 +39,7 @@ const VALID_TARGETS = new Set([ const SEMVER_RE = /^v?\d+\.\d+\.\d+(-[\w.]+)?$/; type UndiciResponse = Awaited>; +type TarFilterEntry = Stats | ReadEntry | { type?: string; linkpath?: unknown }; function normalizeVersion(version: string): string { if (!SEMVER_RE.test(version)) { @@ -193,21 +198,119 @@ async function downloadToFile( return hash.digest('hex'); } -function validateExtractedPaths(resolvedDest: string): void { +function isPathInside(base: string, candidate: string): boolean { + const rel = path.relative(base, candidate); + return ( + rel === '' || + (!!rel && + rel !== '..' && + !rel.startsWith(`..${path.sep}`) && + !path.isAbsolute(rel)) + ); +} + +function normalizeTarEntryPath(entryPath: string): string | null { + const parts = entryPath + .split(/[\\/]+/) + .filter((part) => part && part !== '.'); + if (parts.length === 0 || parts.includes('..')) { + return null; + } + return parts.join('/'); +} + +export function isSafeTarLinkTarget( + entryPath: string, + linkPath: string, + resolvedDest: string, +): boolean { + if (path.posix.isAbsolute(linkPath) || path.win32.isAbsolute(linkPath)) { + return false; + } + const normalizedEntryPath = normalizeTarEntryPath(entryPath); + if (!normalizedEntryPath) { + return false; + } + const archiveRoot = normalizedEntryPath.split('/')[0]; + const linkTarget = path.resolve( + resolvedDest, + path.posix.dirname(normalizedEntryPath), + linkPath, + ); + return isPathInside(path.join(resolvedDest, archiveRoot), linkTarget); +} + +function getTarEntryType(entry: TarFilterEntry): string | undefined { + if ('type' in entry) return entry.type; + if ('isFile' in entry && entry.isFile()) return 'File'; + if ('isDirectory' in entry && entry.isDirectory()) return 'Directory'; + if ('isSymbolicLink' in entry && entry.isSymbolicLink()) { + return 'SymbolicLink'; + } + return undefined; +} + +export function isSafeTarEntry( + entryPath: string, + entry: TarFilterEntry, + resolvedDest: string, +): boolean { + if (!isSafeTarEntryPath(entryPath)) return false; + const entryType = getTarEntryType(entry); + const linkPath = 'linkpath' in entry ? entry.linkpath : undefined; + + if ( + entryType !== 'File' && + entryType !== 'Directory' && + entryType !== 'SymbolicLink' + ) { + return false; + } + + if (entryType === 'SymbolicLink') { + if (linkPath === undefined) return false; + return isSafeTarLinkTarget(entryPath, String(linkPath), resolvedDest); + } + + return true; +} + +function isAlreadyExistsError(err: unknown): boolean { + return ( + typeof err === 'object' && + err !== null && + 'code' in err && + (err as { code?: string }).code === 'EEXIST' + ); +} + +function validateExtractedPaths( + resolvedDest: string, + options: { symlinksOnly?: boolean } = {}, +): void { const entries = fs.readdirSync(resolvedDest, { recursive: true, withFileTypes: true, }); for (const entry of entries) { + if (options.symlinksOnly && !entry.isSymbolicLink()) { + continue; + } const fullPath = path.join( String(entry.parentPath || entry.path), entry.name, ); - const resolved = fs.realpathSync(fullPath); - if ( - !resolved.startsWith(resolvedDest + path.sep) && - resolved !== resolvedDest - ) { + let resolved: string; + try { + resolved = fs.realpathSync(fullPath); + } catch (err) { + fs.rmSync(resolvedDest, { recursive: true, force: true }); + const detail = err instanceof Error ? err.message : String(err); + throw new Error( + `Invalid archive entry: ${entry.name} could not be resolved (${detail})`, + ); + } + if (!isPathInside(resolvedDest, resolved)) { fs.rmSync(resolvedDest, { recursive: true, force: true }); throw new Error( `Path traversal detected in archive: ${entry.name} resolves to ${resolved}`, @@ -221,7 +324,7 @@ export function isSafeTarEntryPath(entryPath: string): boolean { if (path.posix.isAbsolute(entryPath) || path.win32.isAbsolute(entryPath)) { return false; } - return !entryPath.split(/[\\/]+/).includes('..'); + return normalizeTarEntryPath(entryPath) !== null; } async function extractArchive( @@ -250,38 +353,20 @@ async function extractArchive( ps.on('error', reject); }); const resolvedDest = fs.realpathSync(destDir); + // Windows Expand-Archive has no pre-extraction filter like tar.extract, + // so keep the full post-extraction traversal scan here. The Unix/tar path + // can limit its defense-in-depth scan to symlinks because isSafeTarEntry + // validates regular entry paths and rejects hardlinks before extraction. validateExtractedPaths(resolvedDest); } else { - const resolvedDest = path.resolve(destDir); + const resolvedDest = fs.realpathSync(destDir); await tar.extract({ file: archivePath, cwd: destDir, preservePaths: false, - filter: (p, entry) => { - if (!isSafeTarEntryPath(p)) return false; - if ( - 'type' in entry && - entry.type === 'SymbolicLink' && - 'linkpath' in entry - ) { - const linkTarget = path.resolve( - resolvedDest, - path.dirname(p), - String(entry.linkpath), - ); - if ( - !linkTarget.startsWith(resolvedDest + path.sep) && - linkTarget !== resolvedDest - ) { - return false; - } - } - return true; - }, + filter: (p, entry) => isSafeTarEntry(p, entry, resolvedDest), }); - // Post-extraction defense-in-depth: detect chained symlink attacks that - // bypass the string-level filter (e.g. symlink A → ".", then A/payload → "../../etc") - validateExtractedPaths(fs.realpathSync(destDir)); + validateExtractedPaths(fs.realpathSync(destDir), { symlinksOnly: true }); } } @@ -364,27 +449,92 @@ async function smokeTest(newInstallDir: string, target: string): Promise { debugLogger.info(`Smoke test passed: ${version}`); } -function acquireLock(lockPath: string): boolean { +// Check .deferred marker and .new directory for an in-flight swap from a +// previous Windows update. Called from both acquireLock fast-path and +// slow-path so that a freshly created lock file cannot bypass the check. +function checkDeferredSwap(standaloneDir: string): void { + const deferredMarker = `${standaloneDir}.deferred`; + if (fs.existsSync(deferredMarker)) { + try { + const batPid = parseInt( + fs.readFileSync(deferredMarker, 'utf-8').trim(), + 10, + ); + if (!Number.isNaN(batPid) && isProcessAlive(batPid)) { + throw new Error( + 'A previous update is still being applied. Please wait a moment and try again.', + ); + } + } catch (readErr) { + if ( + readErr instanceof Error && + readErr.message.startsWith('A previous update') + ) { + throw readErr; + } + } + // Bat script has exited (or crashed) — clean up stale marker + try { + fs.unlinkSync(deferredMarker); + } catch { + // already gone + } + } + + if (fs.existsSync(`${standaloneDir}.new`)) { + throw new Error( + `A previous update left a pending swap at ${standaloneDir}.new. ` + + 'If no qwen-update.bat process is running, remove the pending swap and .qwen-update.lock, then try again.', + ); + } +} + +export function acquireLock(lockPath: string, standaloneDir?: string): boolean { try { fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' }); + if (standaloneDir) { + checkDeferredSwap(standaloneDir); + } return true; - } catch { - try { - const pidStr = fs.readFileSync(lockPath, 'utf-8').trim(); - const pid = parseInt(pidStr, 10); - if (Number.isNaN(pid) || !isProcessAlive(pid)) { + } catch (fastPathErr) { + if ( + fastPathErr instanceof Error && + (fastPathErr.message.startsWith('A previous update') || + fastPathErr.message.includes('pending swap')) + ) { + // Lock was acquired but deferred swap check failed — release the lock + try { fs.unlinkSync(lockPath); - try { - fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' }); - return true; - } catch { - return false; - } + } catch { + // best-effort } + throw fastPathErr; + } + + let pidStr: string; + try { + pidStr = fs.readFileSync(lockPath, 'utf-8').trim(); } catch { // lock is held by another live process + return false; + } + + const pid = parseInt(pidStr, 10); + if (!Number.isNaN(pid) && isProcessAlive(pid)) { + return false; + } + + if (standaloneDir) { + checkDeferredSwap(standaloneDir); + } + + try { + fs.unlinkSync(lockPath); + fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' }); + return true; + } catch { + return false; } - return false; } } @@ -406,10 +556,23 @@ function cleanupEmptyStandaloneDir(standaloneDir: string): void { } } -const UNSAFE_SHELL_CHARS = /["`$\\;\n\r]/; +export type ShellPathUpdate = { + rcFile?: string; + blockAdded: boolean; + error?: string; +}; + +export type BinWrapperArtifacts = { + wrapperPath?: string; + wrapperCreated: boolean; + shellPathUpdate?: ShellPathUpdate; + wrapperNeedsAttention?: boolean; +}; + +const UNSAFE_SHELL_CHARS = /[\0\n\r]/; const UNSAFE_CMD_CHARS = /[&|<>^%!"`\n\r]/; -function assertSafeForShellEmbed(p: string, context: string): void { +function assertSafeForSingleQuotedShellPath(p: string, context: string): void { if (UNSAFE_SHELL_CHARS.test(p)) { throw new Error( `${context} contains characters unsafe for shell embedding: ${p}`, @@ -417,6 +580,14 @@ function assertSafeForShellEmbed(p: string, context: string): void { } } +function shellQuoteForSh(p: string): string { + return `'${p.replace(/'/g, "'\\''")}'`; +} + +function shellQuoteForFish(p: string): string { + return shellQuoteForSh(p); +} + function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); @@ -458,6 +629,7 @@ function atomicReplace( fs.renameSync(newDir, pendingDir); const lockFile = lockPath; + const deferredMarker = `${standaloneDir}.deferred`; const logFile = path.join(path.dirname(standaloneDir), 'qwen-update.log'); // Bat script runs detached after Node exits. It must: // 1. Wait for this Node process to release file locks (<= 30s). @@ -465,6 +637,9 @@ function atomicReplace( // move #1 so the user is never left without a working install. // 3. Log success/failure to qwen-update.log for post-mortem (the bat // runs with stdio:ignore — the log is the only diagnostic surface). + // 4. Delete the .deferred marker so future `qwen update` calls know the + // swap is complete (the lock file alone is insufficient because + // acquireLock falls through when the Node PID is dead). const script = [ '@echo off', 'set /a TRIES=0', @@ -492,6 +667,7 @@ function atomicReplace( ` echo [%DATE% %TIME%] rollback succeeded >> "${logFile}"`, ')', ':cleanup', + `del /F /Q "${deferredMarker}" 2>nul`, `del /F /Q "${lockFile}" 2>nul`, `del "%~f0"`, ].join('\r\n'); @@ -500,11 +676,24 @@ function atomicReplace( 'qwen-update.bat', ); fs.writeFileSync(scriptPath, script); - spawn('cmd.exe', ['/c', scriptPath], { + const child = spawn('cmd.exe', ['/c', scriptPath], { detached: true, stdio: 'ignore', windowsHide: true, - }).unref(); + }); + child.on('error', (err) => { + debugLogger.warn('Deferred bat script failed to spawn:', err); + }); + child.unref(); + if (!child.pid) { + throw new Error( + 'Failed to spawn deferred update script. Update was not applied.', + ); + } + // Write .deferred marker with the bat script PID so future `qwen update` + // calls can detect the in-flight swap via isProcessAlive(batPid). + // acquireLock checks this marker before allowing lock theft. + fs.writeFileSync(deferredMarker, String(child.pid)); return 'deferred'; } else { // Unix: rename is atomic on same filesystem. newDir is a sibling of @@ -537,8 +726,12 @@ function atomicReplace( * Ensures ~/.local/bin/qwen exists and points to the standalone install. * Required for npm→standalone migration so the new binary is on PATH. */ -export function ensureBinWrapper(standaloneDir: string, target: string): void { +export function ensureBinWrapper( + standaloneDir: string, + target: string, +): BinWrapperArtifacts { const binDir = path.join(path.dirname(standaloneDir), '..', 'bin'); + const artifacts: BinWrapperArtifacts = { wrapperCreated: false }; try { fs.mkdirSync(binDir, { recursive: true }); @@ -549,21 +742,59 @@ export function ensureBinWrapper(standaloneDir: string, target: string): void { ); } const wrapperPath = path.join(binDir, 'qwen.cmd'); - if (!fs.existsSync(wrapperPath)) { - const content = `@echo off\r\ncall "${standaloneDir}\\bin\\qwen.cmd" %*\r\n`; - fs.writeFileSync(wrapperPath, content); + artifacts.wrapperPath = wrapperPath; + const content = `@echo off\r\ncall "${standaloneDir}\\bin\\qwen.cmd" %*\r\n`; + try { + fs.writeFileSync(wrapperPath, content, { flag: 'wx' }); + artifacts.wrapperCreated = true; + } catch (err) { + if (!isAlreadyExistsError(err)) { + throw err; + } + artifacts.wrapperNeedsAttention = !existingWrapperMatches( + wrapperPath, + standaloneDir, + ); } } else { - assertSafeForShellEmbed(standaloneDir, 'standaloneDir'); + assertSafeForSingleQuotedShellPath(standaloneDir, 'standaloneDir'); const wrapperPath = path.join(binDir, 'qwen'); - if (!fs.existsSync(wrapperPath)) { - const content = `#!/bin/sh\nexec "${standaloneDir}/bin/qwen" "$@"\n`; - fs.writeFileSync(wrapperPath, content, { mode: 0o755 }); + artifacts.wrapperPath = wrapperPath; + // Match install-qwen-standalone.sh's write_unix_wrapper: + // uses /usr/bin/env sh for portability, and single-quoted paths. + const quotedQwenBin = shellQuoteForSh( + path.join(standaloneDir, 'bin', 'qwen'), + ); + const content = `#!/usr/bin/env sh\nexec ${quotedQwenBin} "$@"\n`; + try { + fs.writeFileSync(wrapperPath, content, { mode: 0o755, flag: 'wx' }); + artifacts.wrapperCreated = true; + } catch (err) { + if (!isAlreadyExistsError(err)) { + throw err; + } + artifacts.wrapperNeedsAttention = !existingWrapperMatches( + wrapperPath, + standaloneDir, + ); } - ensurePathInShellRc(binDir); + artifacts.shellPathUpdate = ensurePathInShellRc(binDir); } + return artifacts; } catch (err) { - debugLogger.warn('Failed to create bin wrapper:', err); + const detail = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to create bin wrapper: ${detail}`); + } +} + +function existingWrapperMatches( + wrapperPath: string, + standaloneDir: string, +): boolean { + try { + return fs.readFileSync(wrapperPath, 'utf-8').includes(standaloneDir); + } catch { + return false; } } @@ -571,8 +802,8 @@ export function ensureBinWrapper(standaloneDir: string, target: string): void { * Appends binDir to the user's shell rc file if not already present. * Mirrors the logic in install-qwen-standalone.sh maybe_update_shell_path. */ -export function ensurePathInShellRc(binDir: string): void { - assertSafeForShellEmbed(binDir, 'binDir'); +export function ensurePathInShellRc(binDir: string): ShellPathUpdate { + assertSafeForSingleQuotedShellPath(binDir, 'binDir'); const shell = process.env['SHELL'] || ''; let rcFile: string | null = null; @@ -583,10 +814,13 @@ export function ensurePathInShellRc(binDir: string): void { } else if (shell.endsWith('/bash')) { const bashrc = path.join(home, '.bashrc'); const profile = path.join(home, '.bash_profile'); - // macOS bash reads .bash_profile for login shells; Linux reads .bashrc. - // Match install-qwen-standalone.sh's maybe_update_shell_path logic. - if (os.platform() === 'darwin') { - rcFile = fs.existsSync(profile) ? profile : bashrc; + // Match install-qwen-standalone.sh's maybe_update_shell_path logic: + // prefer .bashrc when it exists, otherwise fall back to .bash_profile, + // and create .bashrc if neither file exists. + if (fs.existsSync(bashrc)) { + rcFile = bashrc; + } else if (fs.existsSync(profile)) { + rcFile = profile; } else { rcFile = bashrc; } @@ -594,24 +828,91 @@ export function ensurePathInShellRc(binDir: string): void { rcFile = path.join(home, '.config', 'fish', 'config.fish'); } - if (!rcFile) return; + if (!rcFile) return { blockAdded: false }; try { const content = fs.existsSync(rcFile) ? fs.readFileSync(rcFile, 'utf-8') : ''; - // Use a marker to detect our managed PATH entry precisely, - // avoiding false positives from comments or $PATH-appended entries - const marker = '# Added by Qwen Code standalone installer'; - if (content.includes(marker)) return; + // Use begin/end block markers matching install-qwen-standalone.sh's + // maybe_update_shell_path, so the update codepath is idempotent with the + // install script and does not produce duplicate PATH entries. + const beginMarker = '# Qwen Code PATH block begin'; + const endMarker = '# Qwen Code PATH block end'; + const legacyMarker = '# Added by Qwen Code standalone installer'; + if (content.includes(beginMarker) && content.includes(endMarker)) { + return { rcFile, blockAdded: false }; + } + if (content.includes(legacyMarker)) return { rcFile, blockAdded: false }; const exportLine = shell.endsWith('/fish') - ? `\n${marker}\nfish_add_path "${binDir}"\n` - : `\n${marker}\nexport PATH="${binDir}:$PATH"\n`; - fs.appendFileSync(rcFile, exportLine); + ? `set -gx PATH ${shellQuoteForFish(binDir)} $PATH` + : `export PATH=${shellQuoteForSh(binDir)}:$PATH`; + + const block = `\n${beginMarker}\n${exportLine}\n${endMarker}\n`; + fs.mkdirSync(path.dirname(rcFile), { recursive: true }); + fs.appendFileSync(rcFile, block); debugLogger.info(`Added ${binDir} to ${rcFile}`); + return { rcFile, blockAdded: true }; } catch (err) { debugLogger.debug('Failed to update shell rc:', err); + return { + rcFile, + blockAdded: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +function surfaceBinWrapperWarnings(artifacts?: BinWrapperArtifacts): void { + if (!artifacts) return; + if (artifacts.wrapperNeedsAttention && artifacts.wrapperPath) { + const message = + `Update completed, but the existing wrapper at ${artifacts.wrapperPath} ` + + 'does not point to this standalone installation.'; + debugLogger.warn(message); + updateEventEmitter.emit('update-info', { message }); + } + if (artifacts.shellPathUpdate?.error && artifacts.shellPathUpdate.rcFile) { + const message = + `Update completed, but ${artifacts.shellPathUpdate.rcFile} could not be updated. ` + + 'Add ~/.local/bin to PATH manually.'; + debugLogger.warn(message); + updateEventEmitter.emit('update-info', { message }); + } +} + +function cleanupShellPathBlock(rcFile: string): void { + try { + if (!fs.existsSync(rcFile)) return; + const content = fs.readFileSync(rcFile, 'utf-8'); + const blockPattern = + /\n?# Qwen Code PATH block begin\n(?:.|\n)*?\n# Qwen Code PATH block end\n?/; + const nextContent = content.replace(blockPattern, ''); + if (nextContent !== content) { + fs.writeFileSync(rcFile, nextContent); + } + } catch (err) { + debugLogger.debug('Failed to roll back shell rc PATH block:', err); + } +} + +export function cleanupFirstTimeMigrationArtifacts( + artifacts?: BinWrapperArtifacts, +): void { + if (!artifacts) return; + if (artifacts.wrapperCreated && artifacts.wrapperPath) { + try { + fs.unlinkSync(artifacts.wrapperPath); + } catch (err) { + debugLogger.debug('Failed to roll back bin wrapper:', err); + } + } + if ( + artifacts.shellPathUpdate?.blockAdded && + artifacts.shellPathUpdate.rcFile + ) { + cleanupShellPathBlock(artifacts.shellPathUpdate.rcFile); } } @@ -665,16 +966,22 @@ export async function performStandaloneUpdate( // On first-time migration, standaloneDir (and its parent) may not exist yet. fs.mkdirSync(parentDir, { recursive: true }); - // Use a lockfile to prevent concurrent updates. - // Acquire lock BEFORE creating standaloneDir to prevent a concurrent - // process from seeing the empty directory and throwing a misleading error. + // Use a lockfile (+ .deferred marker on Windows) to prevent concurrent + // updates. Acquire lock BEFORE creating standaloneDir to prevent a + // concurrent process from seeing the empty directory and throwing a + // misleading error. const lockPath = path.join(parentDir, '.qwen-update.lock'); - if (!acquireLock(lockPath)) { + if (!acquireLock(lockPath, standaloneDir)) { throw new Error('Another update is already in progress'); } if (isFirstTimeMigration) { - fs.mkdirSync(standaloneDir, { recursive: true }); + try { + fs.mkdirSync(standaloneDir, { recursive: true }); + } catch (err) { + releaseLock(lockPath); + throw err; + } } // Download to a temp dir in os.tmpdir(), then extract to a sibling dir @@ -684,6 +991,7 @@ export async function performStandaloneUpdate( const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-code-update-')); let extractDir: string; let updateResult: 'done' | 'deferred' | undefined; + let migrationArtifacts: BinWrapperArtifacts | undefined; try { extractDir = fs.mkdtempSync(path.join(parentDir, '.qwen-code-update-')); } catch (err) { @@ -695,6 +1003,9 @@ export async function performStandaloneUpdate( try { const archivePath = path.join(tempDir, filename); debugLogger.info(`Downloading ${filename} (${versionPath})...`); + updateEventEmitter.emit('update-info', { + message: t('Downloading update...'), + }); const archiveHash = await downloadToFile( versionPath, filename, @@ -753,23 +1064,48 @@ export async function performStandaloneUpdate( } } - // Ensure bin wrapper exists (critical for npm→standalone migration) - ensureBinWrapper(standaloneDir, target); + try { + migrationArtifacts = ensureBinWrapper(standaloneDir, target); + surfaceBinWrapperWarnings(migrationArtifacts); + } catch (wrapperErr) { + debugLogger.warn('Failed to refresh bin wrapper:', wrapperErr); + updateEventEmitter.emit('update-info', { + message: isFirstTimeMigration + ? 'Update installed, but the PATH wrapper could not be created. ' + + 'Add the standalone bin directory to your PATH manually, or re-run the update.' + : 'Update completed, but the PATH wrapper could not be refreshed. ' + + 'The existing installation remains usable.', + }); + } debugLogger.info('Standalone update complete.'); return updateResult; } catch (err) { const pendingDir = `${standaloneDir}.new`; - if (fs.existsSync(pendingDir)) { + if (updateResult !== 'deferred' && fs.existsSync(pendingDir)) { fs.rmSync(pendingDir, { recursive: true, force: true }); } cleanupEmptyStandaloneDir(standaloneDir); + if (isFirstTimeMigration) { + cleanupFirstTimeMigrationArtifacts(migrationArtifacts); + } throw err; } finally { // Only keep the lock alive when the bat script was spawned (deferred). // On failure or on Unix, release immediately. if (updateResult !== 'deferred') { releaseLock(lockPath); + // Clean up .deferred marker if a previous run's bat script exited + // without removing it (e.g. crash). The current run's marker was + // never created (non-deferred path), so this is always safe. + const deferredMarker = `${standaloneDir}.deferred`; + if (fs.existsSync(deferredMarker)) { + try { + fs.unlinkSync(deferredMarker); + } catch { + // already gone + } + } } try { fs.rmSync(tempDir, { recursive: true, force: true });