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 });