diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d3e641b390..b96c586b6f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,10 @@ We favor small, atomic PRs that address a single issue or add a single, self-con - **Do:** Create a PR that fixes one specific bug or adds one specific feature. - **Don't:** Bundle multiple unrelated changes (e.g., a bug fix, a new feature, and a refactor) into a single PR. -Large changes should be broken down into a series of smaller, logical PRs that can be reviewed and merged independently. +As a rule of thumb, start splitting a PR once it exceeds about 1,200 changed +lines. PRs above about 2,000 changed lines should either be split into a series +of smaller, logical PRs that can be reviewed and merged independently, or +explain in the PR description why the change needs to land together. #### 3. Use Draft PRs for Work in Progress diff --git a/README.md b/README.md index a0a95c88b1..ae671e153c 100644 --- a/README.md +++ b/README.md @@ -46,15 +46,13 @@ Qwen Code is an open-source AI agent for the terminal, optimized for Qwen series #### Linux / macOS ```bash -bash -c "$(curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh)" +curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash ``` -#### Windows (Run as Administrator) +#### Windows -Works in both Command Prompt and PowerShell: - -```cmd -powershell -Command "Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat' -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path $env:TEMP 'install-qwen.bat')" +```powershell +irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex ``` > **Note**: It's recommended to restart your terminal after installation to ensure environment variables take effect. diff --git a/docs/developers/contributing.md b/docs/developers/contributing.md index b95ef828e4..6dd54b9fb7 100644 --- a/docs/developers/contributing.md +++ b/docs/developers/contributing.md @@ -30,7 +30,10 @@ We favor small, atomic PRs that address a single issue or add a single, self-con - **Do:** Create a PR that fixes one specific bug or adds one specific feature. - **Don't:** Bundle multiple unrelated changes (e.g., a bug fix, a new feature, and a refactor) into a single PR. -Large changes should be broken down into a series of smaller, logical PRs that can be reviewed and merged independently. +As a rule of thumb, start splitting a PR once it exceeds about 1,200 changed +lines. PRs above about 2,000 changed lines should either be split into a series +of smaller, logical PRs that can be reviewed and merged independently, or +explain in the PR description why the change needs to land together. #### 3. Use Draft PRs for Work in Progress diff --git a/docs/users/overview.md b/docs/users/overview.md index a40753d760..c9ed58196c 100644 --- a/docs/users/overview.md +++ b/docs/users/overview.md @@ -10,19 +10,19 @@ ### Install Qwen Code: The recommended installer uses a standalone archive when one is available for -your platform. If it falls back to npm, Node.js 20 or later with npm must be +your platform. If it falls back to npm, Node.js 22 or later with npm must be available on PATH. **Linux / macOS** ```sh -curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash +curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash ``` **Windows** -```cmd -powershell -Command "Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat' -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path $env:TEMP 'install-qwen.bat')" +```powershell +irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex ``` > [!note] diff --git a/docs/users/quickstart.md b/docs/users/quickstart.md index 1d9fc203e7..10bc4da31f 100644 --- a/docs/users/quickstart.md +++ b/docs/users/quickstart.md @@ -21,13 +21,13 @@ To install Qwen Code, use one of the following methods: **Linux / macOS** ```sh -curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash +curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash ``` -**Windows (Run as Administrator)** +**Windows** -```cmd -powershell -Command "Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat' -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path $env:TEMP 'install-qwen.bat')" +```powershell +irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex ``` > [!note] diff --git a/docs/users/support/Uninstall.md b/docs/users/support/Uninstall.md index f8970c8830..96a654381a 100644 --- a/docs/users/support/Uninstall.md +++ b/docs/users/support/Uninstall.md @@ -1,6 +1,6 @@ # Uninstall -Your uninstall method depends on how you ran the CLI. Follow the instructions for either npx or a global npm installation. +Your uninstall method depends on how you installed the CLI. ## Method 1: Using npx @@ -40,3 +40,21 @@ npm uninstall -g @qwen-code/qwen-code ``` This command completely removes the package from your system. + +## Method 3: Standalone Install + +If you installed via the standalone installer (`curl ... | bash` or `irm ... | iex`), use the dedicated uninstall script. + +**Linux / macOS** + +```bash +curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.sh | bash +``` + +**Windows** + +```powershell +irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.ps1 | iex +``` + +The uninstaller removes the standalone runtime, generated `qwen` wrapper, and installer-managed PATH changes. Your Qwen Code configuration (`~/.qwen`) is preserved by default. diff --git a/docs/users/support/troubleshooting.md b/docs/users/support/troubleshooting.md index bcaa97df14..1b17c46690 100644 --- a/docs/users/support/troubleshooting.md +++ b/docs/users/support/troubleshooting.md @@ -37,7 +37,7 @@ This guide provides solutions to common issues and debugging tips, including top ## Frequently asked questions (FAQs) - **Q: How do I update Qwen Code to the latest version?** - - A: If you installed it globally via `npm`, update it using the command `npm install -g @qwen-code/qwen-code@latest`. If you compiled it from source, pull the latest changes from the repository, and then rebuild using the command `npm run build`. + - A: If you installed Qwen Code with the standalone installer, rerun the standalone install command. If you installed it globally via `npm`, update it using the command `npm install -g @qwen-code/qwen-code@latest`. If you compiled it from source, pull the latest changes from the repository, and then rebuild using the command `npm run build`. - **Q: Where are the Qwen Code configuration or settings files stored?** - A: The Qwen Code configuration is stored in two `settings.json` files: @@ -60,6 +60,7 @@ This guide provides solutions to common issues and debugging tips, including top - **Cause:** The CLI is not correctly installed or it is not in your system's `PATH`. - **Solution:** The update depends on how you installed Qwen Code: + - If you installed `qwen` with the standalone installer, rerun the standalone install command and then open a new terminal. - If you installed `qwen` globally, check that your `npm` global binary directory is in your `PATH`. You can update using the command `npm install -g @qwen-code/qwen-code@latest`. - If you are running `qwen` from source, ensure you are using the correct command to invoke it (e.g. `node packages/cli/dist/index.js ...`). To update, pull the latest changes from the repository, and then rebuild using the command `npm run build`. diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx index 23421ad2b1..3814b44167 100644 --- a/packages/cli/src/ui/commands/directoryCommand.test.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -351,10 +351,7 @@ describe('getDirPathCompletions', () => { fs.mkdirSync(path.join(tempTestDir, 'sub1', 'deep'), { recursive: true }); // Add some non-directory files (should be filtered out) fs.writeFileSync(path.join(tempTestDir, 'file.txt'), ''); - fs.writeFileSync( - path.join(tempTestDir, 'sub1', 'nested.txt'), - '', - ); + fs.writeFileSync(path.join(tempTestDir, 'sub1', 'nested.txt'), ''); }); afterAll(() => { diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index 1919e8c113..59e8837fcf 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { SlashCommand, CommandContext, CommandCompletionItem } from './types.js'; +import type { + SlashCommand, + CommandContext, + CommandCompletionItem, +} from './types.js'; import { CommandKind } from './types.js'; import { MessageType } from '../types.js'; import * as fs from 'node:fs'; @@ -58,7 +62,9 @@ function findExistingWorkspaceDirectory( * Returns directory path completions for the given partial argument. * Supports comma-separated paths by completing only the last segment. */ -export function getDirPathCompletions(partialArg: string): CommandCompletionItem[] { +export function getDirPathCompletions( + partialArg: string, +): CommandCompletionItem[] { const lastComma = partialArg.lastIndexOf(','); const prefix = lastComma >= 0 ? partialArg.substring(0, lastComma + 1) : ''; const partial = diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts index 15fc438b3d..61b3b572af 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts @@ -555,7 +555,11 @@ describe('useCommandCompletion', () => { it('should not append trailing space for directory completions', async () => { setupMocks({ atSuggestions: [ - { label: 'src/components/', value: 'src/components/', isDirectory: true }, + { + label: 'src/components/', + value: 'src/components/', + isDirectory: true, + }, ], }); @@ -652,9 +656,7 @@ describe('useCommandCompletion', () => { result.current.handleAutocomplete(0); }); - expect(result.current.textBuffer.text).toBe( - '@src/components/ is a dir', - ); + expect(result.current.textBuffer.text).toBe('@src/components/ is a dir'); }); }); diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index d4a45dfa87..972da98555 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -229,7 +229,10 @@ export function useCommandCompletion( const lineCodePoints = toCodePoints(buffer.lines[cursorRow] || ''); const charAfterCompletion = lineCodePoints[end]; const isDirectory = suggestions[indexToUse].isDirectory; - if (charAfterCompletion !== ' ' && !(isDirectory && !charAfterCompletion)) { + if ( + charAfterCompletion !== ' ' && + !(isDirectory && !charAfterCompletion) + ) { suggestionText += ' '; } diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index 733efbc5ae..23042d712d 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -1125,10 +1125,12 @@ describe('useSlashCompletion', () => { describe('isDirectory propagation', () => { it('should propagate isDirectory from CommandCompletionItem to Suggestion', async () => { - const mockCompletionFn = vi.fn().mockResolvedValue([ - { value: '/tmp/workspace/', isDirectory: true }, - { value: '/tmp/file.txt' }, - ]); + const mockCompletionFn = vi + .fn() + .mockResolvedValue([ + { value: '/tmp/workspace/', isDirectory: true }, + { value: '/tmp/file.txt' }, + ]); const slashCommands = [ createTestCommand({ diff --git a/packages/cli/src/utils/installationInfo.test.ts b/packages/cli/src/utils/installationInfo.test.ts index 1da119db71..2d2e3e5dc5 100644 --- a/packages/cli/src/utils/installationInfo.test.ts +++ b/packages/cli/src/utils/installationInfo.test.ts @@ -26,6 +26,8 @@ vi.mock('fs', async (importOriginal) => { ...actualFs, realpathSync: vi.fn(), existsSync: vi.fn(), + lstatSync: vi.fn(), + readFileSync: vi.fn(), }; }); @@ -40,21 +42,49 @@ vi.mock('child_process', async (importOriginal) => { const mockedIsGitRepository = vi.mocked(isGitRepository); const mockedRealPathSync = vi.mocked(fs.realpathSync); const mockedExistsSync = vi.mocked(fs.existsSync); +const mockedLstatSync = vi.mocked(fs.lstatSync); +const mockedReadFileSync = vi.mocked(fs.readFileSync); const mockedExecSync = vi.mocked(childProcess.execSync); describe('getInstallationInfo', () => { const projectRoot = '/path/to/project'; let originalArgv: string[]; + let originalPlatform: PropertyDescriptor | undefined; + + const setPlatform = (platform: NodeJS.Platform) => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform, + }); + }; + + const fileStats = (mode = 0o755): fs.Stats => + ({ + isFile: () => true, + isSymbolicLink: () => false, + mode, + }) as fs.Stats; + + const symlinkStats = (): fs.Stats => + ({ + isFile: () => true, + isSymbolicLink: () => true, + mode: 0o755, + }) as fs.Stats; beforeEach(() => { vi.resetAllMocks(); originalArgv = [...process.argv]; + originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); // Mock process.cwd() for isGitRepository vi.spyOn(process, 'cwd').mockReturnValue(projectRoot); }); afterEach(() => { process.argv = originalArgv; + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform); + } }); it('should return UNKNOWN when cliPath is not available', () => { @@ -130,10 +160,252 @@ describe('getInstallationInfo', () => { expect(info.updateMessage).toBe('Running via bunx, update not applicable.'); }); - it('should detect Homebrew installation via execSync', () => { - Object.defineProperty(process, 'platform', { - value: 'darwin', + it('should detect standalone installs and avoid npm auto-update', () => { + setPlatform('linux'); + const installDir = '/Users/test/.local/lib/qwen-code'; + const cliPath = `${installDir}/lib/cli.js`; + process.argv[1] = cliPath; + mockedRealPathSync.mockReturnValue(cliPath); + mockedExistsSync.mockImplementation((candidate) => + [ + path.join(installDir, 'manifest.json'), + path.join(installDir, 'bin', 'qwen'), + path.join(installDir, 'node', 'bin', 'node'), + ].includes(String(candidate)), + ); + mockedReadFileSync.mockImplementation((candidate) => { + if (candidate === path.join(installDir, 'manifest.json')) { + return JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'linux-x64', + }); + } + throw new Error(`Unexpected read: ${candidate}`); + }); + mockedLstatSync.mockImplementation((candidate) => { + if ( + [ + path.join(installDir, 'bin', 'qwen'), + path.join(installDir, 'node', 'bin', 'node'), + ].includes(String(candidate)) + ) { + return fileStats(); + } + throw new Error(`Unexpected lstat: ${candidate}`); + }); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.STANDALONE); + expect(info.isGlobal).toBe(true); + expect(info.updateCommand).toBeUndefined(); + expect(info.updateMessage).toContain('Standalone install detected'); + expect(info.updateMessage).toContain('install-qwen-standalone.sh'); + expect(info.updateMessage).not.toContain('npm install'); + }); + + it('should detect Windows standalone installs and avoid npm auto-update', () => { + setPlatform('win32'); + const installDir = 'C:/Users/test/AppData/Local/qwen-code'; + const cliPath = `${installDir}/lib/cli.js`; + process.argv[1] = cliPath; + mockedRealPathSync.mockReturnValue(cliPath); + mockedExistsSync.mockImplementation((candidate) => + [ + `${installDir}/manifest.json`, + `${installDir}/bin/qwen.cmd`, + `${installDir}/node/node.exe`, + ].includes(String(candidate).replace(/\\/g, '/')), + ); + mockedReadFileSync.mockImplementation((candidate) => { + if ( + String(candidate).replace(/\\/g, '/') === `${installDir}/manifest.json` + ) { + return JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'win-x64', + }); + } + throw new Error(`Unexpected read: ${candidate}`); + }); + mockedLstatSync.mockImplementation((candidate) => { + if ( + [`${installDir}/bin/qwen.cmd`, `${installDir}/node/node.exe`].includes( + String(candidate).replace(/\\/g, '/'), + ) + ) { + return fileStats(0o644); + } + throw new Error(`Unexpected lstat: ${candidate}`); }); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.STANDALONE); + expect(info.updateCommand).toBeUndefined(); + expect(info.updateMessage).toContain('install-qwen-standalone.ps1'); + expect(info.updateMessage).not.toContain('npm install'); + }); + + it('should detect macOS standalone installs and avoid npm auto-update', () => { + setPlatform('darwin'); + const installDir = '/Users/test/.local/lib/qwen-code'; + const cliPath = `${installDir}/lib/cli.js`; + process.argv[1] = cliPath; + mockedRealPathSync.mockReturnValue(cliPath); + mockedExistsSync.mockImplementation((candidate) => + [ + path.join(installDir, 'manifest.json'), + path.join(installDir, 'bin', 'qwen'), + path.join(installDir, 'node', 'bin', 'node'), + ].includes(String(candidate)), + ); + mockedReadFileSync.mockImplementation((candidate) => { + if (candidate === path.join(installDir, 'manifest.json')) { + return JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'darwin-arm64', + }); + } + throw new Error(`Unexpected read: ${candidate}`); + }); + mockedLstatSync.mockImplementation((candidate) => { + if ( + [ + path.join(installDir, 'bin', 'qwen'), + path.join(installDir, 'node', 'bin', 'node'), + ].includes(String(candidate)) + ) { + return fileStats(); + } + throw new Error(`Unexpected lstat: ${candidate}`); + }); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.STANDALONE); + expect(info.isGlobal).toBe(true); + expect(info.updateMessage).toContain('Standalone install detected'); + expect(info.updateMessage).toContain('install-qwen-standalone.sh'); + }); + + it('should fall back to npm when manifest.json is malformed', () => { + setPlatform('linux'); + const installDir = '/Users/test/.local/lib/qwen-code'; + const cliPath = `${installDir}/lib/cli.js`; + process.argv[1] = cliPath; + mockedRealPathSync.mockReturnValue(cliPath); + mockedExistsSync.mockImplementation((candidate) => + [ + path.join(installDir, 'manifest.json'), + path.join(installDir, 'bin', 'qwen'), + path.join(installDir, 'node', 'bin', 'node'), + ].includes(String(candidate)), + ); + mockedReadFileSync.mockReturnValue('{invalid json'); + mockedLstatSync.mockReturnValue(fileStats()); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.NPM); + expect(info.updateCommand).toBe( + 'npm install -g @qwen-code/qwen-code@latest', + ); + }); + + it('should ignore standalone-like installs for the wrong target', () => { + setPlatform('linux'); + const installDir = '/Users/test/.local/lib/qwen-code'; + const cliPath = `${installDir}/lib/cli.js`; + process.argv[1] = cliPath; + mockedRealPathSync.mockReturnValue(cliPath); + mockedExistsSync.mockImplementation((candidate) => + [ + path.join(installDir, 'manifest.json'), + path.join(installDir, 'bin', 'qwen'), + path.join(installDir, 'node', 'bin', 'node'), + ].includes(String(candidate)), + ); + mockedReadFileSync.mockReturnValue( + JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'win-x64', + }), + ); + mockedLstatSync.mockReturnValue(fileStats()); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.NPM); + expect(info.updateCommand).toBe( + 'npm install -g @qwen-code/qwen-code@latest', + ); + }); + + it('should ignore standalone-like installs with symlinked runtime files', () => { + setPlatform('linux'); + const installDir = '/Users/test/.local/lib/qwen-code'; + const cliPath = `${installDir}/lib/cli.js`; + process.argv[1] = cliPath; + mockedRealPathSync.mockReturnValue(cliPath); + mockedExistsSync.mockImplementation((candidate) => + [ + path.join(installDir, 'manifest.json'), + path.join(installDir, 'bin', 'qwen'), + path.join(installDir, 'node', 'bin', 'node'), + ].includes(String(candidate)), + ); + mockedReadFileSync.mockReturnValue( + JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'linux-x64', + }), + ); + mockedLstatSync.mockImplementation((candidate) => { + if (candidate === path.join(installDir, 'bin', 'qwen')) { + return symlinkStats(); + } + return fileStats(); + }); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.NPM); + }); + + it('should ignore Unix standalone-like installs with non-executable runtime files', () => { + setPlatform('linux'); + const installDir = '/Users/test/.local/lib/qwen-code'; + const cliPath = `${installDir}/lib/cli.js`; + process.argv[1] = cliPath; + mockedRealPathSync.mockReturnValue(cliPath); + mockedExistsSync.mockImplementation((candidate) => + [ + path.join(installDir, 'manifest.json'), + path.join(installDir, 'bin', 'qwen'), + path.join(installDir, 'node', 'bin', 'node'), + ].includes(String(candidate)), + ); + mockedReadFileSync.mockReturnValue( + JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'linux-x64', + }), + ); + mockedLstatSync.mockImplementation((candidate) => { + if (candidate === path.join(installDir, 'bin', 'qwen')) { + return fileStats(0o644); + } + return fileStats(); + }); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.NPM); + }); + + it('should detect Homebrew installation via execSync', () => { + setPlatform('darwin'); const cliPath = '/usr/local/bin/gemini'; process.argv[1] = cliPath; mockedRealPathSync.mockReturnValue(cliPath); @@ -151,9 +423,7 @@ describe('getInstallationInfo', () => { }); it('should fall through if brew command fails', () => { - Object.defineProperty(process, 'platform', { - value: 'darwin', - }); + setPlatform('darwin'); const cliPath = '/usr/local/bin/gemini'; process.argv[1] = cliPath; mockedRealPathSync.mockReturnValue(cliPath); diff --git a/packages/cli/src/utils/installationInfo.ts b/packages/cli/src/utils/installationInfo.ts index 6eb39b0540..0317388566 100644 --- a/packages/cli/src/utils/installationInfo.ts +++ b/packages/cli/src/utils/installationInfo.ts @@ -17,11 +17,16 @@ export enum PackageManager { BUN = 'bun', BUNX = 'bunx', HOMEBREW = 'homebrew', + STANDALONE = 'standalone', NPX = 'npx', UNKNOWN = 'unknown', } const debugLogger = createDebugLogger('INSTALLATION_INFO'); +const STANDALONE_UNIX_INSTALLER = + 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh'; +const STANDALONE_WINDOWS_INSTALLER = + 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1'; export interface InstallationInfo { packageManager: PackageManager; @@ -76,6 +81,14 @@ export function getInstallationInfo( }; } + const standaloneInfo = getStandaloneInstallInfo( + realPath, + isAutoUpdateEnabled, + ); + if (standaloneInfo) { + return standaloneInfo; + } + // Check for Homebrew if (process.platform === 'darwin') { try { @@ -176,3 +189,98 @@ export function getInstallationInfo( return { packageManager: PackageManager.UNKNOWN, isGlobal: false }; } } + +function getStandaloneInstallInfo( + realPath: string, + isAutoUpdateEnabled: boolean, +): InstallationInfo | null { + const installDir = standaloneInstallDirForCliPath(realPath); + if (!installDir || !isStandaloneInstallDir(installDir)) { + return null; + } + + const updateCommand = + process.platform === 'win32' + ? `powershell -ExecutionPolicy Bypass -c "irm ${STANDALONE_WINDOWS_INSTALLER} | iex"` + : `curl -fsSL ${STANDALONE_UNIX_INSTALLER} | bash`; + const updatePrefix = isAutoUpdateEnabled + ? 'Standalone install detected. Automatic in-place updates are not supported yet.' + : 'Standalone install detected.'; + + return { + packageManager: PackageManager.STANDALONE, + isGlobal: true, + updateMessage: `${updatePrefix} Please rerun the standalone installer to update: ${updateCommand}`, + }; +} + +function standaloneInstallDirForCliPath(realPath: string): string | null { + const normalized = realPath.replace(/\\/g, '/'); + const suffix = '/lib/cli.js'; + if (!normalized.endsWith(suffix)) { + return null; + } + return realPath.slice(0, -suffix.length); +} + +function isStandaloneInstallDir(installDir: string): boolean { + try { + const manifestPath = path.join(installDir, 'manifest.json'); + if (!fs.existsSync(manifestPath)) { + return false; + } + + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { + name?: unknown; + target?: unknown; + }; + // Manifest format is produced by writeManifest in create-standalone-package.js. + if ( + manifest.name !== '@qwen-code/qwen-code' || + typeof manifest.target !== 'string' || + !isStandaloneTargetForCurrentPlatform(manifest.target) + ) { + return false; + } + + const qwenBin = + process.platform === 'win32' + ? path.join(installDir, 'bin', 'qwen.cmd') + : path.join(installDir, 'bin', 'qwen'); + const nodeBin = + process.platform === 'win32' + ? path.join(installDir, 'node', 'node.exe') + : path.join(installDir, 'node', 'bin', 'node'); + + return ( + fs.existsSync(qwenBin) && + fs.existsSync(nodeBin) && + isStandaloneRuntimeFile(qwenBin) && + isStandaloneRuntimeFile(nodeBin) + ); + } catch (err) { + debugLogger.error('Standalone detection failed:', installDir, err); + return false; + } +} + +function isStandaloneTargetForCurrentPlatform(target: string): boolean { + switch (process.platform) { + case 'darwin': + return /^darwin-(arm64|x64)$/.test(target); + case 'linux': + return /^linux-(arm64|x64)$/.test(target); + case 'win32': + return /^win-(arm64|x64)$/.test(target); + default: + return false; + } +} + +function isStandaloneRuntimeFile(filePath: string): boolean { + const stats = fs.lstatSync(filePath); + if (!stats.isFile() || stats.isSymbolicLink()) { + return false; + } + return process.platform === 'win32' || (stats.mode & 0o111) !== 0; +} diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index cb0a7650d2..b39e73f952 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -112,10 +112,7 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr } return ( - isDashscopeOrigin || - isTokenPlanOrigin || - isInternalOrigin || - isProxyMatch + isDashscopeOrigin || isTokenPlanOrigin || isInternalOrigin || isProxyMatch ); } diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index 4f6e52f33f..422d6bcfd9 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -608,4 +608,4 @@ function fail(message) { throw new Error(`Error: ${message}`); } -export { writeSha256Sums }; +export { TARGETS, writeSha256Sums }; diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index e2d863e328..b92075e49b 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -46,11 +46,6 @@ standalone release. The `standalone` suffix intentionally avoids overwriting the existing production `install-qwen.sh` / `install-qwen.bat` OSS objects during the staged rollout. -Public installation documentation intentionally continues to use the existing -production installer in this PR. Update README and other public quick-install -instructions in a follow-up after the standalone-suffixed hosted installers and -release archive sync have been validated in production. - Hosted installer assets are staged separately from GitHub Release archives: - `install-qwen-standalone.sh` is the Linux/macOS hosted entrypoint. diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index d255767b5d..91b51fc2e6 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -296,20 +296,15 @@ echo. echo Usage: install-qwen-standalone.bat [OPTIONS] echo. echo Options: -echo -s, --source SOURCE Record the installation source. -echo Only letters, numbers, dot, underscore, and dash are allowed. -echo --method METHOD Install method: detect, standalone, or npm. -echo --mirror MIRROR Standalone archive mirror: auto, github, or aliyun. -echo Defaults to QWEN_INSTALL_MIRROR or auto, which picks -echo whichever responds first via a HEAD probe. -echo --base-url URL Override standalone archive base URL. -echo --archive PATH Install from a local standalone archive. -echo --version VERSION Standalone release version. Defaults to latest. -echo --registry REGISTRY npm registry to use. -echo Defaults to QWEN_NPM_REGISTRY or https://registry.npmmirror.com -echo --no-modify-path Do not prepend INSTALL_BIN_DIR to user PATH even -echo when a shadowing 'qwen' is detected. -echo -h, --help Show this help message. +echo --method METHOD Install method: detect, standalone, or npm (default: detect) +echo --mirror MIRROR Mirror: auto, github, or aliyun (default: auto) +echo --base-url URL Override standalone archive base URL +echo --archive PATH Install from a local standalone archive +echo --version VERSION Release version (default: latest) +echo --registry URL npm registry (default: https://registry.npmmirror.com) +echo --no-modify-path Do not modify user PATH +echo -s, --source SOURCE Record installation source +echo -h, --help Show this help message exit /b 0 :PrintHeader @@ -317,7 +312,20 @@ set "DISPLAY_VERSION=!VERSION!" if /i not "!DISPLAY_VERSION!"=="latest" ( if /i "!DISPLAY_VERSION:~0,1!"=="v" set "DISPLAY_VERSION=!DISPLAY_VERSION:~1!" ) +echo. echo Installing Qwen Code version: !DISPLAY_VERSION! +echo. +exit /b 0 + +:PrintLogo +rem QWEN CODE logo with color (Windows Terminal VT100 support) +echo. +powershell -NoProfile -ExecutionPolicy Bypass -Command "$e=[char]27; Write-Host \" $e[38;2;71;150;228mQ W E N $e[38;2;132;122;206mC O D E$e[0m\"" +echo. +exit /b 0 + +:PrintProgressComplete +powershell -NoProfile -ExecutionPolicy Bypass -Command "$esc = [char]27; $bar = [string]::new([char]0x25A0, 50); Write-Host \"$esc[38;5;214m$bar 100%%$esc[0m\"" exit /b 0 :ValidateRawEnvironmentOptions @@ -547,11 +555,11 @@ if /i "!MIRROR!"=="auto" ( ) call :RaceMirrorHead 2 "!QWEN_GH_BASE_URL!/SHA256SUMS" "!QWEN_OSS_PROBE_URL!" if /i "!QWEN_RACE_RESULT!"=="timeout" ( - echo INFO: Mirror auto-selection timed out; defaulting to github. + REM Mirror auto-selection timed out; defaulting to github. set "MIRROR=github" ) else ( set "MIRROR=!QWEN_RACE_RESULT!" - echo INFO: Mirror auto-selected via HEAD probe: !QWEN_RACE_RESULT! + REM Mirror auto-selected: !QWEN_RACE_RESULT! ) set "QWEN_GH_BASE_URL=" set "QWEN_OSS_BASE_URL=" @@ -592,7 +600,7 @@ rem already on the user PATH. Uses PowerShell rather than `setx` because setx rem truncates PATH at 1024 chars, which can silently mangle long PATHs. set "QWEN_NEW_BIN=%~1" if "!QWEN_NEW_BIN!"=="" exit /b 0 -powershell -NoProfile -ExecutionPolicy Bypass -Command "$bin = $env:QWEN_NEW_BIN; $userPath = [Environment]::GetEnvironmentVariable('Path', 'User'); if ([string]::IsNullOrEmpty($userPath)) { $userPath = '' }; $entries = $userPath -split ';' | Where-Object { $_ -ne '' }; if ($entries -contains $bin) { Write-Output ('INFO: User PATH already contains ' + $bin + ' (skipping).'); exit 0 }; $newPath = (@($bin) + $entries) -join ';'; [Environment]::SetEnvironmentVariable('Path', $newPath, 'User'); Write-Output ('SUCCESS: Prepended ' + $bin + ' to your user PATH.'); Write-Output 'INFO: Open a NEW command prompt for the change to take effect.'" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$bin = $env:QWEN_NEW_BIN; $userPath = [Environment]::GetEnvironmentVariable('Path', 'User'); if ([string]::IsNullOrEmpty($userPath)) { $userPath = '' }; $entries = @($userPath -split ';' | Where-Object { $_ -ne '' }); $remaining = @($entries | Where-Object { $_ -ne $bin }); if ($entries.Count -gt 0 -and $entries[0] -eq $bin -and $remaining.Count -eq ($entries.Count - 1)) { exit 0 }; $newPath = (@($bin) + $remaining) -join ';'; [Environment]::SetEnvironmentVariable('Path', $newPath, 'User'); exit 0" set "PS_STATUS=%ERRORLEVEL%" set "QWEN_NEW_BIN=" exit /b %PS_STATUS% @@ -611,7 +619,8 @@ set "QWEN_DOWNLOAD_URL=%~1" set "QWEN_DOWNLOAD_DEST=%~2" rem Prefer curl.exe -# for a hash-mark progress bar (Windows 10+ includes it); rem fall back to Invoke-WebRequest (which shows its own progress bar). -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $curl = $env:QWEN_INSTALL_CURL_EXE; if ([string]::IsNullOrEmpty($curl)) { $cmd = Get-Command curl.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1; if ($null -ne $cmd) { $curl = $cmd.Source } }; if (-not [string]::IsNullOrEmpty($curl)) { & $curl --connect-timeout 15 --max-time 300 --retry 2 -#fSLo $env:QWEN_DOWNLOAD_DEST $env:QWEN_DOWNLOAD_URL; if ($LASTEXITCODE -ne 0) { throw ('curl.exe download failed (exit code ' + $LASTEXITCODE + ')') }; exit 0 }; try { try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; Invoke-WebRequest -Uri $env:QWEN_DOWNLOAD_URL -OutFile $env:QWEN_DOWNLOAD_DEST -UseBasicParsing -MaximumRedirection 10 -TimeoutSec 300; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }" +rem Progress output is suppressed (-s overrides -#) because PrintProgressComplete provides the visual. +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $curl = $env:QWEN_INSTALL_CURL_EXE; if ([string]::IsNullOrEmpty($curl)) { $cmd = Get-Command curl.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1; if ($null -ne $cmd) { $curl = $cmd.Source } }; if (-not [string]::IsNullOrEmpty($curl)) { & $curl --connect-timeout 15 --max-time 300 --retry 2 -#fSLo $env:QWEN_DOWNLOAD_DEST $env:QWEN_DOWNLOAD_URL -s --show-error; if ($LASTEXITCODE -ne 0) { throw ('curl.exe download failed (exit code ' + $LASTEXITCODE + ')') }; exit 0 }; try { $ProgressPreference = 'SilentlyContinue'; try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; Invoke-WebRequest -Uri $env:QWEN_DOWNLOAD_URL -OutFile $env:QWEN_DOWNLOAD_DEST -UseBasicParsing -MaximumRedirection 10 -TimeoutSec 300; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }" set "PS_STATUS=%ERRORLEVEL%" set "QWEN_DOWNLOAD_URL=" set "QWEN_DOWNLOAD_DEST=" @@ -667,7 +676,7 @@ if "!RESOLVED_VERSION_PATH!"=="" ( exit /b 1 ) -echo INFO: Resolved Aliyun latest to !RESOLVED_VERSION_PATH!. +REM Resolved Aliyun latest to !RESOLVED_VERSION_PATH! exit /b 0 :VerifyChecksum @@ -683,7 +692,7 @@ if "!CHECKSUM_FILE!"=="" ( call :CreateTempFile "qwen-code-checksums" if !ERRORLEVEL! NEQ 0 exit /b 1 set "TEMP_CHECKSUM=!TEMP_FILE!" - call :DownloadFile "!CHECKSUM_FILE!" "!TEMP_CHECKSUM!" + call :DownloadFileQuiet "!CHECKSUM_FILE!" "!TEMP_CHECKSUM!" if !ERRORLEVEL! NEQ 0 ( if exist "!TEMP_CHECKSUM!" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 echo ERROR: Could not download SHA256SUMS for checksum verification. @@ -733,7 +742,7 @@ if /i not "!EXPECTED_HASH!"=="!ACTUAL_HASH!" ( exit /b 1 ) -echo SUCCESS: Checksum verified for !ARCHIVE_NAME!. +REM Checksum verified for !ARCHIVE_NAME! exit /b 0 :InstallStandalone @@ -820,7 +829,6 @@ if not "!ARCHIVE_PATH!"=="" ( if exist "!ARCHIVE_FILE!" del /F /Q "!ARCHIVE_FILE!" >nul 2>&1 echo WARNING: Aliyun standalone archive download failed; retrying GitHub mirror. call :UseGithubFallbackBaseUrl - echo Downloading !ARCHIVE_NAME! call :DownloadFile "!ARCHIVE_URL!" "!ARCHIVE_FILE!" set "DOWNLOAD_STATUS=!ERRORLEVEL!" ) @@ -830,6 +838,7 @@ if not "!ARCHIVE_PATH!"=="" ( if /i "!METHOD!"=="detect" exit /b 2 exit /b 1 ) + call :PrintProgressComplete ) if "!TEMP_DIR!"=="" ( @@ -1002,8 +1011,7 @@ set "PATH=!INSTALL_BIN_DIR!;!PATH!" call :CreateSourceJson if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 -echo SUCCESS: Qwen Code standalone archive installed successfully. -echo INFO: Installed to !INSTALL_DIR! +REM Standalone archive installed to !INSTALL_DIR! exit /b 0 :CreateTempDir @@ -1050,7 +1058,7 @@ REM with backslash separators even though the ZIP spec requires '/'. We REM accept either separator and reject only entries that, after REM normalization, are empty, absolute, drive-rooted, or contain a '..' REM segment. -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $archive = $null; try { Add-Type -AssemblyName System.IO.Compression.FileSystem; $archive = [IO.Compression.ZipFile]::OpenRead($env:QWEN_ARCHIVE_FILE); foreach ($entry in $archive.Entries) { $raw = $entry.FullName; if ($raw.IndexOfAny([char[]](10,13)) -ge 0) { [Console]::Error.WriteLine('Archive contains unsafe path with control character: ' + $raw); exit 1 }; $name = $raw -replace '\\', '/'; while ($name.StartsWith('./')) { $name = $name.Substring(2) }; if ($name -eq '' -or $name.StartsWith('/') -or $name -match '^[A-Za-z]:' -or $name -match '(^|/)\.\.(/|$)') { [Console]::Error.WriteLine('Archive contains unsafe path: ' + $entry.FullName); exit 1 } } } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 2 } finally { if ($null -ne $archive) { $archive.Dispose() } }" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $archive = $null; try { Add-Type -AssemblyName System.IO.Compression.FileSystem; $archive = [IO.Compression.ZipFile]::OpenRead($env:QWEN_ARCHIVE_FILE); if ($archive.Entries.Count -eq 0) { [Console]::Error.WriteLine('Archive is empty: ' + $env:QWEN_ARCHIVE_FILE); exit 3 }; foreach ($entry in $archive.Entries) { $raw = $entry.FullName; if ($raw.IndexOfAny([char[]](10,13)) -ge 0) { [Console]::Error.WriteLine('Archive contains unsafe path with control character: ' + $raw); exit 1 }; $name = $raw -replace '\\', '/'; while ($name.StartsWith('./')) { $name = $name.Substring(2) }; if ($name -eq '' -or $name.StartsWith('/') -or $name -match '^[A-Za-z]:' -or $name -match '(^|/)\.\.(/|$)') { [Console]::Error.WriteLine('Archive contains unsafe path: ' + $entry.FullName); exit 1 } } } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 2 } finally { if ($null -ne $archive) { $archive.Dispose() } }" set "PS_STATUS=%ERRORLEVEL%" set "QWEN_ARCHIVE_FILE=" if %PS_STATUS% EQU 0 exit /b 0 @@ -1062,6 +1070,10 @@ if %PS_STATUS% EQU 2 ( echo ERROR: Archive could not be inspected before extraction. exit /b 1 ) +if %PS_STATUS% EQU 3 ( + echo ERROR: Archive is empty: %~1 + exit /b 1 +) echo ERROR: Archive validation failed before extraction. exit /b %PS_STATUS% @@ -1113,8 +1125,7 @@ rem Back it up so the user doesn't lose data, then proceed. for /f "delims=" %%t in ('powershell -NoProfile -Command "Get-Date -Format yyyyMMddTHHmmss"') do set "BACKUP_TIMESTAMP=%%t" set "BACKUP_DIR=!MANAGED_DIR!.backup.!BACKUP_TIMESTAMP!" if "!BACKUP_TIMESTAMP!"=="" set "BACKUP_DIR=!MANAGED_DIR!.backup" -echo WARNING: !MANAGED_DIR! exists but is not a Qwen Code standalone install. -echo WARNING: Backing up to !BACKUP_DIR! +rem Silently back up existing directory move /Y "!MANAGED_DIR!" "!BACKUP_DIR!" >nul if !ERRORLEVEL! NEQ 0 ( echo ERROR: Failed to back up !MANAGED_DIR!. Move or remove it manually, then rerun the installer. @@ -1153,19 +1164,18 @@ if %NODE_MAJOR_NUM% LSS 22 ( exit /b 1 ) -echo SUCCESS: Node.js %NODE_VERSION% detected. +REM Node.js %NODE_VERSION% detected. exit /b 0 :RequireNpm where npm >nul 2>&1 if %ERRORLEVEL% NEQ 0 ( - echo ERROR: npm was not found. - echo Please install Node.js with npm included, then rerun this installer. + echo ERROR: npm was not found. Install Node.js with npm from https://nodejs.org/ exit /b 1 ) for /f "delims=" %%i in ('npm -v 2^>nul') do set "NPM_VERSION=%%i" -echo SUCCESS: npm %NPM_VERSION% detected. +REM npm %NPM_VERSION% detected. exit /b 0 :NpmPackageSpec @@ -1185,29 +1195,11 @@ if %ERRORLEVEL% NEQ 0 exit /b 1 call :NpmPackageSpec -where qwen >nul 2>&1 -if %ERRORLEVEL% EQU 0 ( - for /f "delims=" %%i in ('qwen --version 2^>nul') do set "QWEN_VERSION=%%i" - echo INFO: Existing Qwen Code detected: !QWEN_VERSION! - if /i "!VERSION!"=="latest" ( - echo INFO: Upgrading to the latest version. - ) else ( - echo INFO: Installing requested version !VERSION!. - ) -) - -echo INFO: Running: npm install -g !NPM_PACKAGE_SPEC! --registry !NPM_REGISTRY! call npm install -g !NPM_PACKAGE_SPEC! --registry "!NPM_REGISTRY!" if %ERRORLEVEL% NEQ 0 ( - echo ERROR: Failed to install Qwen Code. - echo. - echo This installer does not change your npm prefix or PATH. - echo If the failure is a permission error, fix your npm global package directory, then run: - echo npm install -g !NPM_PACKAGE_SPEC! --registry !NPM_REGISTRY! + echo ERROR: Failed to install. Try: npm install -g !NPM_PACKAGE_SPEC! --registry !NPM_REGISTRY! exit /b 1 ) - -echo SUCCESS: Qwen Code installed successfully. call :CreateSourceJson exit /b 0 @@ -1224,7 +1216,6 @@ echo "source": "!SOURCE!" echo } ) > "!QWEN_DIR!\source.json" -echo SUCCESS: Installation source saved to !USERPROFILE!\.qwen\source.json exit /b 0 :PrintFinalInstructions @@ -1232,6 +1223,7 @@ set "EXTRA_BIN=%~1" set "SUMMARY_INSTALL_DIR=%~2" set "SUMMARY_INSTALL_METHOD=%~3" set "STANDALONE_UNINSTALL_URL=https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.ps1" +set "PATH_UPDATE_APPLIED=0" if "!SUMMARY_INSTALL_METHOD!"=="" set "SUMMARY_INSTALL_METHOD=standalone" set "INSTALLED_BIN=" @@ -1247,77 +1239,27 @@ if not "!INSTALLED_BIN!"=="" if exist "!INSTALLED_BIN!" ( for /f "delims=" %%i in ('"!INSTALLED_BIN!" --version 2^>nul') do set "INSTALLED_VERSION=%%i" ) -echo QWEN CODE -echo. -echo Qwen Code !INSTALLED_VERSION! installed successfully. -echo. -echo To start: -echo cd ^ -echo qwen - -if not "!SUMMARY_INSTALL_DIR!"=="" ( - echo. - echo Installed to: - echo !SUMMARY_INSTALL_DIR! -) - -echo. -echo Uninstall: -if /i "!SUMMARY_INSTALL_METHOD!"=="npm" ( - echo npm uninstall -g @qwen-code/qwen-code -) else ( - if not "!SUMMARY_INSTALL_DIR!"=="" ( - if not "!EXTRA_BIN!"=="" ( - echo set "QWEN_INSTALL_LIB_DIR=!SUMMARY_INSTALL_DIR!" ^&^& set "QWEN_INSTALL_BIN_DIR=!EXTRA_BIN!" ^&^& powershell -ExecutionPolicy Bypass -c "irm !STANDALONE_UNINSTALL_URL! ^| iex" - ) else ( - echo powershell -ExecutionPolicy Bypass -c "irm !STANDALONE_UNINSTALL_URL! ^| iex" - ) - ) else ( - echo powershell -ExecutionPolicy Bypass -c "irm !STANDALONE_UNINSTALL_URL! ^| iex" - ) -) - -rem Build OTHER_QWENS = PRE_INSTALL_QWENS_LIST minus the install we just made. -set "OTHER_QWENS=" -if defined PRE_INSTALL_QWENS_LIST ( - for %%i in ("!PRE_INSTALL_QWENS_LIST:|=" "!") do ( - set "ENTRY=%%~i" - if not "!ENTRY!"=="" if /i not "!ENTRY!"=="!INSTALLED_BIN!" ( - if "!OTHER_QWENS!"=="" ( - set "OTHER_QWENS=!ENTRY!" - ) else ( - set "OTHER_QWENS=!OTHER_QWENS!|!ENTRY!" - ) - ) - ) -) - rem Persist the install bin to user PATH unless --no-modify-path is set. if not "!EXTRA_BIN!"=="" if /i not "!NO_MODIFY_PATH!"=="1" ( call :MaybeUpdateUserPath "!EXTRA_BIN!" if !ERRORLEVEL! NEQ 0 ( echo WARNING: Failed to update user PATH. Add the directory manually: echo !EXTRA_BIN! + ) else ( + set "PATH_UPDATE_APPLIED=1" ) ) -if defined OTHER_QWENS ( - echo. - echo WARNING: Other 'qwen' executables exist on this system. Depending on - echo WARNING: your PATH order, one of these may run instead of the install above: - for %%i in ("!OTHER_QWENS:|=" "!") do ( - set "OQ=%%~i" - if not "!OQ!"=="" echo WARNING: !OQ! - ) - echo. - echo To make this install take priority, restart your command prompt. - echo Or invoke directly: "!INSTALLED_BIN!" - exit /b 0 -) +echo. +echo Qwen Code !INSTALLED_VERSION! installed successfully, to start: +echo. +echo cd ^ +echo qwen +echo. +echo For more information visit https://qwenlm.github.io/qwen-code if /i "!QWEN_INSTALLER_PARENT_POWERSHELL!"=="1" ( - echo INFO: Final PATH refresh is handled by the PowerShell entrypoint. + REM Final PATH refresh is handled by the PowerShell entrypoint. exit /b 0 ) -echo qwen is ready to use in this terminal. exit /b 0 diff --git a/scripts/installation/install-qwen-standalone.ps1 b/scripts/installation/install-qwen-standalone.ps1 index 430547a444..556d680bee 100644 --- a/scripts/installation/install-qwen-standalone.ps1 +++ b/scripts/installation/install-qwen-standalone.ps1 @@ -279,37 +279,24 @@ function Update-CurrentShell { } if ($env:QWEN_NO_MODIFY_PATH -eq '1') { - Write-Output "Run: ${qwenCommandPath}" - Write-Output "INFO: QWEN_NO_MODIFY_PATH=1; skipping current-session PATH refresh." return } $inheritedPath = $env:Path Update-CurrentSessionPath -BinDir $qwenInstallBinDir - Write-Output "Run: qwen" $parentProcessName = Get-ParentProcessName if ($parentProcessName -ieq 'cmd.exe') { if (Test-PathContainsDirectory -PathValue $inheritedPath -Directory $qwenInstallBinDir) { - Write-Output "qwen is ready to use after this installer command returns." return } $shimPath = Install-CurrentCmdPathShim -QwenCommand $qwenCommandPath -PathValue $inheritedPath if (-not [string]::IsNullOrEmpty($shimPath)) { - Write-Output "INFO: Added qwen.cmd to a directory already on this cmd.exe PATH:" - Write-Output "INFO: ${shimPath}" - Write-Output "qwen is ready to use after this installer command returns." return } - - Write-Output "WARNING: Windows does not allow this PowerShell child process to update the parent cmd.exe PATH directly." - Write-Output "Or, for this cmd.exe window, run:" - Write-Output " set `"PATH=${qwenInstallBinDir};%PATH%`"" return } - - Write-Output "qwen is ready to use in this PowerShell session." } $qwenDefaultInstallerUrl = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.bat' diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index 3dc8d5c668..37949b74b8 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -29,7 +29,21 @@ RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' +MUTED='\033[0;2m' NC='\033[0m' +BRAND_ORANGE='\033[38;5;214m' + +supports_truecolor() { + [[ "${COLORTERM:-}" == "truecolor" || "${COLORTERM:-}" == "24bit" ]] +} + +if supports_truecolor; then + BRAND_BLUE='\033[38;2;71;150;228m' + BRAND_PURPLE='\033[38;2;132;122;206m' +else + BRAND_BLUE='\033[38;5;68m' + BRAND_PURPLE='\033[38;5;140m' +fi log_info() { printf '%bINFO:%b %s\n' "${BLUE}" "${NC}" "$1" @@ -51,7 +65,34 @@ command_exists() { command -v "$1" >/dev/null 2>&1 } +is_terminal() { + [ -t 2 ] +} + +print_progress() { + local bytes="$1" + local length="$2" + [ "$length" -gt 0 ] || return 0 + local width=50 + local percent=$(( bytes * 100 / length )) + [ "$percent" -gt 100 ] && percent=100 + local on=$(( percent * width / 100 )) + local off=$(( width - on )) + local filled=$(printf "%*s" "$on" "") + filled=${filled// /■} + local empty=$(printf "%*s" "$off" "") + empty=${empty// /・} + printf "\r${BRAND_ORANGE}%s%s %3d%%${NC}" "$filled" "$empty" "$percent" >&2 +} + +finish_progress() { + print_progress 1 1 + echo "" >&2 +} + TEMP_DIRS=() +ACTIVE_DOWNLOAD_PID="" +PATH_UPDATE_APPLIED=0 cleanup_temp_dirs() { local temp_dir @@ -67,6 +108,17 @@ register_temp_dir() { TEMP_DIRS+=("${temp_dir}") } +restore_cursor() { + printf "\033[?25h" +} + +kill_active_download() { + if [[ -n "${ACTIVE_DOWNLOAD_PID}" ]]; then + kill "${ACTIVE_DOWNLOAD_PID}" 2>/dev/null || true + ACTIVE_DOWNLOAD_PID="" + fi +} + shell_quote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" } @@ -81,8 +133,8 @@ display_install_version() { } trap cleanup_temp_dirs EXIT -trap 'cleanup_temp_dirs; exit 130' INT -trap 'cleanup_temp_dirs; exit 143' TERM +trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 130' INT +trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 143' TERM print_usage() { cat </dev/null || echo unknown)" in - Darwin) - echo " brew install node" - echo " # or download from https://nodejs.org/" - ;; - Linux) - echo " # Use your distribution package manager or:" - echo " https://nodejs.org/en/download/package-manager" - ;; - *) - echo " https://nodejs.org/" - ;; - esac - echo "" - echo "If you already use a Node version manager, activate Node.js 22+" - echo "in this shell before rerunning the installer." + echo "Node.js 22 or newer is required. Install from https://nodejs.org/ then rerun." + echo " brew install node" } require_node() { @@ -367,21 +396,14 @@ require_node() { print_node_help return 1 fi - - log_success "Node.js ${node_version} detected." } require_npm() { if command_exists npm; then - log_success "npm $(npm -v 2>/dev/null || echo unknown) detected." return 0 fi - log_error "npm was not found." - echo "" - echo "Please install Node.js with npm included, then rerun this installer." - echo "Download Node.js from https://nodejs.org/ if your package manager" - echo "installed Node without npm." + log_error "npm was not found. Install Node.js with npm from https://nodejs.org/" return 1 } @@ -423,8 +445,6 @@ create_source_json() { "source": "${escaped_source}" } EOF - - log_success "Installation source saved to ~/.qwen/source.json" } detect_target() { @@ -531,8 +551,12 @@ maybe_update_shell_path() { fi if [[ -f "${rc_file}" ]] && grep -qxF "${export_line}" "${rc_file}" 2>/dev/null; then - log_info "PATH update for ${install_bin_dir} already present in ${rc_file} (skipping)." - return 0 + local current_tail + current_tail=$(tail -n 3 "${rc_file}" 2>/dev/null || true) + if [[ "${current_tail}" == "${begin_marker}"$'\n'"${export_line}"$'\n'"${end_marker}" ]]; then + PATH_UPDATE_APPLIED=1 + return 0 + fi fi mkdir -p "$(dirname "${rc_file}")" 2>/dev/null || true @@ -546,8 +570,7 @@ maybe_update_shell_path() { return 0 } - log_success "Appended PATH prepend to ${rc_file}" - log_info "Open a new terminal, or run: source ${rc_file}" + PATH_UPDATE_APPLIED=1 } github_base_url_for_version() { @@ -639,7 +662,7 @@ resolve_aliyun_version_path() { return 1 fi - log_info "Resolved Aliyun latest to ${resolved_version_path}." >&2 + : # resolved to ${resolved_version_path} echo "${resolved_version_path}" } @@ -733,10 +756,7 @@ standalone_base_url() { fi selected=$(race_mirror_head 2 "${gh_head}" "${oss_head}") if [[ "${selected}" == "timeout" ]]; then - log_info "Mirror auto-selection timed out; defaulting to github." >&2 selected="github" - else - log_info "Mirror auto-selected via HEAD probe: ${selected}" >&2 fi MIRROR="${selected}" fi @@ -752,7 +772,66 @@ standalone_base_url() { github_base_url_for_version "${version_path}" } -download_file() { +get_content_length() { + local url="$1" + curl -fsSLI --retry 1 --connect-timeout 10 --max-time 15 "${url}" 2>/dev/null \ + | grep -i '^content-length:' | tail -1 | tr -d '\r' | awk '{print $2}' +} + +download_with_progress() { + local url="$1" + local output="$2" + + if ! command_exists curl || ! is_terminal; then + download_file_simple "$url" "$output" + return $? + fi + + local content_length + content_length=$(get_content_length "${url}") + + if [[ -z "${content_length}" ]] || [[ "${content_length}" -le 0 ]] 2>/dev/null; then + download_file_simple "$url" "$output" + return $? + fi + + # Skip progress bar for small files (e.g. SHA256SUMS) + if [[ "${content_length}" -lt 102400 ]] 2>/dev/null; then + curl -fsSL --retry 2 --connect-timeout 15 --max-time 300 "${url}" -o "${output}" + return $? + fi + + printf "\033[?25l" >&2 + print_progress 0 "${content_length}" + + curl -fsSL --retry 2 --connect-timeout 15 --max-time 300 "${url}" -o "${output}" & + ACTIVE_DOWNLOAD_PID=$! + + while kill -0 "${ACTIVE_DOWNLOAD_PID}" 2>/dev/null; do + if [[ -f "${output}" ]]; then + local file_size + file_size=$(wc -c < "${output}" 2>/dev/null | tr -d ' ') + if [[ -n "${file_size}" && "${file_size}" -gt 0 ]] 2>/dev/null; then + print_progress "${file_size}" "${content_length}" + fi + fi + sleep 1 + done + + wait "${ACTIVE_DOWNLOAD_PID}" + local exit_code=$? + ACTIVE_DOWNLOAD_PID="" + printf "\033[?25h" >&2 + + if [[ $exit_code -eq 0 ]]; then + finish_progress + else + echo "" >&2 + fi + return $exit_code +} + +download_file_simple() { local url="$1" local destination="$2" @@ -767,17 +846,33 @@ download_file() { wget_args+=(--read-timeout=300) fi if wget --help 2>&1 | grep -q -- '--progress'; then - wget --progress=bar:force:noscroll "${wget_args[@]}" "${url}" -O "${destination}" || return 1 + wget --progress=bar:force:noscroll "${wget_args[@]}" "${url}" -O "${destination}" & + ACTIVE_DOWNLOAD_PID=$! + wait "${ACTIVE_DOWNLOAD_PID}" + local exit_code=$? + ACTIVE_DOWNLOAD_PID="" + return "${exit_code}" else - wget "${wget_args[@]}" "${url}" -O "${destination}" || return 1 + wget "${wget_args[@]}" "${url}" -O "${destination}" & + ACTIVE_DOWNLOAD_PID=$! + wait "${ACTIVE_DOWNLOAD_PID}" + local exit_code=$? + ACTIVE_DOWNLOAD_PID="" + return "${exit_code}" fi - return $? fi log_error "curl or wget is required to download the standalone archive." return 1 } +download_file() { + local url="$1" + local destination="$2" + + download_with_progress "${url}" "${destination}" +} + url_exists() { local url="$1" @@ -855,8 +950,6 @@ verify_checksum() { log_error "Checksum mismatch for ${archive_name}: expected ${expected}, got ${actual}." return 1 fi - - log_success "Checksum verified for ${archive_name}." } validate_archive_entry_path() { @@ -884,6 +977,22 @@ validate_archive_entry_path() { esac } +archive_contains_symlinks_or_hardlinks() { + local archive_path="$1" + + case "${archive_path}" in + *.zip) + unzip -Z -v "${archive_path}" 2>/dev/null | grep -E 'Unix file attributes \(12[0-7]{4} octal\)' >/dev/null + ;; + *.tar.gz|*.tgz|*.tar.xz) + tar -tvf "${archive_path}" 2>/dev/null | awk '$1 ~ /^[lh]/ { found=1 } END { exit found ? 0 : 1 }' + ;; + *) + return 1 + ;; + esac +} + validate_archive_contents() { local archive_path="$1" local entries @@ -912,6 +1021,16 @@ validate_archive_contents() { ;; esac + if [[ -z "${entries}" ]]; then + log_error "Archive is empty: ${archive_path}" + return 1 + fi + + if archive_contains_symlinks_or_hardlinks "${archive_path}"; then + log_error "Archive contains symlinks or hardlinks; refusing to install." + return 1 + fi + while IFS= read -r entry; do validate_archive_entry_path "${entry}" || return 1 done <<< "${entries}" @@ -1111,7 +1230,7 @@ install_standalone() { register_temp_dir "${temp_dir}" archive_path="${temp_dir}/${archive_name}" - echo "Downloading ${archive_name}" + log_info "Downloading ${archive_name}" if ! download_file "${archive_url}" "${archive_path}"; then if [[ -n "${github_fallback_base_url}" ]]; then rm -f "${archive_path}" @@ -1120,7 +1239,6 @@ install_standalone() { MIRROR="github" github_fallback_base_url="" log_warning "Aliyun standalone archive download failed; retrying GitHub mirror." - echo "Downloading ${archive_name}" if download_file "${archive_url}" "${archive_path}"; then : else @@ -1147,13 +1265,11 @@ install_standalone() { register_temp_dir "${temp_dir}" fi - # Verify integrity before extraction or changing the install directory. if ! verify_checksum "${archive_path}" "${checksum_source}" "${archive_name}"; then rm -rf "${temp_dir}" return 1 fi - # Extract into a temporary directory, then validate required entry points. local extract_dir="${temp_dir}/extract" if ! extract_archive "${archive_path}" "${extract_dir}"; then rm -rf "${temp_dir}" @@ -1207,6 +1323,9 @@ install_standalone() { return 1 fi + # Suppress INT/TERM during the critical mv swap to avoid leaving + # INSTALL_LIB_DIR absent if the user presses Ctrl+C between the two moves. + trap '' INT TERM if [[ -e "${INSTALL_LIB_DIR}" ]]; then mv "${INSTALL_LIB_DIR}" "${old_install_dir}" fi @@ -1215,10 +1334,14 @@ install_standalone() { if [[ -e "${old_install_dir}" ]]; then mv "${old_install_dir}" "${INSTALL_LIB_DIR}" fi + trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 130' INT + trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 143' TERM rm -rf "${temp_dir}" "${wrapper_tmp}" log_error "Failed to install standalone archive to ${INSTALL_LIB_DIR}." return 1 fi + trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 130' INT + trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 143' TERM if ! mv -f "${wrapper_tmp}" "${INSTALL_BIN_DIR}/qwen"; then rm -rf "${INSTALL_LIB_DIR}" "${wrapper_tmp}" @@ -1235,9 +1358,6 @@ install_standalone() { create_source_json rm -rf "${temp_dir}" - - log_success "Qwen Code standalone archive installed successfully." - log_info "Installed to ${INSTALL_LIB_DIR}" } npm_package_spec() { @@ -1257,17 +1377,6 @@ install_npm() { local package_spec package_spec=$(npm_package_spec) - if command_exists qwen; then - local qwen_version - qwen_version=$(qwen --version 2>/dev/null || echo "unknown") - log_info "Existing Qwen Code detected: ${qwen_version}" - if [[ "${VERSION}" == "latest" ]]; then - log_info "Upgrading to the latest version." - else - log_info "Installing requested version ${VERSION}." - fi - fi - local install_cmd=( npm install @@ -1277,37 +1386,75 @@ install_npm() { "${NPM_REGISTRY}" ) - log_info "Running: npm install -g ${package_spec} --registry ${NPM_REGISTRY}" if "${install_cmd[@]}"; then - log_success "Qwen Code installed successfully." create_source_json return 0 fi - log_error "Failed to install Qwen Code." - echo "" - echo "This installer does not change your npm prefix or shell profile." - echo "If the failure is a permission error, install Node.js with a user-owned" - echo "Node version manager or fix your npm global package directory, then run:" - echo " npm install -g ${package_spec} --registry ${NPM_REGISTRY}" + log_error "Failed to install. Try: npm install -g ${package_spec} --registry ${NPM_REGISTRY}" return 1 } +gradient_line() { + local text="$1" + local r1=$2 g1=$3 b1=$4 + local r2=$5 g2=$6 b2=$7 + local r3=$8 g3=$9 b3=${10} + local len=${#text} + [ "$len" -eq 0 ] && return + if ! supports_truecolor; then + printf "%b%s%b\n" "${BRAND_PURPLE}" "${text}" "${NC}" + return + fi + local i=0 + local half=$(( len / 2 )) + while [ $i -lt $len ]; do + local char="${text:$i:1}" + local r g b + if [ $i -lt $half ]; then + local t=$(( i * 1000 / half )) + r=$(( (r1 * (1000 - t) + r2 * t) / 1000 )) + g=$(( (g1 * (1000 - t) + g2 * t) / 1000 )) + b=$(( (b1 * (1000 - t) + b2 * t) / 1000 )) + else + local t=$(( (i - half) * 1000 / (len - half) )) + r=$(( (r2 * (1000 - t) + r3 * t) / 1000 )) + g=$(( (g2 * (1000 - t) + g3 * t) / 1000 )) + b=$(( (b2 * (1000 - t) + b3 * t) / 1000 )) + fi + if [ "$char" = " " ]; then + printf " " + else + printf "\033[38;2;%d;%d;%dm%s" "$r" "$g" "$b" "$char" + fi + i=$(( i + 1 )) + done + printf "\033[0m\n" +} + +print_logo() { + # Per-character gradient matching CLI's ink-gradient rendering + # Direction: #4796E4 (blue) → #847ACE (purple) → #C3677F (rose) + gradient_line " ▄▄▄▄▄▄ ▄▄ ▄▄ ▄▄▄▄▄▄▄ ▄▄▄ ▄▄" 71 150 228 132 122 206 195 103 127 + gradient_line "██╔═══██╗██║ ██║██╔════╝████╗ ██║" 71 150 228 132 122 206 195 103 127 + gradient_line "██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║" 71 150 228 132 122 206 195 103 127 + gradient_line "██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║" 71 150 228 132 122 206 195 103 127 + gradient_line "╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║" 71 150 228 132 122 206 195 103 127 + gradient_line " ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝" 71 150 228 132 122 206 195 103 127 +} + print_final_instructions() { local install_bin_dir="${1:-}" local install_dir="${2:-}" local install_method="${3:-standalone}" local installed_bin="" - local quoted_install_bin_dir="" local standalone_uninstall_url="https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.sh" if [[ -n "${install_bin_dir}" ]]; then installed_bin="${install_bin_dir}/qwen" - quoted_install_bin_dir=$(shell_quote "${install_bin_dir}") + export PATH="${install_bin_dir}:${PATH}" fi - # PRE_INSTALL_QWENS was captured by main() BEFORE the install ran - # (newline-separated list of every qwen binary found on disk). Filter out - # the one we just installed; whatever remains may shadow this install. + # Detect shadowing qwen executables local other_qwens="" if [[ -n "${PRE_INSTALL_QWENS:-}" ]]; then local saved_ifs="${IFS}" @@ -1325,12 +1472,11 @@ print_final_instructions() { IFS="${saved_ifs}" fi - if [[ -n "${install_bin_dir}" ]]; then - export PATH="${install_bin_dir}:${PATH}" + if [[ -n "${install_bin_dir}" && "${NO_MODIFY_PATH:-0}" != "1" ]]; then + PATH_UPDATE_APPLIED=0 + maybe_update_shell_path "${install_bin_dir}" fi - echo "" - local installed_version="unknown" if [[ -n "${installed_bin}" && -x "${installed_bin}" ]]; then installed_version=$("${installed_bin}" --version 2>/dev/null || echo "unknown") @@ -1338,53 +1484,24 @@ print_final_instructions() { installed_version=$(qwen --version 2>/dev/null || echo "unknown") fi - echo "QWEN CODE" - echo "" - echo "Qwen Code ${installed_version} installed successfully." - echo "" - echo "To start:" - echo " cd " - echo " qwen" - - if [[ -n "${install_dir}" ]]; then - echo "" - echo "Installed to:" - echo " ${install_dir}" + local rc_name="" + case "${SHELL:-}" in + */zsh) rc_name="~/.zshrc" ;; + */bash) rc_name="~/.bashrc" ;; + */fish) rc_name="~/.config/fish/config.fish" ;; + esac + if [[ "${PATH_UPDATE_APPLIED:-0}" == "1" && -n "${rc_name}" ]]; then + echo -e "${MUTED}Successfully added${NC} qwen ${MUTED}to \$PATH in${NC} ${rc_name}" fi echo "" - echo "Uninstall:" - if [[ "${install_method}" == "npm" ]]; then - echo " npm uninstall -g @qwen-code/qwen-code" - elif [[ -n "${install_dir}" && -n "${install_bin_dir}" ]]; then - echo " curl -fsSL ${standalone_uninstall_url} | QWEN_INSTALL_LIB_DIR=$(shell_quote "${install_dir}") QWEN_INSTALL_BIN_DIR=$(shell_quote "${install_bin_dir}") bash" - else - echo " curl -fsSL ${standalone_uninstall_url} | bash" - fi - - if [[ -n "${install_bin_dir}" && "${NO_MODIFY_PATH:-0}" != "1" ]]; then - maybe_update_shell_path "${install_bin_dir}" - fi - - if [[ -n "${other_qwens}" ]]; then - echo "" - log_warning "Other 'qwen' executables exist on this system. Depending on your" - log_warning "shell PATH order, one of these may run instead of the install above:" - local saved_ifs="${IFS}" - IFS=$'\n' - local path - for path in ${other_qwens}; do - [[ -z "${path}" ]] && continue - log_warning " ${path}" - done - IFS="${saved_ifs}" - echo "" - echo "To make this install take priority, restart your terminal." - echo "Or invoke directly: ${installed_bin}" - return 0 - fi - - echo "(Open a new terminal for the PATH change to take effect.)" + echo -e "${MUTED}Qwen Code ${installed_version} installed successfully, to start:${NC}" + echo "" + echo -e "cd ${MUTED}# Open directory${NC}" + echo -e "qwen ${MUTED}# Run command${NC}" + echo "" + echo -e "${MUTED}For more information visit ${NC}https://qwenlm.github.io/qwen-code" + echo "" } main() { @@ -1441,7 +1558,6 @@ main() { print_final_instructions "$(get_npm_global_bin)" "$(get_npm_global_root)" "npm" ;; detect) - # Try the standalone archive first; fall back only when unavailable. if install_standalone; then print_final_instructions "${INSTALL_BIN_DIR}" "${INSTALL_LIB_DIR}" "standalone" else @@ -1451,12 +1567,11 @@ main() { if install_npm; then print_final_instructions "$(get_npm_global_bin)" "$(get_npm_global_root)" "npm" else - log_warning "Standalone archive was unavailable before npm fallback; npm fallback also failed." - log_warning "Retry with --method standalone to debug the standalone failure, or install Node.js 22+ and rerun --method npm." + log_error "Standalone archive was unavailable; npm fallback also failed." exit 1 fi else - log_warning "Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug the standalone failure." + log_error "Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug." exit "${standalone_status}" fi fi diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index 246ffc5049..46c794f28c 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -306,8 +306,11 @@ exit /b 1 :ValidateVersion if /i "!VERSION!"=="latest" exit /b 0 -echo(!VERSION!| findstr /R /C:"^v*[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[A-Za-z0-9.-]*$" >nul -if %ERRORLEVEL% EQU 0 exit /b 0 +set "QWEN_VERSION_VALUE=!VERSION!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$value = $env:QWEN_VERSION_VALUE; if ($value -match '^v?[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$') { exit 0 }; exit 1" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_VERSION_VALUE=" +if %PS_STATUS% EQU 0 exit /b 0 echo ERROR: --version must be 'latest' or a semver string. exit /b 1 @@ -632,7 +635,7 @@ set "QWEN_ARCHIVE_FILE=%~1" REM Enumerate archive entries and reject any with path traversal indicators: REM empty names, leading '/', drive-rooted paths, '..' segments, or control chars. REM This prevents Zip Slip attacks before extraction. -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $archive = $null; try { Add-Type -AssemblyName System.IO.Compression.FileSystem; $archive = [IO.Compression.ZipFile]::OpenRead($env:QWEN_ARCHIVE_FILE); foreach ($entry in $archive.Entries) { $raw = $entry.FullName; if ($raw.IndexOfAny([char[]](10,13)) -ge 0) { [Console]::Error.WriteLine('Archive contains unsafe path with control character: ' + $raw); exit 1 }; $name = $raw -replace '\\', '/'; while ($name.StartsWith('./')) { $name = $name.Substring(2) }; if ($name -eq '' -or $name.StartsWith('/') -or $name -match '^[A-Za-z]:' -or $name -match '(^|/)\.\.(/|$)') { [Console]::Error.WriteLine('Archive contains unsafe path: ' + $entry.FullName); exit 1 } } } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 2 } finally { if ($null -ne $archive) { $archive.Dispose() } }" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $archive = $null; try { Add-Type -AssemblyName System.IO.Compression.FileSystem; $archive = [IO.Compression.ZipFile]::OpenRead($env:QWEN_ARCHIVE_FILE); if ($archive.Entries.Count -eq 0) { [Console]::Error.WriteLine('Archive is empty: ' + $env:QWEN_ARCHIVE_FILE); exit 3 }; foreach ($entry in $archive.Entries) { $raw = $entry.FullName; if ($raw.IndexOfAny([char[]](10,13)) -ge 0) { [Console]::Error.WriteLine('Archive contains unsafe path with control character: ' + $raw); exit 1 }; $name = $raw -replace '\\', '/'; while ($name.StartsWith('./')) { $name = $name.Substring(2) }; if ($name -eq '' -or $name.StartsWith('/') -or $name -match '^[A-Za-z]:' -or $name -match '(^|/)\.\.(/|$)') { [Console]::Error.WriteLine('Archive contains unsafe path: ' + $entry.FullName); exit 1 } } } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 2 } finally { if ($null -ne $archive) { $archive.Dispose() } }" set "PS_STATUS=%ERRORLEVEL%" set "QWEN_ARCHIVE_FILE=" if %PS_STATUS% EQU 0 exit /b 0 @@ -644,6 +647,10 @@ if %PS_STATUS% EQU 2 ( echo ERROR: Archive could not be inspected before extraction. exit /b 1 ) +if %PS_STATUS% EQU 3 ( + echo ERROR: Archive is empty: %~1 + exit /b 1 +) echo ERROR: Archive validation failed before extraction. exit /b %PS_STATUS% diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 1d8c5d7d75..f07dea9d43 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -624,6 +624,22 @@ validate_archive_entry_path() { esac } +archive_contains_symlinks_or_hardlinks() { + local archive_path="$1" + + case "${archive_path}" in + *.zip) + unzip -Z -v "${archive_path}" 2>/dev/null | grep -E 'Unix file attributes \(12[0-7]{4} octal\)' >/dev/null + ;; + *.tar.gz|*.tgz|*.tar.xz) + tar -tvf "${archive_path}" 2>/dev/null | awk '$1 ~ /^[lh]/ { found=1 } END { exit found ? 0 : 1 }' + ;; + *) + return 1 + ;; + esac +} + validate_archive_contents() { local archive_path="$1" local entries @@ -652,6 +668,16 @@ validate_archive_contents() { ;; esac + if [[ -z "${entries}" ]]; then + log_error "Archive is empty: ${archive_path}" + return 1 + fi + + if archive_contains_symlinks_or_hardlinks "${archive_path}"; then + log_error "Archive contains symlinks or hardlinks; refusing to install." + return 1 + fi + while IFS= read -r entry; do validate_archive_entry_path "${entry}" || return 1 done <<< "${entries}" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index c4153dd7a9..bde60b51fa 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -70,13 +70,11 @@ describe('installation scripts', () => { expect(script).toContain('npm_package_spec()'); expect(script).toContain('@qwen-code/qwen-code@latest'); expect(script).toContain('Installing Qwen Code version:'); - expect(script).toContain('QWEN CODE'); - expect(script).toContain( - 'Qwen Code ${installed_version} installed successfully.', - ); - expect(script).toContain('To start:'); - expect(script).toContain('Installed to:'); - expect(script).toContain('Uninstall:'); + expect(script).toContain('print_logo'); + expect(script).toContain('supports_truecolor()'); + expect(script).toContain('COLORTERM'); + expect(script).toContain('installed successfully, to start:'); + expect(script).toContain('cd '); expect(script).toContain('uninstall-qwen-standalone.sh'); expect(script).not.toContain('rm -rf $(shell_quote "${install_dir}")'); }); @@ -126,7 +124,9 @@ describe('installation scripts', () => { expect(script).toContain('validate_install_path'); expect(script).toContain('validate_https_url "${NPM_REGISTRY}"'); expect(script).toContain('qwen-code/node/bin/node'); - expect(script).toContain('Archive contains symlinks; refusing to install'); + expect(script).toContain( + 'Archive contains symlinks or hardlinks; refusing to install', + ); expect(script).toContain('not a Qwen Code standalone install'); expect(script).toContain( 'Return 2 only when a standalone archive is unavailable', @@ -141,6 +141,9 @@ describe('installation scripts', () => { expect(script).toContain( 'curl -fL --retry 2 --connect-timeout 15 --max-time 300 --progress-bar "${url}" -o "${destination}"', ); + expect(script).not.toContain('--trace-ascii'); + expect(script).not.toContain('mkfifo'); + expect(script).not.toContain('qwen_install_$$'); expect(script).toContain( 'curl -fsSL --retry 2 --connect-timeout 10 --max-time 30 "${url}"', ); @@ -148,12 +151,18 @@ describe('installation scripts', () => { expect(script).toContain( 'wget --progress=bar:force:noscroll "${wget_args[@]}" "${url}" -O "${destination}"', ); + expect(script).toMatch( + /wget --progress=bar:force:noscroll "\$\{wget_args\[@\]\}" "\$\{url\}" -O "\$\{destination\}" &[\s\S]{0,120}ACTIVE_DOWNLOAD_PID=\$!/, + ); + expect(script).toMatch( + /wget "\$\{wget_args\[@\]\}" "\$\{url\}" -O "\$\{destination\}" &[\s\S]{0,120}ACTIVE_DOWNLOAD_PID=\$!/, + ); expect(script).toContain('wget_args+=(--read-timeout=300)'); expect(script).toContain( 'curl -fsL --retry 1 --connect-timeout 10 --max-time "${timeout}"', ); expect(script).toContain('wget_args+=(--read-timeout=30)'); - expect(script).toContain('echo "Downloading ${archive_name}"'); + expect(script).toContain('Downloading ${archive_name}'); expect(script).not.toContain( 'curl -fsSL --retry 2 "${url}" -o "${destination}"', ); @@ -179,6 +188,11 @@ describe('installation scripts', () => { expect(script).toContain( 'restore_stale_install_backup "${old_install_dir}" "${INSTALL_LIB_DIR}"', ); + expect(script).toContain('ACTIVE_DOWNLOAD_PID=""'); + expect(script).toContain('restore_cursor >&2'); + expect(script).toContain( + 'kill "${ACTIVE_DOWNLOAD_PID}" 2>/dev/null || true', + ); expect(script).not.toContain( 'rm -rf "${new_install_dir}" "${old_install_dir}" "${wrapper_tmp}"', ); @@ -219,11 +233,9 @@ describe('installation scripts', () => { expect(script).toContain('Installing Qwen Code version:'); expect(script).toContain('QWEN CODE'); expect(script).toContain( - 'Qwen Code !INSTALLED_VERSION! installed successfully.', + 'Qwen Code !INSTALLED_VERSION! installed successfully, to start:', ); - expect(script).toContain('To start:'); - expect(script).toContain('Installed to:'); - expect(script).toContain('Uninstall:'); + expect(script).toContain('cd ^'); expect(script).toContain('uninstall-qwen-standalone.ps1'); expect(script).toContain('QWEN_VERSION_POINTER_FILE'); expect(script).toContain('QWEN_NORMALIZED_VERSION_FILE'); @@ -234,9 +246,14 @@ describe('installation scripts', () => { expect(script).toContain( '[IO.File]::WriteAllText($env:QWEN_NORMALIZED_VERSION_FILE', ); + expect(script).toContain('set "QWEN_VERSION_VALUE=!VERSION!"'); + expect(script).toContain( + "$value -match '^v?[0-9]+\\.[0-9]+\\.[0-9]+([.-][A-Za-z0-9]+)*$'", + ); expect(script).not.toContain( - 'findstr /R /C:"^[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*$"', + 'findstr /R /C:"^[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*[A-Za-z0-9.-]*$"', ); + expect(script).not.toContain('[A-Za-z0-9][A-Za-z0-9.-]*$'); expect(script).not.toContain('rmdir /S /Q "!SUMMARY_INSTALL_DIR!"'); expect(script).not.toContain('del /F /Q "!INSTALLED_BIN!"'); }); @@ -313,6 +330,7 @@ describe('installation scripts', () => { expect(script).toContain('Falling back to npm installation'); expect(script).toContain('set "STANDALONE_STATUS=!ERRORLEVEL!"'); expect(script).toContain('if !STANDALONE_STATUS! EQU 2'); + expect(script).toContain('Archive is empty: %~1'); expect(script).toContain('set "ARG_KEY=%~1"'); expect(script).toContain('set "ARG_HAS_INLINE_VALUE=0"'); expect(script).toContain('if "!ARG_HAS_INLINE_VALUE!"=="1"'); @@ -329,6 +347,7 @@ describe('installation scripts', () => { expect(script).toContain('Archive contains symlinks or reparse points'); expect(script).toContain('unsafe path with control character'); expect(script).toContain('Failed to update user PATH'); + expect(script).toContain('PRE_INSTALL_QWENS_LIST'); expect(script).toContain('QWEN_INSTALL_ROOT'); expect(script).toContain('npm fallback also failed'); expect(script).toContain('echo Downloading !ARCHIVE_NAME!'); @@ -838,19 +857,8 @@ describe('standalone release packaging', () => { expect(installPowerShellSource).not.toContain( "$preferredDirectories += Join-Path $env:LOCALAPPDATA 'Microsoft\\WindowsApps'", ); - expect(installPowerShellSource).toContain( - 'QWEN_NO_MODIFY_PATH=1; skipping current-session PATH refresh.', - ); + expect(installPowerShellSource).toContain('QWEN_NO_MODIFY_PATH'); expect(installPowerShellSource).not.toContain('doskey.exe'); - expect(installPowerShellSource).toContain( - 'qwen is ready to use in this PowerShell session.', - ); - expect(installPowerShellSource).toContain( - 'Added qwen.cmd to a directory already on this cmd.exe PATH:', - ); - expect(installPowerShellSource).toContain( - 'Windows does not allow this PowerShell child process to update the parent cmd.exe PATH directly.', - ); expect(installBatchSource).toContain('QWEN_INSTALLER_PARENT_POWERSHELL'); expect(installBatchSource).toContain( @@ -1206,6 +1214,67 @@ describe('standalone release packaging', () => { } }); + it('rejects unexpected files and non-file release assets', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseDirectory } = + await import(installationReleaseVerificationScriptUrl); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-verify-')); + + try { + writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); + writeFileSync(path.join(tmpDir, '.DS_Store'), ''); + await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( + /Unexpected file\(s\) in release directory: \.DS_Store/, + ); + + rmSync(path.join(tmpDir, '.DS_Store')); + rmSync(path.join(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES[0])); + mkdirSync(path.join(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES[0])); + await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( + /Release asset is not a regular file: qwen-code-/, + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + itOnUnix('rejects symlinked release assets and checksum files', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseDirectory } = + await import(installationReleaseVerificationScriptUrl); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-verify-')); + let linkedAsset = ''; + let linkedChecksums = ''; + + try { + writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); + const assetName = EXPECTED_STANDALONE_ARCHIVE_NAMES[0]; + const assetPath = path.join(tmpDir, assetName); + linkedAsset = path.join(tmpDir, '..', `${assetName}.linked`); + writeFileSync(linkedAsset, `${assetName}\n`); + rmSync(assetPath); + symlinkSync(linkedAsset, assetPath); + + await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( + /Release asset is not a regular file: qwen-code-/, + ); + + rmSync(assetPath); + writeFileSync(assetPath, `${assetName}\n`); + const checksumPath = path.join(tmpDir, 'SHA256SUMS'); + linkedChecksums = path.join(tmpDir, '..', 'SHA256SUMS.linked'); + writeFileSync(linkedChecksums, readScript(checksumPath)); + rmSync(checksumPath); + symlinkSync(linkedChecksums, checksumPath); + + await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( + /SHA256SUMS is not a regular file/, + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (linkedAsset) rmSync(linkedAsset, { force: true }); + if (linkedChecksums) rmSync(linkedChecksums, { force: true }); + } + }); + it('verifies release asset URLs from SHA256SUMS', async () => { const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = await import(installationReleaseVerificationScriptUrl); @@ -1291,6 +1360,83 @@ describe('standalone release packaging', () => { ).rejects.toThrow(/--base-url must use https/); }); + it('does not follow remote release redirects', async () => { + const { verifyReleaseBaseUrl } = await import( + installationReleaseVerificationScriptUrl + ); + const fetchedOptions = []; + + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async (_url, options = {}) => { + fetchedOptions.push(options); + return new Response(null, { + status: 302, + headers: { Location: 'https://169.254.169.254/latest/meta-data/' }, + }); + }, + }), + ).rejects.toThrow(/Redirect responses are not allowed/); + + expect(fetchedOptions).toHaveLength(1); + expect(fetchedOptions[0].redirect).toBe('manual'); + }); + + it('does not follow remote archive body redirects', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = + await import(installationReleaseVerificationScriptUrl); + const checksumContent = placeholderChecksumContent( + EXPECTED_STANDALONE_ARCHIVE_NAMES, + ); + const redirectedAsset = EXPECTED_STANDALONE_ARCHIVE_NAMES[0]; + + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async (url) => { + if (url.endsWith('/SHA256SUMS')) { + return new Response(checksumContent); + } + if (url.endsWith(`/${redirectedAsset}`)) { + return new Response(null, { + status: 302, + headers: { + Location: 'https://169.254.169.254/latest/meta-data/', + }, + }); + } + const assetName = EXPECTED_STANDALONE_ARCHIVE_NAMES.find((name) => + url.endsWith(`/${name}`), + ); + return new Response(`${assetName}\n`); + }, + }), + ).rejects.toThrow(/Redirect responses are not allowed/); + }); + + it('rejects private release base URLs at the verification entry point', async () => { + const { verifyReleaseBaseUrl } = await import( + installationReleaseVerificationScriptUrl + ); + + await expect( + verifyReleaseBaseUrl('https://127.0.0.1/releases/'), + ).rejects.toThrow(/must not target a private network/); + await expect( + verifyReleaseBaseUrl('https://169.254.169.254/latest/meta-data/'), + ).rejects.toThrow(/must not target a private network/); + await expect( + verifyReleaseBaseUrl('https://sub.localhost./releases/'), + ).rejects.toThrow(/must not target a private network/); + // IPv4-mapped IPv6 + await expect( + verifyReleaseBaseUrl('https://[::ffff:127.0.0.1]/releases/'), + ).rejects.toThrow(/must not target a private network/); + // IPv4-compatible IPv6 + await expect( + verifyReleaseBaseUrl('https://[::7f00:1]/releases/'), + ).rejects.toThrow(/must not target a private network/); + }); + it('downloads release archive bodies instead of relying on HEAD probes', async () => { const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = await import(installationReleaseVerificationScriptUrl); @@ -1818,7 +1964,7 @@ describe('standalone release packaging', () => { expect(guide).toContain('ALIYUN_OSS_ACCESS_KEY_SECRET'); expect(guide).toContain('ALIYUN_OSS_BUCKET'); expect(guide).toContain('ALIYUN_OSS_ENDPOINT'); - expect(guide).toContain('Public installation documentation'); + expect(guide).toContain('hosted entrypoint'); expect(guide).toContain('node-pty'); expect(guide).toContain('clipboard'); }); @@ -1862,6 +2008,168 @@ describe('standalone release packaging', () => { }); }); +describe('isPrivateOrReservedHost', () => { + it('rejects empty hostname', async () => { + const { isPrivateOrReservedHost } = await import( + installationReleaseVerificationScriptUrl + ); + expect(isPrivateOrReservedHost('')).toBe(true); + }); + + it('rejects localhost variants', async () => { + const { isPrivateOrReservedHost } = await import( + installationReleaseVerificationScriptUrl + ); + expect(isPrivateOrReservedHost('localhost')).toBe(true); + expect(isPrivateOrReservedHost('sub.localhost')).toBe(true); + expect(isPrivateOrReservedHost('localhost.')).toBe(true); + expect(isPrivateOrReservedHost('sub.localhost.')).toBe(true); + expect(isPrivateOrReservedHost('LOCALHOST')).toBe(true); + }); + + it('rejects private IPv4 addresses', async () => { + const { isPrivateOrReservedHost } = await import( + installationReleaseVerificationScriptUrl + ); + expect(isPrivateOrReservedHost('127.0.0.1')).toBe(true); + expect(isPrivateOrReservedHost('10.0.0.1')).toBe(true); + expect(isPrivateOrReservedHost('192.168.1.1')).toBe(true); + expect(isPrivateOrReservedHost('172.16.0.1')).toBe(true); + expect(isPrivateOrReservedHost('169.254.1.1')).toBe(true); + }); + + it('rejects IPv6 loopback and link-local', async () => { + const { isPrivateOrReservedHost } = await import( + installationReleaseVerificationScriptUrl + ); + expect(isPrivateOrReservedHost('::1')).toBe(true); + expect(isPrivateOrReservedHost('[::1]')).toBe(true); + expect(isPrivateOrReservedHost('fe80::1')).toBe(true); + }); + + it('rejects IPv4-mapped IPv6 addresses (2-part hex)', async () => { + const { isPrivateOrReservedHost } = await import( + installationReleaseVerificationScriptUrl + ); + expect(isPrivateOrReservedHost('::ffff:7f00:1')).toBe(true); + expect(isPrivateOrReservedHost('::ffff:a00:1')).toBe(true); + }); + + it('rejects IPv4-mapped IPv6 addresses (3-part hex from Node normalization)', async () => { + const { isPrivateOrReservedHost } = await import( + installationReleaseVerificationScriptUrl + ); + expect(isPrivateOrReservedHost('::ffff:0:7f00:1')).toBe(true); + expect(isPrivateOrReservedHost('::ffff:0:a00:1')).toBe(true); + expect(isPrivateOrReservedHost('::ffff:0:c0a8:101')).toBe(true); + }); + + it('does not collapse nonzero 3-part IPv4-mapped IPv6 prefixes', async () => { + const { isPrivateOrReservedHost } = await import( + installationReleaseVerificationScriptUrl + ); + expect(isPrivateOrReservedHost('::ffff:abcd:7f00:1')).toBe(false); + }); + + it('blocks IPv4-compatible IPv6 addresses (deprecated but parseable)', async () => { + const { isPrivateOrReservedHost } = await import( + installationReleaseVerificationScriptUrl + ); + // ::7f00:1 → 127.0.0.1 (loopback) + expect(isPrivateOrReservedHost('::7f00:1')).toBe(true); + // ::a9fe:a9fe → 169.254.169.254 (cloud metadata) + expect(isPrivateOrReservedHost('::a9fe:a9fe')).toBe(true); + // ::a00:1 → 10.0.0.1 (private) + expect(isPrivateOrReservedHost('::a00:1')).toBe(true); + // ::c0a8:101 → 192.168.1.1 (private) + expect(isPrivateOrReservedHost('::c0a8:101')).toBe(true); + }); + + it('allows public IP addresses', async () => { + const { isPrivateOrReservedHost } = await import( + installationReleaseVerificationScriptUrl + ); + expect(isPrivateOrReservedHost('8.8.8.8')).toBe(false); + expect(isPrivateOrReservedHost('142.250.80.46')).toBe(false); + expect(isPrivateOrReservedHost('example.com')).toBe(false); + expect(isPrivateOrReservedHost('example.com.')).toBe(false); + // Public IPv6 + expect(isPrivateOrReservedHost('2607:f8b0:4004:800::200e')).toBe(false); + }); + + it('does not flag decimal or octal encoded IPs (URL API normalizes them before reaching the helper)', async () => { + const { isPrivateOrReservedHost } = await import( + installationReleaseVerificationScriptUrl + ); + // Decimal-encoded 127.0.0.1 — not 4 dotted parts, so parseIpv4Octets + // returns null and the value is treated as a non-IP hostname (safe). + expect(isPrivateOrReservedHost('2130706433')).toBe(false); + // Octal-encoded 127.0.0.1 — parsed as dotted quad but leading zeros + // are interpreted as decimal by Number(), so 0177 → 177 (not 127). + // The resulting IP 177.0.0.1 is public, so this returns false. + // Node's URL API normalizes these before they reach isPrivateOrReservedHost. + expect(isPrivateOrReservedHost('0177.0.0.1')).toBe(false); + }); + + it('handles IPv6 zone IDs and empty brackets', async () => { + const { isPrivateOrReservedHost } = await import( + installationReleaseVerificationScriptUrl + ); + expect(isPrivateOrReservedHost('[]')).toBe(true); + // Node's URL API rejects URLs with IPv6 zone IDs as invalid, so this + // value would not normally reach isPrivateOrReservedHost. If it arrives + // raw, fe80::1%25eth0 contains ':' and is parsed as IPv6 link-local. + expect(isPrivateOrReservedHost('fe80::1%25eth0')).toBe(true); + }); +}); + +describe('redactUrlForLog', () => { + it('strips username and password from URLs', async () => { + const { redactUrlForLog } = await import( + installationReleaseVerificationScriptUrl + ); + expect(redactUrlForLog('https://user:pass@example.com/path')).toBe( + 'https://example.com/path', + ); + }); + + it('strips query parameters to prevent credential leakage', async () => { + const { redactUrlForLog } = await import( + installationReleaseVerificationScriptUrl + ); + expect( + redactUrlForLog( + 'https://example.com/path?X-Amz-Signature=secret&token=abc', + ), + ).toBe('https://example.com/path'); + }); + + it('strips URL fragments to prevent credential leakage', async () => { + const { redactUrlForLog } = await import( + installationReleaseVerificationScriptUrl + ); + expect( + redactUrlForLog('https://example.com/path#access_token=secret'), + ).toBe('https://example.com/path'); + }); + + it('redacts malformed URLs containing @, ?, or #', async () => { + const { redactUrlForLog } = await import( + installationReleaseVerificationScriptUrl + ); + expect(redactUrlForLog('not-a-url@with-creds')).toBe(''); + expect(redactUrlForLog('not-a-url?with-query')).toBe(''); + expect(redactUrlForLog('not-a-url#with-fragment')).toBe(''); + }); + + it('passes through safe non-URL strings', async () => { + const { redactUrlForLog } = await import( + installationReleaseVerificationScriptUrl + ); + expect(redactUrlForLog('just-a-string')).toBe('just-a-string'); + }); +}); + // These end-to-end installs spawn child processes via execFileSync; // the default 5s vitest timeout is too tight on slow CI runners even // without Windows' cmd.exe + node.exe startup overhead. @@ -1895,24 +2203,10 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { .trim(); expect(version).toBe('0.0.0-smoke'); expect(output).toContain('Installing Qwen Code version: latest'); - expect(output).toContain('QWEN CODE'); - expect(output).toContain( - 'Qwen Code 0.0.0-smoke installed successfully.', - ); - expect(output).toContain('To start:\n cd \n qwen'); - expect(output).toContain( - `Installed to:\n ${path.join(installRoot, 'lib', 'qwen-code')}`, - ); - expect(output).toContain('Uninstall:'); - expect(output).toContain( - 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.sh', - ); - expect(output).toContain( - `QWEN_INSTALL_LIB_DIR='${path.join(installRoot, 'lib', 'qwen-code')}'`, - ); - expect(output).toContain( - `QWEN_INSTALL_BIN_DIR='${path.join(installRoot, 'bin')}'`, - ); + expect(output).toContain('installed successfully, to start:'); + expect(output).toContain('0.0.0-smoke'); + expect(output).toContain('cd '); + expect(output).toContain('qwenlm.github.io/qwen-code'); expect(output).not.toContain('rm -rf'); } finally { rmSync(tmpDir, { recursive: true, force: true }); @@ -2236,6 +2530,133 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { }, ); + itOnUnix( + 'warns when an existing qwen could shadow the standalone install', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const fakeBin = path.join(tmpDir, 'old-bin'); + const existingQwen = path.join(fakeBin, 'qwen'); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + + mkdirSync(fakeBin, { recursive: true }); + writeFileSync(existingQwen, '#!/usr/bin/env sh\necho old-qwen\n'); + chmodSync(existingQwen, 0o755); + + const output = runUnixInstaller( + archive, + installRoot, + home, + 'standalone', + { + PATH: `${fakeBin}:${process.env.PATH}`, + SHELL: '/bin/bash', + }, + ).toString(); + + const installedBin = path.join(installRoot, 'bin', 'qwen'); + const bashrc = readScript(path.join(home, '.bashrc')); + + expect(output).toContain('installed successfully, to start:'); + expect(bashrc).toContain('# Qwen Code PATH block begin'); + expect(bashrc).toContain( + `export PATH='${path.join(installRoot, 'bin')}':$PATH`, + ); + + const resolvedQwen = execFileSync( + 'bash', + ['-c', 'source "${HOME}/.bashrc"; command -v qwen'], + { + env: { + ...process.env, + HOME: home, + PATH: `${fakeBin}:${process.env.PATH}`, + SHELL: '/bin/bash', + }, + }, + ) + .toString() + .trim(); + expect(resolvedQwen).toBe(installedBin); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); + } + }, + ); + + itOnUnix( + 'appends a fresh PATH block when an existing PATH line is not last', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const fakeBin = path.join(tmpDir, 'old-bin'); + const existingQwen = path.join(fakeBin, 'qwen'); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + const installBinDir = path.join(installRoot, 'bin'); + const installedBin = path.join(installBinDir, 'qwen'); + const bashrc = path.join(home, '.bashrc'); + + mkdirSync(fakeBin, { recursive: true }); + mkdirSync(home, { recursive: true }); + writeFileSync(existingQwen, '#!/usr/bin/env sh\necho old-qwen\n'); + chmodSync(existingQwen, 0o755); + writeFileSync( + bashrc, + [ + `export PATH='${installBinDir}':$PATH`, + `export PATH='${fakeBin}':$PATH`, + ].join('\n') + '\n', + ); + + runUnixInstaller(archive, installRoot, home, 'standalone', { + PATH: `${fakeBin}:${process.env.PATH}`, + SHELL: '/bin/bash', + }); + + const bashrcContents = readScript(bashrc); + expect(bashrcContents).toContain('# Qwen Code PATH block begin'); + expect( + bashrcContents.endsWith( + [ + '# Qwen Code PATH block begin', + `export PATH='${installBinDir}':$PATH`, + '# Qwen Code PATH block end', + '', + ].join('\n'), + ), + ).toBe(true); + + const resolvedQwen = execFileSync( + 'bash', + ['-c', 'source "${HOME}/.bashrc"; command -v qwen'], + { + env: { + ...process.env, + HOME: home, + PATH: `${fakeBin}:${process.env.PATH}`, + SHELL: '/bin/bash', + }, + }, + ) + .toString() + .trim(); + expect(resolvedQwen).toBe(installedBin); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); + } + }, + ); + itOnUnix( 'removes installer-owned shell rc PATH blocks even when extra lines are inserted', () => { @@ -2420,6 +2841,7 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { ).toString(); expect(output).toContain('Unsupported shell for automatic PATH update'); + expect(output).toContain(path.join(installRoot, 'bin')); expect(existsSync(path.join(home, '.profile'))).toBe(false); } finally { rmSync(tmpDir, { recursive: true, force: true }); @@ -2637,6 +3059,30 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { } }); + itOnUnix('rejects empty standalone archives', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = path.join(tmpDir, 'qwen-code-linux-x64.tar.gz'); + execFileSync('tar', ['-czf', archive, '-T', '/dev/null'], { + stdio: 'ignore', + }); + writeChecksumFile(tmpDir, path.basename(archive)); + + expect(() => + runUnixInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + ), + ).toThrow(/Archive is empty/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); + } + }); + itOnUnix( 'rejects standalone archives containing path traversal entries', () => { diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index 283001959d..75d3b9446c 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -13,6 +13,7 @@ import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; import { RELEASE_TARGETS } from './build-standalone-release.js'; +import { TARGETS } from './create-standalone-package.js'; import { fail, isMainModule, @@ -42,9 +43,8 @@ const REMOTE_FETCH_TIMEOUT_MS = 30_000; // has to be reflected here (and there) before a new target ships, otherwise // the verify and the build will disagree on expected filenames. function standaloneArchiveNamesFromReleaseTargets(releaseTargets) { - return releaseTargets.map( - ({ qwenTarget }) => - `qwen-code-${qwenTarget}.${qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'}`, + return releaseTargets.map(({ qwenTarget }) => + standaloneArchiveName(qwenTarget), ); } @@ -118,14 +118,23 @@ async function verifyReleaseDirectory(dir, options = {}) { const { silent = false } = options; const checksums = readReleaseChecksums(dir); assertExpectedChecksumEntries(checksums); - assertExpectedArchiveFiles(dir); + + const unexpected = fs + .readdirSync(dir) + .filter((fileName) => !EXPECTED_RELEASE_ASSET_NAMES.includes(fileName)) + .sort(); + if (unexpected.length > 0) { + fail(`Unexpected file(s) in release directory: ${unexpected.join(', ')}`); + } for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) { const assetPath = path.join(dir, assetName); if (!fs.existsSync(assetPath)) { fail(`Missing release asset: ${assetName}`); } - + if (!fs.lstatSync(assetPath).isFile()) { + fail(`Release asset is not a regular file: ${assetName}`); + } const actual = await sha256File(assetPath); const expected = checksums.get(assetName); if (actual !== expected) { @@ -145,6 +154,7 @@ async function verifyReleaseDirectory(dir, options = {}) { async function verifyReleaseBaseUrl(baseUrl, options = {}) { const { fetchImpl = fetch } = options; const normalizedBaseUrl = normalizeHttpsBaseUrl(baseUrl); + const displayBaseUrl = redactUrlForLog(normalizedBaseUrl); const checksumUrl = new URL('SHA256SUMS', normalizedBaseUrl).toString(); const checksums = parseSha256Sums(await fetchText(checksumUrl, fetchImpl)); assertExpectedChecksumEntries(checksums); @@ -152,7 +162,7 @@ async function verifyReleaseBaseUrl(baseUrl, options = {}) { await assertRemoteAssetChecksums(normalizedBaseUrl, checksums, fetchImpl); console.log( - `Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release assets at ${baseUrl}`, + `Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release assets at ${displayBaseUrl}`, ); } @@ -161,6 +171,9 @@ function readReleaseChecksums(dir) { if (!fs.existsSync(checksumPath)) { fail(`SHA256SUMS was not found at ${checksumPath}`); } + if (!fs.lstatSync(checksumPath).isFile()) { + fail('SHA256SUMS is not a regular file'); + } return parseSha256Sums(fs.readFileSync(checksumPath, 'utf8')); } @@ -182,18 +195,6 @@ function assertExpectedChecksumEntries(checksums) { } } -function assertExpectedArchiveFiles(dir) { - const expected = new Set(EXPECTED_RELEASE_ASSET_NAMES); - const extra = fs - .readdirSync(dir) - .filter((assetName) => !expected.has(assetName)) - .sort(); - - if (extra.length > 0) { - fail(`Unexpected release asset: ${extra.join(', ')}`); - } -} - function releaseAssetPaths(dir) { return EXPECTED_RELEASE_ASSET_NAMES.map((assetName) => path.join(dir, assetName), @@ -228,8 +229,9 @@ async function assertRemoteAssetChecksums( return; } if (failures.length === EXPECTED_STANDALONE_ARCHIVE_NAMES.length) { + const displayBaseUrl = redactUrlForLog(normalizedBaseUrl); fail( - `All ${failures.length} release asset URLs are unavailable; check --base-url: ${normalizedBaseUrl}`, + `All ${failures.length} release asset URLs are unavailable; check --base-url: ${displayBaseUrl}`, ); } fail( @@ -240,14 +242,16 @@ async function assertRemoteAssetChecksums( } async function fetchSha256(url, fetchImpl) { + const displayUrl = redactUrlForLog(url); const response = await fetchWithTimeout(fetchImpl, url); + assertNotRedirectResponse(response, displayUrl); if (!response.ok) { fail( - `Failed to download ${url}: ${response.status} ${response.statusText}`, + `Failed to download ${displayUrl}: ${response.status} ${response.statusText}`, ); } if (!response.body) { - fail(`Downloaded response has no body: ${url}`); + fail(`Downloaded response has no body: ${displayUrl}`); } const hash = crypto.createHash('sha256'); @@ -263,10 +267,12 @@ function formatErrorReason(reason) { } async function fetchText(url, fetchImpl) { + const displayUrl = redactUrlForLog(url); const response = await fetchWithTimeout(fetchImpl, url); + assertNotRedirectResponse(response, displayUrl); if (!response.ok) { fail( - `Failed to download ${url}: ${response.status} ${response.statusText}`, + `Failed to download ${displayUrl}: ${response.status} ${response.statusText}`, ); } return response.text(); @@ -275,29 +281,221 @@ async function fetchText(url, fetchImpl) { function fetchWithTimeout(fetchImpl, url, options = {}) { return fetchImpl(url, { ...options, + redirect: 'manual', signal: AbortSignal.timeout(REMOTE_FETCH_TIMEOUT_MS), }); } +function assertNotRedirectResponse(response, displayUrl) { + if (response.status >= 300 && response.status < 400) { + fail(`Redirect responses are not allowed: ${displayUrl}`); + } +} + function normalizeHttpsBaseUrl(baseUrl) { let parsed; try { parsed = new URL(baseUrl); } catch { - fail(`--base-url must be a valid URL: ${baseUrl}`); + fail(`--base-url must be a valid URL: ${redactUrlForLog(baseUrl)}`); } + const displayBaseUrl = redactUrlForLog(parsed.toString()); if (parsed.protocol !== 'https:') { - fail(`--base-url must use https: ${baseUrl}`); + fail(`--base-url must use https: ${displayBaseUrl}`); } + if (isPrivateOrReservedHost(parsed.hostname)) { + fail(`--base-url must not target a private network: ${displayBaseUrl}`); + } + parsed.username = ''; + parsed.password = ''; if (!parsed.pathname.endsWith('/')) { parsed.pathname = `${parsed.pathname}/`; } return parsed.toString(); } +function redactUrlForLog(url) { + try { + const parsed = new URL(url); + parsed.username = ''; + parsed.password = ''; + parsed.search = ''; + parsed.hash = ''; + return parsed.toString(); + } catch { + const value = String(url); + return value.includes('@') || value.includes('?') || value.includes('#') + ? '' + : value; + } +} + +function standaloneArchiveName(qwenTarget) { + const targetConfig = TARGETS.get(qwenTarget); + if (!targetConfig) { + fail(`Unknown release target: ${qwenTarget}`); + } + return `qwen-code-${qwenTarget}.${targetConfig.outputExtension}`; +} + +function isPrivateOrReservedHost(hostname) { + const normalized = hostname + .toLowerCase() + .replace(/^\[|\]$/g, '') + .replace(/\.$/, ''); + if (!normalized) { + return true; + } + if (normalized === 'localhost' || normalized.endsWith('.localhost')) { + return true; + } + + const mappedIpv4 = ipv4FromMappedIpv6(normalized); + if (mappedIpv4) { + return isPrivateOrReservedIpv4(mappedIpv4); + } + + if (parseIpv4Octets(normalized)) { + return isPrivateOrReservedIpv4(normalized); + } + + if (!normalized.includes(':')) { + return false; + } + + // IPv4-compatible IPv6 (deprecated RFC 4291 §2.5.5.1): ::x.x.x.x or ::HHHH:HHHH + const compatIpv4 = ipv4FromCompatibleIpv6(normalized); + if (compatIpv4) { + return isPrivateOrReservedIpv4(compatIpv4); + } + + return isPrivateOrReservedIpv6(normalized); +} + +function parseIpv4Octets(value) { + const parts = value.split('.'); + if (parts.length !== 4 || !parts.every((part) => /^\d+$/.test(part))) { + return null; + } + + const octets = parts.map(Number); + if (octets.some((octet) => octet < 0 || octet > 255)) { + return null; + } + return octets; +} + +function isPrivateOrReservedIpv4(value) { + const octets = parseIpv4Octets(value); + if (!octets) { + return false; + } + + const [first, second, third] = octets; + return ( + first === 0 || + first === 10 || + first === 127 || + (first === 100 && second >= 64 && second <= 127) || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 0 && third === 0) || + (first === 192 && second === 0 && third === 2) || + (first === 192 && second === 168) || + (first === 198 && (second === 18 || second === 19)) || + (first === 198 && second === 51 && third === 100) || + (first === 203 && second === 0 && third === 113) || + first >= 224 + ); +} + +function ipv4FromMappedIpv6(value) { + const match = value.match(/^(?:::ffff:|0:0:0:0:0:ffff:)(.+)$/i); + if (!match) { + return null; + } + + const suffix = match[1]; + if (parseIpv4Octets(suffix)) { + return suffix; + } + + // Node.js normalizes IPv4-mapped IPv6 to hex form. Handle both 2-part + // (::ffff:7f00:1) and 3-part (::ffff:0:7f00:1) representations. + const hexParts = suffix.split(':'); + if ( + (hexParts.length !== 2 && hexParts.length !== 3) || + !hexParts.every((part) => /^[0-9a-f]{1,4}$/i.test(part)) + ) { + return null; + } + + // For 3 parts like "0:7f00:1", skip the leading zero segment. + const relevantParts = + hexParts.length === 3 + ? hexParts[0] === '0' + ? hexParts.slice(-2) + : null + : hexParts; + if (!relevantParts) { + return null; + } + const high = Number.parseInt(relevantParts[0], 16); + const low = Number.parseInt(relevantParts[1], 16); + return `${(high >> 8) & 255}.${high & 255}.${(low >> 8) & 255}.${low & 255}`; +} + +// Detect IPv4-compatible IPv6 addresses (::x.x.x.x or ::HHHH:HHHH form). +// These are deprecated (RFC 4291) but Node.js URL parser still accepts them. +function ipv4FromCompatibleIpv6(value) { + // Must start with :: but NOT ::ffff: (already handled by ipv4FromMappedIpv6) + if (!value.startsWith('::') || /^::ffff:/i.test(value)) { + return null; + } + const suffix = value.slice(2); + if (!suffix || suffix.startsWith(':')) { + return null; + } + + // Dotted-quad form: ::169.254.169.254 + if (parseIpv4Octets(suffix)) { + return suffix; + } + + // Hex form: ::a9fe:a9fe (two hex groups encoding 4 IPv4 octets) + const hexParts = suffix.split(':'); + if ( + hexParts.length !== 2 || + !hexParts.every((part) => /^[0-9a-f]{1,4}$/i.test(part)) + ) { + return null; + } + const high = Number.parseInt(hexParts[0], 16); + const low = Number.parseInt(hexParts[1], 16); + return `${(high >> 8) & 255}.${high & 255}.${(low >> 8) & 255}.${low & 255}`; +} + +function isPrivateOrReservedIpv6(value) { + if (value === '::' || value === '::1' || value === '0:0:0:0:0:0:0:1') { + return true; + } + + const firstHextet = Number.parseInt(value.split(':', 1)[0] || '0', 16); + if (Number.isNaN(firstHextet)) { + return false; + } + + return ( + (firstHextet >= 0xfc00 && firstHextet <= 0xfdff) || + (firstHextet >= 0xfe80 && firstHextet <= 0xfebf) + ); +} + export { EXPECTED_STANDALONE_ARCHIVE_NAMES, EXPECTED_RELEASE_ASSET_NAMES, + isPrivateOrReservedHost, + redactUrlForLog, releaseAssetPaths, verifyReleaseBaseUrl, verifyReleaseDirectory,