diff --git a/package-lock.json b/package-lock.json index ecd526cb0c..8789deebe9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17597,6 +17597,7 @@ "string-width": "^7.1.0", "strip-ansi": "^7.1.0", "strip-json-comments": "^3.1.1", + "tar": "^7.5.2", "undici": "^6.22.0", "update-notifier": "^7.3.1", "wrap-ansi": "^10.0.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index d8b8c3f4c9..0a19666cc2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -77,6 +77,7 @@ "string-width": "^7.1.0", "strip-ansi": "^7.1.0", "strip-json-comments": "^3.1.1", + "tar": "^7.5.2", "undici": "^6.22.0", "update-notifier": "^7.3.1", "wrap-ansi": "^10.0.0", diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 5776d269ae..d08e9cdd89 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1901,6 +1901,17 @@ export default { 'Show current process memory diagnostics', 'Record a CPU profile for Chrome DevTools analysis': 'Record a CPU profile for Chrome DevTools analysis', + 'Roll back a standalone update to the previous version': + 'Roll back a standalone update to the previous version', + 'Rollback is not available in ACP mode.': + 'Rollback is not available in ACP mode.', + 'Rollback is only available for standalone installations.': + 'Rollback is only available for standalone installations.', + 'Rollback successful. Restart your terminal to use the previous version.': + 'Rollback successful. Restart your terminal to use the previous version.', + 'Rollback failed:': 'Rollback failed:', + 'Rollback on Windows requires manual intervention. Rename qwen-code.old to qwen-code in your installation directory.': + 'Rollback on Windows requires manual intervention. Rename qwen-code.old to qwen-code in your installation directory.', 'Save a durable memory to the memory system.': 'Save a durable memory to the memory system.', 'Ask a quick side question without affecting the main conversation': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 32191f9588..89b30e01f5 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -1488,6 +1488,16 @@ export default { 'Show current process memory diagnostics': '顯示目前程序的內存診斷。', 'Record a CPU profile for Chrome DevTools analysis': '錄製 CPU 效能分析檔案,用於 Chrome DevTools 分析', + 'Roll back a standalone update to the previous version': + '將獨立安裝回滾到上一個版本', + 'Rollback is not available in ACP mode.': '回滾在 ACP 模式下不可用。', + 'Rollback is only available for standalone installations.': + '回滾僅適用於獨立安裝。', + 'Rollback successful. Restart your terminal to use the previous version.': + '回滾成功。請重啟終端以使用上一個版本。', + 'Rollback failed:': '回滾失敗:', + 'Rollback on Windows requires manual intervention. Rename qwen-code.old to qwen-code in your installation directory.': + '在 Windows 上回滾需要手動操作。請將安裝目錄中的 qwen-code.old 重新命名為 qwen-code。', 'Save a durable memory to the memory system.': '將持久記憶保存到記憶系統。', 'Ask a quick side question without affecting the main conversation': '在不影響主對話的情況下快速提問旁支問題', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 84d16ab892..3a03478d45 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1723,6 +1723,16 @@ export default { 'Show current process memory diagnostics': '显示当前进程的内存诊断。', 'Record a CPU profile for Chrome DevTools analysis': '录制 CPU 性能分析文件,用于 Chrome DevTools 分析', + 'Roll back a standalone update to the previous version': + '将独立安装回滚到上一个版本', + 'Rollback is not available in ACP mode.': '回滚在 ACP 模式下不可用。', + 'Rollback is only available for standalone installations.': + '回滚仅适用于独立安装。', + 'Rollback successful. Restart your terminal to use the previous version.': + '回滚成功。请重启终端以使用上一个版本。', + 'Rollback failed:': '回滚失败:', + 'Rollback on Windows requires manual intervention. Rename qwen-code.old to qwen-code in your installation directory.': + '在 Windows 上回滚需要手动操作。请将安装目录中的 qwen-code.old 重命名为 qwen-code。', 'Save a durable memory to the memory system.': '将一条持久记忆保存到记忆系统。', 'Show per-item context usage breakdown.': '显示按项目划分的上下文使用详情。', diff --git a/packages/cli/src/ui/commands/doctorCommand.test.ts b/packages/cli/src/ui/commands/doctorCommand.test.ts index 01f51be294..871b2dc9ed 100644 --- a/packages/cli/src/ui/commands/doctorCommand.test.ts +++ b/packages/cli/src/ui/commands/doctorCommand.test.ts @@ -178,6 +178,7 @@ describe('doctorCommand', () => { await expect(doctorCommand.completion!(mockContext, '')).resolves.toEqual([ 'memory', 'cpu-profile', + 'rollback', ]); await expect( doctorCommand.completion!(mockContext, 'mem'), @@ -185,6 +186,9 @@ describe('doctorCommand', () => { await expect( doctorCommand.completion!(mockContext, 'cpu'), ).resolves.toEqual(['cpu-profile']); + await expect( + doctorCommand.completion!(mockContext, 'roll'), + ).resolves.toEqual(['rollback']); await expect(doctorCommand.completion!(mockContext, 'x')).resolves.toEqual( [], ); @@ -1054,7 +1058,7 @@ describe('doctorCommand', () => { it('should advertise the memory subcommand on the parent doctor argumentHint', () => { expect(doctorCommand.argumentHint).toBe( - '[memory|cpu-profile] [--sample] [--snapshot] [--duration]', + '[memory|cpu-profile|rollback] [--sample] [--snapshot] [--duration]', ); }); }); diff --git a/packages/cli/src/ui/commands/doctorCommand.ts b/packages/cli/src/ui/commands/doctorCommand.ts index 3a3bd93637..4833ecc978 100644 --- a/packages/cli/src/ui/commands/doctorCommand.ts +++ b/packages/cli/src/ui/commands/doctorCommand.ts @@ -21,6 +21,8 @@ import { startCpuProfile, stopCpuProfile, } from '../../utils/cpuProfiler.js'; +import { rollbackStandaloneUpdate } from '../../utils/standalone-update.js'; +import { getInstallationInfo } from '../../utils/installationInfo.js'; import { t } from '../../i18n/index.js'; import { collectMemoryDiagnostics, @@ -30,7 +32,8 @@ import { formatMemoryUsage } from '../utils/formatters.js'; const MEMORY_SUBCOMMAND = 'memory'; const CPU_PROFILE_SUBCOMMAND = 'cpu-profile'; -const DOCTOR_SUBCOMMANDS = [MEMORY_SUBCOMMAND, CPU_PROFILE_SUBCOMMAND] as const; +const ROLLBACK_SUBCOMMAND = 'rollback'; +const DOCTOR_SUBCOMMANDS = [MEMORY_SUBCOMMAND, CPU_PROFILE_SUBCOMMAND, ROLLBACK_SUBCOMMAND] as const; function getHeapSnapshotSensitiveDataWarning(): string { return t( 'Heap snapshot may contain prompts, file contents, tool results, and other sensitive data. Do not share it publicly without reviewing it first.', @@ -55,7 +58,7 @@ export const doctorCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, - argumentHint: '[memory|cpu-profile] [--sample] [--snapshot] [--duration]', + argumentHint: '[memory|cpu-profile|rollback] [--sample] [--snapshot] [--duration]', examples: [ '/doctor', '/doctor memory', @@ -63,6 +66,7 @@ export const doctorCommand: SlashCommand = { '/doctor memory --snapshot', '/doctor cpu-profile', '/doctor cpu-profile --duration 10', + '/doctor rollback', ], completion: async (_context, partialArg) => { const trimmed = partialArg.trimStart(); @@ -79,6 +83,17 @@ export const doctorCommand: SlashCommand = { const shouldWriteHeapSnapshot = subCommandArgs.includes('--snapshot'); const shouldSampleMemory = subCommandArgs.includes('--sample'); + if (subCommand === ROLLBACK_SUBCOMMAND) { + if (executionMode === 'acp') { + return { + type: 'message' as const, + messageType: 'error' as const, + content: t('Rollback is not available in ACP mode.'), + }; + } + return rollbackDoctorAction(context); + } + if (subCommand === MEMORY_SUBCOMMAND) { if (abortSignal?.aborted) { return; @@ -255,6 +270,15 @@ export const doctorCommand: SlashCommand = { argumentHint: '[--duration ]', action: cpuProfileDoctorAction, }, + { + name: 'rollback', + get description() { + return t('Roll back a standalone update to the previous version'); + }, + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive', 'non_interactive'] as const, + action: rollbackDoctorAction, + }, ], }; @@ -580,3 +604,57 @@ async function cpuProfileDoctorAction( } return { type: 'message', messageType: 'info', content: successMsg }; } + +function rollbackDoctorAction(context: CommandContext) { + const installInfo = getInstallationInfo(process.cwd(), false); + if (!installInfo.isStandalone || !installInfo.standaloneDir) { + const msg = t('Rollback is only available for standalone installations.'); + if (context.executionMode === 'interactive') { + context.ui.addItem({ type: 'info', text: msg }, Date.now()); + return; + } + return { + type: 'message' as const, + messageType: 'info' as const, + content: msg, + }; + } + + if (process.platform === 'win32') { + const winMsg = t( + 'Rollback on Windows requires manual intervention. Rename qwen-code.old to qwen-code in your installation directory.', + ); + if (context.executionMode === 'interactive') { + context.ui.addItem({ type: 'info', text: winMsg }, Date.now()); + return; + } + return { + type: 'message' as const, + messageType: 'info' as const, + content: winMsg, + }; + } + + const result = rollbackStandaloneUpdate(installInfo.standaloneDir); + let msg: string; + let messageType: 'info' | 'error'; + if (result.ok) { + msg = t( + 'Rollback successful. Restart your terminal to use the previous version.', + ); + messageType = 'info'; + } else { + msg = `${t('Rollback failed:')} ${result.detail}`; + messageType = 'error'; + } + + if (context.executionMode === 'interactive') { + context.ui.addItem({ type: messageType, text: msg }, Date.now()); + return; + } + return { + type: 'message' as const, + messageType: messageType as 'info' | 'error', + content: msg, + }; +} diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts index 97bbd5f81c..848d6e2c9a 100644 --- a/packages/cli/src/utils/handleAutoUpdate.test.ts +++ b/packages/cli/src/utils/handleAutoUpdate.test.ts @@ -12,6 +12,7 @@ import type { UpdateObject } from '../ui/utils/updateCheck.js'; import type { LoadedSettings } from '../config/settings.js'; import EventEmitter from 'node:events'; import { handleAutoUpdate, setUpdateHandler } from './handleAutoUpdate.js'; +import { performStandaloneUpdate } from './standalone-update.js'; import { MessageType } from '../ui/types.js'; vi.mock('./installationInfo.js', async () => { @@ -22,6 +23,10 @@ vi.mock('./installationInfo.js', async () => { }; }); +vi.mock('./standalone-update.js', () => ({ + performStandaloneUpdate: vi.fn(), +})); + vi.mock('./updateEventEmitter.js', async () => { const { EventEmitter } = await import('node:events'); return { @@ -38,6 +43,7 @@ interface MockChildProcess extends EventEmitter { } const mockGetInstallationInfo = vi.mocked(getInstallationInfo); +const mockPerformStandaloneUpdate = vi.mocked(performStandaloneUpdate); describe('handleAutoUpdate', () => { let mockSpawn: Mock; @@ -263,6 +269,125 @@ describe('handleAutoUpdate', () => { }); }); +describe('handleAutoUpdate — standalone path', () => { + let mockSpawn: Mock; + let mockUpdateInfo: UpdateObject; + let mockSettings: LoadedSettings; + let emitSpy: ReturnType; + + beforeEach(() => { + mockSpawn = vi.fn(); + vi.clearAllMocks(); + emitSpy = vi.spyOn(updateEventEmitter, 'emit'); + mockUpdateInfo = { + update: { + latest: '2.0.0', + current: '1.0.0', + type: 'major', + name: '@qwen-code/qwen-code', + }, + message: 'An update is available!', + }; + mockSettings = { + merged: { general: { enableAutoUpdate: true } }, + } as LoadedSettings; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('calls performStandaloneUpdate and does NOT spawn npm', async () => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @qwen-code/qwen-code@latest', + updateMessage: '', + isGlobal: false, + isStandalone: true, + standaloneDir: '/home/user/.local/lib/qwen-code', + packageManager: PackageManager.NPM, + }); + mockPerformStandaloneUpdate.mockResolvedValue('done'); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); + await vi.waitFor(() => + expect(emitSpy).toHaveBeenCalledWith('update-success', expect.anything()), + ); + + expect(mockPerformStandaloneUpdate).toHaveBeenCalledWith( + '/home/user/.local/lib/qwen-code', + '2.0.0', + ); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('emits deferred message when result is "deferred"', async () => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: undefined, + updateMessage: '', + isGlobal: false, + isStandalone: true, + standaloneDir: '/home/user/.local/lib/qwen-code', + packageManager: PackageManager.NPM, + }); + mockPerformStandaloneUpdate.mockResolvedValue('deferred'); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); + await vi.waitFor(() => + expect(emitSpy).toHaveBeenCalledWith('update-success', expect.anything()), + ); + + expect(emitSpy).toHaveBeenCalledWith('update-success', { + message: + 'Update downloaded. It will be applied after you exit this session.', + }); + }); + + it('emits "done" success message when result is "done"', async () => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: undefined, + updateMessage: '', + isGlobal: false, + isStandalone: true, + standaloneDir: '/home/user/.local/lib/qwen-code', + packageManager: PackageManager.NPM, + }); + mockPerformStandaloneUpdate.mockResolvedValue('done'); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); + await vi.waitFor(() => + expect(emitSpy).toHaveBeenCalledWith('update-success', expect.anything()), + ); + + expect(emitSpy).toHaveBeenCalledWith('update-success', { + message: + 'Update successful! The new version will be used on your next run.', + }); + }); + + it('emits update-failed on rejection', async () => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: undefined, + updateMessage: '', + isGlobal: false, + isStandalone: true, + standaloneDir: '/home/user/.local/lib/qwen-code', + packageManager: PackageManager.NPM, + }); + mockPerformStandaloneUpdate.mockRejectedValue(new Error('Download failed')); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); + await vi.waitFor(() => + expect(emitSpy).toHaveBeenCalledWith('update-failed', expect.anything()), + ); + + expect(emitSpy).toHaveBeenCalledWith('update-failed', { + message: + 'Automatic update failed: Download failed. Re-run the installer to update manually.', + }); + expect(mockSpawn).not.toHaveBeenCalled(); + }); +}); + describe('setUpdateHandler', () => { let addItem: Mock; let setUpdateInfo: Mock; @@ -288,7 +413,7 @@ describe('setUpdateHandler', () => { expect(addItem).toHaveBeenCalledWith( { type: MessageType.INFO, - text: 'Update successful! The new version will be used on your next run.', + text: 'Update successful!', }, expect.any(Number), ); @@ -342,7 +467,7 @@ describe('setUpdateHandler', () => { expect(addItem).toHaveBeenCalledWith( { type: MessageType.INFO, - text: 'Update successful! The new version will be used on your next run.', + text: 'Update successful!', }, expect.any(Number), ); @@ -369,7 +494,7 @@ describe('setUpdateHandler', () => { expect(addItem).toHaveBeenCalledWith( { type: MessageType.ERROR, - text: 'Automatic update failed. Please try updating manually', + text: 'Update failed', }, expect.any(Number), ); @@ -402,7 +527,7 @@ describe('setUpdateHandler', () => { 2, { type: MessageType.INFO, - text: 'Update successful! The new version will be used on your next run.', + text: 'Success!', }, expect.any(Number), ); diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/utils/handleAutoUpdate.ts index 05dd62c3de..215d300420 100644 --- a/packages/cli/src/utils/handleAutoUpdate.ts +++ b/packages/cli/src/utils/handleAutoUpdate.ts @@ -11,6 +11,7 @@ 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 type { spawn } from 'node:child_process'; import os from 'node:os'; @@ -43,6 +44,27 @@ export function handleAutoUpdate( message: combinedMessage, }); + if ( + installationInfo.isStandalone && + installationInfo.standaloneDir && + isAutoUpdateEnabled + ) { + performStandaloneUpdate(installationInfo.standaloneDir, info.update.latest) + .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.'; + 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.`, + }); + }); + return; + } + // Don't automatically run the update if auto-update is disabled or no update command if (!installationInfo.updateCommand || !isAutoUpdateEnabled) { return; @@ -113,20 +135,24 @@ export function setUpdateHandler( }, 60000); }; - const handleUpdateFailed = () => { + const handleUpdateFailed = (data: { message?: string }) => { setUpdateInfo(null); addItemOrDefer({ type: MessageType.ERROR, - text: `Automatic update failed. Please try updating manually`, + text: + data?.message || + 'Automatic update failed. Please try updating manually', }); }; - const handleUpdateSuccess = () => { + const handleUpdateSuccess = (data: { message?: string }) => { successfullyInstalled = true; setUpdateInfo(null); addItemOrDefer({ type: MessageType.INFO, - text: `Update successful! The new version will be used on your next run.`, + text: + data?.message || + 'Update successful! The new version will be used on your next run.', }); }; diff --git a/packages/cli/src/utils/installationInfo.test.ts b/packages/cli/src/utils/installationInfo.test.ts index 1da119db71..b169668711 100644 --- a/packages/cli/src/utils/installationInfo.test.ts +++ b/packages/cli/src/utils/installationInfo.test.ts @@ -26,6 +26,7 @@ vi.mock('fs', async (importOriginal) => { ...actualFs, realpathSync: vi.fn(), existsSync: vi.fn(), + accessSync: vi.fn(), }; }); diff --git a/packages/cli/src/utils/installationInfo.ts b/packages/cli/src/utils/installationInfo.ts index 6eb39b0540..8402d04e66 100644 --- a/packages/cli/src/utils/installationInfo.ts +++ b/packages/cli/src/utils/installationInfo.ts @@ -6,6 +6,7 @@ import { createDebugLogger, isGitRepository } from '@qwen-code/qwen-code-core'; import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import * as childProcess from 'node:child_process'; @@ -26,10 +27,40 @@ const debugLogger = createDebugLogger('INSTALLATION_INFO'); export interface InstallationInfo { packageManager: PackageManager; isGlobal: boolean; + isStandalone?: boolean; + standaloneDir?: string; updateCommand?: string; updateMessage?: string; } +// CLI entry → dist → package root: walk up at most 3 levels to find manifest.json +const MAX_MANIFEST_SEARCH_DEPTH = 3; + +function findStandaloneDir(realPath: string): string | null { + let dir = path.dirname(realPath); + for (let i = 0; i < MAX_MANIFEST_SEARCH_DEPTH; i++) { + const manifestPath = path.join(dir, 'manifest.json'); + try { + if (fs.existsSync(manifestPath)) { + const raw = fs.readFileSync(manifestPath, 'utf-8'); + const manifest = JSON.parse(raw) as { + name?: string; + target?: string; + }; + if (manifest.name === '@qwen-code/qwen-code' && manifest.target) { + return dir; + } + } + } catch { + // ignore parse errors + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + export function getInstallationInfo( projectRoot: string, isAutoUpdateEnabled: boolean, @@ -161,7 +192,59 @@ export function getInstallationInfo( }; } - // Assume global npm + // Check for standalone install (manifest.json with @qwen-code/qwen-code) + const standaloneDir = findStandaloneDir(realPath); + if (standaloneDir) { + return { + packageManager: PackageManager.UNKNOWN, + isGlobal: true, + isStandalone: true, + standaloneDir, + updateMessage: isAutoUpdateEnabled + ? 'Standalone install detected. Attempting to automatically update now...' + : 'Standalone install detected. Re-run the installer to update.', + }; + } + + // Check if the package directory is writable to determine whether npm update requires sudo + const npmPackageDir = path.dirname(path.dirname(realPath)); + let npmPrefixWritable = false; + try { + fs.accessSync(npmPackageDir, fs.constants.W_OK); + npmPrefixWritable = true; + } catch { + // Not writable (e.g., /usr/local/lib/node_modules owned by root) + } + + if (!npmPrefixWritable && isAutoUpdateEnabled) { + // npm prefix requires sudo — fall back to standalone update path + // which installs to ~/.local/lib/qwen-code/ (user-writable) + const installRoot = process.env['HOME'] || os.homedir(); + if (!installRoot || installRoot === '/') { + // Cannot determine a safe user-writable location; skip migration + return { + packageManager: PackageManager.NPM, + isGlobal: true, + updateMessage: + 'Update requires sudo. Run: sudo npm install -g @qwen-code/qwen-code@latest', + }; + } + const fallbackStandaloneDir = path.join( + installRoot, + '.local', + 'lib', + 'qwen-code', + ); + return { + packageManager: PackageManager.NPM, + isGlobal: true, + isStandalone: true, + standaloneDir: fallbackStandaloneDir, + updateMessage: + 'npm install requires sudo. Migrating to standalone installer for automatic updates.', + }; + } + const updateCommand = 'npm install -g @qwen-code/qwen-code@latest'; return { packageManager: PackageManager.NPM, diff --git a/packages/cli/src/utils/standalone-update-verify.test.ts b/packages/cli/src/utils/standalone-update-verify.test.ts new file mode 100644 index 0000000000..c72bb7f914 --- /dev/null +++ b/packages/cli/src/utils/standalone-update-verify.test.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { verifySignature } from './standalone-update-verify.js'; + +describe('standalone-update-verify', () => { + // Since we can't use the embedded key's private counterpart in tests, + // we test the structure and error paths. + + describe('verifySignature', () => { + it('rejects signature with wrong length', () => { + expect(() => verifySignature('test content', 'dG9vc2hvcnQ=')).toThrow( + 'Invalid signature length', + ); + }); + + it('rejects invalid signature (correct length but wrong bytes)', () => { + // 64 bytes of zeros, base64 encoded + const fakeSig = Buffer.alloc(64, 0).toString('base64'); + expect(() => verifySignature('test content', fakeSig)).toThrow( + 'signature verification failed', + ); + }); + + it('rejects tampered content with valid-length signature', () => { + // Even a random 64-byte signature should fail verification + const randomSig = Buffer.from( + Array.from({ length: 64 }, () => Math.floor(Math.random() * 256)), + ).toString('base64'); + expect(() => + verifySignature('some SHA256SUMS content', randomSig), + ).toThrow('signature verification failed'); + }); + }); +}); diff --git a/packages/cli/src/utils/standalone-update-verify.ts b/packages/cli/src/utils/standalone-update-verify.ts new file mode 100644 index 0000000000..adac9244da --- /dev/null +++ b/packages/cli/src/utils/standalone-update-verify.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Ed25519 signature verification for standalone update integrity. + * + * The release CI signs SHA256SUMS with an Ed25519 private key, producing + * SHA256SUMS.sig (base64-encoded raw 64-byte signature). This module + * verifies that signature using the embedded public key. + * + * Key generation (one-time): + * openssl genpkey -algorithm Ed25519 -out release-signing-key.pem + * openssl pkey -in release-signing-key.pem -pubout -outform DER | base64 + */ + +import { createPublicKey, verify } from 'node:crypto'; + +// Ed25519 public key in DER/SPKI format, base64-encoded. +// Replace this with the production key generated by the release team. +// The corresponding private key must be stored in CI secrets only. +const RELEASE_PUBLIC_KEY_DER_B64 = + 'MCowBQYDK2VwAyEAr9WRFLDauibZQKCe1oKfuFZn6zRMaEkD5+KVqN6mKM4='; + +/** + * Verifies an Ed25519 signature over the SHA256SUMS content. + * @param sha256sumsContent - The raw text of SHA256SUMS + * @param signatureBase64 - Base64-encoded 64-byte Ed25519 signature + * @throws if signature is invalid or verification fails + */ +export function verifySignature( + sha256sumsContent: string, + signatureBase64: string, +): void { + const key = createPublicKey({ + key: Buffer.from(RELEASE_PUBLIC_KEY_DER_B64, 'base64'), + format: 'der', + type: 'spki', + }); + + const signature = Buffer.from(signatureBase64, 'base64'); + if (signature.length !== 64) { + throw new Error( + `Invalid signature length: expected 64 bytes, got ${signature.length}`, + ); + } + + const valid = verify(null, Buffer.from(sha256sumsContent), key, signature); + if (!valid) { + throw new Error( + 'SHA256SUMS signature verification failed — possible tampering detected', + ); + } +} diff --git a/packages/cli/src/utils/standalone-update.test.ts b/packages/cli/src/utils/standalone-update.test.ts new file mode 100644 index 0000000000..b59552b37e --- /dev/null +++ b/packages/cli/src/utils/standalone-update.test.ts @@ -0,0 +1,405 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + rollbackStandaloneUpdate, + ensureBinWrapper, + ensurePathInShellRc, + performStandaloneUpdate, +} from './standalone-update.js'; + +describe('standalone-update', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-update-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('rollbackStandaloneUpdate', () => { + it('returns no-old when .old directory does not exist', () => { + const standaloneDir = path.join(tempDir, 'qwen-code'); + fs.mkdirSync(standaloneDir); + fs.writeFileSync( + path.join(standaloneDir, 'manifest.json'), + JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'darwin-arm64', + }), + ); + + const result = rollbackStandaloneUpdate(standaloneDir); + expect(result.ok).toBe(false); + expect(result).toHaveProperty('reason', 'no-old'); + }); + + it('returns no-manifest when .old directory has no manifest.json', () => { + const standaloneDir = path.join(tempDir, 'qwen-code'); + const oldDir = `${standaloneDir}.old`; + fs.mkdirSync(standaloneDir); + fs.mkdirSync(oldDir); + fs.writeFileSync( + path.join(standaloneDir, 'manifest.json'), + JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'darwin-arm64', + }), + ); + + const result = rollbackStandaloneUpdate(standaloneDir); + expect(result.ok).toBe(false); + expect(result).toHaveProperty('reason', 'no-manifest'); + }); + + it('swaps current with .old directory on valid rollback', () => { + const standaloneDir = path.join(tempDir, 'qwen-code'); + const oldDir = `${standaloneDir}.old`; + fs.mkdirSync(standaloneDir); + fs.mkdirSync(oldDir); + + fs.writeFileSync( + path.join(standaloneDir, 'manifest.json'), + JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'darwin-arm64', + version: '0.17.0', + }), + ); + fs.writeFileSync(path.join(standaloneDir, 'marker.txt'), 'new'); + + fs.writeFileSync( + path.join(oldDir, 'manifest.json'), + JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'darwin-arm64', + version: '0.16.2', + }), + ); + fs.writeFileSync(path.join(oldDir, 'marker.txt'), 'old'); + + const result = rollbackStandaloneUpdate(standaloneDir); + expect(result.ok).toBe(true); + + const manifest = JSON.parse( + fs.readFileSync(path.join(standaloneDir, 'manifest.json'), 'utf-8'), + ); + expect(manifest.version).toBe('0.16.2'); + expect( + fs.readFileSync(path.join(standaloneDir, 'marker.txt'), 'utf-8'), + ).toBe('old'); + expect(fs.existsSync(oldDir)).toBe(false); + }); + + it('succeeds even with minimal manifest in .old', () => { + const standaloneDir = path.join(tempDir, 'qwen-code'); + const oldDir = `${standaloneDir}.old`; + fs.mkdirSync(standaloneDir); + fs.mkdirSync(oldDir); + + fs.writeFileSync( + path.join(standaloneDir, 'manifest.json'), + JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.17.0' }), + ); + fs.writeFileSync(path.join(oldDir, 'manifest.json'), '{}'); + + const result = rollbackStandaloneUpdate(standaloneDir); + expect(result.ok).toBe(true); + }); + }); + + describe('ensureBinWrapper', () => { + // Unix wrapper test relies on POSIX file permissions (mode bits) and + // the SHELL env var, neither of which behave consistently on Windows. + it.skipIf(process.platform === 'win32')( + 'creates a Unix shell wrapper script', + () => { + const libDir = path.join(tempDir, '.local', 'lib'); + const standaloneDir = path.join(libDir, 'qwen-code'); + fs.mkdirSync(standaloneDir, { recursive: true }); + + // Isolate HOME so ensurePathInShellRc doesn't touch real shell rc + const origHome = process.env['HOME']; + const origShell = process.env['SHELL']; + process.env['HOME'] = tempDir; + process.env['SHELL'] = '/bin/zsh'; + try { + ensureBinWrapper(standaloneDir, 'darwin-arm64'); + } finally { + process.env['HOME'] = origHome; + process.env['SHELL'] = origShell; + } + + 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(standaloneDir); + const mode = fs.statSync(wrapperPath).mode; + expect(mode & 0o111).toBeGreaterThan(0); + }, + ); + + it('creates a Windows cmd wrapper', () => { + const libDir = path.join(tempDir, '.local', 'lib'); + const standaloneDir = path.join(libDir, 'qwen-code'); + fs.mkdirSync(standaloneDir, { recursive: true }); + + ensureBinWrapper(standaloneDir, 'win-x64'); + + const wrapperPath = path.join(tempDir, '.local', 'bin', 'qwen.cmd'); + expect(fs.existsSync(wrapperPath)).toBe(true); + const content = fs.readFileSync(wrapperPath, 'utf-8'); + expect(content).toContain('@echo off'); + }); + + it.skipIf(process.platform === 'win32')( + 'does not overwrite existing wrapper', + () => { + const libDir = path.join(tempDir, '.local', 'lib'); + const standaloneDir = path.join(libDir, 'qwen-code'); + const binDir = path.join(tempDir, '.local', 'bin'); + fs.mkdirSync(standaloneDir, { recursive: true }); + fs.mkdirSync(binDir, { recursive: true }); + + const origHome = process.env['HOME']; + const origShell = process.env['SHELL']; + process.env['HOME'] = tempDir; + process.env['SHELL'] = '/bin/zsh'; + + const wrapperPath = path.join(binDir, 'qwen'); + fs.writeFileSync(wrapperPath, 'existing-content', { mode: 0o755 }); + + try { + ensureBinWrapper(standaloneDir, 'linux-x64'); + expect(fs.readFileSync(wrapperPath, 'utf-8')).toBe( + 'existing-content', + ); + } finally { + process.env['HOME'] = origHome; + process.env['SHELL'] = origShell; + } + }, + ); + }); + + describe('performStandaloneUpdate', () => { + it('rejects invalid version format', async () => { + const standaloneDir = path.join(tempDir, 'qwen-code'); + fs.mkdirSync(standaloneDir); + fs.writeFileSync( + path.join(standaloneDir, 'manifest.json'), + JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'darwin-arm64', + }), + ); + + await expect( + performStandaloneUpdate(standaloneDir, 'not-a-version'), + ).rejects.toThrow('Invalid version format'); + }); + + it('rejects directory without manifest as non-managed install', async () => { + const standaloneDir = path.join(tempDir, 'qwen-code'); + fs.mkdirSync(standaloneDir); + // No manifest.json — could be user data + + await expect( + performStandaloneUpdate(standaloneDir, '1.0.0'), + ).rejects.toThrow('not a Qwen Code standalone install'); + }); + + it('rejects unknown target in manifest', async () => { + const standaloneDir = path.join(tempDir, 'qwen-code'); + fs.mkdirSync(standaloneDir); + fs.writeFileSync( + path.join(standaloneDir, 'manifest.json'), + JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'freebsd-mips', + }), + ); + + await expect( + performStandaloneUpdate(standaloneDir, '1.0.0'), + ).rejects.toThrow('Unknown target'); + }); + + it('fails gracefully when another update is in progress', async () => { + const standaloneDir = path.join(tempDir, 'qwen-code'); + const parentDir = path.dirname(standaloneDir); + fs.mkdirSync(standaloneDir, { recursive: true }); + fs.writeFileSync( + path.join(standaloneDir, 'manifest.json'), + JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'darwin-arm64', + }), + ); + + // Simulate held lock from a live process (current PID) + const lockPath = path.join(parentDir, '.qwen-update.lock'); + fs.writeFileSync(lockPath, String(process.pid)); + + await expect( + performStandaloneUpdate(standaloneDir, '1.0.0'), + ).rejects.toThrow('Another update is already in progress'); + + // Clean up lock + fs.unlinkSync(lockPath); + }); + }); + + describe('rollbackStandaloneUpdate — concurrent lock protection', () => { + it('returns error when an active update holds the lock', () => { + const standaloneDir = path.join(tempDir, 'qwen-code'); + const oldDir = `${standaloneDir}.old`; + const lockPath = path.join(tempDir, '.qwen-update.lock'); + fs.mkdirSync(standaloneDir); + fs.mkdirSync(oldDir); + fs.writeFileSync(path.join(standaloneDir, 'manifest.json'), '{}'); + fs.writeFileSync(path.join(oldDir, 'manifest.json'), '{}'); + fs.writeFileSync(lockPath, String(process.pid)); + const result = rollbackStandaloneUpdate(standaloneDir); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.detail).toContain('auto-update is currently in progress'); + } + fs.unlinkSync(lockPath); + }); + + it('proceeds when lock has dead PID', () => { + const standaloneDir = path.join(tempDir, 'qwen-code'); + const oldDir = `${standaloneDir}.old`; + const lockPath = path.join(tempDir, '.qwen-update.lock'); + fs.mkdirSync(standaloneDir); + fs.mkdirSync(oldDir); + fs.writeFileSync( + path.join(standaloneDir, 'manifest.json'), + JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.17.0' }), + ); + fs.writeFileSync( + path.join(oldDir, 'manifest.json'), + JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.16.0' }), + ); + fs.writeFileSync(lockPath, '999999999'); + const result = rollbackStandaloneUpdate(standaloneDir); + expect(result.ok).toBe(true); + }); + }); + + describe.skipIf(process.platform === 'win32')('ensurePathInShellRc', () => { + it('appends PATH export to zshrc when SHELL is zsh', () => { + const binDir = path.join(tempDir, 'bin'); + 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); + const content = fs.readFileSync(zshrc, 'utf-8'); + expect(content).toContain('# Added by Qwen Code standalone installer'); + expect(content).toContain(`export PATH="${binDir}:$PATH"`); + } finally { + process.env['SHELL'] = origShell; + process.env['HOME'] = origHome; + } + }); + + it('skips if marker already in rc file', () => { + const binDir = path.join(tempDir, 'bin'); + const zshrc = path.join(tempDir, '.zshrc'); + fs.writeFileSync( + zshrc, + `# Added by Qwen Code standalone installer\nexport PATH="${binDir}:$PATH"\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( + /# Added by Qwen Code standalone installer/g, + ); + expect(matches).toHaveLength(1); + } finally { + process.env['SHELL'] = origShell; + process.env['HOME'] = origHome; + } + }); + + it('appends fish_add_path for fish shell', () => { + const binDir = path.join(tempDir, '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('fish_add_path'); + expect(content).toContain(binDir); + } finally { + process.env['SHELL'] = origShell; + process.env['HOME'] = origHome; + } + }); + + it('rejects binDir with shell metacharacters', () => { + const binDir = path.join(tempDir, 'bin$(evil)'); + 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('does nothing for unknown shells', () => { + const binDir = path.join(tempDir, 'bin'); + const origShell = process.env['SHELL']; + const origHome = process.env['HOME']; + process.env['SHELL'] = '/bin/csh'; + process.env['HOME'] = tempDir; + + try { + ensurePathInShellRc(binDir); + // No rc file should be created + expect( + fs.readdirSync(tempDir).filter((f) => f.startsWith('.')), + ).toHaveLength(0); + } finally { + process.env['SHELL'] = origShell; + process.env['HOME'] = origHome; + } + }); + }); +}); diff --git a/packages/cli/src/utils/standalone-update.ts b/packages/cli/src/utils/standalone-update.ts new file mode 100644 index 0000000000..09448d1c10 --- /dev/null +++ b/packages/cli/src/utils/standalone-update.ts @@ -0,0 +1,840 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +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 { fetch } from 'undici'; +import * as tar from 'tar'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import { verifySignature } from './standalone-update-verify.js'; + +const debugLogger = createDebugLogger('STANDALONE_UPDATE'); + +const OSS_BASE = + 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code'; +const GITHUB_BASE = 'https://github.com/QwenLM/qwen-code/releases/download'; +const FETCH_TIMEOUT_MS = 30_000; +const ARCHIVE_TIMEOUT_MS = 300_000; // 5 min — archives are 50–150 MB + +const VALID_TARGETS = new Set([ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64', + 'linux-x64', + 'win-x64', +]); + +const SEMVER_RE = /^v?\d+\.\d+\.\d+(-[\w.]+)?$/; + +type UndiciResponse = Awaited>; + +function normalizeVersion(version: string): string { + if (!SEMVER_RE.test(version)) { + throw new Error(`Invalid version format: ${version}`); + } + return version.startsWith('v') ? version : `v${version}`; +} + +function validateTarget(target: string): void { + if (!VALID_TARGETS.has(target)) { + throw new Error(`Unknown target: ${target}`); + } +} + +function archiveFilename(target: string): string { + const ext = target.startsWith('win') ? 'zip' : 'tar.gz'; + return `qwen-code-${target}.${ext}`; +} + +function escapePS(s: string): string { + return s.replace(/'/g, "''"); +} + +async function tryFetch( + url: string, + timeoutMs = FETCH_TIMEOUT_MS, +): Promise< + | { response: UndiciResponse; error?: undefined } + | { response?: undefined; error: Error } +> { + try { + const res = await fetch(url, { + signal: AbortSignal.timeout(timeoutMs), + }); + if (res.ok) return { response: res }; + await res.body?.cancel().catch(() => {}); + return { error: new Error(`HTTP ${res.status} ${res.statusText}`) }; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + debugLogger.debug(`Fetch failed for ${url}: ${error.message}`); + return { error }; + } +} + +async function downloadWithFallback( + versionPath: string, + filename: string, + timeoutMs = FETCH_TIMEOUT_MS, +): Promise { + const ossUrl = `${OSS_BASE}/${versionPath}/${filename}`; + const ossResult = await tryFetch(ossUrl, timeoutMs); + if (ossResult.response) return ossResult.response; + + const ghUrl = `${GITHUB_BASE}/${versionPath}/${filename}`; + const ghResult = await tryFetch(ghUrl, timeoutMs); + if (ghResult.response) return ghResult.response; + + throw new Error( + `Failed to download ${filename}: OSS (${ossResult.error?.message ?? 'unknown'}), GitHub (${ghResult.error?.message ?? 'unknown'})`, + ); +} + +async function verifyChecksum( + actualHash: string, + filename: string, + versionPath: string, +): Promise { + const response = await downloadWithFallback(versionPath, 'SHA256SUMS'); + const text = await response.text(); + + // Ed25519 signature verification of SHA256SUMS. + // NOTE: Currently uses a test key. Once release CI signs with the production + // key and publishes SHA256SUMS.sig, set QWEN_REQUIRE_SIGNATURE=1 to enforce. + // Until then, verification is best-effort (passes when .sig exists, warns when not). + const requireSig = process.env['QWEN_REQUIRE_SIGNATURE'] === '1'; + let sigResponse: UndiciResponse | undefined; + try { + sigResponse = await downloadWithFallback(versionPath, 'SHA256SUMS.sig'); + } catch { + // .sig not available from any mirror + } + if (sigResponse) { + const sigContent = await sigResponse.text(); + verifySignature(text, sigContent.trim()); + debugLogger.info('SHA256SUMS signature verified.'); + } else if (requireSig) { + throw new Error( + 'SHA256SUMS.sig not found and QWEN_REQUIRE_SIGNATURE=1 is set', + ); + } else { + debugLogger.warn( + 'SHA256SUMS.sig not available — update integrity relies on SHA256 checksum only. ' + + 'Set QWEN_REQUIRE_SIGNATURE=1 to enforce signature verification.', + ); + } + + const expectedLine = text.split('\n').find((line) => { + const parts = line.trim().split(/\s+/); + if (parts.length < 2) return false; + // Handle GNU coreutils binary-mode prefix: "hash *filename" + const name = parts[parts.length - 1]!.replace(/^\*/, ''); + return name === filename; + }); + if (!expectedLine) { + throw new Error(`No checksum found for ${filename} in SHA256SUMS`); + } + const expectedHash = expectedLine.trim().split(/\s+/)[0]!; + + if (actualHash !== expectedHash) { + throw new Error( + `Checksum mismatch: expected ${expectedHash}, got ${actualHash}`, + ); + } +} + +const MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024; // 512 MB + +async function downloadToFile( + versionPath: string, + filename: string, + destPath: string, +): Promise { + const response = await downloadWithFallback( + versionPath, + filename, + ARCHIVE_TIMEOUT_MS, + ); + const body = response.body; + if (!body) throw new Error('Empty response body'); + + const contentLength = response.headers.get('content-length'); + if (contentLength && parseInt(contentLength, 10) > MAX_DOWNLOAD_BYTES) { + await body.cancel().catch(() => {}); + throw new Error( + `Download too large: ${contentLength} bytes exceeds ${MAX_DOWNLOAD_BYTES} limit`, + ); + } + + const hash = createHash('sha256'); + let bytesWritten = 0; + const dest = fs.createWriteStream(destPath); + const sizeGuard = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + bytesWritten += chunk.length; + if (bytesWritten > MAX_DOWNLOAD_BYTES) { + callback( + new Error(`Download exceeded ${MAX_DOWNLOAD_BYTES} byte limit`), + ); + } else { + hash.update(chunk); + callback(null, chunk); + } + }, + }); + await pipeline(Readable.fromWeb(body), sizeGuard, dest); + return hash.digest('hex'); +} + +function validateExtractedPaths(resolvedDest: string): void { + const entries = fs.readdirSync(resolvedDest, { + recursive: true, + withFileTypes: true, + }); + for (const entry of entries) { + const fullPath = path.join( + String(entry.parentPath || entry.path), + entry.name, + ); + const resolved = fs.realpathSync(fullPath); + if ( + !resolved.startsWith(resolvedDest + path.sep) && + resolved !== resolvedDest + ) { + fs.rmSync(resolvedDest, { recursive: true, force: true }); + throw new Error( + `Path traversal detected in archive: ${entry.name} resolves to ${resolved}`, + ); + } + } +} + +async function extractArchive( + archivePath: string, + destDir: string, + target: string, +): Promise { + fs.mkdirSync(destDir, { recursive: true }); + + if (target.startsWith('win')) { + await new Promise((resolve, reject) => { + const ps = spawn( + 'powershell.exe', + [ + '-NoProfile', + '-Command', + `Expand-Archive -Path '${escapePS(archivePath)}' -DestinationPath '${escapePS(destDir)}' -Force`, + ], + { stdio: 'ignore' }, + ); + ps.on('close', (code) => + code === 0 + ? resolve() + : reject(new Error(`Expand-Archive exited with code ${code}`)), + ); + ps.on('error', reject); + }); + const resolvedDest = fs.realpathSync(destDir); + validateExtractedPaths(resolvedDest); + } else { + const resolvedDest = path.resolve(destDir); + await tar.extract({ + file: archivePath, + cwd: destDir, + preservePaths: false, + filter: (p, entry) => { + if (p.startsWith('/') || p.includes('..')) 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; + }, + }); + } +} + +/** + * Runs a command and captures stdout, stderr, and exit code. + */ +function spawnAndCapture( + command: string, + args: string[], + timeoutMs: number, +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + let settled = false; + const child = execFile( + command, + args, + { timeout: timeoutMs }, + (err, out, stderr) => { + if (settled) return; + settled = true; + if ( + err && + (('killed' in err && err.killed) || ('signal' in err && err.signal)) + ) { + reject(new Error('Smoke test timed out')); + return; + } + if (err) { + const exitCode = + 'code' in err && typeof err.code === 'number' ? err.code : 1; + resolve({ exitCode, stdout: out || '', stderr: stderr || '' }); + return; + } + resolve({ exitCode: 0, stdout: out || '', stderr: stderr || '' }); + }, + ); + child.on('error', (e) => { + if (settled) return; + settled = true; + reject(e); + }); + }); +} + +/** + * Verifies the new installation can actually run by invoking --version. + * Prevents replacing a working install with a broken binary. + */ +async function smokeTest(newInstallDir: string, target: string): Promise { + const resolvedInstallDir = path.resolve(newInstallDir); + const nodeBin = target.startsWith('win') + ? path.join(resolvedInstallDir, 'node', 'node.exe') + : path.join(resolvedInstallDir, 'node', 'bin', 'node'); + const cliBin = path.join(resolvedInstallDir, 'lib', 'cli.js'); + + if (!fs.existsSync(nodeBin)) { + throw new Error(`Smoke test failed: node binary not found at ${nodeBin}`); + } + if (!fs.existsSync(cliBin)) { + throw new Error(`Smoke test failed: cli.js not found at ${cliBin}`); + } + + const { exitCode, stdout, stderr } = await spawnAndCapture( + nodeBin, + [cliBin, '--version'], + 10_000, + ); + if (exitCode !== 0) { + const detail = stderr.trim() ? `: ${stderr.trim()}` : ''; + throw new Error( + `Smoke test failed: new binary exited with code ${exitCode}${detail}`, + ); + } + const version = stdout.trim(); + if (!SEMVER_RE.test(version)) { + throw new Error( + `Smoke test failed: unexpected version output "${version}"`, + ); + } + debugLogger.info(`Smoke test passed: ${version}`); +} + +function acquireLock(lockPath: string): boolean { + try { + fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' }); + return true; + } catch { + try { + const pidStr = fs.readFileSync(lockPath, 'utf-8').trim(); + const pid = parseInt(pidStr, 10); + if (Number.isNaN(pid) || !isProcessAlive(pid)) { + fs.unlinkSync(lockPath); + try { + fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' }); + return true; + } catch { + return false; + } + } + } catch { + // lock is held by another live process + } + return false; + } +} + +function releaseLock(lockPath: string): void { + try { + fs.unlinkSync(lockPath); + } catch { + // already gone + } +} + +// Remove an empty standaloneDir left behind by a failed first-time migration +// (no manifest.json means it was just mkdir'd, never populated). Prevents +// permanently blocking future updates with "exists but is not a standalone install". +function cleanupEmptyStandaloneDir(standaloneDir: string): void { + const manifestPath = path.join(standaloneDir, 'manifest.json'); + if (fs.existsSync(standaloneDir) && !fs.existsSync(manifestPath)) { + fs.rmSync(standaloneDir, { recursive: true, force: true }); + } +} + +const UNSAFE_SHELL_CHARS = /["`$\\;\n\r]/; +const UNSAFE_CMD_CHARS = /[&|<>^%!"`\n\r]/; + +function assertSafeForShellEmbed(p: string, context: string): void { + if (UNSAFE_SHELL_CHARS.test(p)) { + throw new Error( + `${context} contains characters unsafe for shell embedding: ${p}`, + ); + } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function atomicReplace( + standaloneDir: string, + newDir: string, + lockPath: string, +): 'done' | 'deferred' { + const oldDir = `${standaloneDir}.old`; + const pendingDir = `${standaloneDir}.new`; + + if (fs.existsSync(oldDir)) { + fs.rmSync(oldDir, { recursive: true, force: true }); + } + + if (os.platform() === 'win32') { + // On Windows, the running node.exe holds file locks. Stage the new dir + // as a sibling, then spawn a helper script that waits for this process + // to exit before completing the swap. + // Validate paths BEFORE any filesystem mutations + if ( + UNSAFE_CMD_CHARS.test(standaloneDir) || + UNSAFE_CMD_CHARS.test(oldDir) || + UNSAFE_CMD_CHARS.test(pendingDir) + ) { + throw new Error( + 'Installation path contains characters unsafe for deferred update script', + ); + } + if (fs.existsSync(pendingDir)) { + fs.rmSync(pendingDir, { recursive: true, force: true }); + } + fs.renameSync(newDir, pendingDir); + + const lockFile = lockPath; + 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). + // 2. Run both moves with errorlevel checks; if move #2 fails, roll back + // 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). + const script = [ + '@echo off', + 'set /a TRIES=0', + ':wait', + 'set /a TRIES+=1', + 'if %TRIES% GTR 30 goto proceed', + `tasklist /FI "PID eq ${process.pid}" 2>nul | find "${process.pid}" >nul && (timeout /t 1 >nul & goto wait)`, + ':proceed', + `echo [%DATE% %TIME%] starting swap >> "${logFile}"`, + `move /Y "${standaloneDir}" "${oldDir}"`, + 'if errorlevel 1 goto move1_failed', + `move /Y "${pendingDir}" "${standaloneDir}"`, + 'if errorlevel 1 goto move2_failed', + `echo [%DATE% %TIME%] swap completed >> "${logFile}"`, + 'goto cleanup', + ':move1_failed', + `echo [%DATE% %TIME%] ERROR: failed to rename install to .old (errorlevel %errorlevel%) >> "${logFile}"`, + 'goto cleanup', + ':move2_failed', + `echo [%DATE% %TIME%] ERROR: failed to promote .new; rolling back >> "${logFile}"`, + `move /Y "${oldDir}" "${standaloneDir}"`, + 'if errorlevel 1 (', + ` echo [%DATE% %TIME%] CRITICAL: rollback also failed; manual recovery: move "${oldDir}" "${standaloneDir}" >> "${logFile}"`, + ') else (', + ` echo [%DATE% %TIME%] rollback succeeded >> "${logFile}"`, + ')', + ':cleanup', + `del /F /Q "${lockFile}" 2>nul`, + `del "%~f0"`, + ].join('\r\n'); + const scriptPath = path.join( + path.dirname(standaloneDir), + 'qwen-update.bat', + ); + fs.writeFileSync(scriptPath, script); + spawn('cmd.exe', ['/c', scriptPath], { + detached: true, + stdio: 'ignore', + windowsHide: true, + }).unref(); + return 'deferred'; + } else { + // Unix: rename is atomic on same filesystem. newDir is a sibling of + // standaloneDir (same parent), so EXDEV won't happen. + fs.renameSync(standaloneDir, oldDir); + try { + fs.renameSync(newDir, standaloneDir); + } catch (promoteErr) { + // Recovery rename can also fail (e.g. FS hiccup, oldDir grabbed by + // another process). Surface BOTH errors with manual-recovery steps so + // the user is never silently left with a missing install. + try { + fs.renameSync(oldDir, standaloneDir); + } catch (rollbackErr) { + const detail = + `Standalone update failed AND rollback failed.\n` + + `Original error: ${(promoteErr as Error).message}\n` + + `Rollback error: ${(rollbackErr as Error).message}\n` + + `Manual recovery: mv "${oldDir}" "${standaloneDir}"`; + throw new Error(detail); + } + throw promoteErr; + } + // Keep .old for rollback instead of deleting immediately + return 'done'; + } +} + +/** + * 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 { + const binDir = path.join(path.dirname(standaloneDir), '..', 'bin'); + + try { + fs.mkdirSync(binDir, { recursive: true }); + if (target.startsWith('win')) { + if (UNSAFE_CMD_CHARS.test(standaloneDir)) { + throw new Error( + 'standaloneDir contains characters unsafe for cmd.exe wrapper', + ); + } + 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); + } + } else { + assertSafeForShellEmbed(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 }); + } + ensurePathInShellRc(binDir); + } + } catch (err) { + debugLogger.debug('Failed to create bin wrapper:', err); + } +} + +/** + * 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'); + + const shell = process.env['SHELL'] || ''; + let rcFile: string | null = null; + const home = process.env['HOME'] || os.homedir(); + + if (shell.endsWith('/zsh')) { + rcFile = path.join(home, '.zshrc'); + } else if (shell.endsWith('/bash')) { + const bashrc = path.join(home, '.bashrc'); + const profile = path.join(home, '.bash_profile'); + if (os.platform() === 'darwin') { + rcFile = fs.existsSync(profile) ? profile : bashrc; + } else { + rcFile = fs.existsSync(bashrc) ? bashrc : profile; + } + } else if (shell.endsWith('/fish')) { + rcFile = path.join(home, '.config', 'fish', 'config.fish'); + } + + if (!rcFile) return; + + 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; + + const exportLine = shell.endsWith('/fish') + ? `\n${marker}\nfish_add_path "${binDir}"\n` + : `\n${marker}\nexport PATH="${binDir}:$PATH"\n`; + fs.appendFileSync(rcFile, exportLine); + debugLogger.info(`Added ${binDir} to ${rcFile}`); + } catch (err) { + debugLogger.debug('Failed to update shell rc:', err); + } +} + +/** + * Detect the current platform target string for standalone archives. + */ +function detectTarget(): string { + const platform = os.platform(); + const arch = os.arch(); + if (platform === 'darwin') { + return arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64'; + } + if (platform === 'win32') return 'win-x64'; + if (platform === 'linux') { + if (arch === 'arm64') return 'linux-arm64'; + if (arch === 'x64') return 'linux-x64'; + } + throw new Error(`Unsupported platform: ${platform}-${arch}`); +} + +export async function performStandaloneUpdate( + standaloneDir: string, + newVersion: string, +): Promise<'done' | 'deferred'> { + const versionPath = normalizeVersion(newVersion); + + let target: string; + const manifestPath = path.join(standaloneDir, 'manifest.json'); + if (fs.existsSync(manifestPath)) { + const manifestRaw = fs.readFileSync(manifestPath, 'utf-8'); + const manifest = JSON.parse(manifestRaw) as { target?: string }; + target = manifest.target || detectTarget(); + } else if (fs.existsSync(standaloneDir)) { + // Directory exists but has no manifest — not a managed Qwen install. + // Refuse to overwrite to avoid data loss. + throw new Error( + `${standaloneDir} exists but is not a Qwen Code standalone install. Remove it manually to proceed.`, + ); + } else { + // First-time migration from npm — directory does not exist yet + target = detectTarget(); + fs.mkdirSync(standaloneDir, { recursive: true }); + } + validateTarget(target); + + const filename = archiveFilename(target); + const parentDir = path.dirname(standaloneDir); + + // Use a lockfile to prevent concurrent updates + const lockPath = path.join(parentDir, '.qwen-update.lock'); + if (!acquireLock(lockPath)) { + cleanupEmptyStandaloneDir(standaloneDir); + throw new Error('Another update is already in progress'); + } + + // Download to a temp dir in os.tmpdir(), then extract to a sibling dir + // of standaloneDir to avoid EXDEV (cross-device rename). + // extractDir uses mkdtempSync (random suffix) to prevent symlink + // pre-creation attacks on predictable directory names. + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-code-update-')); + let extractDir: string; + let updateResult: 'done' | 'deferred' | undefined; + try { + extractDir = fs.mkdtempSync(path.join(parentDir, '.qwen-code-update-')); + } catch (err) { + fs.rmSync(tempDir, { recursive: true, force: true }); + releaseLock(lockPath); + throw err; + } + + try { + const archivePath = path.join(tempDir, filename); + debugLogger.info(`Downloading ${filename} (${versionPath})...`); + const archiveHash = await downloadToFile( + versionPath, + filename, + archivePath, + ); + + debugLogger.info('Verifying checksum...'); + await verifyChecksum(archiveHash, filename, versionPath); + + debugLogger.info('Extracting archive...'); + await extractArchive(archivePath, extractDir, target); + + const newInstallDir = path.join(extractDir, 'qwen-code'); + if (!fs.existsSync(path.join(newInstallDir, 'manifest.json'))) { + throw new Error( + 'Extracted archive does not contain expected qwen-code directory', + ); + } + + debugLogger.info('Running smoke test...'); + await smokeTest(newInstallDir, target); + + debugLogger.info('Replacing installation...'); + updateResult = atomicReplace(standaloneDir, newInstallDir, lockPath); + + // Write rollback metadata so /doctor rollback knows what version is preserved + const oldDir = `${standaloneDir}.old`; + if (fs.existsSync(oldDir)) { + try { + // Read the old manifest to capture its version + const oldManifestPath = path.join(oldDir, 'manifest.json'); + let oldVersion = 'unknown'; + if (fs.existsSync(oldManifestPath)) { + const oldManifest = JSON.parse( + fs.readFileSync(oldManifestPath, 'utf-8'), + ) as { version?: string }; + oldVersion = oldManifest.version || 'unknown'; + } + const rollbackInfo = { + preservedVersion: oldVersion, + updatedTo: versionPath, + timestamp: new Date().toISOString(), + reason: 'auto-update', + }; + fs.writeFileSync( + path.join(oldDir, '.qwen-rollback-info.json'), + JSON.stringify(rollbackInfo, null, 2), + ); + } catch { + // Non-critical — rollback still works without metadata + } + } + + // Ensure bin wrapper exists (critical for npm→standalone migration) + ensureBinWrapper(standaloneDir, target); + + debugLogger.info('Standalone update complete.'); + return updateResult; + } catch (err) { + const pendingDir = `${standaloneDir}.new`; + if (fs.existsSync(pendingDir)) { + fs.rmSync(pendingDir, { recursive: true, force: true }); + } + cleanupEmptyStandaloneDir(standaloneDir); + 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); + } + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Best-effort cleanup + } + try { + fs.rmSync(extractDir, { recursive: true, force: true }); + } catch { + // Best-effort cleanup + } + } +} + +export type RollbackResult = + | { ok: true } + | { + ok: false; + reason: 'no-old' | 'no-manifest' | 'rename-failed'; + detail: string; + }; + +/** + * Rolls back a standalone installation to the previous version (.old directory). + */ +export function rollbackStandaloneUpdate( + standaloneDir: string, +): RollbackResult { + const lockPath = path.join(path.dirname(standaloneDir), '.qwen-update.lock'); + try { + const pidStr = fs.readFileSync(lockPath, 'utf-8').trim(); + const pid = parseInt(pidStr, 10); + if (!Number.isNaN(pid) && isProcessAlive(pid)) { + return { + ok: false, + reason: 'rename-failed', + detail: + 'An auto-update is currently in progress. Wait for it to finish before rolling back.', + }; + } + } catch { + // No lock file — safe to proceed + } + + const oldDir = `${standaloneDir}.old`; + + if (!fs.existsSync(oldDir)) { + return { ok: false, reason: 'no-old', detail: `${oldDir} does not exist` }; + } + + const oldManifest = path.join(oldDir, 'manifest.json'); + if (!fs.existsSync(oldManifest)) { + debugLogger.error('Rollback failed: .old directory has no manifest.json'); + return { + ok: false, + reason: 'no-manifest', + detail: `${oldDir}/manifest.json missing — .old may be corrupt`, + }; + } + + const failedDir = `${standaloneDir}.failed`; + try { + if (fs.existsSync(failedDir)) { + fs.rmSync(failedDir, { recursive: true, force: true }); + } + fs.renameSync(standaloneDir, failedDir); + fs.renameSync(oldDir, standaloneDir); + debugLogger.info('Rollback successful.'); + try { + fs.rmSync(failedDir, { recursive: true, force: true }); + } catch { + debugLogger.debug(`Leftover .failed dir at ${failedDir}, safe to delete`); + } + return { ok: true }; + } catch (err) { + debugLogger.error('Rollback failed:', err); + // Attempt to restore current if we moved it + if (!fs.existsSync(standaloneDir) && fs.existsSync(failedDir)) { + try { + fs.renameSync(failedDir, standaloneDir); + return { + ok: false, + reason: 'rename-failed', + detail: `Filesystem error: ${(err as Error).message}. Current installation was restored automatically.`, + }; + } catch { + // Critical failure — both dirs are in bad state + } + } + return { + ok: false, + reason: 'rename-failed', + detail: `Filesystem error: ${(err as Error).message}. Manual recovery: mv "${oldDir}" "${standaloneDir}"`, + }; + } +} diff --git a/scripts/sign-release.sh b/scripts/sign-release.sh new file mode 100755 index 0000000000..0d68f11b8f --- /dev/null +++ b/scripts/sign-release.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Signs SHA256SUMS with an Ed25519 private key for release integrity verification. +# +# Usage: +# RELEASE_SIGNING_KEY_PEM=/path/to/key.pem ./scripts/sign-release.sh dist/releases/vX.Y.Z/SHA256SUMS +# +# Output: +# Creates SHA256SUMS.sig alongside the input file (base64-encoded 64-byte signature). +# +# Key generation (one-time): +# openssl genpkey -algorithm Ed25519 -out release-signing-key.pem +# openssl pkey -in release-signing-key.pem -pubout -outform DER | base64 +# # Embed the base64 public key in standalone-update-verify.ts + +set -euo pipefail + +if [[ -z "${RELEASE_SIGNING_KEY_PEM:-}" ]]; then + echo "ERROR: RELEASE_SIGNING_KEY_PEM environment variable must point to the Ed25519 private key." >&2 + exit 1 +fi + +if [[ ! -f "${RELEASE_SIGNING_KEY_PEM}" ]]; then + echo "ERROR: Key file not found: ${RELEASE_SIGNING_KEY_PEM}" >&2 + exit 1 +fi + +SHA256SUMS_FILE="${1:-}" +if [[ -z "${SHA256SUMS_FILE}" || ! -f "${SHA256SUMS_FILE}" ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +SIG_FILE="${SHA256SUMS_FILE}.sig" + +# openssl pkeyutl -rawin signs raw content with Ed25519, outputs 64-byte signature +openssl pkeyutl -sign -rawin \ + -inkey "${RELEASE_SIGNING_KEY_PEM}" \ + -in "${SHA256SUMS_FILE}" \ + -out /dev/stdout 2>/dev/null | base64 > "${SIG_FILE}" + +echo "Signed: ${SIG_FILE}" +echo "Signature (base64): $(cat "${SIG_FILE}")"